dsh-plugin-wsl-env 0.8.0 → 0.10.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 (54) hide show
  1. package/CHANGELOG.md +488 -12
  2. package/CONTRIBUTING.md +125 -62
  3. package/README.md +99 -68
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +26 -10
  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 +83 -16
  9. package/cordis.patch.yml +50 -35
  10. package/docs/ARCHITECTURE.md +203 -55
  11. package/docs/CONFIGURATION.md +185 -18
  12. package/docs/LIMITATIONS.md +125 -45
  13. package/docs/PARITY.md +68 -38
  14. package/docs/PEER-PARITY.md +103 -33
  15. package/docs/README.zh.md +105 -64
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +56 -14
  18. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  19. package/lib/agent-protocol.d.ts +10 -4
  20. package/lib/agent-protocol.js +10 -3
  21. package/lib/agent.d.ts +84 -0
  22. package/lib/agent.js +167 -15
  23. package/lib/bwrap.d.ts +8 -0
  24. package/lib/bwrap.js +4 -1
  25. package/lib/fs-routing.d.ts +40 -4
  26. package/lib/fs-routing.js +80 -12
  27. package/lib/fsio-agent.js +1 -1
  28. package/lib/index.d.ts +134 -0
  29. package/lib/index.js +202 -9
  30. package/lib/native-agent.d.ts +124 -0
  31. package/lib/native-agent.js +198 -0
  32. package/lib/ports.d.ts +13 -2
  33. package/lib/ports.js +18 -5
  34. package/lib/preflight.d.ts +91 -0
  35. package/lib/preflight.js +117 -0
  36. package/lib/probe-cache.d.ts +12 -1
  37. package/lib/probe-cache.js +11 -3
  38. package/lib/sandbox-core.d.ts +70 -0
  39. package/lib/sandbox-core.js +187 -18
  40. package/lib/session-runtime.d.ts +161 -0
  41. package/lib/session-runtime.js +220 -0
  42. package/lib/session-scope.d.ts +124 -0
  43. package/lib/session-scope.js +191 -0
  44. package/lib/shell-env.js +10 -3
  45. package/lib/watcher.d.ts +25 -12
  46. package/lib/watcher.js +120 -32
  47. package/lib/workspace-files-route.d.ts +8 -4
  48. package/lib/workspace-files-route.js +42 -14
  49. package/lib/workspace-files-wsl.js +25 -8
  50. package/lib/wsl.js +10 -4
  51. package/locale/preflight/en.json +6 -0
  52. package/locale/preflight/zh.json +6 -0
  53. package/package.json +44 -27
  54. package/scripts/diagnose.sh +92 -0
package/README.md CHANGED
@@ -6,11 +6,11 @@
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
- This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) use a WSL distro as its working environment: commands run inside the distro, the model's file tools read and write real distro files, the folder picker can open a distro folder, and the GUI terminal opens inside the distro.
9
+ This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) work inside a WSL distro. The model's commands run there. Its file tools read and write the real distro files. The folder picker can open a distro folder, and the GUI terminal opens a shell inside it.
10
10
 
11
- **Windows + WSL2 only.** One install command, no dependencies of its own. The environment applies per session, so a session on a Windows folder keeps its normal Windows environment.
11
+ **Windows + WSL2 only.** One install command, no dependencies of its own. The environment is chosen per session: a session on a Windows folder keeps the normal Windows tools.
12
12
 
