dsh-plugin-wsl-env 0.9.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 (44) hide show
  1. package/CHANGELOG.md +417 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +84 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +53 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +97 -74
  15. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  16. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  17. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  18. package/lib/agent-protocol.d.ts +10 -4
  19. package/lib/agent-protocol.js +10 -3
  20. package/lib/agent.d.ts +84 -0
  21. package/lib/agent.js +167 -15
  22. package/lib/bwrap.d.ts +8 -0
  23. package/lib/bwrap.js +4 -1
  24. package/lib/fs-routing.d.ts +40 -4
  25. package/lib/fs-routing.js +80 -12
  26. package/lib/fsio-agent.js +1 -1
  27. package/lib/index.d.ts +134 -0
  28. package/lib/index.js +191 -3
  29. package/lib/native-agent.d.ts +124 -0
  30. package/lib/native-agent.js +198 -0
  31. package/lib/ports.d.ts +13 -2
  32. package/lib/ports.js +18 -5
  33. package/lib/preflight.js +24 -1
  34. package/lib/session-runtime.d.ts +161 -0
  35. package/lib/session-runtime.js +220 -0
  36. package/lib/session-scope.d.ts +124 -0
  37. package/lib/session-scope.js +191 -0
  38. package/lib/shell-env.js +10 -3
  39. package/lib/watcher.d.ts +25 -12
  40. package/lib/watcher.js +120 -32
  41. package/lib/workspace-files-route.d.ts +8 -4
  42. package/lib/workspace-files-route.js +42 -14
  43. package/lib/workspace-files-wsl.js +25 -8
  44. package/package.json +34 -25
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,77 +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
- **Before the first command, give the distro bubblewrap.** Every command runs confined by [bubblewrap](#sandbox), which most distros do not preinstall, and without it every command fails closed — by design, never unconfined:
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
31
  ```powershell
32
32
  wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap # Debian/Ubuntu
33
33
  ```
34
34
 
35
- From a checkout of this plugin, `pnpm run bootstrap -- <distro>` checks all four tools the plugin uses (bubblewrap, ripgrep, git, inotify-tools) and prints the install command for the distro's own package family; add `--install` to run it. A skipped step announces itself: when a distro session opens, the plugin probes bubblewrap and warns with the exact remedy — the first command never has to be the discovery moment.
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
36
 
37
- 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.
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.
38
40
 
39
41
  Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
40
42
 
41
- > **Changed anything under `lib/`? Sync the mirror, then restart the app.** The app loads the Windows-side runtime mirror, so run `pnpm run sync:windows` first — a restart without the sync just reloads the old code; a running process also caches ES modules. See [Development](#development).
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).
42
44
 
43
45
  ## Using it
44
46
 
45
- 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
57
+
58
+ Three ways for a Windows coding tool to work on a WSL project:
46
59
 
47
- - **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.
48
- - **Commands** run inside the distro in your login shell, so your `PATH`, `nvm`, `cargo`, `pyenv` and rc files apply; the shell is not hardcoded to bash. The default is the resident in-distro agent — one long-lived process per distro; while it is out (or with `agent: false`) the same command falls back to one `wsl.exe -d <distro> --cd <linux dir> --exec <your login shell> -lc <command>` process.
49
- - **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.
50
- - **The terminal** (right sidebar, then *New terminal*) opens a shell inside the distro, in the session's folder.
51
- - **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.
52
- - **The model sees its shell environment.** The plugin registers `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, `DSH_WSL_HOME` and the port snapshot `DSH_WSL_PORTS` in the managed `DSH_*` namespace.
53
- - **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 retry it once with the narrowest wider mode that suffices; if you approve, that one call runs in the approved mode — from `read-only` that is usually `workspace-write`, still inside the sandbox, and only an escalation to `danger-full-access` runs without one.
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.
54
73
 
55
74
  ## Configure
56
75
 
57
- 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:
58
77
 
59
78
  | Row | Key | Default | Meaning |
60
79
  |---|---|---|---|
61
- | `wsl-shell` | `distro` | `''` | distro name; empty means WSL's default distro |
62
- | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
63
- | `wsl-fs` | `distro` | `''` | as above |
64
- | | `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 |
65
- | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
66
- | | `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 |
67
- | `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 |
68
90
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
69
91
 
70
- `wsl-shell` and `wsl-fs` are the two rows a by-id patch **cannot** reach: they are plugins of the `wsl` agent preset, nested inside the `preset-wsl` row's `config.plugins`, so `- id: wsl-shell` in your layer changes nothing — the loader only warns `patch: entry "wsl-shell" not found` and the shipped value stays in force. Override the `preset-wsl` row as a whole instead; [docs/CONFIGURATION.md](docs/CONFIGURATION.md#overriding-the-rows-inside-preset-wsl) has the recipe.
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.
71
93
 
72
- [`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.
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.
73
95
 
74
96
  ## Recipes
75
97
 
76
- - **Git credential sharing**: let git inside the distro use the Windows-side Git
77
- Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
78
- (adjust the path to your Windows Git install).
79
- - **Paths and performance**: the model sees and operates on Linux paths
80
- (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
81
- 9p — noticeably slow for many small files; keep heavy-IO projects on the
82
- distro filesystem. `pnpm run bootstrap -- <distro>` also reports whether
83
- ripgrep, git and inotifywait (the search, snapshot and watch backends) are in place.
84
- - **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
85
- This plugin admits the managed `DSH_*` namespace by prefix, translating the
86
- two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
87
- 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.
88
101
 
89
102
  ## Architecture
90
103
 
91
- 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:
92
105
 
93
106
  ```text
94
107
  composition (app level, one per process)
@@ -102,28 +115,30 @@ composition (app level, one per process)
102
115
  ├─ wsl-shell-env the DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS facts the model sees
103
116
  └─ auto-preset binds the wsl preset when a session opens a distro folder
104
117
 
105
- 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)
106
119
  ├─ wsl-shell ctx.shell — wsl.exe --exec <login shell>, confined by bubblewrap inside the distro
107
120
  └─ wsl-fs ctx.fs — real distro files on the resident agent substrate (ext4)
108
121
  ```
109
122
 
110
- **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. `workspace-files-wsl` and `fs-routing` are composition-level for the mirror-image reason: the GUI file tree and the root `ctx.fs` are session-less, so they cannot be served from a preset. They replace the shipped `workspace-files` and root `fs-sandbox` rows outright; [docs/root-fs-routing.md](docs/root-fs-routing.md) is the design of the root-plane routing.
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.
111
126
 
112
- **Commands and files.** By default a command runs over the resident in-distro agent — one long-lived process per distro, which carries the cwd as part of the request instead of a per-command `--cd`. What it runs is the same `<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; while the agent is out, or with `agent: false`, the command falls back to one `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>` process. 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.
113
128
 
114
129
  ## Sandbox
115
130
 
116
- 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.
117
132
 
118
133
  | Mode | What a command inside the distro can do |
119
134
  |---|---|
120
- | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
121
- | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
122
- | `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 |
123
138
 
124
- **`bubblewrap` is required, and it fails closed.** Without it every confined command reports `SANDBOX_UNAVAILABLE` instead of running unconfined. To opt out, set `sandbox: false` on the row that owns the operation — `wsl-shell` for commands, `wsl-fs` for the write fence, and the top-level `fs-routing` row for its own copy. Note that the two preset rows are reached through `preset-wsl`, not by id (see [Configure](#configure)). 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.
125
140
 
126
- **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. `maskWindowsDrive: true` on the `wsl-shell`, `wsl-fs` and `fs-routing` rows narrows it — the confined profile shadows `/mnt` with an empty tmpfs, so the drive's files cannot be read or exfiltrated and its executables cannot be launched — but does not close it: a command can still write an executable into the workspace and run it, so enforcement stays `partial`. The complete closure is distro-level, `[interop] enabled=false` in `wsl.conf` (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md)).
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)).
127
142
 
128
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.
129
144
 
@@ -131,23 +146,23 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
131
146
 
132
147
  | Symptom | Cause | Fix |
133
148
  |---|---|---|
134
- | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is missing from the distro, or present but unusable — the error text distinguishes the two and carries the matching remedy | follow the remedy in the error, or set `sandbox: false` on `wsl-shell` (commands) and `wsl-fs` (writes) — see [Configure](#configure) for how to reach those rows |
135
- | 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 |
136
151
  | writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
137
- | `dsh plugin add` prints no confirmation that a layer was added | the dependency is already installed and the bundle list is unchanged, so `add` reconciles to the same manifest without reporting anything — that is the success path, not a failure | nothing to do; the layer is already in place, and `dsh --profile wsl --dump-config` still shows `# == dsh-plugin-wsl-env` |
138
- | 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 }` |
139
- | changes to `lib/` have no effect | the app loads the Windows-side runtime mirror, and a running process also caches ES modules | run `pnpm run sync:windows` to bring the mirror up to date, then restart the app |
140
- | `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) |
141
- | `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 |
142
- | 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 |
143
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) |
144
159
 
145
160
  ## Development
146
161
 
147
162
  ```bash
