dsh-plugin-wsl-env 0.6.0 → 0.7.3

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,235 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.7.3] - 2026-10-05
16
+
17
+ One theme: the resident agent stops taking its world on faith - the
18
+ handshake now verifies the deployment's content, the agent's lifetime
19
+ survives a wedged host only by lease, and the one 9P consumer no row
20
+ replacement could reach ships its seam-ready traversal.
21
+
22
+ ### Added
23
+
24
+ - **The resident agent verifies its own deployment, and outlives a wedged
25
+ host only by lease.** The agent script is read in place from the
26
+ distro-visible path - in the deployed layout a `/mnt/c` mirror - so a
27
+ stale or half-synced runtime copy used to pass the version handshake
28
+ while drifting in behavior. The HELLO line now carries the sha256 of the
29
+ file the distro actually read (protocol v4), and both resident factories
30
+ pin the hash of the copy the package shipped: a mismatch is refused like
31
+ a version mismatch, with the resync command in the message. The agent
32
+ also terminates itself when its host goes silent without ever delivering
33
+ EOF - the wedged-relay case stdin EOF cannot cover: the host forwards a
34
+ client lease (`idleMs` + grace) through the managed `DSH_` namespace, and
35
+ an in-distro watchdog polls an in-flight marker so a long command's
36
+ silence never counts against it (dash has no `read -t`; a marker file is
37
+ the portable form). A boot sweep removes sibling `wsl-agent.*` temp dirs
38
+ no request has touched for an hour - the residue of agents whose host
39
+ died before their exit trap could run.
40
+ - **The `@` completion's distro traversal is built and waiting for its seam.**
41
+ The one 9P consumer the takeover series could not reach - the GUI's `@`
42
+ file completion - funnels all workspace traversal through three
43
+ module-internal functions of the upstream `file-reference-local` service,
44
+ so no row replacement can reach them. The plugin now ships the consumer
45
+ side of RFC addendum ① (`lib/file-reference-wsl.js`): a traversal strategy
46
+ that lists a distro workspace through the resident agent with
47
+ `find -printf '%y\t%f'` - keeping readdir's Dirent fidelity, where
48
+ symlinks report neither kind and are skipped instead of resolved through
49
+ like `ls -L` would - replaces the upstream per-segment lstat walk with one
50
+ agent exec, and returns absolutes in the root's own coordinate system,
51
+ which the upstream scan joins children with. Deliberately not wired into
52
+ the composition: with the seam absent, reaching the funnels would mean
53
+ re-implementing the fuzzy ranking engine, the behavior-regression path
54
+ the RFC rules out. The module ships tested; the takeover lands the day
55
+ upstream takes the ~10-line parameter. The RFC addendum now carries the
56
+ concrete interface proposal.
57
+
58
+ ### Fixed
59
+
60
+ - **The Windows CI legs are green again.** The fs boot test's end-to-end
61
+ legs read `platform is win32` as `the real-distro topology exists`, but
62
+ the CI Windows runners ship wsl.exe with no distro installed - every
63
+ Windows leg since the boot test landed failed there, which kept the
64
+ release gates red through 0.7.0-0.7.2 even after the missing-module
65
+ fix. The guard now asks WSL for its distro list and skips unless the
66
+ test's distro is actually installed.
67
+
68
+ ## [0.7.2] - 2026-10-05
69
+
70
+ One theme: the root-plane routing lands - and the composition that
71
+ silently dropped it and the workspace-files takeover is repaired and
72
+ pinned by a test that runs the plugin's real patch file through the
73
+ loader's real patch algorithm.
74
+
75
+ ### Added
76
+
77
+ - **The root-plane filesystem routes by coordinate.** The root `ctx.fs`
78
+ (behind the GUI's change feed and every un-mounted root consumer) is
79
+ replaced by a coordinate-routing variant (`lib/fs-routing.js`): distro UNC
80
+ identities are served by the resident agent - ext4 reads, and real watch
81
+ events for the change feed, which previously never fired on a 9p share -
82
+ while drive paths stay host-native. The root write fence retires with this
83
+ round: the root plane has no write consumers (the model's writes ride the
84
+ preset filesystems, which enforce their own containment). Design note:
85
+ `docs/root-fs-routing.md`.
86
+
87
+ ### Fixed
88
+
89
+ - **The 0.7.1 composition never mounted what it announced.** The
90
+ `workspace-files` disable, the `fs-sandbox` disable, and both replacement
91
+ rows were written inside a bare `insert:` list — but a patch layer is a
92
+ flat list: an `insert` list is data to append, never nested patches. The
93
+ loader therefore mounted the two disable rows as duplicate-id anonymous
94
+ entries that *shadowed the real upstream rows* (`EntryGroup.update` keys
95
+ rows by id, last occurrence wins): the running app had no root `ctx.fs`
96
+ and no GUI file-tree service at all, while `fs-routing` and
97
+ `workspace-files-wsl` - trapped inside nested `insert:` lists - never
98
+ started. Opening a distro workspace failed accordingly. Both takeovers are
99
+ now top-level entries: disable by id, then append the replacement with a
100
+ bare insert. `test/composition.test.mjs` runs the plugin's actual patch
101
+ file through the loader's actual `applyEntryPatches` over stand-in
102
+ upstream rows, asserting every composed row is a real named entry, ids
103
+ stay unique, the shipped rows are disabled under their own names, and
104
+ both replacements mount - the shape cannot regress silently again.
105
+ - The terminal-activity live check marked its shell with a runtime
106
+ `export`, which `/proc/<pid>/environ` never shows (it reflects the
107
+ exec-time block only), so the probe could never count past one and the
108
+ busy assertion failed deterministically. The marker now rides the spawn
109
+ environment - what `wsl.exe` does when it imports `DSH_TERMINAL_ID`
110
+ before `--exec`.
111
+
112
+ ## [0.7.1] - 2026-10-04
113
+
114
+ One theme: the remaining root-plane 9P consumers move inside the distro -
115
+ the GUI file tree and previews, and the workspace-changes git snapshots -
116
+ plus the test-infrastructure maturity (a REAL-composition boot test and
117
+ the pure text-mechanics refusals under direct unit test) that the
118
+ promotion work was built on.
119
+
120
+ ### Added
121
+
122
+ - **git snapshot commands run the distro's git.** `workspace-changes`
123
+ snapshots a workspace by spawning the host git through
124
+ `ctx.subprocess.spawn` — on a distro workspace that was Windows git
125
+ scanning the full work tree across the 9p share, the most
126
+ metadata-heavy operation there is. Spawns whose working directory names
127
+ a distro and whose argv carries none of the absolute-output discovery
128
+ flags are rewritten into `wsl.exe -d <distro> --cd <linux dir> --exec
129
+ git …` (`lib/git-route.js`). The snapshot is safe to route because it
130
+ already runs in a fully isolated git environment (`GIT_INDEX_FILE` /
131
+ `GIT_OBJECT_DIRECTORY` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` in the
132
+ tool's private scratch — the repository's index, object store, work
133
+ tree, and refs stay unchanged); the route translates those path-shaped
134
+ env values into distro coordinates and forwards them through `WSLENV`.
135
+ Verified against a live distro: the snapshot sequence completes with the
136
+ user's index, object store, and refs untouched.
137
+ - **The GUI file tree and previews are served from inside the distro.**
138
+ `workspace-files` - the root-plane consumer behind the GUI's file tree
139
+ and previews - read UNC workspaces through the 9p share. The plugin
140
+ replaces the composition row with a distro-routed variant
141
+ (`lib/workspace-files-wsl.js`): listings, stats, line-paged reads and
142
+ byte windows for a \\wsl.localhost\\<distro> workspace run through the
143
+ resident agent, with the upstream refusals mirrored (not-found,
144
+ outside-workspace, not-directory, not-regular-file, too-large,
145
+ not-text) and `absolutePath` synthesized in the UNC display form so
146
+ the GUI is unchanged. Windows-folder sessions keep the shipped
147
+ host-backed service; the watch stream delegates unchanged.
148
+ (`lib/workspace-files-route.js` holds the pure builders/parsers;
149
+ `test/workspace-files-wsl.test.mjs` covers every wire shape and refusal
150
+ over a fake agent runner.)
151
+ - **bootstrap installs and reports git.** The git snapshot routing needs
152
+ git inside the distro; `bootstrap --install` includes it in every
153
+ package manager's list and the report names it alongside ripgrep (the
154
+ snapshot backend for workspace-changes).
155
+ - **A REAL-composition boot test.** The plugin's fs row is booted through
156
+ the cordis Loader from a test-only yml composition and asserted against
157
+ a live distro read (`test/fs.boot.test.mjs`), alongside direct unit
158
+ tests for the pure text mechanics' refusal edges
159
+ (`test/fsio-text.test.mjs`).
160
+
161
+ ## [0.7.0] - 2026-10-04
162
+
163
+ One theme: the 9p share is out of the model's reach. Two changes land together
164
+ because they close the same measured gap — the file-search spawn was the one
165
+ model-facing I/O path that still crossed the share, in its slow direction
166
+ (~2 s per search where the distro's own rg answers in single-digit
167
+ milliseconds; `\\wsl.localhost` metadata round trips at ~17 ms per file), and
168
+ the share substrate was the configured way back onto that path.
169
+
170
+ ### Added
171
+
172
+ - **The directory picker lists distro levels from inside the distro.** A level
173
+ under `\\wsl.localhost\<distro>` is served by the resident agent's
174
+ `ls -1ALp` in one round trip — a one-shot `wsl.exe --exec ls` when the
175
+ resident is out, never the host's 9p walk, which paid one metadata round
176
+ trip per entry plus one more per symlink, in the share's slow direction.
177
+ `-L` folds the per-entry symlink stat the host walk used to pay; `-p`'s
178
+ trailing `/` is what the row parser keys on, and a broken link drops out
179
+ exactly as the old per-entry `stat` dropped it. `createDirectory` follows
180
+ the same route (`mkdir` through the agent, one-shot otherwise). The parse
181
+ is the pure `parseLsListing`/`lsListingArgv` pair in `lib/listing.js`
182
+ (`test/listing.test.mjs`); the accepted limitation — a filename containing
183
+ a newline would split into two rows — is documented in LIMITATIONS.md.
184
+ - **The file search runs the distro's own rg.** `WslSubprocessRuntime` now
185
+ overrides `spawn` beside `spawnTerminal`: a spawn of the packaged ripgrep
186
+ binary whose working directory names a distro (a `\\wsl.localhost\<distro>`
187
+ UNC — the form every distro workspace search carries) is rewritten into
188
+ `wsl.exe -d <distro> --cd <linux dir> --exec rg …` with every argument
189
+ forwarded verbatim, so the tool reads the distro rg's output format and exit
190
+ codes unchanged. The decision is the peer-free `lib/search-route.js`
191
+ (mirroring `terminal-route.js`), and the rewrite delegates to the shipped
192
+ spawn — the handle still wraps a real host process, so the consumer's output
193
+ budget, spill machinery and teardown all work unchanged. One guard rides
194
+ the rewrite: when the tool named no search path (its pattern rides
195
+ `--regexp=`; a bare `--` is how it spells a path), the rewrite appends
196
+ `-- .` — because `wsl.exe --exec` hands distro-side rg a relay fifo on
197
+ stdin, and rg's readable-stdin heuristic then searches stdin instead of the
198
+ directory. The packaged binary saw a non-readable NUL stdin under the old
199
+ path, which is why the heuristic never fired there. A drive-path
200
+ directory keeps the host binary, which is native there; a distro without rg
201
+ surfaces rg's own "command not found" (exit 127) instead of a silent fall
202
+ back to the share — `npm run bootstrap -- <distro> --install` installs it.
203
+ There is no 9p path left to fall back to, by design. (`test/search-route.test.mjs`.)
204
+ - **The search rides the warm resident, not a one-shot spawn.** The rewrite's
205
+ distro command is exec'd by the resident agent — one round trip (~10 ms)
206
+ instead of a `wsl.exe` process startup (~400 ms) per search. The tool's
207
+ handle surface is small (`done` plus `collected`), and `lib/search-exec.js`
208
+ builds exactly that facade: the agent's out state is the lazy fallback cue,
209
+ delegating to the one-shot `wsl.exe --exec` handle inside `done` — never an
210
+ error, never the share. The tool's output budget rides the agent's
211
+ per-stream capture cap; cancellation and `kill()` abort the agent exec and
212
+ settle as the killed outcome the one-shot path reports.
213
+ (`test/search-exec.test.mjs`.)
214
+ - **Idle distro terminals are reclaimed.** The controller reclaims an
215
+ unattended terminal after 2 h of observed idleness, but its observation is
216
+ host-side and a distro terminal can never answer it: the shipped
217
+ shell-activity integration is gated to interactive `bash`/`zsh` launched
218
+ directly on a POSIX host, every branch of `inspectActivity` settles on
219
+ `unknown` for a `wsl.exe` terminal, and `unknown` never accumulates idle —
220
+ so distro terminals had to be closed by hand. The terminal is now marked at
221
+ launch (a per-terminal `DSH_TERMINAL_ID` through the existing `WSLENV`
222
+ forwarding) and its `inspectActivity` counts the marked processes through
223
+ the resident agent: exactly one (the shell at its prompt) is idle, more
224
+ than one (a running command or a nested shell) is busy, and the agent
225
+ being out answers `unknown` — reclamation pauses, because a probe that
226
+ cannot answer must not authorize a close. A nested shell keeps the
227
+ terminal busy by design, as does anything a shell's startup files spawn
228
+ with the inherited marker. `terminalIdleReclaim: false` restores
229
+ close-by-hand. (`lib/terminal-activity.js`, `test/terminal-activity.test.mjs`.)
230
+
231
+ ### Breaking changes
232
+
233
+ - **`substrate: "share"` is refused at construction.** The Windows-side share
234
+ backend was the file tools' opt-out onto the 9p share; the resident agent
235
+ serves every distro read and write, and no file tool crosses the share any
236
+ more. A profile still carrying the value fails to boot with the migration
237
+ (`shareSubstrateRefusal`): delete the `substrate` line or set it to
238
+ `"agent"`. The failure message for an agent that is out
239
+ (`substrateFailure`) no longer offers the share as a way out and names the
240
+ recovery path instead. The share-only plumbing (the symlink retry, the 9p
241
+ publication hooks, and the `agent` and `resolveSymlinks` keys that gated
242
+ them) is removed with this change — `lib/fs-publish.js` with it.
243
+
15
244
  ## [0.6.0] - 2026-10-04
16
245
 
17
246
  One theme: the P2 backlog from the 2026-10-03 review, closed with the same
package/README.md CHANGED
@@ -57,11 +57,11 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
57
57
  | `wsl-fs` | `distro` | `''` | as above |
58
58
  | | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
59
59
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
60
- | | `substrate` | `agent` | which I/O substrate serves the file tools: `agent` — the resident in-distro agent, where reads, writes and identities run on ext4 (native symlinks and mode bits; the write guard survives to publication, kernel-enforced under a confined policy); `share` — the opt-out: the Windows-side host stack over the 9p share. Both fence writes with the same policy; see [LIMITATIONS.md](docs/LIMITATIONS.md) for what changes |
60
+ | | `substrate` | `agent` | which I/O substrate serves the file tools. Only the resident in-distro agent remains: reads, writes and identities run on ext4 (native symlinks and mode bits; the write guard survives to publication, kernel-enforced under a confined policy). The former `"share"` opt-out — the Windows-side host stack over the 9p share — is refused at boot; no file tool crosses the share |
61
61
  | `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
62
62
  | `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
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
+ [`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.
65
65
 
66
66
  ## Recipes
67
67
 
@@ -74,7 +74,7 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
74
74
  (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
75
75
  9p — noticeably slow for many small files; keep heavy-IO projects on the
76
76
  distro filesystem. `npm run bootstrap -- <distro>` also reports whether
77
- ripgrep and inotifywait (the search and watch backends) are in place.
77
+ ripgrep, git and inotifywait (the search, snapshot and watch backends) are in place.
78
78
  - **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
79
79
  This plugin admits the managed `DSH_*` namespace by prefix, translating the
80
80
  two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
@@ -100,7 +100,7 @@ preset-wsl (the wsl agent preset; its services run in isolate realms)
100
100
 
101
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
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.
103
+ **Commands and files.** A command runs as `wsl.exe -d <distro> --cd <linux dir> --exec <login shell> -lc <cmd>` inside a distro-side bubblewrap profile assembled with the same arguments as DSH's own Linux runner, so confinement semantics and error messages match a Linux host. The file tools read and write real distro files on the resident in-distro agent — reads, writes and identities run on ext4 with native symlinks and mode bits — and the file-search spawn is rewritten the same way: a search over a distro workspace runs the distro's own `rg` (`wsl.exe --exec`), never the Windows binary over the 9p share. Writes are checked against the same policy the command sandbox enforces.
104
104
 
105
105
  ## Sandbox
106
106
 
@@ -129,8 +129,8 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
129
129
  | the terminal still opens `cmd.exe` | the `terminal-controller` row from the layer did not apply | check that `dsh --profile wsl --dump-config` shows `shell: { path: wsl.exe, name: WSL }` |
130
130
  | changes to `lib/` have no effect | ES module cache | restart the app |
131
131
  | `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the runtime mirror, see [Development](#development) |
132
- | `glob` and `grep` are slow | the Windows-side ripgrep walks the 9p share | expected; narrow the path, or use `bash` to call tools inside the distro |
133
- | the terminal reports `unknown` activity | shell integration only covers `bash`/`zsh` started directly on a POSIX host | close the tab to release the process; idle reclamation does not run for these terminals |
132
+ | `glob` and `grep` are slow | a distro search without rg inside the distro falls back to rg's own "command not found"; a Windows-folder search is native and unaffected | run `npm run bootstrap -- <distro> --install` (installs ripgrep); distro searches always run the distro's rg — they never walk the 9p share |
133
+ | the terminal reports `unknown` activity | only while the resident agent is out — distro terminals are observed from inside the distro (a `DSH_TERMINAL_ID` marker scanned in `/proc`: a shell alone is `idle`, a shell running anything is `busy`) | check the distro is running; idle terminals are reclaimed automatically after the controller's unattended timeout (2 h by default), and `terminalIdleReclaim: false` restores the close-by-hand posture |
134
134
  | a result names an `FS_*` code | the code says what refused it, and what clears it | see the error-code table in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#error-codes) |
135
135
 
136
136
  ## Development
@@ -12,7 +12,11 @@
12
12
  # one thing a distro is guaranteed to have is a shell.
13
13
  #
14
14
  # Handshake — the first line the agent writes:
15
- # HELLO|wsl-agent|<protocol version>
15
+ # HELLO|wsl-agent|<protocol version>|<sha256 of this script file>
16
+ # The digest lets the host verify that the script it is about to rely on is
17
+ # byte-identical to the copy it shipped. The script is read in place — a
18
+ # /mnt/c mirror in the deployed layout — so a stale or half-synced runtime
19
+ # copy would otherwise pass the version handshake while drifting in behavior.
16
20
  # Requests the agent accepts, one per line on stdin:
17
21
  # PING -> PONG
18
22
  # SHUTDOWN -> exits 0
@@ -57,11 +61,55 @@
57
61
 
58
62
  set -u
59
63
 
60
- PROTO_VERSION=3
64
+ PROTO_VERSION=4
61
65
  TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
62
66
  OUT_FILE="$TMPDIR_AGENT/out"
63
67
  ERR_FILE="$TMPDIR_AGENT/err"
64
- trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; exit 0' EXIT
68
+ trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; [ -z "${WATCHDOG_PID:-}" ] || kill "$WATCHDOG_PID" 2>/dev/null; exit 0' EXIT
69
+
70
+ # The content identity of this script file, reported in the handshake: the
71
+ # host hashes its own shipped copy and refuses an agent whose file differs.
72
+ # cut with an explicit space: sha256sum separates hash and file with spaces,
73
+ # and the default tab delimiter would leave the whole line in the field.
74
+ AGENT_DIGEST=$(sha256sum -- "$0" 2>/dev/null | cut -d' ' -f1)
75
+
76
+ # Bounded cleanup: temp dirs from agents whose host died without giving them
77
+ # an EXIT (a `wsl --shutdown`, a force-killed wsl.exe). Own pattern only, and
78
+ # only when nothing inside moved for an hour — a live agent's dir mtime moves
79
+ # on every request (the inflight marker in run_capture is created and
80
+ # removed), so a sibling being served right now is never touched. A confined
81
+ # agent's /tmp is a private tmpfs: its own dir vanishes with it and it sees
82
+ # no siblings, so the sweep is a no-op there.
83
+ for stale in "${TMPDIR:-/tmp}"/wsl-agent.*; do
84
+ [ -d "$stale" ] || continue
85
+ [ "$stale" = "$TMPDIR_AGENT" ] && continue
86
+ [ -z "$(find "$stale" -maxdepth 1 -mmin -60 -print -quit 2>/dev/null)" ] && rm -rf -- "$stale" 2>/dev/null
87
+ done
88
+
89
+ # The client lease: when the host goes silent without closing the pipe (a
90
+ # wedged relay, not a dead one — EOF exits the read loop), the agent
91
+ # terminates itself after the lease window instead of lingering with its
92
+ # temp dir. The window rides DSH_AGENT_LEASE_MS from the host's pinned
93
+ # environment, set above the host's own idle shutdown, so the lease only
94
+ # fires when the host is gone in every way that matters. A request in
95
+ # flight pauses the lease (the inflight marker) — a long command with no
96
+ # traffic is silence, not absence. Unset (an older host, a manual run)
97
+ # disables the lease: the host keeps the lifetime it always had.
98
+ LEASE_S=0
99
+ case ${DSH_AGENT_LEASE_MS:-} in
100
+ ''|*[!0-9]*) ;;
101
+ *) if [ "$DSH_AGENT_LEASE_MS" -gt 0 ]; then
102
+ LEASE_S=$(( (DSH_AGENT_LEASE_MS + 999) / 1000 ))
103
+ (
104
+ while :; do
105
+ sleep "$LEASE_S" || exit 0
106
+ kill -0 "$$" 2>/dev/null || exit 0
107
+ [ -f "$TMPDIR_AGENT/inflight" ] || kill -s TERM "$$"
108
+ done
109
+ ) &
110
+ WATCHDOG_PID=$!
111
+ fi ;;
112
+ esac
65
113
 
66
114
  # The request currently in flight, for KILL and for the exit trap.
67
115
  CURRENT_PID=
@@ -150,6 +198,10 @@ run_capture() {
150
198
  : >"$OUT_FILE"
151
199
  : >"$ERR_FILE"
152
200
  CURRENT_ID=$req_id
201
+ # The inflight marker pauses the lease watchdog for this request and
202
+ # refreshes the temp dir's mtime for the boot sweep; removing it after the
203
+ # answer re-arms both.
204
+ : >"$TMPDIR_AGENT/inflight"
153
205
  if [ "$mode" = subshell ]; then
154
206
  ( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
155
207
  elif [ -n "$SETSID" ]; then
@@ -192,6 +244,7 @@ run_capture() {
192
244
  printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
193
245
  "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")" 0 0
194
246
  fi
247
+ rm -f "$TMPDIR_AGENT/inflight"
195
248
  }
196
249
 
197
250
  # --- filesystem substrate (FS frames) -------------------------------------
@@ -488,7 +541,7 @@ fs_dispatch() {
488
541
  esac
489
542
  }
490
543
 
491
- printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
544
+ printf 'HELLO|wsl-agent|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST"
492
545
 
493
546
  while IFS= read -r line; do
494
547
  case $line in
package/cordis.patch.yml CHANGED
@@ -102,6 +102,54 @@
102
102
 
103
103
  - id: directory-picker-browse-surface
104
104
  name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'
105
+
106
+ # ═════════════════════════════════════════════════════════════════════════════
107
+ # The distro-side takeovers of the two root-plane services.
108
+ #
109
+ # A patch layer is a flat list: an `id:` entry patches the shipped row, and a
110
+ # bare `insert:` list appends new rows as data. A disable written INSIDE an
111
+ # insert list is not a patch — it becomes a duplicate-id data row that shadows
112
+ # the real service row (the loader keys rows by id, last occurrence wins), and
113
+ # a nested `insert:` list is never interpreted at all. Both takeovers are
114
+ # therefore two top-level entries: disable the shipped row by id, then append
115
+ # the replacement with a bare insert. test/composition.test.mjs runs this file
116
+ # through the real patch algorithm to keep the shape honest.
117
+ # ═════════════════════════════════════════════════════════════════════════════
118
+
119
+ # The GUI file tree and previews for a distro workspace are served from inside
120
+ # the distro (resident agent) instead of Windows-side 9P walks. The variant
121
+ # extends the shipped service — a distro UNC routes distro-side, a drive path
122
+ # calls super (host native), the watch stream delegates unchanged — so
123
+ # replacing the row is safe for Windows-folder sessions too.
124
+ - id: workspace-files
125
+ disabled: true
126
+
127
+ # The root-plane filesystem routes by coordinate: distro UNC identities are
128
+ # served by the resident agent (real watch events, ext4 reads), drive paths
129
+ # stay host-native. Replaces the root fs-sandbox row, whose write fence has no
130
+ # root-plane consumers (the model's writes ride the preset filesystems; see
131
+ # docs/root-fs-routing.md).
132
+ - id: fs-sandbox
133
+ disabled: true
134
+
135
+ - insert:
136
+ - id: workspace-files-wsl
137
+ name: 'dsh-plugin-wsl-env/workspace-files-wsl'
138
+ config:
139
+ maxBytes: 2097152
140
+ maxFileBytes: 33554432
141
+ maxLines: 5000
142
+ maxEntries: 2000
143
+
144
+ - id: fs-routing
145
+ name: 'dsh-plugin-wsl-env/fs-routing'
146
+ config:
147
+ distro: ''
148
+ wslPath: wsl.exe
149
+ sandbox: true
150
+ maskWindowsDrive: false
151
+ restrictToDistro: true
152
+ watchMaxDepth: 0
105
153
  # Contributes the WSL environment's facts (distro / real shell / home) to the
106
154
  # managed DSH_* namespace that dsh-shell-env injects into every model shell
107
155
  # call. That is DSH's sanctioned channel for shell-environment facts.
@@ -19,8 +19,8 @@ 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 resident in-distro agent on ext4 (default), or the Windows-side host stack over the distro's UNC share. |
23
- | `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace. |
22
+ | `ctx.fs` | `WslFileSystem` | [`lib/index.js`](../lib/index.js) | Serves the file tools from real distro files on the resident in-distro agent, on ext4 — the former Windows-side share substrate is retired (see below). |
23
+ | `ctx.subprocess` | `WslSubprocessRuntime` | [`lib/subprocess.js`](../lib/subprocess.js) | Opens the GUI terminal inside the distro, in the session workspace, and rewrites the file-search spawn into the distro's own rg (see below). |
24
24
 
25
25
  `WslShellExecutor` extends the shipped `LocalBashExecutor`, and `WslFileSystem`
26
26
  extends the shipped `LocalFileSystem`. Only the parts that need a distro are
@@ -30,7 +30,7 @@ overridden.
30
30
 
31
31
  | Subpath | Class or name | File | What it does |
32
32
  |---|---|---|---|
33
- | `dsh-plugin-wsl-env/picker` | `WslDirectoryPicker` | [`lib/picker.js`](../lib/picker.js) | Adds the installed distros to the folder picker, next to the Windows home directory. |
33
+ | `dsh-plugin-wsl-env/picker` | `WslDirectoryPicker` | [`lib/picker.js`](../lib/picker.js) | Adds the installed distros to the folder picker, next to the Windows home directory, and lists distro levels from inside the distro (the resident's `ls`, one-shot `wsl.exe` when it is out). |
34
34
  | `dsh-plugin-wsl-env/auto-preset` | `auto-preset` | [`lib/auto-preset.js`](../lib/auto-preset.js) | Binds the `wsl` agent preset when a new session's workspace is inside a distro. |
35
35
  | `dsh-plugin-wsl-env/shell-env` | `wsl-shell-env` | [`lib/shell-env.js`](../lib/shell-env.js) | Contributes `DSH_WSL_DISTRO`, `DSH_WSL_SHELL`, and `DSH_WSL_HOME` to the managed `DSH_*` namespace. |
36
36
 
@@ -89,7 +89,7 @@ Three coordinate systems appear in the code:
89
89
  | System | Example | Used by |
90
90
  |---|---|---|
91
91
  | Linux inside the distro | `/home/you/proj/a.ts` | commands, `ctx.shell` |
92
- | The distro's UNC share | `\\wsl.localhost\ubuntu\home\you\proj\a.ts` | the host filesystem stack, `ctx.fs` |
92
+ | The distro's UNC share | `\\wsl.localhost\ubuntu\home\you\proj\a.ts` | the harness's path identities (`ctx.fs` target keys), the picker and terminal routing — a coordinate, not an I/O path: no file tool reads or writes through the share |
93
93
  | The Windows filesystem | `C:\Users\you\...`, or `/mnt/c/Users/you/...` from inside | sessions opened on a Windows folder |
94
94
 
95
95
  [`lib/paths.js`](../lib/paths.js) converts between them. It is pure: it imports no
@@ -98,22 +98,35 @@ path sits under a writable root. The filesystem fence uses them.
98
98
 
99
99
  ## The filesystem substrate
100
100
 
101
- `wsl-fs` serves `ctx.fs` on one of two I/O substrates, chosen by
102
- `substrate: share | agent` (default `agent`):
103
-
104
- | | `agent` (default) | `share` (opt-out) |
105
- |---|---|---|
106
- | Reads, writes, identities | the resident in-distro agent, on ext4 | the Windows-side host stack (`dsh-fs-local`) over the distro's 9p share |
107
- | Symlinks | native; every identity is a distro-side `realpath` | the share cannot traverse them, so a missing read is retried once through the distro's canonical path |
108
- | Mode bits | native; the publication rename is the kernel's | the share drops a host-side chmod, so publication re-applies the mode from inside the distro before an atomic rename |
109
- | Guarded creates | the guard is forwarded; publication is a no-replace link, closing the check-then-write window for creates. An overwrite or edit carries its version into the write op and the agent re-verifies it one syscall before the rename — a concurrent writer wins, the stale write refuses with `FS_STALE_VERSION` | checked, then published without a guard (the share has no hard links) |
110
- | Kernel enforcement | stage two: a mutation runs on a confined resident — one long-lived agent per (distro, policy), whose bwrap profile binds exactly what the mode grants — so the kernel refuses what the check would have, and a check bug cannot become a write outside the workspace. Escalated writes (`danger-full-access`) ride the plain resident. A kernel refusal carries `FS_SANDBOX_DENIED`, the same dialect as the command path. | none: the fence is the host-side check |
111
- | New files | 0600 — the host backend's own POSIX publication semantics, as on a Linux host | the distro's umask default (0644) |
112
- | Agent outage | `FS_IO_ERROR` naming the substrate; no silent fallback to the share. A distro without usable bwrap refuses confined mutations with the bootstrap command, the same closed failure the command path has | not applicable |
113
-
114
- Both substrates fence writes with the same host-side policy check
115
- (`checkedTarget`), and neither changes what `sandboxMode` reports. The wire
116
- protocol the agent substrate speaks is documented in
101
+ `wsl-fs` serves `ctx.fs` on one I/O substrate: the resident in-distro agent.
102
+ The former Windows-side share backend (`substrate: "share"`) is retired — a
103
+ profile carrying the value is refused at boot, and no file tool crosses the
104
+ 9p share. The substrate's facts:
105
+
106
+ - **Reads, writes, identities** run on ext4 through the resident; symlinks and
107
+ mode bits are native (every identity is a distro-side `realpath`; the
108
+ publication rename is the kernel's).
109
+ - **Guarded writes** carry their intent to the publication: a `createIfAbsent`
110
+ publishes with a no-replace link, closing the check-then-write window for
111
+ creates outright; an overwrite or edit carries its version into the write op
112
+ and the agent re-verifies it one syscall before the rename — a concurrent
113
+ writer wins, the stale write refuses with `FS_STALE_VERSION`.
114
+ - **Kernel enforcement** is stage two: a mutation runs on a confined resident —
115
+ one long-lived agent per (distro, policy), whose bwrap profile binds exactly
116
+ what the mode grants — so the kernel refuses what the check would have, and a
117
+ check bug cannot become a write outside the workspace. Escalated writes
118
+ (`danger-full-access`) ride the plain resident. A kernel refusal carries
119
+ `FS_SANDBOX_DENIED`, the same dialect as the command path.
120
+ - **New files** publish 0600 — the host backend's own POSIX publication
121
+ semantics, as on a Linux host.
122
+ - **Agent outage** fails closed: `FS_IO_ERROR` naming the substrate and the
123
+ recovery path. There is no fallback I/O; a distro without usable bwrap
124
+ refuses confined mutations with the bootstrap command, the same closed
125
+ failure the command path has.
126
+
127
+ Writes fence with the same host-side policy check (`checkedTarget`) the
128
+ command path uses, and the substrate does not change what `sandboxMode`
129
+ reports. The wire protocol the substrate speaks is documented in
117
130
  [`agent/wsl-agent.sh`](../agent/wsl-agent.sh); the orchestration layers are
118
131
  [`lib/fsio-agent.js`](../lib/fsio-agent.js) (fsio's mechanics over the FS
119
132
  frames), [`lib/fs-substrate.js`](../lib/fs-substrate.js) (the provider-shaped
@@ -23,8 +23,7 @@ are schema defaults, listed because they are the ones worth knowing.
23
23
  | | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
24
24
  | | `sandbox` | `true` | check `writeText` and `editText` against the policy |
25
25
  | | `maskWindowsDrive` | `false` | as on `wsl-shell`: confined fs mutations publish through a resident whose profile shadows `/mnt` (same honest limits) |
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
- | | `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 |
26
+ | | `substrate` | `agent` *(shipped)* | which I/O substrate serves the file tools. Only the resident in-distro agent (`lib/fs-substrate.js`) exists: 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. The former `share` opt-out (the Windows-side host stack over the 9p share) is refused at boot; delete the line |
28
27
  | | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
29
28
  | | `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 |
30
29
  | `directory-picker-wsl` | `preferredDistro` | `''` *(shipped)* | the distro listed first in the picker |
@@ -34,6 +33,7 @@ are schema defaults, listed because they are the ones worth knowing.
34
33
  | | `shell` | `''` | pin a shell; empty lets `wsl.exe` decide |
35
34
  | | `loginShell` | `true` | whether a pinned shell uses login semantics |
36
35
  | | `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 |
36
+ | | `terminalIdleReclaim` | `true` | let the controller reclaim an idle distro terminal: the launch is marked with a per-terminal `DSH_TERMINAL_ID`, and the handle's `inspectActivity` counts the marked processes through the resident agent — one (the shell at its prompt) is idle, more than one (a running command or a nested shell) is busy, `unknown` (agent out) pauses reclamation. `false` restores close-by-hand |
37
37
 
38
38
  You can override the other keys in the same way; they keep their defaults. Those are
39
39
  `wslPath`, `hostCwd` and `forwardEnv` on the shell and terminal rows,