13
- [Install](#install) · [Using it](#using-it) · [Configure](#configure) · [Recipes](#recipes) · [Architecture](#architecture) · [Sandbox](#sandbox) ·
13
+ [Install](#install) · [Using it](#using-it) · [How it compares](#how-it-compares) · [Configure](#configure) · [Recipes](#recipes) · [Architecture](#architecture) · [Sandbox](#sandbox) ·
14
14
  [Troubleshooting](#troubleshooting) · [Development](#development) · [Documentation](#documentation)
15
15
 
16
16
  ## Install
@@ -18,71 +18,90 @@ This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
18
18
  Run these four commands in a Windows terminal:
19
19
 
20
20
  ```powershell
21
- dsh wsl --from-default-profile web --dump-config # 1. create the profile from the Web template
22
- dsh plugin --profile wsl add dsh-plugin-wsl-env # 2. install the code and its configuration layer
23
- dsh --profile wsl --dump-config # 3. compose only, no boot (the fast check)
21
+ dsh wsl --from-default-profile web --dump-config # 1. create a profile (a named set of dsh settings) from the Web template
22
+ dsh plugin --profile wsl add dsh-plugin-wsl-env # 2. install the plugin and its settings
23
+ dsh --profile wsl --dump-config # 3. a quick check: see the merged settings without starting the app
24
24
  dsh --profile wsl # 4. run
25
25
  ```
26
26
 
27
- Step 3 should print a layer named `# == dsh-plugin-wsl-env`, and `- id: terminal-controller` should now carry `shell: { path: wsl.exe, name: WSL }`. The package is a DSH bundle, so step 2 applies [`cordis.patch.yml`](cordis.patch.yml) as a configuration layer; nothing is merged by hand.
27
+ Step 3 should print a line `# == dsh-plugin-wsl-env`. That line is the plugin's settings arriving in your profile. The plugin ships its own settings, so step 2 wires everything in automatically — there is nothing to merge by hand.
28
28
 
29
- Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
29
+ **Before the first command, give the distro bubblewrap.** Every command runs inside a bubblewrap sandbox, and most distros do not have bubblewrap preinstalled. Without it, every command fails instead of running unsandboxed:
30
30
 
31
- You also need **bubblewrap inside the distro**: run `pnpm run bootstrap -- <distro> --install` (drop `--install` for a read-only check), or paste `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
31
+ ```powershell
32
+ wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap # Debian/Ubuntu
33
+ ```
34
+
35
+ From a plugin checkout, `pnpm run bootstrap -- <distro>` checks the four tools the plugin uses (bubblewrap, ripgrep, git, inotify-tools) and prints the install command for the distro's package manager; `--install` runs it. If this step is skipped, the session reports the missing package with the exact install command when it opens.
36
+
37
+ **The shipped preset also needs systemd.** Commands run in one systemd user scope per session (background dev servers survive, `/tmp` is shared, `ps` sees the session). Set `[boot] systemd=true` in `/etc/wsl.conf`, restart WSL, and enable lingering once: `loginctl enable-linger "$USER"`. The session refuses with those remedies if any piece is missing; `sessionSandbox: false` on the preset's `wsl-shell` restores the per-command sandbox.
38
+
39
+ Then open a folder like `\\wsl.localhost\<distro>\...` in the GUI. The folder picker lists every installed distro, and **New terminal** opens a shell inside the distro.
32
40
 
33
41
  Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
34
42
 
35
- > **Changed anything under `lib/`? Restart the app.** A running process caches ES modules and keeps the old code otherwise.
43
+ > **Changed anything under `lib/`? Sync the Windows-side copy, then restart the app.** The app loads the Windows-side copy of the code, so run `pnpm run sync:windows` first. A restart without the sync just reloads the old code, and a running process also caches modules. See [Development](#development).
36
44
 
37
45
  ## Using it
38
46
 
39
- Open `\\wsl.localhost\ubuntu\home\you\project` as the workspace, then ask the model "what kernel am I on, and what is in `/etc/os-release`?". It runs `uname -r` and reads that file inside the distro. Nothing goes through `/mnt/c`, and no files are copied.
47
+ Open a distro folder as the workspace — for example `\\wsl.localhost\ubuntu\home\you\project` — and ask the model "what kernel am I on, and what is in `/etc/os-release`?". It runs `uname -r` and reads that file inside the distro. Nothing is copied, and nothing crosses `/mnt/c`.
48
+
49
+ - **A distro folder gets the distro environment.** When a session opens a distro folder, it is automatically given the WSL environment. The very first command is already right.
50
+ - **Commands run where you expect.** They run inside the distro, in your own login shell — so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files all apply. The shell comes from the distro's configuration; it is not hardcoded to bash.
51
+ - **Files are the real files.** `/home/you/x` and `\\wsl.localhost\ubuntu\home\you\x` are the same file. `/mnt/c/...` reaches the Windows disk as usual.
52
+ - **The terminal too.** Right sidebar, *New terminal*: a shell inside the distro, in the session's folder.
53
+ - **Port visibility.** The model sees which ports are listening inside the distro (refreshed about every 10 seconds) and can return the exact URL of a dev server it starts. WSL2 forwards localhost to Windows, so the URL opens in the browser directly.
54
+ - **Permissions work like on a Linux host.** The Permissions selector offers `read-only`, `workspace-write` (the default) and `danger-full-access`. When something is refused, the model is offered one retry with the smallest permission that would work. Only `danger-full-access` runs without the sandbox.
55
+
56
+ ## How it compares
40
57
 
41
- - **A distro folder gets the distro environment automatically.** The `wsl` preset is bound while the session is created, so the first tool call is already correct.
42
- - **Commands** run as `wsl.exe -d <distro> --cd <linux dir> --exec <your login shell> -lc <command>`, so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files apply. The shell is not hardcoded to bash.
43
- - **Files are the distro's real files.** `/home/you/x` and `\\wsl.localhost\ubuntu\home\you\x` are the same file, and `/mnt/c/...` reaches the Windows disk.
44
- - **The terminal** (right sidebar, then *New terminal*) opens a shell inside the distro, in the session's folder.
45
- - **Port visibility.** The model sees which ports have a listener inside the distro (`DSH_WSL_PORTS`, refreshed about every 10 s), so it can hand you the exact URL of a dev server it just started; WSL2's localhost forwarding makes it reachable from Windows directly.
46
- - **The model sees its shell environment.** The plugin registers `DSH_WSL_DISTRO`, `DSH_WSL_SHELL` and `DSH_WSL_HOME` in the managed `DSH_*` namespace.
47
- - **Permissions work as on a Linux host.** The Permissions selector switches between `read-only`, `workspace-write` (the default) and `danger-full-access`. A refused command or write comes back with an offer to run it with wider permissions; if you approve, that one call runs without the sandbox.
58
+ Three ways for a Windows coding tool to work on a WSL project:
59
+
60
+ 1. **Install the tool inside WSL.** Codex CLI, Claude Code and ZCode CLI all recommend this, and they are right — it is the simplest path when a CLI tool covers your work. It needs the whole tool to live inside WSL. DeepSeek Harness cannot, so it needs one of the next two.
61
+ 2. **Connect from a desktop app.** VS Code (Remote-WSL, and its open-source mirror [open-remote-wsl](https://github.com/jeanp413/open-remote-wsl)) and agent desktops like [ZCode](https://github.com/zai-org/ZCode) keep the app on Windows and set up a helper server inside the distro. Battle-tested. The cost: a server folder (`~/.vscode-server`, `~/.zcode/server`), a port, and no sandbox for commands.
62
+ 3. **This plugin.** The harness stays on Windows. Nothing is installed in the distro, no port is opened, and commands run inside a bubblewrap sandbox. If something is missing, the session says what to install the moment it opens.
63
+
64
+ | | Option 2 (desktop remote) | Option 3 (this plugin) |
65
+ |---|---|---|
66
+ | installed in the distro | a server folder | nothing |
67
+ | opens a port | yes | no |
68
+ | command sandbox | none | bubblewrap + systemd session scope |
69
+ | after uninstall | remove the server manually | nothing left |
70
+ | after a WSL upgrade | server may need repair | the next command recovers automatically |
71
+
72
+ **Where the others win.** A desktop remote re-opens a workspace instantly — its server stays warm — and VS Code's remote has years of hardening behind it. A CLI inside WSL needs none of this. This plugin is for one case: DeepSeek Harness on Windows, working on WSL projects, with commands sandboxed.
48
73
 
49
74
  ## Configure
50
75
 
51
- Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys worth knowing:
76
+ Each setting lives on a named row. To change one, add a row with the same id to `$DSH_HOME/profiles/<name>/cordis.patch.yml` (`$DSH_HOME` is DSH's settings folder). The keys worth knowing:
52
77
 
53
78
  | Row | Key | Default | Meaning |
54
79
  |---|---|---|---|
55
- | `wsl-shell` | `distro` | `''` | distro name; empty means WSL's default distro |
56
- | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
57
- | `wsl-fs` | `distro` | `''` | as above |
58
- | | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
59
- | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
60
- | | `substrate` | `agent` | which I/O substrate serves the file tools. Only the resident in-distro agent remains: reads, writes and identities run on ext4 (native symlinks and mode bits; the write guard survives to publication, kernel-enforced under a confined policy). The former `"share"` opt-out — the Windows-side host stack over the 9p share — is refused at boot; no file tool crosses the share |
61
- | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
80
+ | `wsl-shell` | `distro` | `''` | which distro to use; empty means WSL's default distro |
81
+ | | `sandbox` | `true` | run commands inside the `bubblewrap` sandbox; `false` turns it off |
82
+ | | `sessionSandbox` | `true` | run the session's commands in one persistent systemd user scope: background dev servers survive, `/tmp` is shared, `ps` sees the session. Needs systemd as PID 1, a reachable user manager and `Linger`; the preflight refuses with the remedy. `false` restores the per-command sandbox |
83
+ | | `agentBackend` | `auto` | the session runtime's agent: `auto` uses the native in-distro agent when a verified source exists (packaged static-musl binary at `agent/native/linux-<arch>/dsh-wsl-agent` (x86_64/aarch64) or the `DSH_WSL_AGENT_NATIVE` override), else the shell agent; `native` refuses instead of falling back; `shell` forces the shell agent. Covers commands and confined writes. The native backend is the default; the shell agent is the fallback |
84
+ | `wsl-fs` | `distro` | `''` | same as above |
85
+ | | `restrictToDistro` | `true` | refuse paths that belong to a **different** distro. (`/mnt/c` belongs to this distro, so it is not affected.) The refusal is `FS_OUTSIDE_DISTRO`; wider permissions cannot lift it |
86
+ | | `sandbox` | `true` | check file writes against the same policy |
87
+ | | `sessionSandbox` | `true` | run confined file writes in the same session runtime as commands (one real `/tmp`, one workspace mount view); needs the same systemd/linger setup; `false` uses the per-policy confined resident |
88
+ | | `substrate` | `agent` | how file operations reach the distro. Only one value remains: the in-distro agent, so reads and writes run on the distro's own filesystem, with native symlinks and permissions. The former `"share"` option used the 9p share and is rejected at startup |
89
+ | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory in the picker |
62
90
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
63
91
 
64
- [`cordis.patch.yml`](cordis.patch.yml) is the commented reference for every shipped value. [docs/CONFIGURATION.md](docs/CONFIGURATION.md) lists the rest, including `shell`, `loginShell`, `cwd`, `timeoutMs`, `preferredDistro` and `maxEntries`; [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml) is a machine-local layer to copy from.
92
+ `wsl-shell` and `wsl-fs` cannot be reached by adding `- id: wsl-shell` to your override file: they live inside the `wsl` preset's own settings (a preset is a named environment DSH can give a session), so the loader only warns `patch: entry "wsl-shell" not found` and the shipped value stays in force. To change them, copy and override the whole `preset-wsl` row — [docs/CONFIGURATION.md](docs/CONFIGURATION.md#overriding-the-rows-inside-preset-wsl) has the recipe.
93
+
94
+ [`cordis.patch.yml`](cordis.patch.yml) is the commented reference for every value the plugin ships. [docs/CONFIGURATION.md](docs/CONFIGURATION.md) lists the rest — `shell`, `loginShell`, `cwd`, `timeoutMs`, `preferredDistro`, `maxEntries` — and [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml) is a settings file you can copy and edit.
65
95
 
66
96
  ## Recipes
67
97
 
68
- - **Git credential sharing**: let git inside the distro use the Windows-side Git
69
- Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
70
- (adjust the path to your Windows Git install; WSL2's localhost forwarding is
71
- platform behaviour, so ports a dev server listens on inside the distro are
72
- reachable from Windows directly).
73
- - **Paths and performance**: the model sees and operates on Linux paths
74
- (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
75
- 9p — noticeably slow for many small files; keep heavy-IO projects on the
76
- distro filesystem. `pnpm run bootstrap -- <distro>` also reports whether
77
- ripgrep, git and inotifywait (the search, snapshot and watch backends) are in place.
78
- - **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
79
- This plugin admits the managed `DSH_*` namespace by prefix, translating the
80
- two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
81
- deliberately never forwarded — it would shadow the distro's own PATH.
98
+ - **Use your Windows git credentials inside the distro**: `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"` (adjust the path to where your Windows Git is installed). After this, `git push` inside the distro uses the same saved credentials as Windows.
99
+ - **Keep projects on the distro's own disk.** The model works on Linux paths (`/home/...`) on the distro's own disk, which is fast. It can still reach Windows files through `/mnt/c`, but that bridge is slow when many small files are involved. `pnpm run bootstrap -- <distro>` tells you whether ripgrep, git and inotifywait — used for search and file watching — are installed.
100
+ - **Environment variables**: WSL only forwards the variables named in `WSLENV`. This plugin forwards its own `DSH_*` values, and translates the two that hold Windows paths (`DSH_HOME`, `DSH_PROFILE_DIR`) into Linux paths. Your `PATH` is never forwarded — forwarding it would hide the distro's own PATH.
82
101
 
83
102
  ## Architecture
84
103
 
85
- One DSH process serves both kinds of session at once — a workspace on a Windows folder, and a workspace inside the distro — because its providers mount at two levels:
104
+ One DSH process can serve both kinds of session at the same time: a session on a Windows folder, and a session inside the distro. The plugin's parts attach at two levels, because some things belong to one session and some belong to the whole app:
86
105
 
87
106
  ```text
88
107
  composition (app level, one per process)
@@ -90,31 +109,36 @@ composition (app level, one per process)
90
109
  │ WSL-folder session → the distro shell, in the session's Linux directory
91
110
  │ Windows-folder session → powershell.exe, in the session's Windows directory
92
111
  ├─ directory-picker-wsl installed distros listed beside the Windows home
112
+ ├─ workspace-files-wsl the GUI file tree and previews of a distro workspace, served from inside it
113
+ ├─ fs-routing the root filesystem, routed by coordinate
114
+ │ distro UNC → the resident agent; a drive path → the host backend
93
115
  ├─ wsl-shell-env the DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS facts the model sees
94
116
  └─ auto-preset binds the wsl preset when a session opens a distro folder
95
117
 
96
- preset-wsl (the wsl agent preset; its services run in isolate realms)
118
+ preset-wsl (the wsl agent preset; its settings are separate from the app's)
97
119
  ├─ wsl-shell ctx.shell — wsl.exe --exec <login shell>, confined by bubblewrap inside the distro
98
- └─ wsl-fs ctx.fs — real distro files; agent substrate on ext4, or the 9p share
120
+ └─ wsl-fs ctx.fs — real distro files on the resident agent substrate (ext4)
99
121
  ```
100
122
 
101
- **Why two levels.** `wsl-shell` and `wsl-fs` live inside the `wsl` agent preset, which `auto-preset` binds whenever a session's workspace is inside the distro: a Windows-folder session keeps the stock providers, a distro session gets the WSL ones — the environment is a property of the session, not of the process. The terminal controller is the exception: it resolves its execution world through the root context, which never sees a preset's isolate realms, so `subprocess-wsl` sits at the composition level instead.
123
+ **Why two levels.** The `wsl` preset holds `wsl-shell` and `wsl-fs`, the two things that differ per session. When a session opens a distro folder, `auto-preset` gives that session the preset; a Windows-folder session keeps the normal Windows tools. One environment per session, in one process. The GUI terminal is the exception: it reads its shell from the app level, which never sees a preset, so `subprocess-wsl` is mounted at the app level. The GUI file tree and the root filesystem have the same shape of problem — every session uses them, so no single preset can own them — and `workspace-files-wsl` and `fs-routing` sit at the app level for that reason. They replace two shipped rows; [docs/root-fs-routing.md](docs/root-fs-routing.md) has the routing design.
124
+
125
+ **How a command runs.** A session gets one resident agent inside a systemd user scope. By default that agent is the native `dsh-wsl-agent` — the packaged static-musl binary for the distro's architecture, selected by `agentBackend: auto`; the runtime's shell agent is the fallback when the binary is missing or fails its copy/digest/exec check, and `agentBackend: shell` forces it while `native` refuses instead of falling back. The agent executes each command in your login shell under a bubblewrap mount profile. Because the scope outlives the command, a background dev server survives, `/tmp` is the distro's real `/tmp`, and `ps` sees the whole session. With `sessionSandbox: false`, the agent stays per distro, each command gets its own bwrap profile (`--unshare-pid`, a fresh `/tmp`), background processes do not survive, and the native backend is not used. If the agent is unavailable (or you set `agent: false`), the one-shot `wsl.exe` path runs the command instead — same result, slightly slower, and per-command isolation.
102
126
 
103
- **Commands and files.** A command runs as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>` inside a distro-side bubblewrap profile assembled with the same arguments as DSH's own Linux runner, so confinement semantics and error messages match a Linux host. The file tools read and write real distro files on the resident in-distro agent — reads, writes and identities run on ext4 with native symlinks and mode bits — and the file-search spawn is rewritten the same way: a search over a distro workspace runs the distro's own `rg` (`wsl.exe --exec`), never the Windows binary over the 9p share. Writes are checked against the same policy the command sandbox enforces.
127
+ **How file operations run.** Reads, writes and searches also happen inside the distro, on its own filesystem, with the distro's own tools. A search over a distro workspace runs the distro's `rg`, never the Windows binary over the 9p share. File writes are checked against the same policy the command sandbox enforces; with `wsl-fs` `sessionSandbox` on (the shipped preset), a confined write runs in the same session runtime as the commands — the same native/shell agent — so it sees the same `/tmp` and workspace mounts. Reads use the shared shell agent.
104
128
 
105
129
  ## Sandbox
106
130
 
107
- Commands are confined by `bubblewrap` inside the distro, and file writes are checked against the same policy. The Windows ACL sandbox cannot be used here: its restricted token cannot reach WSL at all.
131
+ Commands run inside a `bubblewrap` sandbox in the distro, and file writes are checked against the same policy. The Windows sandbox cannot be used here: its restricted account cannot reach WSL at all.
108
132
 
109
133
  | Mode | What a command inside the distro can do |
110
134
  |---|---|
111
- | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
112
- | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
113
- | `danger-full-access` | no sandbox; used for an approved wider-permission request |
135
+ | `read-only` | read the whole distro. A fresh `/dev` is mounted so `/dev/null` and `/dev/shm` work; nothing else is writable |
136
+ | `workspace-write` | all of the above, plus the session workspace is writable and `/tmp` is the distro's real `/tmp` (with `sessionSandbox: false`, the per-command profile mounts a fresh tmpfs there instead) |
137
+ | `danger-full-access` | no sandbox; used when you approve a wider-permission request |
114
138
 
115
- **`bubblewrap` is required, and it fails closed.** Without it every confined command reports `SANDBOX_UNAVAILABLE` instead of running unconfined. Set `sandbox: false` on either provider to opt out; the tool layer then tells the model these operations have no sandbox.
139
+ **bubblewrap is required, and the plugin fails closed.** Without it, every confined command reports `SANDBOX_UNAVAILABLE` — the command does not run unsandboxed. To turn the sandbox off, set `sandbox: false` on the row that owns the operation: `wsl-shell` for commands, `wsl-fs` for file writes, and the top-level `fs-routing` row for writes to the app's own filesystem. (The two preset rows are reached through `preset-wsl` — see [Configure](#configure).) The model is then told that these operations have no sandbox.
116
140
 
117
- **The reported enforcement is `partial`, not `full`.** A process inside the distro can still run a Windows program through WSL interop, for example `/mnt/c/.../*.exe`, and bubblewrap does not govern it. `pnpm run probe:sandbox` demonstrates the boundary on your machine.
141
+ **Known limits.** A command inside the distro can still start a Windows program (anything under `/mnt/c/.../*.exe`), and bubblewrap does not watch Windows programs. `pnpm run probe:sandbox` demonstrates this boundary on your machine. Setting `maskWindowsDrive: true` on the three rows narrows it: `/mnt` disappears from the command's view, so the drive's files cannot be read and its programs cannot be started. It does not close the hole completely — a command could still copy a Windows program into the workspace and run it — so the report stays `partial`. The only complete fix is in the distro itself: `[interop] enabled=false` in `wsl.conf` (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md)).
118
142
 
119
143
  See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [docs/LIMITATIONS.md](docs/LIMITATIONS.md) for everything the plugin does not do.
120
144
 
@@ -122,47 +146,54 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
122
146
 
123
147
  | Symptom | Cause | Fix |
124
148
  |---|---|---|
125
- | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `pnpm run bootstrap -- <distro> --install`, or set `sandbox: false` on both providers |
126
- | a command or write is refused outside the session folder | expected behaviour of `workspace-write` | accept the wider-permission offer, or open a session on the folder you need |
149
+ | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is missing from the distro, or present but broken — the error text tells you which one it is and what to do | follow the fix in the error, or set `sandbox: false` on `wsl-shell` (commands) and `wsl-fs` (writes) — see [Configure](#configure) for how to reach those rows |
150
+ | a command or write is refused outside the session folder | expected: `workspace-write` only allows writes in the session folder | accept the wider-permission offer, or open the session on the folder you need |
127
151
  | writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
128
- | `dsh plugin add` warns that no layer was activated | the dependency was already installed, so `add` had nothing to record | run `dsh plugin --profile wsl remove dsh-plugin-wsl-env`, then add it again |
129
- | the terminal still opens `cmd.exe` | the `terminal-controller` row from the layer did not apply | check that `dsh --profile wsl --dump-config` shows `shell: { path: wsl.exe, name: WSL }` |
130
- | changes to `lib/` have no effect | ES module cache | restart the app |
131
- | `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the runtime mirror, see [Development](#development) |
132
- | `glob` and `grep` are slow | a distro search without rg inside the distro falls back to rg's own "command not found"; a Windows-folder search is native and unaffected | run `pnpm run bootstrap -- <distro> --install` (installs ripgrep); distro searches always run the distro's rg — they never walk the 9p share |
133
- | the terminal reports `unknown` activity | only while the resident agent is out — distro terminals are observed from inside the distro (a `DSH_TERMINAL_ID` marker scanned in `/proc`: a shell alone is `idle`, a shell running anything is `busy`) | check the distro is running; idle terminals are reclaimed automatically after the controller's unattended timeout (2 h by default), and `terminalIdleReclaim: false` restores the close-by-hand posture |
152
+ | `dsh plugin add` prints no confirmation that anything was added | the plugin is already installed, so there is nothing new to report — this is success, not failure | nothing to do: the settings are already in place, and `dsh --profile wsl --dump-config` still shows `# == dsh-plugin-wsl-env` |
153
+ | the terminal still opens `cmd.exe` | the `terminal-controller` row from the plugin's settings did not apply | check that `dsh --profile wsl --dump-config` shows `shell: { path: wsl.exe, name: WSL }` |
154
+ | changes to `lib/` have no effect | the app runs a Windows-side copy of the code, and a running process also caches modules | run `pnpm run sync:windows` to update the copy, then restart the app |
155
+ | `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the Windows-side copy, see [Development](#development) |
156
+ | `glob` and `grep` are slow | searches inside the distro need `rg` installed in the distro; Windows-folder searches are unaffected | run `pnpm run bootstrap -- <distro> --install` (installs ripgrep); distro searches always run the distro's rg — they never cross the slow bridge |
157
+ | the terminal reports `unknown` activity | only while the in-distro agent is temporarily unavailable — distro terminals are watched from inside the distro (a `DSH_TERMINAL_ID` marker scanned in `/proc`: a shell alone is `idle`, a shell running anything is `busy`) | check the distro is running; idle terminals are closed automatically after the controller's idle timeout (2 h by default), and `terminalIdleReclaim: false` turns the auto-close off |
134
158
  | a result names an `FS_*` code | the code says what refused it, and what clears it | see the error-code table in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#error-codes) |
135
159
 
136
160
  ## Development
137
161
 
138
162
  ```bash
139
- pnpm test # style and packaging checks, syntax check, unit tests
163
+ pnpm run build # compile src/ into lib/ (the built files are committed; rebuild after editing src/)
164
+ pnpm test # style checks, syntax pass, build consistency, type check, and unit tests
165
+ pnpm run sync:windows # copy the checkout onto the Windows-side copy the app loads (after any lib/ change, then restart the app)
140
166
  pnpm run test:coverage # the unit tests with coverage thresholds (Node 22.8+)
167
+ pnpm run diagnose # read-only diagnostic report for a bug issue: versions, tools, bwrap probe
168
+ pnpm run bootstrap -- <distro> # check the four distro tools; add --install to install what is missing
141
169
  pnpm run probe:sandbox # measure inside the distro what bubblewrap does and does not confine
142
170
  pnpm run probe # filesystem probe against a real distro (Windows + WSL only)
143
171
  pnpm run probe:sandbox-shell # boot a real harness and drive the confined executor
144
172
  pnpm run probe:terminal # open a PTY through the terminal provider
145
- pnpm run probe:substrate # drive the agent filesystem substrate over a real wsl.exe transport (from inside the distro)
173
+ pnpm run probe:substrate # drive the agent filesystem over a real wsl.exe transport (from inside the distro)
146
174
  pnpm run probe:watch # arm the in-distro watcher over a real directory (from inside the distro)
147
- pnpm run probe:agent # the resident-vs-one-shot fallback parity legs (from inside the distro)
148
- pnpm run probe:exec # the agent-backed execution handle: timeout, kill, cwd failure (from inside the distro)
175
+ pnpm run probe:agent # compare the resident path against the fallback path (from inside the distro)
176
+ pnpm run probe:exec # the agent-backed execution: timeout, kill, cwd failure (from inside the distro)
149
177
  pnpm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
150
178
  pnpm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
151
- pnpm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
179
+ pnpm run probe:mode # which POSIX-mode facts survive the share (needs a Windows node and an installed harness; opens no profile)
152
180
  pnpm run probe:sandbox-off # prove sandbox: false unconfines both providers (Windows + WSL only)
153
181
  ```
154
182
 
155
- One `pnpm install` reproduces the pinned development dependencies before the first run: the 0.7.x tests import pinned `@deepseek-ai/*` packages (the composition and boot tests exercise the loader's real patch algorithm), while the runtime package itself still ships zero dependencies. pnpm is resolved through the `packageManager` field, so any corepack-enabled Node works. CI runs `pnpm test` on Node 22 and 24, on Linux and Windows, and `pnpm run test:coverage` on Node 24; `engines` matches the harness host's own floor (`^22.19.0 || >=24.0.0`).
183
+ The app runs a Windows-side copy of the code, not your checkout. After changing anything under `lib/`, run `pnpm run sync:windows` before restarting the app — otherwise the restart just loads the old code. `pnpm run build` regenerates `lib/` from `src/`; the built files are committed, and `pnpm test` checks that they match.
184
+
185
+ Run `pnpm install` once to get the development dependencies. The tests use the pinned `@deepseek-ai/*` packages; the plugin itself ships none. CI runs the tests on Node 22 and 24, on Linux and Windows, and coverage on Node 24.
156
186
 
157
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, the mounting, and the sandbox design. [CONTRIBUTING.md](CONTRIBUTING.md) has the development loop, the full gate list, and what has been verified.
187
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) has the file layout, how the parts attach, and the sandbox design. [CONTRIBUTING.md](CONTRIBUTING.md) has the development loop, the Windows-side copy, the full gate list, and what has been verified.
158
188
 
159
189
  ## Documentation
160
190
 
161
191
  | Document | What it covers |
162
192
  |---|---|
163
193
  | [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | every configuration key, and how to override it |
164
- | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | providers, mounting, path coordinates, sandbox, test layers, file layout |
194
+ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | the plugin's parts and how they attach, path coordinates, the sandbox, testing, file layout |
165
195
  | [docs/LIMITATIONS.md](docs/LIMITATIONS.md) | what the plugin does not do, and why |
196
+ | [docs/VALUE.zh.md](docs/VALUE.zh.md) | the plugin's value in Chinese: what it is worth, what it costs, and when not to use it |
166
197
  | [CONTRIBUTING.md](CONTRIBUTING.md) | the development loop, gates, conventions, verification |
167
198
  | [SECURITY.md](SECURITY.md) | reporting a vulnerability privately |
168
199
  | [SUPPORT.md](SUPPORT.md) | supported versions, and where to ask |
package/SECURITY.md CHANGED
@@ -45,6 +45,21 @@ These are targets, not a contract.
45
45
 
46
46
  If a report is out of scope, we will say so and explain why.
47
47
 
48
+ ## Disclosure
49
+
50
+ A confirmed vulnerability is fixed and disclosed through the ordinary release
51
+ process, not through a separate security track.
52
+
53
+ - The fix ships in the next release of the package, which is the only version the
54
+ Supported versions table above covers.
55
+ - That release records the fix in [`CHANGELOG.md`](CHANGELOG.md), and the GitHub
56
+ Release notes are taken from that section.
57
+ - The advisory is published on this repository's Security tab, where the private
58
+ report was filed, once the release that carries the fix is out.
59
+
60
+ A report that is out of scope, or that we cannot confirm as a vulnerability, is
61
+ closed without a published advisory.
62
+
48
63
  ## Scope
49
64
 
50
65
  In scope:
@@ -69,8 +84,9 @@ process inside the distro can still start a Windows program through WSL interop,
69
84
  for example `/mnt/c/.../*.exe`. That program runs outside the Linux sandbox.
70
85
 
71
86
  This is a documented design boundary, not a vulnerability by itself. The command
72
- `npm run probe:sandbox` demonstrates it on your machine, and the README states it
73
- in its Sandbox section.
87
+ `npm run probe:sandbox` demonstrates it on your machine when run from inside the
88
+ distro, where `bubblewrap` and `node` must be installed; the README states it in
89
+ its Sandbox section.
74
90
 
75
91
  A report is still welcome if you find an escape that does not depend on interop,
76
92
  or if the plugin ever reports `full` while interop is reachable.
package/SUPPORT.md CHANGED
@@ -5,10 +5,14 @@
5
5
  | Area | Supported |
6
6
  |---|---|
7
7
  | Operating system | Windows 10 and Windows 11 |
8
- | Linux side | WSL2 with at least one distro |
9
- | DSH packages | the `0.2.0-rc.2`-era packages listed in `peerDependencies` |
8
+ | Linux side | WSL2 with at least one distro, and systemd as PID 1 with a lingering user manager (`loginctl enable-linger`) for the shipped session runtime. Set `sessionSandbox: false` on the preset's `wsl-shell` to keep the per-command sandbox instead |
9
+ | DSH packages | the `0.2.1-rc.2`-era packages listed in `peerDependencies` |
10
10
  | Plugin version | the latest published version only |
11
- | Node | 20, 22, or 24 |
11
+ | Node | 22.19+ (`^22.19.0`), or 24 and later (`>=24.0.0`) |
12
+
13
+ Node 20 is outside the range on purpose: the floor is the harness host's own, and
14
+ CI covers 22 and 24. `engines` in `package.json` is the authoritative statement,
15
+ not this table.
12
16
 
13
17
  Outside Windows there is nothing for the plugin to attach to. It runs `ctx.shell`
14
18
  inside the distro through `wsl.exe`. A Linux host, a macOS host, or WSL1 is not a
@@ -21,7 +25,10 @@ Open an issue in the repository:
21
25
  https://github.com/Big-Dao/dsh-plugin-wsl-env/issues
22
26
 
23
27
  Use it for bug reports and for questions about configuration. Search the open and
24
- the closed issues first.
28
+ the closed issues first, and check whether the answer is already written down:
29
+ [docs/LIMITATIONS.md](docs/LIMITATIONS.md) lists the known design boundaries, and
30
+ the Troubleshooting table in [README.md](README.md#troubleshooting) covers the
31
+ common failures (`SANDBOX_UNAVAILABLE`, a missing `rg`, and the rest).
25
32
 
26
33
  For a security issue, follow [SECURITY.md](SECURITY.md). Do not open a public
27
34
  issue for one.
@@ -31,15 +38,19 @@ issue for one.
31
38
  - macOS, and Linux hosts that are not WSL2.
32
39
  - Windows without WSL2.
33
40
  - A modified DSH build, or a fork of the harness packages.
34
- - A distro the plugin cannot recognize. Examples are a replaced `/bin/sh`, a
35
- container-style root filesystem, and a missing `bwrap`.
41
+ - A distro the plugin cannot recognize, which is what its probes report: no
42
+ distro from `wsl.exe -l -q`, a `$HOME` that is empty or not an absolute path,
43
+ or no absolute shell from the `getent passwd` / `$SHELL` probes. The detection
44
+ lives in `src/wsl.ts`.
45
+ - A distro without `bwrap`. Commands there fail closed rather than run
46
+ unconfined.
36
47
  - The upstream `@deepseek-ai/*` packages. Report those to their own projects.
37
48
  - Third-party npm mirrors. Install from the registry the package was published to.
38
49
 
39
50
  ## Compatibility expectations
40
51
 
41
52
  The upstream packages are a release candidate, so the peer versions are pinned
42
- exactly. One example is `@deepseek-ai/dsh-fs: 0.2.0-rc.2`. An upstream change can
53
+ exactly. One example is `@deepseek-ai/dsh-fs: 0.2.1-rc.2`. An upstream change can
43
54
  require a new release of this plugin. The exact pins are what make that break
44
55
  loudly instead of quietly.
45
56
 
@@ -47,14 +58,19 @@ There is no long-term support branch. Fixes go to the latest version.
47
58
 
48
59
  ## What a good bug report contains
49
60
 
61
+ The one-command path: `pnpm run diagnose` (from a checkout of the plugin, or
62
+ `bash scripts/diagnose.sh`) collects most of the table below — Windows build,
63
+ WSL status, distro, kernel, package family, the four tools with their versions,
64
+ and a bwrap functional probe — as a paste-ready report. Run it first, then add
65
+ what it cannot see:
66
+
50
67
  | Item | How to get it |
51
68
  |---|---|
69
+ | The diagnose report | `pnpm run diagnose -- <distro>` (read-only; safe to paste) |
52
70
  | Plugin version | `npm ls dsh-plugin-wsl-env`, or the git tag you built from |
53
71
  | DSH version | the version of the harness you run |
54
- | Distro and kernel | `wsl.exe -l -v`, and `uname -r` inside the distro |
55
- | Windows build | `winver` |
56
72
  | The composed configuration | `dsh --profile <name> --dump-config`, in particular the `dsh-plugin-wsl-env` layer and the rows it patches |
57
- | Sandbox behaviour | the output of `npm run probe:sandbox`, when the sandbox is involved |
73
+ | Sandbox behaviour, in depth | the output of `npm run probe:sandbox`, run from inside the distro (`wsl.exe -d <distro>` first) — the probe calls `bwrap` and reads `lib/bwrap.js` there, so it needs both in the distro |
58
74
  | What you expected and what happened | one paragraph each |
59
75
  | A reproduction | the exact command, or the exact sequence of clicks |
60
76
 
@@ -63,7 +63,7 @@
63
63
 
64
64
  set -u
65
65
 
66
- PROTO_VERSION=4
66
+ PROTO_VERSION=5
67
67
  TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
68
68
  OUT_FILE="$TMPDIR_AGENT/out"
69
69
  ERR_FILE="$TMPDIR_AGENT/err"
@@ -108,7 +108,7 @@ case ${DSH_AGENT_LEASE_MS:-} in
108
108
  kill -0 "$$" 2>/dev/null || exit 0
109
109
  [ -f "$TMPDIR_AGENT/inflight" ] || kill -s TERM "$$"
110
110
  done
111
- ) &
111
+ ) </dev/null >/dev/null 2>&1 &
112
112
  WATCHDOG_PID=$!
113
113
  fi ;;
114
114
  esac
@@ -164,7 +164,22 @@ shutdown() {
164
164
  kill_current
165
165
  exit 0
166
166
  }
167
+
168
+ # Cancellation arrives as USR1, not as a frame: while a request runs this loop
169
+ # is blocked in `wait`, so a KILL line would sit in the pipe until the command
170
+ # ended and be dropped by the time it was read. The host delivers the signal
171
+ # from a second wsl.exe; the trap runs inside the interrupted wait and kills
172
+ # the request's whole group, TERM first and KILL after the timeout watcher's
173
+ # grace, so a command that ignores TERM still cannot outlive the cancel.
174
+ cancel_current() {
175
+ [ -n "${CURRENT_PID:-}" ] || return 0
176
+ cancel_pid=$CURRENT_PID
177
+ kill_request TERM "$cancel_pid"
178
+ ( sleep 3; kill_request KILL "$cancel_pid" ) </dev/null >/dev/null 2>&1 &
179
+ }
180
+
167
181
  trap shutdown TERM INT HUP
182
+ trap cancel_current USR1
168
183
 
169
184
  # Whether EXEC requests can run as their own session. `setsid` makes the
170
185
  # request process a session AND process-group leader (pid = pgid), which is
@@ -199,6 +214,7 @@ timeout_watcher() {
199
214
  # functions, not executables — run.
200
215
  run_capture() {
201
216
  local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
217
+ local out_fifo err_fifo out_reader err_reader spawn_out spawn_err killer_pid
202
218
  req_id=$1
203
219
  timeout_s=$2
204
220
  maxout=$3
@@ -209,24 +225,57 @@ run_capture() {
209
225
  esac
210
226
  : >"$OUT_FILE"
211
227
  : >"$ERR_FILE"
228
+ # With an output budget, capture through a FIFO drained by a bounded reader:
229
+ # a bounded number of 4 KiB blocks lands in the file, the rest is discarded,
230
+ # so a chatty command cannot fill the distro's /tmp. `dd` writes each block
231
+ # as it arrives (unlike `head`, whose buffered output is lost when the
232
+ # reader is stopped), and a block or two of slack is what tells "cut" from
233
+ # "exactly at the cap". A piped capture is still not a TTY, and the reader
234
+ # drains rather than closing the pipe, so the command does not see SIGPIPE.
235
+ # Without a budget (FS frames) the files are used directly, as before.
236
+ out_fifo=
237
+ err_fifo=
238
+ out_reader=
239
+ err_reader=
240
+ spawn_out=$OUT_FILE
241
+ spawn_err=$ERR_FILE
242
+ if [ "$maxout" -gt 0 ]; then
243
+ out_fifo="$TMPDIR_AGENT/out.fifo"
244
+ err_fifo="$TMPDIR_AGENT/err.fifo"
245
+ rm -f -- "$out_fifo" "$err_fifo"
246
+ if mkfifo -- "$out_fifo" "$err_fifo" 2>/dev/null; then
247
+ ( dd bs=4096 count="$((maxout / 4096 + 2))" of="$OUT_FILE" 2>/dev/null; cat >/dev/null ) <"$out_fifo" 2>/dev/null &
248
+ out_reader=$!
249
+ ( dd bs=4096 count="$((maxout / 4096 + 2))" of="$ERR_FILE" 2>/dev/null; cat >/dev/null ) <"$err_fifo" 2>/dev/null &
250
+ err_reader=$!
251
+ spawn_out=$out_fifo
252
+ spawn_err=$err_fifo
253
+ else
254
+ rm -f -- "$out_fifo" "$err_fifo"
255
+ fi
256
+ fi
212
257
  CURRENT_ID=$req_id
213
258
  # The inflight marker pauses the lease watchdog for this request and
214
259
  # refreshes the temp dir's mtime for the boot sweep; removing it after the
215
260
  # answer re-arms both.
216
261
  : >"$TMPDIR_AGENT/inflight"
217
262
  if [ "$mode" = subshell ]; then
218
- ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
263
+ ( "$@" ) >"$spawn_out" 2>"$spawn_err" &
219
264
  elif [ -n "$SETSID" ]; then
220
- ( exec setsid "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
265
+ ( exec setsid "$@" ) >"$spawn_out" 2>"$spawn_err" &
221
266
  else
222
- "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
267
+ "$@" >"$spawn_out" 2>"$spawn_err" &
223
268
  fi
224
269
  CURRENT_PID=$!
270
+ # Dispatched: the child exists now, so the host may arm its execution
271
+ # watchdog AND deliver USR1 to cancel it. Announcing earlier would race a
272
+ # signal against CURRENT_PID and lose the cancellation.
273
+ printf 'ACK|%s\n' "$req_id"
225
274
  watcher_pid=
226
275
  case $timeout_s in
227
276
  ''|0) ;;
228
277
  *)
229
- timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
278
+ timeout_watcher "$timeout_s" "$CURRENT_PID" 3 </dev/null >/dev/null 2>&1 &
230
279
  watcher_pid=$!
231
280
  ;;
232
281
  esac
@@ -238,11 +287,31 @@ run_capture() {
238
287
  kill "$watcher_pid" 2>/dev/null
239
288
  wait "$watcher_pid" 2>/dev/null
240
289
  fi
290
+ # Wait for the bounded readers to drain what the request wrote, but not for
291
+ # a background descendant that still holds the FIFO: after a short grace the
292
+ # killer stops them. A normal request's readers see EOF when the command
293
+ # exits, so the wait returns at once and the killer is cancelled — this is
294
+ # also what gives a just-exited command's output a chance to reach the file
295
+ # before the reader is stopped.
296
+ if [ -n "$out_reader" ] || [ -n "$err_reader" ]; then
297
+ (
298
+ sleep 1
299
+ [ -z "$out_reader" ] || kill "$out_reader" 2>/dev/null
300
+ [ -z "$err_reader" ] || kill "$err_reader" 2>/dev/null
301
+ ) </dev/null >/dev/null 2>&1 &
302
+ killer_pid=$!
303
+ [ -z "$out_reader" ] || wait "$out_reader" 2>/dev/null
304
+ [ -z "$err_reader" ] || wait "$err_reader" 2>/dev/null
305
+ kill "$killer_pid" 2>/dev/null
306
+ wait "$killer_pid" 2>/dev/null
307
+ fi
241
308
  # The cap is what keeps a chatty command from becoming a multi-gigabyte
242
309
  # base64 blob inside THIS shell's command substitution: only the first
243
310
  # $maxout bytes of each stream are encoded, and the flags tell the host
244
- # which streams were cut. FS frames pass 0 — their payloads are the host's
245
- # own read windows, already bounded by the protocol.
311
+ # which streams were cut. The bounded reader kept the file within a block or
312
+ # two of the budget, so the size check is still a sound cut signal. FS frames
313
+ # pass 0 — their payloads are the host's own read windows, already bounded by
314
+ # the protocol.
246
315
  out_cut=0
247
316
  err_cut=0
248
317
  if [ "$maxout" -gt 0 ]; then
@@ -564,7 +633,7 @@ fs_dispatch() {
564
633
  esac
565
634
  }
566
635
 
567
- printf 'HELLO|wsl-agent|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST"
636
+ printf 'HELLO|wsl-agent|%s|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST" "$$"
568
637
 
569
638
  while IFS= read -r line; do
570
639
  case $line in
@@ -636,9 +705,8 @@ while IFS= read -r line; do
636
705
  printf 'ERR|%s|protocol|%s\n' "$req_id" "$(printf '%s' "an argv payload failed to decode" | base64 | tr -d '\n')"
637
706
  continue
638
707
  fi
639
- # Dispatched: tell the host now, so its watchdog measures execution and
640
- # not the queue wait this request just went through.
641
- printf 'ACK|%s\n' "$req_id"
708
+ # Dispatched: run_capture prints the ACK once the child exists, so the
709
+ # host's watchdog and USR1 cancel both target a real process.
642
710
  run_capture "$req_id" "$timeout_s" "$maxout" exec "$@"
643
711
  ;;
644
712
  FS\|*)
@@ -660,10 +728,9 @@ while IFS= read -r line; do
660
728
  n=$((n + 1))
661
729
  set -- "$@" "$arg_b64"
662
730
  done
663
- # Same dispatch signal as EXEC: the FS budget starts at the op, not at
664
- # the queue position. No capture cap — the payloads are the host's own
665
- # read windows, bounded by the protocol.
666
- printf 'ACK|%s\n' "$req_id"
731
+ # Same dispatch signal as EXEC: run_capture ACKs after the child exists.
732
+ # No capture cap — the payloads are the host's own read windows, bounded
733
+ # by the protocol.
667
734
  run_capture "$req_id" "$timeout_s" 0 subshell fs_dispatch "$op" "$@"
668
735
  ;;
669
736
  *)