148
- pnpm run build # compile src/ into lib/ (the artifact is committed; rebuild after editing src/)
163
+ pnpm run build # compile src/ into lib/ (the built files are committed; rebuild after editing src/)
149
164
  pnpm test # style checks, syntax pass, build consistency, type check, and unit tests
150
- pnpm run sync:windows # mirror the checkout onto the Windows-side copy the app loads (after any lib/ change, then restart the app)
165
+ pnpm run sync:windows # copy the checkout onto the Windows-side copy the app loads (after any lib/ change, then restart the app)
151
166
  pnpm run test:coverage # the unit tests with coverage thresholds (Node 22.8+)
152
167
  pnpm run diagnose # read-only diagnostic report for a bug issue: versions, tools, bwrap probe
153
168
  pnpm run bootstrap -- <distro> # check the four distro tools; add --install to install what is missing
@@ -155,29 +170,30 @@ pnpm run probe:sandbox # measure inside the distro what bubblewrap does a
155
170
  pnpm run probe # filesystem probe against a real distro (Windows + WSL only)
156
171
  pnpm run probe:sandbox-shell # boot a real harness and drive the confined executor
157
172
  pnpm run probe:terminal # open a PTY through the terminal provider
158
- 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)
159
174
  pnpm run probe:watch # arm the in-distro watcher over a real directory (from inside the distro)
