dsh-plugin-wsl-env 0.4.0 → 0.6.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 CHANGED
@@ -12,6 +12,132 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.6.0] - 2026-10-04
16
+
17
+ One theme: the P2 backlog from the 2026-10-03 review, closed with the same
18
+ evidence bar as its P0/P1 — every fix carries a check that fails without it,
19
+ and the real-distro probes run green over the changed paths — plus the
20
+ import-time regression that backlog carried, caught at the first
21
+ sync-and-restart: `watchMaxDepth` was written `z.number().int()`, a member the
22
+ pinned schemastery does not publish, which took `wsl-shell` and `wsl-fs` down
23
+ in every distro workspace session. The key now uses `z.natural()`, and
24
+ `test/syntax.mjs` reads the chained half of the schemastery surface so the
25
+ next such member dies in CI instead of in the app.
26
+
27
+ ### Fixed
28
+
29
+ - **The in-distro watcher no longer fails silently (review M10).** Four defects
30
+ in `lib/watcher.js`, fixed together because they share one loop:
31
+ the stamp file is removed on every way out (EXIT trap; TERM/INT/HUP routed
32
+ through a plain exit so the trap runs); a target that vanishes mid-watch now
33
+ ends the loop with a report through the error channel instead of arming
34
+ forever (`E` protocol line, unit- and probe-tested); the scan's depth is
35
+ boundable through the new `wsl-fs.watchMaxDepth` key (`0`, the default,
36
+ keeps the whole-tree walk); and the loop's stderr is drained continuously
37
+ with its tail kept for error context, so a chatty `sh` can no longer stall
38
+ the watch at the 64 KiB pipe limit.
39
+ - **`watchMaxDepth` no longer takes both preset rows down at import (a
40
+ regression the M10 entry above introduced, caught after the first
41
+ sync-and-restart).** The key was written `z.number().int().min(0)`, but the
42
+ pinned schemastery publishes no `int` on any schema instance, and a Config
43
+ schema is a class field — so `lib/index.js` threw
44
+ `TypeError: z.number(...).int is not a function` while it was still being
45
+ imported, the loader left `wsl-shell` and `wsl-fs` fiber-less, and the preset
46
+ audit reported exactly those two rows as `never started`: every session in a
47
+ distro workspace, new or historical, failed at the first message. The key is
48
+ now `z.natural().default(0)` — the pinned spelling of "integer >= 0", the
49
+ idiom `picker` and `auto-preset` already use — and `test/syntax.mjs` now
50
+ reads the chained half of the schemastery surface (a balanced walk that
51
+ survives nested call arguments, string literals and template
52
+ interpolations), so a member the peer does not publish fails the suite
53
+ instead of waiting for the Desktop app. Reproduced and verified in a
54
+ peer-stub harness: the `lib/index.js` import went from the `TypeError` to
55
+ clean with the fix, and the new check fails on the old line.
56
+ - **A wedged WSL service can no longer hang a tool call forever (review M11).**
57
+ Every `wsl.exe` resolution call in `lib/wsl.js` now runs under a deadline
58
+ (`DEFAULT_WSL_DEADLINE_MS`, 60 s — generous on purpose for cold boots, `0`
59
+ waits forever), and the deadline kill is named for what it is in the error,
60
+ with the `wsl.exe --shutdown` remedy. Separately, the agent execution path's
61
+ `confine()` now receives the request's abort signal, the same one the
62
+ one-shot path always passed.
63
+ - **A timed-out command can no longer leave descendants running (review M12).**
64
+ EXEC requests run as their own session and process group (`setsid`, detected
65
+ at startup with a documented fallback), and the in-distro timeout and KILL
66
+ frames signal the whole group — TERM, then KILL after the grace, descendants
67
+ included. Proven by a new real-script check: a command that backgrounds a
68
+ marked shell and outlives its budget leaves zero survivors.
69
+ - **Documentation drift cluster (review M9).** ARCHITECTURE.md's service table
70
+ claimed the share substrate was the default (it is `agent`), pointed at
71
+ `lib/sandbox.js` for `bwrapProfileArgs` (built in `lib/bwrap.js`), and kept
72
+ the long-gone `watch()` refusal in the error-code table; the `substrate`
73
+ schema docstring claimed watches run through the substrate (both substrates
74
+ ride the same `lib/watcher.js` poll loop); PARITY.md now records the rg
75
+ interception as designed-but-never-implemented instead of leaving it
76
+ implied; and the probe checklists (both READMEs, CONTRIBUTING, RELEASING,
77
+ the PR template, CI's comment) list the agent, watch, substrate and exec
78
+ probes that had shipped without being listed anywhere.
79
+
80
+ ### Changed
81
+
82
+ - **Test structure (review M15).** The sandbox checks run everywhere now:
83
+ the probe-and-confine logic moved to the peer-free `lib/sandbox-core.js`
84
+ (one implementation, still shipped), `lib/sandbox.js` binds it to the peer's
85
+ `SandboxUnavailableError`, and the tests inject a stand-in with the same
86
+ `code` — CI no longer skips them. `FS_SANDBOX_DENIED`'s write-only
87
+ classification carries three unit checks (write denial classified, the same
88
+ signature ignored off the write path, ordinary permission failures keep
89
+ theirs). The probes stopped carrying inline bwrap profiles: `exec.sh` and
90
+ `sandbox.sh` derive their profiles from `lib/bwrap.js` itself, so a profile
91
+ change fails the probe instead of hiding behind a copy. `lib/wsl.js`'s
92
+ spawning half — previously probe-only — gained `test/wsl.test.mjs`.
93
+ `test/probe/agent.sh` is wired as `npm run probe:agent` like its siblings.
94
+
95
+ ## [0.5.0] - 2026-10-04
96
+
97
+ One theme: telling the truth, in the reader's language. The Plugins page now
98
+ renders localized titles and descriptions for every row this bundle owns, the
99
+ README runs shallow to deep and carries an Architecture section in both
100
+ languages, the tab-title limitation is documented with its upstream proposal,
101
+ and the statements `hostSessions` had already falsified are gone.
102
+
103
+ ### Added
104
+
105
+ - **The plugin page can speak the bundle's language.** The bundle and each of
106
+ its four inserted rows (`subprocess`, `picker`, `shell-env`, `auto-preset`)
107
+ now export localized display metadata — `locale/<lang>.json` with a
108
+ `meta.title` / `meta.description` pair, exposed through `exports` per row —
109
+ which is what the Plugins page reads before falling back to a package.json
110
+ `description`. A Chinese UI now renders Chinese titles and descriptions for
111
+ every row this package owns; previously the rows fell back to raw module
112
+ specifiers (no description at all), and the two official rows it inserts
113
+ keep their upstream English text, which only upstream locale files can fix.
114
+ - **docs/UPSTREAM-TERMINAL-TITLE.md — the upstream proposal behind a documented
115
+ limitation, posted as
116
+ [deepseek-ai/deepseek-harness#8769](https://github.com/deepseek-ai/deepseek-harness/discussions/8769).** `subprocess-wsl.hostSessions` routes a Windows-folder session's
117
+ terminal to `powershell.exe`, but the tab still reads `WSL`: the controller
118
+ titles a tab from the composition's single shell profile before the provider
119
+ is consulted, the terminal spawn spec carries no display name, and a plugin
120
+ cannot bridge the gap (a patch layer asserts rather than renames a row's
121
+ module, and disabling the `terminal-controller` row fails web boot — its id
122
+ is what the client's `webTerminals` mount waits behind). The doc records the
123
+ gap, the three closed escape hatches with the web-boot failure that closed
124
+ the second one live, and the smallest upstream change that would close it.
125
+ LIMITATIONS.md gains the matching limitation paragraph with the workarounds
126
+ that do work: double-click rename, and a user-layer `shellCandidates` entry
127
+ whose manual selection is titled after itself.
128
+ - **The README carries an Architecture section** (both languages): the two
129
+ mount heights — composition-level `subprocess-wsl` and the preset realm's
130
+ `wsl-shell` / `wsl-fs` — with the per-session terminal routing and the
131
+ command and file execution paths in one diagram and two paragraphs.
132
+ [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) remains the full reference.
133
+
134
+ ### Fixed
135
+
136
+ - Stale statements that `hostSessions` had already falsified were corrected in
137
+ place: `cordis.patch.yml`'s consequence note and ARCHITECTURE.md's "the
138
+ terminal follows the app configuration" paragraph still described the
139
+ pre-routing behaviour.
140
+
15
141
  ## [0.4.0] - 2026-10-03
16
142
 
17
143
  One theme: the enterprise review's remediation cycle completes. The
package/CONTRIBUTING.md CHANGED
@@ -69,6 +69,10 @@ terminal if you would rather not see the prompt.
69
69
  | `npm run probe:sandbox` | what bubblewrap confines and what it does not | no |
70
70
  | `npm run probe:sandbox-shell` | the confined executor through a real boot | yes |
71
71
  | `npm run probe:terminal` | the terminal provider | yes |
72
+ | `npm run probe:substrate` | the agent filesystem substrate over a real `wsl.exe` transport | yes; inside the distro |
73
+ | `npm run probe:watch` | the in-distro watcher over a real directory | yes; inside the distro |
74
+ | `npm run probe:agent` | the resident-vs-one-shot fallback parity legs | yes; inside the distro |
75
+ | `npm run probe:exec` | the agent-backed execution handle: timeout, kill, cwd failure | yes; inside the distro |
72
76
  | `npm run probe:missing-wsl` | how a `wslPath` that cannot start is reported | yes |
73
77
  | `npm run probe:picker` | the picker's root level, its refusals and its cap | yes |
74
78
  | `npm run probe:mode` | which POSIX-mode facts survive the share | no; Windows Node |
package/README.md CHANGED
@@ -10,7 +10,7 @@ This plugin lets [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harn
10
10
 
11
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.
12
12
 
13
- [Install](#install) · [Using it](#using-it) · [Configure](#configure) · [Sandbox](#sandbox) ·
13
+ [Install](#install) · [Using it](#using-it) · [Configure](#configure) · [Recipes](#recipes) · [Architecture](#architecture) · [Sandbox](#sandbox) ·
14
14
  [Troubleshooting](#troubleshooting) · [Development](#development) · [Documentation](#documentation)
15
15
 
16
16
  ## Install
@@ -42,6 +42,7 @@ Open `\\wsl.localhost\ubuntu\home\you\project` as the workspace, then ask the mo
42
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
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
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.
45
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.
46
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.
47
48
 
@@ -62,22 +63,6 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
62
63
 
63
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`, `resolveSymlinks`, `preferredDistro` and `maxEntries`; [examples/profile.cordis.patch.yml](examples/profile.cordis.patch.yml) is a machine-local layer to copy from.
64
65
 
65
- ## Sandbox
66
-
67
- 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.
68
-
69
- | Mode | What a command inside the distro can do |
70
- |---|---|
71
- | `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
72
- | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
73
- | `danger-full-access` | no sandbox; used for an approved wider-permission request |
74
-
75
- **`bubblewrap` is required, and the failure is 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.
76
-
77
- **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. `npm run probe:sandbox` demonstrates the boundary on your machine.
78
-
79
- 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.
80
-
81
66
  ## Recipes
82
67
 
83
68
  - **Git credential sharing**: let git inside the distro use the Windows-side Git
@@ -95,6 +80,44 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
95
80
  two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
96
81
  deliberately never forwarded — it would shadow the distro's own PATH.
97
82
 
83
+ ## Architecture
84
+
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:
86
+
87
+ ```text
88
+ composition (app level, one per process)
89
+ ├─ subprocess-wsl the GUI terminal's execution world
90
+ │ WSL-folder session → the distro shell, in the session's Linux directory
91
+ │ Windows-folder session → powershell.exe, in the session's Windows directory
92
+ ├─ directory-picker-wsl installed distros listed beside the Windows home
93
+ ├─ wsl-shell-env the DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS facts the model sees
94
+ └─ auto-preset binds the wsl preset when a session opens a distro folder
95
+
96
+ preset-wsl (the wsl agent preset; its services run in isolate realms)
97
+ ├─ 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
99
+ ```
100
+
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.
102
+
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 one of two substrates: the default `agent` — a resident in-distro process, where reads, writes and identities run on ext4 with native symlinks and mode bits — or `share`, the opt-out: the Windows-side host stack over the 9p share. Both check writes against the same policy the command sandbox enforces.
104
+
105
+ ## Sandbox
106
+
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.
108
+
109
+ | Mode | What a command inside the distro can do |
110
+ |---|---|
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 |
114
+
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.
116
+
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. `npm run probe:sandbox` demonstrates the boundary on your machine.
118
+
119
+ 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
+
98
121
  ## Troubleshooting
99
122
 
100
123
  | Symptom | Cause | Fix |
@@ -120,6 +143,9 @@ npm run probe # filesystem probe against a real distro (Windows +
120
143
  npm run probe:sandbox-shell # boot a real harness and drive the confined executor
121
144
  npm run probe:terminal # open a PTY through the terminal provider
122
145
  npm run probe:substrate # drive the agent filesystem substrate over a real wsl.exe transport (from inside the distro)
146
+ npm run probe:watch # arm the in-distro watcher over a real directory (from inside the distro)
147
+ npm run probe:agent # the resident-vs-one-shot fallback parity legs (from inside the distro)
148
+ npm run probe:exec # the agent-backed execution handle: timeout, kill, cwd failure (from inside the distro)
123
149
  npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
124
150
  npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
125
151
  npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
@@ -27,6 +27,10 @@
27
27
  # With a cap, stdout and stderr are each cut at that many bytes and
28
28
  # the flags say which streams were cut; 0 means the whole capture.
29
29
  # or ERR|<id>|<reason>|<b64 message> (reason: cwd)
30
+ # An EXEC request runs as its own session and process group (`setsid`),
31
+ # so a timeout or a KILL frame takes the whole descendant tree — not
32
+ # just the direct child, which is how a build tool's daemonized
33
+ # grandchild outlives the kill.
30
34
  # FS|<id>|<op>|<timeout seconds|0>|<n args>
31
35
  # followed by <n args> lines of base64, one argument each — arguments
32
36
  # arrive still base64 so a write's content never passes through a shell
@@ -81,31 +85,58 @@ b64enc_file() {
81
85
 
82
86
  kill_current() {
83
87
  if [ -n "$CURRENT_PID" ]; then
84
- kill -TERM "$CURRENT_PID" 2>/dev/null
88
+ kill_request TERM "$CURRENT_PID"
85
89
  fi
86
90
  }
87
91
 
92
+ # Signal one in-flight request, descendants included. With `setsid` (EXEC
93
+ # requests, below) the request is its own process-group leader, so the
94
+ # negative PID signals the WHOLE group — every descendant that did not make
95
+ # its own session. Without it the group does not exist, the negative-PID kill
96
+ # fails with ESRCH, and the direct kill keeps the old guarantee. A shell whose
97
+ # `kill` builtin cannot parse `-- -PID` at all also lands here.
98
+ kill_request() {
99
+ # $1 = signal name, $2 = request PID.
100
+ kill -s "$1" -- "-$2" 2>/dev/null || kill -s "$1" "$2" 2>/dev/null || :
101
+ }
102
+
88
103
  shutdown() {
89
104
  kill_current
90
105
  exit 0
91
106
  }
92
107
  trap shutdown TERM INT HUP
93
108
 
109
+ # Whether EXEC requests can run as their own session. `setsid` makes the
110
+ # request process a session AND process-group leader (pid = pgid), which is
111
+ # what lets kill_request signal the whole descendant tree; the agent's own
112
+ # shell cannot do this portably — `set -m` in a non-interactive dash has been
113
+ # observed skipping the setpgid entirely, and a kill against a PID that is
114
+ # not a group leader is a no-op. A distro without `setsid` keeps working:
115
+ # requests then run in the agent's own group and the direct-child kill of the
116
+ # pre-setsid behaviour applies.
117
+ SETSID=
118
+ if command -v setsid >/dev/null 2>&1; then
119
+ SETSID=setsid
120
+ fi
121
+
94
122
  timeout_watcher() {
95
- # $1 = seconds, $2 = pid, $3 = grace seconds. TERM first; a process that
96
- # ignores TERM gets KILLed after the grace, so a stuck command cannot hang
97
- # the request forever.
123
+ # $1 = seconds, $2 = pid, $3 = grace seconds. TERM first — the whole group,
124
+ # so a command that ignores TERM takes its descendants with it when the
125
+ # KILL lands; a process that ignores TERM gets KILLed after the grace, so a
126
+ # stuck command cannot hang the request forever.
98
127
  sleep "$1"
99
- kill -TERM "$2" 2>/dev/null
128
+ kill_request TERM "$2"
100
129
  sleep "${3:-3}"
101
- kill -KILL "$2" 2>/dev/null
130
+ kill_request KILL "$2"
102
131
  }
103
132
 
104
133
  # Capture one request's output and answer with RES. $1 = request id, $2 =
105
134
  # timeout seconds ('' or 0 = none), $3 = per-stream output cap in bytes (0 or
106
135
  # empty = unlimited), $4 = launch mode: `exec` runs "$@" (argv words) as the
107
- # request process; `subshell` runs ( "$@" ) in a forked subshell, which is how
108
- # the FS handlers — shell functions, not executables — run.
136
+ # request process, in its own session via `setsid` when the distro has it —
137
+ # the timeout and KILL then signal the whole descendant group; `subshell` runs
138
+ # ( "$@" ) in a forked subshell, which is how the FS handlers — shell
139
+ # functions, not executables — run.
109
140
  run_capture() {
110
141
  local req_id timeout_s maxout mode rc watcher_pid out_cut err_cut
111
142
  req_id=$1
@@ -121,6 +152,8 @@ run_capture() {
121
152
  CURRENT_ID=$req_id
122
153
  if [ "$mode" = subshell ]; then
123
154
  ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
155
+ elif [ -n "$SETSID" ]; then
156
+ ( exec setsid "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
124
157
  else
125
158
  "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
126
159
  fi
package/cordis.patch.yml CHANGED
@@ -43,10 +43,12 @@
43
43
  # directory, so offering host executables there would advertise shells this
44
44
  # terminal never starts.
45
45
  #
46
- # Consequence: the terminal follows the composition, not the Session. A Session
47
- # whose workspace is a Windows folder gets the same distro terminal, started in
48
- # that folder as `/mnt/<drive>/…`. That is the honest limit of a per-session
49
- # execution world that no Session identity reaches.
46
+ # Consequence: a terminal's EXECUTION WORLD follows the Session — a WSL-folder
47
+ # session runs the distro's shell, a Windows-folder session runs PowerShell in
48
+ # its own directory (`hostSessions` on `subprocess-wsl`) — while the shell menu
49
+ # and the tab title stay the composition's single `WSL` profile, which no
50
+ # Session identity reaches. The title that implies is documented in
51
+ # docs/LIMITATIONS.md and bridged upstream in docs/UPSTREAM-TERMINAL-TITLE.md.
50
52
  # ═════════════════════════════════════════════════════════════════════════════
51
53
  - id: subprocess
52
54
  disabled: true
@@ -19,7 +19,7 @@ smaller integrations.
19
19
  | Service | Class | File | What it does |
20
20
  |---|---|---|---|
21
21
  | `ctx.shell` | `WslShellExecutor` | [`lib/index.js`](../lib/index.js) | Runs each command as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>`, wrapped in a distro-side `bwrap` sandbox. |
22
- | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the Windows-side host stack over the distro's UNC share (default), or the resident in-distro agent on ext4. |
22
+ | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files, on one of two I/O substrates (see below): the resident in-distro agent on ext4 (default), or the Windows-side host stack over the distro's UNC share. |
23
23
  | `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace. |
24
24
 
25
25
  `WslShellExecutor` extends the shipped `LocalBashExecutor`, and `WslFileSystem`
@@ -74,9 +74,13 @@ So the layer disables the shipped `subprocess` row and inserts
74
74
  overrides `spawnTerminal` only. Ordinary `spawn()`, the host ripgrep search, the
75
75
  pwsh executor, and the LSP host reach the shipped implementation unchanged.
76
76
 
77
- The cost is that the terminal follows the app configuration, not the session. A
78
- session opened on a Windows folder still gets a distro terminal, started in
79
- `/mnt/<drive>/...`.
77
+ The terminal's execution world follows the session (`hostSessions`, default on):
78
+ a WSL-folder session runs the distro shell, a Windows-folder session runs
79
+ `powershell.exe` in its own directory. What still follows the app configuration
80
+ is the shell menu and the tab title, both derived from the composition's single
81
+ `WSL` shell profile — so a host session's tab reads `WSL` over a PowerShell
82
+ process. See [LIMITATIONS.md](LIMITATIONS.md) and
83
+ [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
80
84
 
81
85
  ## Path coordinates
82
86
 
@@ -142,7 +146,8 @@ wsl.exe -d <distro> --cd <linux dir> --exec bwrap \
142
146
 
143
147
  These are the same arguments DSH's Linux runner uses (`dsh-sandbox-local`), so the
144
148
  behaviour and the error messages match a Linux host. The function
145
- `bwrapProfileArgs` in [`lib/sandbox.js`](../lib/sandbox.js) builds them.
149
+ `bwrapProfileArgs` in [`lib/bwrap.js`](../lib/bwrap.js) builds them — the
150
+ peer-free builder every confinement site composes from.
146
151
 
147
152
  | Mode | What a command inside the distro can do |
148
153
  |---|---|
@@ -204,7 +209,7 @@ finds it here.
204
209
  | `FS_OUTSIDE_DISTRO` | `WslFileSystem.worldPath` | `restrictToDistro` is on and the path names **another distro**'s share — a configuration fence, not a sandbox decision | open a session in that distro, or set `restrictToDistro: false`. A wider permission does **not** lift it |
205
210
  | `FS_NOT_OBSERVED` | `WslFileSystem.assertGuard` | a guarded create (`createIfAbsent`) targeted a file that exists and had not been read first | read the file, then overwrite with the version guard or edit it |
206
211
  | `FS_STALE_VERSION` | `WslFileSystem.assertGuard` | the version the caller holds no longer matches: the file changed, or is gone | read it again and retry with the fresh version |
207
- | `FS_IO_ERROR` | `WslFileSystem.watch` | `watch()` is refused, because a 9p share cannot be watched reliably | poll, or watch from inside the distro |
212
+ | `FS_IO_ERROR` | `WslFileSystem.watch` | the watch target is not a path inside the pinned distro, so there is nothing the in-distro poll loop can observe | watch a distro path; the watch itself runs where the files live (`lib/watcher.js`) |
208
213
  | `FS_NOT_FOUND` | the host filesystem stack | the path does not exist — the ordinary answer, passed through | — |
209
214
 
210
215
  Two failures come from `wsl.exe` itself rather than from a code of ours, and both used
@@ -26,12 +26,14 @@ are schema defaults, listed because they are the ones worth knowing.
26
26
  | | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools: `agent` — the resident in-distro agent (`lib/fs-substrate.js`), where reads, writes and identities run on ext4: native symlinks, native mode bits, and the mutation guard survives to the write's atomic publication, kernel-enforced by the confined resident when the policy is confined. `share` — the opt-out: the Windows-side host stack over the 9p share, with the symlink-retry and publication hooks. Both substrates fence writes with the same host-side policy check. See [LIMITATIONS.md](LIMITATIONS.md) for what changes between them |
27
27
  | | `resolveSymlinks` | `true` | *(share substrate only)* follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` — the agent substrate follows them natively |
28
28
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
29
+ | | `watchMaxDepth` | `0` | how deep the in-distro watch scan walks; `0` walks the whole tree. Bound it (e.g. `4`) when the watched tree holds a `node_modules` — a change below the bound is seen only when another change above it fires the same callback |
29
30
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
30
31
  | | `includeHostHome` | `true` *(shipped)* | also list the Windows home directory |
31
32
  | | `maxEntries` | `1000` *(shipped)* | maximum entries listed per directory |
32
33
  | `subprocess-wsl` | `distro` | `''` *(shipped)* | which distro the GUI terminal opens in |
33
34
  | | `shell` | `''` | pin a shell; empty lets `wsl.exe` decide |
34
35
  | | `loginShell` | `true` | whether a pinned shell uses login semantics |
36
+ | | `hostSessions` | `true` | give a session opened on a Windows folder a host `powershell.exe` in its own directory instead of the distro shell at `/mnt/<drive>/…`; `false` restores the composition-owned distro terminal for every session |
35
37
 
36
38
  You can override the other keys in the same way; they keep their defaults. Those are
37
39
  `wslPath`, `hostCwd` and `forwardEnv` on the shell and terminal rows,
@@ -110,5 +110,15 @@ Everything below applies to the share substrate unless it says otherwise.
110
110
  default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
111
111
  `powershell.exe` in its own directory. The shell MENU remains the single configured
112
112
  profile, and a directory-less launch still lands in the distro.
113
+ - A host session's TAB TITLE still reads `WSL`, the composition's shell profile
114
+ name, over a PowerShell process. The controller titles a tab from that profile
115
+ before the provider rewrites the launch, and a plugin cannot correct it: a
116
+ patch layer asserts rather than renames a row's module, and the row's id is
117
+ load-bearing for the web composition (disabling it fails web boot with
118
+ "waiting for service: webTerminals"). The full account and the proposed
119
+ upstream change are in [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
120
+ Meanwhile, double-click a tab's title to rename it, or add `shellCandidates`
121
+ to the `terminal-controller` row in your own profile layer — a manually
122
+ selected shell is titled after itself.
113
123
  - Terminal activity reporting stops at `wsl.exe`, so the controller never reclaims
114
124
  these terminals when they are idle.
package/docs/PARITY.md CHANGED
@@ -40,6 +40,10 @@ shaped a phase of the plan.
40
40
  distro's share, rewrite the spawn to run a distro-side `rg` (falling back
41
41
  to `grep -rE` + `find` when absent) with identical stdout conventions.
42
42
  Under the agent, that exec rides the agent's `exec` capability.
43
+ **Not implemented, and recorded so the gap is a decision, not an
44
+ omission:** search still runs the packaged Windows rg over the share. The
45
+ interception was designed (above) but never landed; nothing in the phase
46
+ table below delivers it, and no phase claims it.
43
47
  3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
44
48
  this plugin replaces: chokidar on 9p is the unreliability that made
45
49
  `WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
package/docs/README.zh.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **只支持 Windows + WSL2。** 安装只需一条命令,插件自身没有依赖。环境按会话生效:打开 Windows 文件夹的会话继续使用原来的 Windows 环境。
12
12
 
13
- [安装](#安装) · [使用](#使用) · [配置](#配置) · [沙箱](#沙箱) · [常见问题](#常见问题) ·
13
+ [安装](#安装) · [使用](#使用) · [配置](#配置) · [配方](#配方) · [架构](#架构) · [沙箱](#沙箱) · [常见问题](#常见问题) ·
14
14
  [开发](#开发) · [文档](#文档)
15
15
 
16
16
  ## 安装
@@ -63,6 +63,36 @@ dsh --profile wsl # 4. 启动
63
63
 
64
64
  [`cordis.patch.yml`](../cordis.patch.yml) 是每个随包值的注释参考。[docs/CONFIGURATION.md](CONFIGURATION.md) 列出其余键,包括 `shell`、`loginShell`、`cwd`、`timeoutMs`、`resolveSymlinks`、`preferredDistro` 和 `maxEntries`;[examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) 是一份可照抄的本机层。
65
65
 
66
+ ## 配方
67
+
68
+ - **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
69
+ `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
70
+ (路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
71
+ - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
72
+ - **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
73
+
74
+ ## 架构
75
+
76
+ 一个 DSH 进程同时服务两类会话:工作区开在 Windows 目录的,和工作区在子系统内的。能做到这一点,是因为 provider 分两层挂载:
77
+
78
+ ```text
79
+ 组合层(composition,每个进程一份)
80
+ ├─ subprocess-wsl GUI 终端的执行世界
81
+ │ 子系统目录会话 → 子系统 shell,落在会话的 Linux 目录
82
+ │ Windows 目录会话 → powershell.exe,落在会话自己的目录
83
+ ├─ directory-picker-wsl 在 Windows 家目录旁列出已安装的子系统
84
+ ├─ wsl-shell-env 向模型暴露 DSH_WSL_DISTRO / _SHELL / _HOME / _PORTS
85
+ └─ auto-preset 会话打开子系统目录时自动绑定 wsl preset
86
+
87
+ preset-wsl(wsl agent preset;其服务运行在 isolate realm 内)
88
+ ├─ wsl-shell ctx.shell — wsl.exe --exec <登录 shell>,在子系统内经 bubblewrap 约束
89
+ └─ wsl-fs ctx.fs — 子系统的真实文件;agent 基座落在 ext4,或走 9p 共享
90
+ ```
91
+
92
+ **为什么分两层。** `wsl-shell` 和 `wsl-fs` 位于 `wsl` agent preset 内,`auto-preset` 在会话工作区位于子系统内时自动绑定它:Windows 目录的会话继续使用原生 provider,子系统会话拿到 WSL provider——环境是会话的属性,而不是进程的。终端控制器是例外:它经根上下文解析执行世界,永远看不到 preset 的 isolate realm,所以 `subprocess-wsl` 必须挂在组合层。
93
+
94
+ **命令与文件。** 命令以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <登录 shell> -lc <命令>` 执行,外层是子系统内的 bubblewrap profile,参数与 DSH 自家 Linux runner 完全一致——约束语义与报错文案都和 Linux 主机相同。文件工具读写子系统的真实文件,基座二选一:默认 `agent`——常驻子系统内的进程,读写与路径解析落在 ext4,原生符号链接与权限位;或 `share`——退出项:Windows 侧宿主文件栈走 9p 共享。两个基座与命令沙箱使用同一份策略检查写入。
95
+
66
96
  ## 沙箱
67
97
 
68
98
  命令由**子系统内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
@@ -73,20 +103,12 @@ dsh --profile wsl # 4. 启动
73
103
  | `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
74
104
  | `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
75
105
 
76
- **bubblewrap 是必需项,而且失败是关闭的。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
106
+ **bubblewrap 是必需项,缺失即失败关闭。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
77
107
 
78
108
  **上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
79
109
 
80
110
  设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
81
111
 
82
- ## 配方
83
-
84
- - **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
85
- `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
86
- (路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
87
- - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
88
- - **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
89
-
90
112
  ## 常见问题
91
113
 
92
114
  | 现象 | 原因 | 处理 |
@@ -112,6 +134,9 @@ npm run probe # 文件系统探针,需要真实子系统(仅
112
134
  npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
113
135
  npm run probe:terminal # 通过终端 provider 打开一个 PTY
114
136
  npm run probe:substrate # 用真实 wsl.exe 传输驱动 agent 文件基座(在子系统内运行)
137
+ npm run probe:watch # 在真实目录上装上 distro 内轮询 watcher(在子系统内运行)
138
+ npm run probe:agent # resident 与 one-shot 的回退不变量对比腿(在子系统内运行)
139
+ npm run probe:exec # agent 执行句柄:超时、杀停、cwd 失败(在子系统内运行)
115
140
  npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
116
141
  npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
117
142
  npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
@@ -0,0 +1,81 @@
1
+ # Upstream proposal: a terminal tab title that follows the session's shell
2
+
3
+ Status: proposed upstream as
4
+ [deepseek-ai/deepseek-harness#8769](https://github.com/deepseek-ai/deepseek-harness/discussions/8769)
5
+ (targeting `packages/api/terminal-controller`, version line `0.2.0-rc.x`). This
6
+ is the record of a gap this plugin worked around, could not work around, and
7
+ now documents: the GUI terminal titles every tab from the composition's single
8
+ shell profile, so a per-session shell choice is invisible in the title.
9
+
10
+ ## The gap
11
+
12
+ `TerminalController.spawn` fixes a terminal's display title from the resolved
13
+ shell profile before the provider is ever consulted:
14
+
15
+ ```ts
16
+ const info: WebTerminalInfo = {
17
+ id: request.id, shell, title: shell.name, cwd: environment.cwd, ...
18
+ }
19
+ ```
20
+
21
+ `shell` is `resolveShell(subprocess, this.config.shell, signal)` — the
22
+ composition's configured profile (`shell.name: WSL` in this plugin's layer),
23
+ kept verbatim; `resolveExecutable` swaps only the `path`. Two properties of the
24
+ seam make the title unable to follow a per-session shell:
25
+
26
+ - `SubprocessTerminalSpawnSpec` carries no display-name field, so a provider
27
+ that rewrites the launch (this plugin routes a Windows-folder session's
28
+ `wsl.exe` launch to `powershell.exe` — `subprocess-wsl.hostSessions`) cannot
29
+ say what it actually started.
30
+ - The controller never reads the effective program back from the
31
+ `SubprocessTerminalHandle`, so the rewrite is invisible to `info.title`.
32
+
33
+ The result on this plugin's default configuration: a terminal that runs
34
+ PowerShell under a tab that reads `WSL`.
35
+
36
+ ## Why a plugin cannot bridge it
37
+
38
+ Three escape hatches were evaluated against `0.2.0-rc.2` and each is closed:
39
+
40
+ 1. **Replace the controller row's module with a subclass** that corrects the
41
+ title after `super.spawn` (the class exports itself; `BrowserTerminal.rename`
42
+ is public and lands before `create()` publishes the terminal). Closed by the
43
+ patch-layer contract: *"A truthy name asserts the existing plugin name
44
+ rather than renaming it"* (`packages/boot/app-boot/src/config-schema/document.ts`)
45
+ — an id-targeted patch can replace `config`, but not the module.
46
+ 2. **Disable the shipped row and insert the subclass under a new id.** Closed
47
+ by the web composition: the row id is load-bearing. The web-app bundle
48
+ inserts `terminal-controller`, and the client model that the
49
+ `dsh-client-ui-sidebar-terminal` entry waits for mounts with
50
+ `inject = ['remote', 'remote.terminal']` — the `terminal` namespace the host
51
+ controller registers in its constructor. Disabling the row fails web boot:
52
+ `1 entry did not activate: @deepseek-ai/dsh-client-ui-sidebar-terminal:
53
+ pending (waiting for service: webTerminals)`. Verified live against
54
+ `dsh-desktop 0.2.0-rc.2`.
55
+ 3. **Per-session config.** The `shell` profile is composition state; `!!js`
56
+ expressions evaluate once at config resolution and reach no Session
57
+ identity.
58
+
59
+ ## The proposal
60
+
61
+ Any one of these closes the gap; the first is the smallest and keeps the
62
+ profile name as the fallback:
63
+
64
+ - `SubprocessTerminalSpawnSpec` gains an optional `displayTitle` (or the handle
65
+ exposes the launched `argv`), and `create()` uses it for `info.title` when
66
+ present — falling back to `shell.name`. A provider that rewrites a launch
67
+ states the program it started; every other launch keeps the profile name.
68
+ - Alternatively, the controller derives the title from the effective program
69
+ itself: title `executableName(effectiveArgv[0])` when it differs from
70
+ `shell.path`, else `shell.name`.
71
+
72
+ With either, this plugin deletes the limitation paragraph in
73
+ [LIMITATIONS.md](LIMITATIONS.md) and the `WSL`-over-PowerShell title, for any
74
+ provider that rewrites launches — not just WSL.
75
+
76
+ ## Workarounds until then
77
+
78
+ - Double-click a tab's title to rename it (a shipped client feature; the
79
+ controller's `rename` Remote API accepts 1–120 characters).
80
+ - Add `shellCandidates` to the `terminal-controller` row in a user layer: a
81
+ manually selected shell is titled after itself.