dsh-plugin-wsl-env 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,274 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ The reasoning and the measurements behind each entry are in the archived
8
+ engineering record,
9
+ [docs/archive/engineering-record.zh.md](docs/archive/engineering-record.zh.md)
10
+ (Chinese, repository only — it is history, never a second README). Every `§`
11
+ reference below points at that record's numbering.
12
+
13
+ ## [Unreleased]
14
+
15
+ ## [0.1.0] - 2026-10-02
16
+
17
+ First release. A WSL distro can be picked as a workspace, read and written
18
+ with the model's own file tools, and run commands in; the GUI terminal opens
19
+ inside it; commands are confined by a Linux-side sandbox built in the distro;
20
+ and the package installs as a bundle that contributes its own profile layer.
21
+
22
+ ### Added
23
+
24
+ - **Distro-side sandboxing** (`lib/sandbox.js`, and `sandbox: true` on both
25
+ providers): WSL commands are now confined instead of running unconfined. The
26
+ Windows ACL runner can never reach WSL — its restricted token fails `wsl.exe`
27
+ with `Wsl/E_ACCESSDENIED` — so the confinement is built on the host and
28
+ executed *inside* the distro: `wsl.exe … --exec bwrap --ro-bind / / --dev /dev
29
+ --unshare-pid --proc /proc --die-with-parent [--tmpfs /tmp --bind <workspace>
30
+ <workspace>] -- <shell> -lc <cmd>`. The profile arguments, the denial dialect
31
+ (`read-only file system`) and the runner-failure rules mirror
32
+ `@deepseek-ai/dsh-sandbox-local`'s Linux rung, so a denial reads the same
33
+ whether the command ran on a Linux host or in a distro. `WslShellExecutor` and
34
+ `WslFileSystem` advertise the mode through their `sandboxMode` capability fact,
35
+ which brings back the Permissions selector and the denied→escalate flow
36
+ (`danger-full-access` skips the wrap entirely). Enforcement is reported as
37
+ **`partial`**, not `full`: a distro process can still execute a Windows binary
38
+ through interop, which bubblewrap does not govern — measured, not assumed, by
39
+ the probe below. Without `bubblewrap` in the distro the providers fail closed
40
+ with `SANDBOX_UNAVAILABLE`; `sandbox: false` restores the old posture and
41
+ removes the capability fact with it.
42
+ - Sandbox probes. `test/probe/sandbox.sh` (`npm run probe:sandbox`) needs no
43
+ harness: it applies the exact profile arguments and asserts what bubblewrap
44
+ governs — a write outside the workspace refused with EROFS, an ephemeral
45
+ sandbox `/tmp`, a read-only `/mnt/c`, and the interop escape, recorded as
46
+ `INFO` because a Linux sandbox cannot govern a Windows process.
47
+ `test/probe/sandbox-shell.sh` (`npm run probe:sandbox-shell`) boots the
48
+ throwaway profile with the executor mounted and drives the same three modes
49
+ through `ctx.shell`, asserting the result's `denied`/`enforcement` facts.
50
+ - `WslSubprocessRuntime` (`ctx.subprocess`, subpath
51
+ `dsh-plugin-wsl-env/subprocess`): the GUI's right-sidebar terminal window now
52
+ opens a shell *inside the distro*, in the Session workspace, instead of
53
+ `cmd.exe` in a UNC directory. It is a `LocalSubprocessRuntime` subclass whose
54
+ only override is `spawnTerminal`, so every ordinary command, the host ripgrep
55
+ search and every other subprocess consumer keeps the shipped implementation.
56
+ The `wsl.exe` launch is rewritten to `wsl.exe -d <distro> --cd <linux dir>`,
57
+ with the request's `DSH_*` facts forwarded through `WSLENV`, and the distro's
58
+ own login shell is left to `wsl.exe` unless the provider pins one.
59
+ - Terminal probe (`test/probe/terminal.sh`, `npm run probe:terminal`): boots the
60
+ throwaway Web profile with the provider in place, asks for the same
61
+ `spawnTerminal` request the GUI makes, and asserts the distro, the translated
62
+ initial directory, and the forwarded `DSH_SESSION_ID`.
63
+
64
+ - `WslShellExecutor` (`ctx.shell`): runs every command inside the distro as
65
+ `wsl.exe -d <distro> --cd <linux dir> --exec <shell> -lc <cmd>`, where the
66
+ shell is the distro user's login shell rather than a hardcoded bash. (§3, §12)
67
+ - `WslFileSystem` (`ctx.fs`): maps Linux paths onto the distro's UNC share so the
68
+ host fs stack — including the packaged ripgrep — operates on real distro files,
69
+ with Linux paths as the model-facing `displayPath` and the UNC path as the
70
+ opaque `targetKey`. (§3)
71
+ - Symlinked paths resolve by asking the distro for `readlink -f` and retrying
72
+ once, which is what makes `/etc/os-release`, `/bin` and `/lib` readable over a
73
+ share that cannot traverse POSIX symlinks. (§10.4)
74
+ - `WslDirectoryPicker` (`ctx.directoryPicker`): lists Windows home and every
75
+ installed distro at the root level, so the shipped GUI dialog becomes the
76
+ single workspace picker for host and distro folders alike. It reports
77
+ `kind: 'browse'` — a third kind would invalidate the three Remote verbs that
78
+ require it. (§8)
79
+ - `auto-preset`: binds the `wsl` agent preset when a new session's workspace is
80
+ inside a distro, inside the `ensureSession` frame so the very first mount is
81
+ already correct and the host ACL sandbox never touches a 9p path. (§13, §15)
82
+ - `shell-env`: contributes `DSH_WSL_DISTRO`, `DSH_WSL_SHELL` and `DSH_WSL_HOME`
83
+ to the managed `DSH_*` namespace, so the model can read the real environment
84
+ instead of inferring it from a tool name. (§14)
85
+ - Behavioural probe harness under `test/probe/`, with two throwaway profiles and
86
+ a negative-control run. (§16.5)
87
+ ### Changed
88
+
89
+ - `WslFileSystem` fences its mutations by `ctx.sandboxPolicy`: `writeText` and
90
+ `editText` check the policy before any I/O, re-resolve the target so the checked
91
+ identity is the mutated one, refuse `read-only` and anything outside the
92
+ writable roots with `FS_SANDBOX_DENIED`, and refuse nothing at all under
93
+ `danger-full-access`. The writable roots are the workspace plus the temp area of
94
+ the world that workspace lives in — a distro workspace grants the distro's
95
+ `/tmp`, a Windows-folder workspace keeps upstream's `/tmp` + `os.tmpdir()`.
96
+ `@deepseek-ai/dsh-sandbox` returns as a peer: the plugin imports the shared
97
+ `canonicalPath`/`writableRoots` and the runner-failure/diagnostic classifiers
98
+ rather than restating them.
99
+ - The README's "one constraint that shapes everything" section is gone: it said
100
+ the plugin inherits `sandboxMode === undefined`, which is no longer true. The
101
+ replacement states the mechanism (Linux bubblewrap inside the distro), the mode
102
+ table, the `partial` enforcement and its interop cause, the `bubblewrap`
103
+ requirement and the fail-closed behaviour.
104
+ - An empty `cwd` now means **the distro user's home** in both WSL providers,
105
+ instead of the host path the shipped default resolves to. `WslFileSystem`'s
106
+ default was `process.cwd()` — a Windows directory no `wsl.exe --cd` accepts —
107
+ and `cordis.patch.yml` papered over it with the author's own `/home/andy`, a
108
+ value that is wrong on every other machine. `WslShellExecutor.resolve()` keeps
109
+ the seam's synchronous contract (`ctx.shell.execute(ctx.shell.resolve(…))` is
110
+ called with no `await` in between) and marks the case with an empty `workdir`;
111
+ `execute()` fills it from the distro's `$HOME`, one cached `wsl.exe` query per
112
+ provider instance. Only relative input consults the base at all, so an
113
+ absolute path never pays for the query, and `DSH_WSL_HOME` now takes the POSIX
114
+ home directly (`linuxHomePath`) instead of converting the world path back. The
115
+ `wslfs` probe follows: its profile no longer pins a `cwd`, and `fs-probe.mjs`
116
+ resolves a relative name and asserts it lands under the home the distro itself
117
+ reports — which is the only place that fallback was reachable at all, since the
118
+ tool layer always passes an absolute Session directory.
119
+ - The package is now a DSH **bundle**, so installing it applies the layer instead
120
+ of merely dropping code into a profile. `package.json` declares
121
+ `dsh.bundle.patch` → `./cordis.patch.yml`, which is the declaration
122
+ `dsh plugin --profile <name> add dsh-plugin-wsl-env` reads; `private: true` is
123
+ gone (npm refuses to publish it otherwise), and `repository`/`homepage`/`bugs`
124
+ now point at the GitHub repository. The package is plain JS with no
125
+ dependencies, so a published install needs no `allowBuilds` permission and no
126
+ build step. README's Install section follows: create the profile from the Web
127
+ template, `dsh plugin … add`, then `--dump-config`.
128
+ - `cordis.patch.yml` is now only the plugin's own layer. The four machine-local
129
+ rows it used to carry — `agent-default-model`, `ui-settings-account`,
130
+ `ui-chat`, `ui-settings` — moved to
131
+ [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml), which
132
+ documents what a reader's own `$DSH_HOME/profiles/<name>/cordis.patch.yml` is
133
+ for and what a by-id row override looks like.
134
+ - The terminal is bound at the **composition** level, not in the `wsl` preset.
135
+ `dsh-api-terminal-controller` resolves its execution world with
136
+ `agent.ctx.get("subprocess")`, and an Agent's context is created by the agent
137
+ loop under the root realm (`createScope(loopCtx, …)`, `loopCtx` = the
138
+ root-mounted `ctx.agents`), while a preset's isolate realms belong to
139
+ `dsh-agent-preset-registry`'s own context (`createScope(this.owner, …)`). A
140
+ `subprocess` provider inside `preset-wsl` is therefore invisible to the
141
+ terminal window — the registry's `agentPresets.serviceFor(agent, name)` is the
142
+ sanctioned channel for that gap, and the controller does not use it.
143
+ `cordis.patch.yml` consequently disables the shipped `subprocess` row and
144
+ inserts this provider, and configures `terminal-controller` with
145
+ `shell: { path: wsl.exe, name: WSL }` and `shellCandidates: []`.
146
+ - `@deepseek-ai/dsh-subprocess-local` is now a declared peer. A linked plugin's
147
+ bare imports are routed through its `peerDependencies` by the runtime's
148
+ resolution interception, so an undeclared peer is not merely untidy — the
149
+ module fails to import with `failed to import`.
150
+ - The README is now **current state only**. The five sections describing designs
151
+ that this one replaced — the per-process `DSH_WSL` switch and the three
152
+ abandoned tool-naming routes — moved to
153
+ [docs/archive/design-history.zh.md](docs/archive/design-history.zh.md), which
154
+ keeps their original numbering and records where each conclusion lives now.
155
+ The README renumbered accordingly; its §0.1 explains the split. The one part of
156
+ the old desktop section that is still current (regenerating the generated
157
+ `preset-wsl` block after an app upgrade) stayed, as §13.9.
158
+ - Static review: the manifest now says only what the shipped code does.
159
+ - `main` pointed at `lib/index.js` while `exports["."]` resolved to
160
+ `lib/shell.js`. Only the latter exposes a default export, so a tool that
161
+ read `main` instead of `exports` got a module with no plugin in it.
162
+ - `lib/index.js` re-exported path helpers that nothing could reach: the
163
+ `exports` map does not expose `lib/index.js`, and the helpers already have
164
+ their own `./paths` and `./wsl` subpaths.
165
+ - Dropped an unused `ENV_OVERRIDES` import.
166
+ ### Known limitations
167
+
168
+ - The sandbox does not govern WSL interop: a confined distro command can still
169
+ execute a Windows binary from `/mnt/c`, and that process runs outside
170
+ bubblewrap under the ordinary Windows token. Enforcement is therefore reported
171
+ as `partial`, and `npm run probe:sandbox` re-measures the escape. Closing it
172
+ would mean denying execution under `/mnt`, which breaks `/mnt/c/…` Sessions.
173
+ - `bubblewrap` must be installed in the distro, and both providers **fail
174
+ closed** without it (`SANDBOX_UNAVAILABLE` rather than a silent unconfined
175
+ run). `sandbox: false` is the documented opt-out. A `workspace-write` profile
176
+ also refuses to bind a workspace root that does not exist, so a Session whose
177
+ workspace directory was deleted reports a runner diagnostic instead of being
178
+ recreated.
179
+ - The GUI terminal is an interactive shell and is not wrapped in the sandbox,
180
+ exactly like the shipped terminal provider.
181
+ - The runtime mirror's destination lies outside every Session workspace, so the
182
+ sync step of `npm run probe` (and of a manual `sync-to-windows.sh`) needs an
183
+ approved `danger-full-access` escalation when an agent runs it.
184
+ - The terminal follows the composition, not the Session: a Session whose
185
+ workspace is a Windows folder gets the same distro terminal, started in that
186
+ folder as `/mnt/<drive>/…`.
187
+ - Terminal activity reporting stops at `wsl.exe`, so the controller's unattended
188
+ idle reclamation never fires for these terminals; close the tab to release the
189
+ process.
190
+
191
+ See the archived record's §7 for the full list, including the 9p
192
+ performance caveat, unsupported `watch()`, and the missing executable bit on
193
+ freshly created files.
194
+ ### Removed
195
+
196
+ - `README.zh.md` stopped being a README: the file moved to
197
+ `docs/archive/engineering-record.zh.md` and left the package's `files` list, so
198
+ npm and GitHub now render exactly one README. The reasoning is under Fixed
199
+ below; nothing was deleted, only renamed out of the slot that made it look
200
+ current.
201
+ - Three peer declarations this plugin neither imports nor injects:
202
+ `@deepseek-ai/dsh-sandbox`, `@deepseek-ai/dsh-shell` and
203
+ `@deepseek-ai/dsh-tools`. They were required by the abandoned tool-renaming
204
+ designs (archive §15/§18), which could only stub the sandbox symbols locally
205
+ (archive §15.5). The remaining set is checkable in one command —
206
+ `grep -rho 'from "@deepseek-ai/[^"]*"' lib/ | sort -u` — plus
207
+ `@deepseek-ai/dsh-subprocess` (the service `static inject` requires) and
208
+ `@deepseek-ai/cordis`, which every DSH plugin declares.
209
+ (`@deepseek-ai/dsh-sandbox` came back in this same release: the distro-side
210
+ sandbox imports `canonicalPath`/`writableRoots` and the runner diagnostics, so
211
+ the declaration is earned again rather than vestigial — and that same check is
212
+ what surfaced it, by listing the package this entry had removed.)
213
+ ### Fixed
214
+
215
+ - `lib/listing.js` built breadcrumbs with the platform-default
216
+ `dirname`/`basename` from `node:path` rather than the `win32` flavour its own
217
+ comments describe. On Linux — where the CI runners run, and where this
218
+ checkout itself lives — a UNC path parses as a single opaque POSIX segment,
219
+ so the ancestry chain collapsed to `['.', <whole path>]` and three
220
+ `test/listing.test.mjs` checks failed. The helpers serve Windows paths only;
221
+ they now call `win32.dirname`/`win32.basename` explicitly, and the suite
222
+ passes on Linux again.
223
+
224
+ Documentation that a reader would have acted on, and that was no longer true:
225
+
226
+ - The repository briefly carried two READMEs. `README.zh.md` called itself the
227
+ current description of the plugin while its conclusions had been superseded
228
+ three times over — most sharply by the sandbox work below — which is exactly
229
+ the ambiguity a second README creates. It is now
230
+ [docs/archive/engineering-record.zh.md](docs/archive/engineering-record.zh.md):
231
+ the same content, renamed out of the README slot and banner-headed as history,
232
+ with README.md the single statement of current state. Nothing was deleted from
233
+ the record; §0.1 names the conclusions that are now historical.
234
+ - §2.1 and §3.4 of that record conclude that "any WSL plugin must run on a
235
+ non-sandboxing provider" and that both providers report `sandboxMode:
236
+ undefined` (§9's recorded self-check prints exactly that). The Windows ACL
237
+ constraint in §2.1 holds and is unchanged; the *conclusion* it drew about our
238
+ own confinement does not, because the sandbox now runs inside the distro — see
239
+ the README's *Sandboxing* section.
240
+ - §9.4's three-step install ends with writing the plugin's rows into the profile's
241
+ own `cordis.patch.yml` by hand. The package is a bundle now, so that step is
242
+ gone (and the row set it describes has since gained the sandbox config); the
243
+ current install is in README.md.
244
+ - §9.6 and §10.5 claimed the `wsl` and `wsltest` profiles were still available,
245
+ and §13.6 said the same about `envweb`. All three are gone.
246
+ - The desktop section documented the superseded `build-desktop-patch.mjs`
247
+ regeneration flow instead of `build-preset-wsl.mjs`.
248
+ - §15.5 answered "nothing to do after an app upgrade" while the desktop section
249
+ said the generated preset copy has to be regenerated.
250
+ - References to `dsh-wsl-research/` now say it is a machine-local directory that
251
+ is not part of the repository.
252
+
253
+ - `wsl.exe` was handed the command without `--exec`, so the distro's default
254
+ shell expanded it once first: `$x`, `$?`, `$(...)`, `${...}` and backticks were
255
+ silently eaten. (§11)
256
+ - The shell was hardcoded to bash, discarding the PATH and rc setup of a user
257
+ whose login shell is zsh, fish or dash. (§12)
258
+ - Guarded creation (`createIfAbsent`) failed with `FS_IO_ERROR`, because the host
259
+ backend publishes it with a hard link and the share rejects hard links with
260
+ `ENOTSUP`; the guard is now checked by this backend and publication is a plain
261
+ rename. (§2.2)
262
+ - The managed `DSH_*` namespace never reached the distro. WSL imports only the
263
+ names listed in `WSLENV`, and the executor forwarded a fixed allowlist; it now
264
+ forwards the whole prefix, and marks the two Windows-path facts `DSH_HOME` and
265
+ `DSH_PROFILE_DIR` with `/p` so the distro sees `/mnt/c/...`. (§14.7)
266
+ - **Every write to an existing file, and therefore every edit, failed** with
267
+ `EIO: GetFileSecurityW EIO (Win32 1)`. `dsh-fs-local` takes a Windows branch
268
+ whenever the destination exists — to inherit the replaced file's DACL — and a
269
+ 9p share carries no Windows security descriptor. Publication now uses the POSIX
270
+ path. (§16)
271
+ - Overwriting or editing a file silently dropped its POSIX mode, so editing a
272
+ script made it non-executable: the share ignores a host-side `chmod`, and a
273
+ host-side `stat` always reports 0666. The target's real mode is now copied onto
274
+ the staged temp file inside the distro, where a rename preserves it. (§16.3)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Zhao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,314 @@
1
+ # dsh-plugin-wsl-env
2
+
3
+ Run a [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) session
4
+ against a WSL distro. Commands execute *inside* the distro, the model's file tools
5
+ read and write real distro files, and the GUI's folder dialog can open one.
6
+
7
+ > The engineering record this file summarises — design rationale, measurements,
8
+ > and every discarded design with the reason it was abandoned — is archived at
9
+ > **[docs/archive/engineering-record.zh.md](docs/archive/engineering-record.zh.md)**
10
+ > (Chinese). It is history, not a second README: where it and this file disagree,
11
+ > **this file is the current state**. Its §0.1 lists which of its conclusions the
12
+ > later sandbox work superseded.
13
+
14
+ ## What it does
15
+
16
+ DSH is a Cordis application whose capabilities are exposed as *service seams*.
17
+ The model-facing tools (`bash`, `read`, `write`, `edit`, `glob`, `grep`) consume
18
+ those seams and never touch a filesystem or a shell directly. A WSL integration
19
+ is therefore not a new tool; it is two seam providers:
20
+
21
+ | Seam | Provider | Effect |
22
+ |---|---|---|
23
+ | `ctx.shell` | `WslShellExecutor` | every command runs as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>`, wrapped in a distro-side `bwrap` sandbox |
24
+ | `ctx.fs` | `WslFileSystem` | Linux paths map onto the distro's UNC share, so the host fs stack (and the packaged ripgrep) operates on real distro files, fenced by the same policy |
25
+ | `ctx.subprocess` | `WslSubprocessRuntime` | the GUI's right-sidebar terminal window opens a shell *inside the distro*, in the Session workspace, instead of `cmd.exe` in a UNC directory |
26
+
27
+ It also ships three smaller integrations:
28
+
29
+ | Package | Provides |
30
+ |---|---|
31
+ | `dsh-plugin-wsl-env/picker` | `ctx.directoryPicker` — lists Windows home *and* every installed distro at the root level, so one dialog opens a host folder or a distro folder |
32
+ | `dsh-plugin-wsl-env/auto-preset` | binds the `wsl` agent preset when a new session's workspace is inside a distro, in the `ensureSession` frame, so the very first mount is already correct and the host ACL sandbox never touches a 9p path |
33
+ | `dsh-plugin-wsl-env/shell-env` | contributes `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, `DSH_WSL_HOME` to the managed `DSH_*` namespace the model reads |
34
+
35
+ ## Why the terminal provider sits at the composition level
36
+
37
+ `ctx.shell` and `ctx.fs` are served *per Session*: they live in the `wsl` agent
38
+ preset's isolate realm, so a host workspace keeps the shipped sandboxed
39
+ PowerShell environment while a WSL workspace gets the distro, concurrently.
40
+
41
+ The terminal window cannot be served that way, and the reason is not visible in
42
+ the profile YAML. `dsh-api-terminal-controller` resolves its execution world from
43
+ the Session's own context — `agent.ctx.get("subprocess")` — and an Agent's
44
+ context is created by the agent loop under the **root** realm
45
+ (`createScope(loopCtx, …)`, where `loopCtx` is the root-mounted `ctx.agents`),
46
+ while a preset's isolate realms are created by `dsh-agent-preset-registry` under
47
+ the **registry's** context (`createScope(this.owner, …)`). The two subtrees never
48
+ meet, so a `subprocess` provider mounted inside `preset-wsl` is invisible to the
49
+ terminal window. The registry ships `agentPresets.serviceFor(agent, name)` for
50
+ exactly that gap; the terminal controller does not use it.
51
+
52
+ So [`cordis.patch.yml`](cordis.patch.yml) replaces the composition-level
53
+ `subprocess` row instead, with a subclass whose only override is
54
+ `spawnTerminal`: a `wsl.exe` launch is rewritten to
55
+ `wsl.exe -d <distro> --cd <linux dir>`, and everything else — every ordinary
56
+ `spawn()`, the host ripgrep search, the pwsh executor, the LSP host — reaches the
57
+ shipped implementation untouched.
58
+
59
+ Consequence worth stating plainly: the terminal follows the **composition**, not
60
+ the Session. A Session whose workspace is a Windows folder gets the same distro
61
+ terminal, started in that folder as `/mnt/<drive>/…`. That is the honest limit of
62
+ a per-session execution world that no Session identity reaches; and because
63
+ `terminalEnvironment()` and `resolveExecutable()` receive no Session at all, the
64
+ controller is configured with the single shell profile that matters
65
+ (`shell: { path: wsl.exe }`) rather than having executable lookup rewritten for
66
+ every root consumer to make one shell menu prettier.
67
+
68
+ ## Sandboxing: a different mechanism, not the Windows one
69
+
70
+ On Windows, DSH confines commands with `dsh-sandbox-windows-acl`: a restricted,
71
+ low-integrity token plus a write allowlist. **That token cannot reach WSL at
72
+ all** — `wsl.exe` fails with `Wsl/E_ACCESSDENIED` and `\\wsl.localhost\<distro>`
73
+ reports access denied. Both work normally outside the sandbox. There is no way
74
+ to run a distro command under that token, so the WSL execution world cannot
75
+ inherit the Windows sandbox.
76
+
77
+ It gets the Linux one instead. `lib/sandbox.js` builds a **bubblewrap** profile
78
+ on the host and hands it to `wsl.exe --exec`, so the confinement is created and
79
+ enforced *inside* the distro:
80
+
81
+ ```
82
+ wsl.exe -d <distro> --cd <linux dir> --exec bwrap \
83
+ --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent \
84
+ [--tmpfs /tmp --bind <workspace> <workspace>] -- <shell> -lc <cmd>
85
+ ```
86
+
87
+ That is DSH's own Linux rung, argument for argument (`dsh-sandbox-local`), which
88
+ is why the semantics and the diagnostics line up with a Linux host:
89
+
90
+ | Mode | What the distro command gets |
91
+ |---|---|
92
+ | `read-only` | the whole distro read-only, `/dev/null` writable — the sink a shell needs |
93
+ | `workspace-write` | the above plus the Session workspace bound read-write and an ephemeral `/tmp` |
94
+ | `danger-full-access` | no wrap at all; the approved escalation |
95
+
96
+ `WslFileSystem` fences the same policy on its own mutation path (`writeText`,
97
+ `editText`) against the same writable roots, so "the write tool cannot write
98
+ /tmp but bash can" asymmetries do not arise. Both providers report the mode
99
+ through their `sandboxMode` capability fact, which is what brings back the
100
+ Permissions selector and the *denied → request escalation* flow.
101
+
102
+ **Enforcement is `partial`, and this is the honest part.** A WSL distro process
103
+ can execute a *Windows* binary through interop (`/mnt/c/…/*.exe`). That process
104
+ is not a Linux process: bubblewrap does not govern it, and it runs under your
105
+ ordinary Windows token, able to write anywhere you can. `npm run probe:sandbox`
106
+ demonstrates exactly that, and keeps demonstrating it. Closing the hole means
107
+ denying execution under `/mnt`, which would also break `/mnt/c/…` Sessions — so
108
+ it is stated rather than papered over. Network and process visibility are
109
+ outside `SandboxMode`'s vocabulary on every platform.
110
+
111
+ Two consequences worth knowing:
112
+
113
+ - **`bubblewrap` is required in the distro** (`sudo apt install bubblewrap`).
114
+ Without it the provider *fails closed*: every confined command reports
115
+ `SANDBOX_UNAVAILABLE` instead of quietly running unconfined. The capability
116
+ fact also disappears with the enforcement — an unusable runner never leaves a
117
+ "confined" claim behind.
118
+ - Configure `sandbox: false` on either provider to get the pre-sandbox
119
+ behaviour back: commands run unconfined and `sandboxMode` returns `undefined`,
120
+ so the tool layer tells the model these operations are not confined.
121
+
122
+ ## Requirements
123
+
124
+ - Windows with WSL2 and at least one distro.
125
+ - `bubblewrap` inside the distro, for the sandbox (see above).
126
+ - DSH `0.2.0-rc.2`-era packages (`@deepseek-ai/dsh-base`, `dsh-web-app`).
127
+ - **No dependencies.** Everything the package needs is a peer, supplied by the
128
+ profile that mounts it.
129
+
130
+ ## Install
131
+
132
+ The package is a DSH **bundle**: its `package.json` declares
133
+ `dsh.bundle.patch`, so `dsh plugin add` installs the code *and* applies
134
+ [`cordis.patch.yml`](cordis.patch.yml) as a configuration layer. There is no
135
+ patch to merge by hand. Start from the Web template, because the layer
136
+ substitutes web-surface rows — the composition-level `subprocess` provider, the
137
+ terminal controller, the directory picker:
138
+
139
+ ```powershell
140
+ dsh wsl --from-default-profile web --dump-config # create the profile from the Web template
141
+ dsh plugin --profile wsl add dsh-plugin-wsl-env # npm; or link:C:\path\to\checkout while developing
142
+ dsh --profile wsl --dump-config # compose only, no boot: the fastest check
143
+ dsh --profile wsl
144
+ ```
145
+
146
+ `cordis.patch.yml` is commented line by line and is the authoritative install
147
+ reference, including the generated `preset-wsl` block. Two of its rows are not
148
+ additions but substitutions: it **disables** the shipped composition-level
149
+ `subprocess` provider and inserts this package's in its place, and it gives
150
+ `terminal-controller` the `wsl.exe` shell profile. Both are needed for the
151
+ terminal window and neither can live in the preset — see above.
152
+
153
+ Machine-local preferences do not belong in that file. Layers compose as
154
+ bundles, then the profile's own `cordis.patch.yml`, then the home-level one,
155
+ then `--patch` overlays, and a later layer wins per row — so override a row by
156
+ id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`.
157
+ [`examples/profile.cordis.patch.yml`](examples/profile.cordis.patch.yml) is one
158
+ such layer, and shows what a row override looks like.
159
+
160
+ > A running process caches ES modules. **Restart the app after changing `lib/`**,
161
+ > or the old code stays loaded.
162
+
163
+ ## Layout
164
+
165
+ ```
166
+ lib/paths.js pure path translation between the three coordinate systems
167
+ lib/wsl.js wsl.exe interop primitives (no DSH imports)
168
+ lib/listing.js pure directory-listing and breadcrumb helpers (no DSH imports)
169
+ lib/index.js WslShellExecutor (ctx.shell) + WslFileSystem (ctx.fs)
170
+ lib/sandbox.js the distro-side bwrap confinement both providers apply
171
+ lib/picker.js WslDirectoryPicker (ctx.directoryPicker)
172
+ lib/subprocess.js WslSubprocessRuntime (ctx.subprocess) — the terminal window
173
+ lib/auto-preset.js per-session environment selection
174
+ lib/shell-env.js DSH_WSL_* environment facts
175
+ lib/{shell,fs}.js one-line subpath entry points
176
+ cordis.patch.yml the bundle patch layer (dsh.bundle), with comments
177
+ examples/ a machine-local profile layer, for contrast
178
+ test/ unit tests and the behavioural probes
179
+ docs/archive/ the designs this one replaced, and why
180
+ ```
181
+
182
+ ## Testing
183
+
184
+ ```bash
185
+ npm test # syntax check + unit tests — dependency-free, runs anywhere
186
+ npm run probe:sandbox # the bwrap profile's semantics, measured inside the distro
187
+ npm run probe # filesystem probe against a real distro (Windows + WSL only)
188
+ npm run probe:sandbox-shell # shell probe: drives the confined executor through a real boot
189
+ npm run probe:terminal # terminal probe: opens a PTY through the provider
190
+ ```
191
+
192
+ `npm test` covers the pure modules plus a `--check` parse pass over every shipped
193
+ module. It cannot import the service modules: they need DSH peers that a bare
194
+ checkout does not have. That leaves an evaluation-time gap which only booting the
195
+ harness closes — the archived record's §15.4 documents the five rounds of
196
+ misdiagnosis that gap once caused.
197
+
198
+ `test/probe/sandbox.sh` needs no harness at all: it applies the exact profile
199
+ arguments `lib/sandbox.js` builds and asserts what bubblewrap does and does not
200
+ govern — including the interop escape, which it records as `INFO` rather than a
201
+ failure because a Linux sandbox cannot govern a Windows process.
202
+
203
+ `test/probe/` drives the seams inside throwaway profiles bound to the distro.
204
+ The filesystem probe asserts the whole publication path — create, read, version
205
+ guard, edit, overwrite, mode preservation, and the two guard rejections — plus
206
+ the fence: a write outside the policy root and a `read-only` write are both
207
+ refused with `FS_SANDBOX_DENIED`, and `danger-full-access` is not fenced. The
208
+ shell probe boots the same profile with the WSL executor mounted and asserts the
209
+ same three modes through `ctx.shell`, including the denial classification the
210
+ tool layer renders. The terminal probe boots the Web profile with the provider in
211
+ place, asks for the same `spawnTerminal` request the GUI makes, and asserts the
212
+ shell it lands in: distro, initial directory, and the `DSH_*` fact forwarded
213
+ through `WSLENV`.
214
+
215
+ Those probes need a Windows-side profile whose `node_modules/dsh-plugin-wsl-env`
216
+ points at the checkout; `test/probe/run.sh` documents the one-time setup in its
217
+ header, and `terminal.sh` and `sandbox-shell.sh` reuse that same profile.
218
+
219
+ ## Verified
220
+
221
+ Local, on Windows 11 + WSL2 (Ubuntu 26.04):
222
+
223
+ - the seam wiring, UNC primitives and picker behaviour, in and out of process;
224
+ - the plugin mounted in a real profile, end to end;
225
+ - a **real model turn** in a WSL-only headless profile:
226
+ `write → chmod → read → edit → execute`, with the executable bit surviving the
227
+ edit;
228
+ - the same `write`/`edit` tools in the daily GUI profile;
229
+ - the **terminal provider**, booted in the Web composition:
230
+ `spawnTerminal({ argv: ["wsl.exe"], cwd: <UNC workspace> })` lands in
231
+ `ubuntu` as the distro user's login shell (`/usr/bin/zsh`), in the workspace's
232
+ Linux path, with the request's `DSH_SESSION_ID` forwarded. `npm run
233
+ probe:terminal` re-runs exactly that, and it is what the throwaway Web boot
234
+ showed;
235
+ - the same provider **in the daily GUI profile**: pressing *New terminal* starts,
236
+ from the harness process, exactly
237
+ `wsl.exe -d ubuntu --cd /home/andy/Projects/dsh/plugins/dsh-plugin-wsl-env`
238
+ (recorded from the live process table), and the window lands in the distro
239
+ user's zsh.
240
+ - the **sandbox**, three ways: the profile arguments measured directly in the
241
+ distro (`npm run probe:sandbox`: a write outside the workspace is refused with
242
+ EROFS, the sandbox's `/tmp` is ephemeral, `/mnt/c` is read-only, and the
243
+ interop escape is recorded); the filesystem fence (`npm run probe`: a write
244
+ outside the policy root and a `read-only` write both refused with
245
+ `FS_SANDBOX_DENIED`, `danger-full-access` not fenced); and the shell path
246
+ (`npm run probe:sandbox-shell`: `sandboxMode` advertised, the same three modes
247
+ through `ctx.shell`, the refusal classified as a denial with
248
+ `enforcement: partial`).
249
+ - the sandbox **in the daily GUI profile**, from the agent's own session: a
250
+ command that writes outside the Session workspace is refused inside the distro
251
+ (`zsh: read-only file system: /home/andy/…`), and the tool layer answers with
252
+ the escalation hint that `sandbox_permissions` then grants — which is how the
253
+ runtime mirror gets synced now that its destination sits outside the workspace.
254
+
255
+ 42 unit assertions, 18 filesystem-probe assertions and 10 shell-probe checks
256
+ pass; the bwrap probe adds 10 measured expectations plus the recorded escape, and
257
+ the terminal probe asserts three more.
258
+
259
+ ## Known limitations
260
+
261
+ - **The sandbox does not govern WSL interop.** A confined distro command can
262
+ still execute a Windows binary from `/mnt/c`, and that Windows process runs
263
+ outside bubblewrap under your ordinary token. Closing it would mean denying
264
+ execution under `/mnt`, which breaks `/mnt/c/…` Sessions; the provider reports
265
+ `enforcement: partial` for exactly this reason, and `npm run probe:sandbox`
266
+ re-measures it. Network and process visibility are outside the mode vocabulary
267
+ on every platform.
268
+ - **`bubblewrap` must be installed in the distro**, and the providers fail
269
+ closed without it (`SANDBOX_UNAVAILABLE` for every confined command). That is
270
+ deliberate: the alternative is a silent run without the boundary the model was
271
+ told it has. `sandbox: false` is the documented opt-out.
272
+ - A `workspace-write` profile binds the workspace root read-write, and bubblewrap
273
+ refuses a bind whose source does not exist — so a Session whose workspace
274
+ directory has been deleted fails closed with a runner diagnostic rather than
275
+ being recreated.
276
+ - Freshly created files get the distro umask default (0644), not an executable
277
+ bit: a host-side `chmod` over the share is silently ignored. Overwrites and
278
+ edits *do* preserve the mode. `chmod +x` from inside the distro when needed.
279
+ - Guards are check-then-act, not atomic: this backend checks the caller's guard
280
+ itself because the host backend's guarded publication uses a hard link the
281
+ share rejects. The race is the one `dsh-fs-sandbox` already documents.
282
+ - `watch()` is refused outright rather than armed unreliably over 9p.
283
+ - `glob`/`grep` run the host ripgrep over the share: correct, but not fast, and
284
+ the `.gitignore` semantics are the host's.
285
+ - `editText` reads and rewrites the whole file in memory.
286
+ - The terminal is a **composition** property, not a Session one (see above): a
287
+ Windows-folder Session gets the distro terminal at `/mnt/<drive>/…`, and the
288
+ shell menu is deliberately reduced to the one configured profile.
289
+ - The GUI terminal is an interactive shell and is **not** wrapped in the
290
+ sandbox, exactly like the shipped terminal provider: the mode vocabulary
291
+ governs command execution and file mutations, not a human's interactive
292
+ session.
293
+ - Terminal activity reporting stops at `wsl.exe`. The shipped provider's shell
294
+ integration runs only for a direct `bash`/`zsh` launch on a POSIX host, so a
295
+ distro terminal reports `unknown` activity and the controller's unattended idle
296
+ reclamation never fires for it — close the tab to release the process.
297
+
298
+ ## Repository notes
299
+
300
+ The checkout is developed inside the distro, but the harness is a Windows process
301
+ and a profile can only link a Windows path (`link:\\wsl.localhost\…` is rewritten
302
+ to a broken `/wsl.localhost/…` symlink by pnpm). The Windows copy under
303
+ `default-workspace/dsh-plugin-wsl` is therefore a **runtime mirror**; keep the two
304
+ in sync with `test/probe/sync-to-windows.sh` before launching the app.
305
+
306
+ That destination is outside every Session workspace, so an agent running the sync
307
+ through a confined shell is refused by the workspace-write policy and has to
308
+ approve `danger-full-access` for that one command. That is the sandbox working as
309
+ described above, not a broken script — run it from a plain distro terminal when
310
+ the prompt is unwelcome.
311
+
312
+ ## License
313
+
314
+ MIT — see [LICENSE](LICENSE).