160
- pnpm run probe:agent # the resident-vs-one-shot fallback parity legs (from inside the distro)
161
- 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)
162
177
  pnpm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
163
178
  pnpm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
164
179
  pnpm run probe:mode # which POSIX-mode facts survive the share (needs a Windows node and an installed harness; opens no profile)
165
180
  pnpm run probe:sandbox-off # prove sandbox: false unconfines both providers (Windows + WSL only)
166
181
  ```
167
182
 
168
- The app loads the Windows-side runtime mirror, not the repository checkout: after changing anything under `lib/`, run `pnpm run sync:windows` before restarting the app — otherwise the restart just reloads the old code. `pnpm run build` regenerates `lib/` from `src/`; the artifact is committed, and the build-freshness check inside `pnpm test` verifies it.
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.
169
184
 
170
- One `pnpm install` reproduces the pinned development dependencies before the first run: the 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`).
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.
171
186
 
172
- [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 runtime mirror, 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.
173
188
 
174
189
  ## Documentation
175
190
 
176
191
  | Document | What it covers |
177
192
  |---|---|
178
193
  | [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | every configuration key, and how to override it |
179
- | [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 |
180
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 |
181
197
  | [CONTRIBUTING.md](CONTRIBUTING.md) | the development loop, gates, conventions, verification |
182
198
  | [SECURITY.md](SECURITY.md) | reporting a vulnerability privately |
183
199
  | [SUPPORT.md](SUPPORT.md) | supported versions, and where to ask |
package/SUPPORT.md CHANGED
@@ -5,8 +5,8 @@
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
11
  | Node | 22.19+ (`^22.19.0`), or 24 and later (`>=24.0.0`) |
12
12
 
@@ -50,7 +50,7 @@ issue for one.
50
50
  ## Compatibility expectations
51
51
 
52
52
  The upstream packages are a release candidate, so the peer versions are pinned
53
- 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
54
54
  require a new release of this plugin. The exact pins are what make that break
55
55
  loudly instead of quietly.
56
56
 
@@ -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
  *)