dsh-plugin-wsl-env 0.1.9 → 0.2.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,127 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ### Fixed
16
+
17
+ - **`truncated` missing from the agent path's output streams** — the bash tool's
18
+ canonical result copies the field unconditionally (`canonicalBashResult`), so an
19
+ `undefined` failed the wire's lossless-JSON snapshot and EVERY shell tool call
20
+ returned "tool bash returned invalid output: value is not lossless JSON". The
21
+ agent path now always reports `truncated: false` (it never truncates; there are
22
+ no spill files). Regression assertion in `test/agent-exec.test.mjs`.
23
+ - **The agent execution path failed every shell call whose request carried an
24
+ absolute UNC workdir** — which is every call a GUI session makes, so the
25
+ harness's own agent session lost its bash tool until the fix. The one-shot
26
+ path converts the spec's workdir inside `argv()` (`toLinuxPath`); the agent
27
+ path carried the workdir as a raw frame field, so the raw UNC reached the
28
+ in-distro `chdir`, the synthesized relay text fired `throwIfWorkdirMissing`,
29
+ and the call died with "could not enter the working directory". The conversion
30
+ now happens at the call site (regression test in `test/shell.test.mjs`).
31
+
32
+ ### Added
33
+
34
+ - **Phase 5: per-session terminal routing.** `spawnTerminal` now routes on the
35
+ request's directory coordinates (`lib/terminal-route.js`, `subprocess-wsl.hostSessions`,
36
+ default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
37
+ a host `powershell.exe` in its own directory — no more distro terminal at
38
+ `/mnt/<drive>/…` for host sessions. The shell menu stays the single configured profile,
39
+ and every non-`wsl.exe` consumer is untouched. Still limited (LIMITATIONS.md): the
40
+ shell menu is one entry, and directory-less launches land in the distro.
41
+ - **Phase 7: developer-loop recipes.** Both READMEs gain a Recipes section: Git
42
+ credential sharing through the Windows Credential Manager, `/mnt/c` 9p
43
+ performance guidance, and the `WSLENV` passthrough policy (prefix-admitted
44
+ `DSH_*`, `/p` translation, `PATH` deliberately excluded).
45
+ - **Phase 6: listening-port visibility.** `DSH_WSL_PORTS` joins the managed
46
+ `DSH_*` facts: the ports with a listener inside the distro, comma-separated,
47
+ refreshed every `portsRefreshMs` (default 10s) through the resident agent by
48
+ reading `/proc/net/tcp{,6}` directly — no `ss`, nothing to install. The
49
+ registry's resolve is synchronous by contract, so the snapshot is served
50
+ stale-but-recent; a dev server started moments ago appears on the next
51
+ refresh. Scoped as before: only sessions whose workspace is inside a distro
52
+ see it. Probed live: a real HTTP server appears in the next snapshot.
53
+ - **Phase 4: bootstrap.** `npm run bootstrap -- [distro] [--install]` checks the
54
+ distro for bubblewrap (required) plus ripgrep and inotifywait (the optional
55
+ backends later phases prefer), prints the exact copy-paste install command for
56
+ the distro's package family (apt/dnf/pacman/zypper), and with `--install` runs
57
+ it as the distro's root through `wsl.exe -u root` — no sudo prompt inside the
58
+ distro, nothing touches the Windows side. The `SANDBOX_UNAVAILABLE` message now
59
+ names the same command instead of a bare `sudo apt install`. Probed live:
60
+ detect mode on a distro that already has bubblewrap.
61
+ - **Phase 3: shell execution through the agent.** `WslShellExecutor` now runs commands
62
+ over the resident agent when it is up (`wsl-shell.agent`, default on): one long-lived
63
+ in-distro process instead of a `wsl.exe` per command, with the timeout enforced
64
+ INSIDE the distro (TERM, then KILL after the grace) instead of against the Windows
65
+ process tree. The confinement argv, the execution-handle shape, the result
66
+ decoration (runner failures, denials, `enforcement: partial`) and the workdir
67
+ failure are all identical to the one-shot path — an out-of-service agent falls back
68
+ to it per call, permanently after the agent's rebuild budget. Probed end-to-end:
69
+ `npm run probe:exec` (confined completion, verbatim shell parsing, in-distro
70
+ timeout, relay-shaped cwd failure, kill). Documented deviation: the agent path has
71
+ no live output streaming — reads after settlement carry the whole stream, and no
72
+ spill files (the protocol line already holds it in memory).
73
+ - **Phase 2 (second half, first slice): in-distro publication through the agent.**
74
+ `WslFileSystem` now carries an optional resident agent (`wsl-fs.agent`, default on):
75
+ when available, publication — the replaced file's mode copied onto the staged temp,
76
+ then the atomic rename — is ONE `exec` inside the distro (`lib/fs-publish.js`)
77
+ instead of a `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
78
+ legacy two-step path for that call; the flag changes round trips, never what is
79
+ published. Probed: the publication script's mode preservation and creation
80
+ behaviour run in `test/probe/agent.sh` (now 10 checks).
81
+ - **Phase 2 (first half): real `watch()`.** `WslFileSystem.watch` now observes from
82
+ inside the distro — a long-lived `wsl.exe` `find -newer` poll loop (`lib/watcher.js`)
83
+ firing the seam's coarse invalidation callback — instead of refusing with
84
+ `FS_IO_ERROR`. Activates on a READY barrier from the loop (a write racing the first
85
+ tick is never invisible), sees creations and deletions (both bump the parent's
86
+ mtime), and cleans up on close. Documented blind spot: files created with an
87
+ mtime older than the stamp (`cp -p`, `tar -x`). Probed end-to-end:
88
+ `npm run probe:watch`. The native-ext4 migration of the remaining fs operations
89
+ stays with Phase 3 of the plan.
90
+ - **Phase 1 of the Remote-WSL parity plan (`docs/PARITY.md`): the resident in-distro
91
+ agent.** `agent/wsl-agent.sh` is a dependency-free POSIX shell peer spawned once per
92
+ distro; `lib/agent.js` owns the lifecycle (handshake, lazy start, idle shutdown, one
93
+ rebuild, permanent fallback to the shipped one-shot `wsl.exe` path) and
94
+ `lib/agent-protocol.js` the line protocol (base64 payloads, NUL-safe argv). Not wired
95
+ into the seams yet — the executor and filesystem migrations are the next phases.
96
+ Probed end-to-end against a real distro: `test/probe/agent.sh` (8 checks, including
97
+ binary-safe payloads, an in-distro timeout kill, and a 2 MB single-line response).
98
+ Unit tests cover the codecs and the lifecycle state machine with a fake transport.
99
+
100
+ ### Added
101
+
102
+ - `docs/PARITY.md`: the plan to reach VS Code Remote-WSL-grade experience — a resident
103
+ in-distro agent as the core, with the `fs` and `shell` seams migrated onto it and the
104
+ current 9p/one-shot paths kept as the documented fallback — plus the Phase-0 contract
105
+ audit that shaped it (watch is a coarse invalidation callback; search spawns the
106
+ packaged ripgrep through `ctx.subprocess`, so the interception point is the
107
+ `subprocess-wsl` subclass).
108
+
109
+ ### Changed
110
+
111
+ - The package description is rewritten in Chinese to state what the plugin does first —
112
+ it makes a WSL distro the session's execution environment — then covers the execution,
113
+ UI, and sandbox aspects in one clause each.
114
+ - Chinese text now says “WSL 子系统” instead of “WSL 发行版”, the phrasing Chinese readers
115
+ more commonly use for a WSL distro (`docs/README.zh.md` and the two archived Chinese
116
+ documents; 99 occurrences).
117
+
118
+ ## [0.1.10] - 2026-10-02
119
+
120
+ Metadata and one documentation addition. No behaviour change.
121
+
122
+ ### Changed
123
+
124
+ - The package description now covers all four capabilities — commands, the file tools,
125
+ the folder picker, the GUI terminal — and the confinement, instead of naming only two
126
+ of them. It is the line npm shows under the package name in search results.
127
+
128
+ ### Documentation
129
+
130
+ - The error table in `docs/ARCHITECTURE.md` states exactly what the model sees on a
131
+ filesystem denial: the denial marker and the escalation hint, line for line. It also
132
+ records why that makes `FS_OUTSIDE_DISTRO` worth its own code — a code the tool layer
133
+ does not decorate with those lines.
134
+
135
+
15
136
  ## [0.1.9] - 2026-10-02
16
137
 
17
138
  The last test gap from the closure review: the documented opt-out. No runtime change.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Step 3 should print a layer named `# == dsh-plugin-wsl-env`, and `- id: terminal
28
28
 
29
29
  Then open a folder under `\\wsl.localhost\<distro>\...` in the GUI. The picker lists every installed distro at its root level, and **New terminal** opens a shell in the distro.
30
30
 
31
- You also need **bubblewrap inside the distro**: `sudo apt install bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
31
+ You also need **bubblewrap inside the distro**: run `npm run bootstrap -- <distro> --install` (drop `--install` for a read-only check), or paste `wsl.exe -d <distro> -u root -- apt-get install -y bubblewrap`. Without it every command fails closed, see [Sandbox](#sandbox).
32
32
 
33
33
  Uninstall: `dsh plugin --profile wsl remove dsh-plugin-wsl-env`. Upgrade: run the same `add` command again.
34
34
 
@@ -77,11 +77,28 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
77
77
 
78
78
  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.
79
79
 
80
+ ## Recipes
81
+
82
+ - **Git credential sharing**: let git inside the distro use the Windows-side Git
83
+ Credential Manager — `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
84
+ (adjust the path to your Windows Git install; WSL2's localhost forwarding is
85
+ platform behaviour, so ports a dev server listens on inside the distro are
86
+ reachable from Windows directly).
87
+ - **Paths and performance**: the model sees and operates on Linux paths
88
+ (`/home/...`) on the distro's own ext4. `/mnt/c` reaches the Windows disk over
89
+ 9p — noticeably slow for many small files; keep heavy-IO projects on the
90
+ distro filesystem. `npm run bootstrap -- <distro>` also reports whether
91
+ ripgrep and inotifywait (the search and watch backends) are in place.
92
+ - **WSLENV passthrough**: WSL imports only the variables listed in `WSLENV`.
93
+ This plugin admits the managed `DSH_*` namespace by prefix, translating the
94
+ two Windows-path ones (`DSH_HOME`, `DSH_PROFILE_DIR`) with `/p`. `PATH` is
95
+ deliberately never forwarded — it would shadow the distro's own PATH.
96
+
80
97
  ## Troubleshooting
81
98
 
82
99
  | Symptom | Cause | Fix |
83
100
  |---|---|---|
84
- | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `sudo apt install bubblewrap`, or set `sandbox: false` on both providers |
101
+ | every command reports `SANDBOX_UNAVAILABLE` | `bubblewrap` is not installed in the distro | run `npm run bootstrap -- <distro> --install`, or set `sandbox: false` on both providers |
85
102
  | a command or write is refused outside the session folder | expected behaviour of `workspace-write` | accept the wider-permission offer, or open a session on the folder you need |
86
103
  | writes are refused even inside the workspace | the session is in `read-only` mode | switch the Permissions selector |
87
104
  | `dsh plugin add` warns that no layer was activated | the dependency was already installed, so `add` had nothing to record | run `dsh plugin --profile wsl remove dsh-plugin-wsl-env`, then add it again |
@@ -0,0 +1,156 @@
1
+ #!/bin/sh
2
+ # wsl-agent — the resident in-distro peer of dsh-plugin-wsl-env.
3
+ #
4
+ # The plugin spawns one long-lived instance per distro:
5
+ #
6
+ # wsl.exe -d <distro> --exec <login shell> <this script>
7
+ #
8
+ # and speaks a line protocol over stdio. Fields are separated by `|`; binary
9
+ # payloads (cwd, argv words, env values, captured output) travel as single-line
10
+ # base64. The protocol deliberately avoids JSON: the agent must run under a
11
+ # bare POSIX sh with no dependencies beyond coreutils' `base64`, because the
12
+ # one thing a distro is guaranteed to have is a shell.
13
+ #
14
+ # Handshake — the first line the agent writes:
15
+ # HELLO|wsl-agent|<protocol version>
16
+ # Requests the agent accepts, one per line on stdin:
17
+ # PING -> PONG
18
+ # SHUTDOWN -> exits 0
19
+ # KILL|<id> -> SIGTERM the in-flight request with that id
20
+ # SETENV|<key>|<b64 value> -> export the variable for later requests
21
+ # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>
22
+ # followed by <n args> lines of base64, one argv word each
23
+ # -> RES|<id>|<exit code>|<b64 stdout>|<b64 stderr>
24
+ # or ERR|<id>|<reason>|<b64 message> (reason: cwd)
25
+ #
26
+ # Confinement is NOT this script's business: the host wraps argv in the bwrap
27
+ # profile (lib/sandbox.js) before the frame is sent, so the agent executes
28
+ # exactly what the host decided, sandbox included.
29
+ #
30
+ # Exit codes: 0 on SHUTDOWN or EOF; 130 on an interrupted read. A crash is the
31
+ # host's signal to rebuild once and then fall back to one-shot `wsl.exe` mode.
32
+
33
+ set -u
34
+
35
+ PROTO_VERSION=1
36
+ TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
37
+ OUT_FILE="$TMPDIR_AGENT/out"
38
+ ERR_FILE="$TMPDIR_AGENT/err"
39
+ trap 'rm -rf "$TMPDIR_AGENT"; exit 0' EXIT
40
+
41
+ # The request currently in flight, for KILL and for the exit trap.
42
+ CURRENT_PID=
43
+ CURRENT_ID=
44
+
45
+ b64dec() {
46
+ # `base64 -d` with GNU and BusyBox spellings; a decode failure yields empty.
47
+ printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 --decode 2>/dev/null || :
48
+ }
49
+
50
+ b64enc_file() {
51
+ # `tr` strips the 76-column wraps GNU and BusyBox base64 both emit; the
52
+ # protocol is one line per payload, so a wrapped encode would tear a large
53
+ # RES line into protocol garbage the host would read as unknown lines.
54
+ base64 "$1" 2>/dev/null | tr -d '\n' || base64 -- "$1" 2>/dev/null | tr -d '\n' || :
55
+ }
56
+
57
+ kill_current() {
58
+ if [ -n "$CURRENT_PID" ]; then
59
+ kill -TERM "$CURRENT_PID" 2>/dev/null
60
+ fi
61
+ }
62
+
63
+ shutdown() {
64
+ kill_current
65
+ exit 0
66
+ }
67
+ trap shutdown TERM INT HUP
68
+
69
+ timeout_watcher() {
70
+ # $1 = seconds, $2 = pid, $3 = grace seconds. TERM first; a process that
71
+ # ignores TERM gets KILLed after the grace, so a stuck command cannot hang
72
+ # the request forever.
73
+ sleep "$1"
74
+ kill -TERM "$2" 2>/dev/null
75
+ sleep "${3:-3}"
76
+ kill -KILL "$2" 2>/dev/null
77
+ }
78
+
79
+ printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
80
+
81
+ while IFS= read -r line; do
82
+ case $line in
83
+ PING)
84
+ printf 'PONG\n'
85
+ ;;
86
+ SHUTDOWN)
87
+ shutdown
88
+ ;;
89
+ KILL\|*)
90
+ kill_id=${line#KILL|}
91
+ if [ "$kill_id" = "$CURRENT_ID" ]; then
92
+ kill_current
93
+ fi
94
+ ;;
95
+ SETENV\|*)
96
+ rest=${line#SETENV|}
97
+ key=${rest%%|*}
98
+ b64=${rest#*|}
99
+ # Command substitution strips trailing newlines from the decoded value;
100
+ # acceptable for environment values, documented in the protocol.
101
+ decoded=$(b64dec "$b64")
102
+ export "$key=$decoded"
103
+ ;;
104
+ EXEC\|*)
105
+ header=${line#EXEC|}
106
+ req_id=${header%%|*}
107
+ rest=${header#*|}
108
+ cwd_b64=${rest%%|*}
109
+ rest=${rest#*|}
110
+ timeout_s=${rest%%|*}
111
+ nargs=${rest#*|}
112
+ cwd=$(b64dec "$cwd_b64")
113
+ if ! cd "$cwd" 2>/dev/null; then
114
+ printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
115
+ continue
116
+ fi
117
+ # Build the argv from the following base64 lines.
118
+ # shellcheck disable=SC2086
119
+ set --
120
+ n=0
121
+ while [ "$n" -lt "$nargs" ]; do
122
+ IFS= read -r arg_b64 || break
123
+ n=$((n + 1))
124
+ set -- "$@" "$(b64dec "$arg_b64")"
125
+ done
126
+ : >"$OUT_FILE"
127
+ : >"$ERR_FILE"
128
+ CURRENT_ID=$req_id
129
+ "$@" >"$OUT_FILE" 2>"$ERR_FILE" &
130
+ CURRENT_PID=$!
131
+ watcher_pid=
132
+ case $timeout_s in
133
+ ''|0) ;;
134
+ *)
135
+ timeout_watcher "$timeout_s" "$CURRENT_PID" 3 &
136
+ watcher_pid=$!
137
+ ;;
138
+ esac
139
+ wait "$CURRENT_PID"
140
+ rc=$?
141
+ CURRENT_PID=
142
+ CURRENT_ID=
143
+ if [ -n "$watcher_pid" ]; then
144
+ kill "$watcher_pid" 2>/dev/null
145
+ wait "$watcher_pid" 2>/dev/null
146
+ fi
147
+ printf 'RES|%s|%s|%s|%s\n' "$req_id" "$rc" "$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")"
148
+ ;;
149
+ *)
150
+ printf 'ERR||protocol|%s\n' "$(printf '%s' "unrecognized request" | base64 | tr -d '\n')"
151
+ ;;
152
+ esac
153
+ done
154
+
155
+ # EOF on stdin: the host is gone. The EXIT trap removes the temp dir.
156
+ exit 0
@@ -189,6 +189,14 @@ to be invisible:
189
189
  | the distro is missing, stopped or unregistered | a `Wsl/Service/WSL_E_*` code on **stdout**, UTF-16 on some builds, with an empty stderr and exit 255 | `WslShellExecutor` reads the code with the NULs stripped and fails with the distro name and the remedy. Deliberately not a sandbox code: a wider permission cannot create a distro |
190
190
  | the working directory cannot be entered | a relay line on stderr, exit **0**, and the command runs in `/` | `WslShellExecutor` fails with the directory and the fallback, instead of reporting a success that acted on the wrong tree |
191
191
 
192
+
193
+ What the model actually sees on a filesystem denial is two lines the tool layer
194
+ prepends: `[sandbox: file access denied under <mode> mode]`, then
195
+ `[sandbox: escalation available — retry this exact operation once with
196
+ sandbox_permissions (the narrowest wider mode that suffices) + justification; the
197
+ approval prompt asks the user]`. A code that is not `FS_SANDBOX_DENIED` gets neither
198
+ line — which is the reason `FS_OUTSIDE_DISTRO` has its own code.
199
+
192
200
  ## The runtime mirror
193
201
 
194
202
  The checkout lives inside the distro. A DSH profile can only link a Windows path,
@@ -14,6 +14,7 @@ are schema defaults, listed because they are the ones worth knowing.
14
14
  | `wsl-shell` | `distro` | `''` *(shipped)* | distro name; empty means WSL's default distro |
15
15
  | | `shell` | `''` | pin a shell inside the distro; empty means the user's login shell |
16
16
  | | `loginShell` | `true` *(shipped)* | use `<shell> -lc`, which sources your profile, instead of a bare `-c` |
17
+ | | `agent` | `true` | run commands over the resident in-distro agent (one long-lived process; in-distro timeout); any agent failure falls back to the one-shot `wsl.exe` path |
17
18
  | | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
18
19
  | | `cwd` | `''` | default working directory; empty means the distro user's home |
19
20
  | | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
@@ -50,3 +51,10 @@ confinement. `sandboxMode` then reports `undefined`, and the tool layer tells th
50
51
  model that these operations have no sandbox. That is the documented opt-out; see
51
52
  [ARCHITECTURE.md](ARCHITECTURE.md#sandbox) for what the sandbox does and does not
52
53
  cover.
54
+
55
+ ## `wsl-shell-env`
56
+
57
+ | Key | Default | Meaning |
58
+ | --- | --- | --- |
59
+ | `distro` | `''` | the distro the `DSH_WSL_*` facts describe |
60
+ | `portsRefreshMs` | `10000` | how often the `DSH_WSL_PORTS` listening-port snapshot refreshes through the resident agent |
@@ -31,13 +31,18 @@ boundary; one that is not is a bug report waiting to happen.
31
31
  checks the caller's guard itself, because the Windows backend publishes a guarded
32
32
  new file with a hard link, and the share does not support hard links. This is the
33
33
  race that `dsh-fs-sandbox` already documents.
34
- - `watch()` (file change monitoring) is not provided. Watching a 9p share is
35
- unreliable, so the plugin refuses instead of arming it.
34
+ - `watch()` observes from inside the distro (a `find -newer` poll loop, see
35
+ `lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
36
+ and they arrive on the poll cadence, not instantly. A file created with an mtime
37
+ older than the watcher's stamp (`cp -p`, `tar -x`) is not seen until something
38
+ else touches the tree. Watching a 9p share from the Windows side remains
39
+ unsupported — the loop runs where the kernel can actually report change.
36
40
  - `glob` and `grep` use the Windows-side ripgrep over the share: correct results, but
37
41
  not fast, and `.gitignore` is interpreted with Windows rules. `editText` reads the
38
42
  whole file into memory before writing it back.
39
- - The terminal belongs to the whole app, not to one session. A session opened on a
40
- Windows folder still gets the distro terminal, starting in `/mnt/<drive>/...`, and
41
- its shell menu is deliberately reduced to the one configured profile.
43
+ - Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
44
+ default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
45
+ `powershell.exe` in its own directory. The shell MENU remains the single configured
46
+ profile, and a directory-less launch still lands in the distro.
42
47
  - Terminal activity reporting stops at `wsl.exe`, so the controller never reclaims
43
48
  these terminals when they are idle.
package/docs/PARITY.md ADDED
@@ -0,0 +1,70 @@
1
+ # Parity with VS Code Remote-WSL
2
+
3
+ What parity means for this plugin, the seam contracts the plan was audited
4
+ against, and the phase checklist. Every phase below ends with rows of this
5
+ table ticked and this document updated in the same change.
6
+
7
+ ## What parity means here
8
+
9
+ Remote-WSL's experience rests on one thing: a resident server inside the
10
+ distro. Everything users love — native ext4 semantics, inotify, native
11
+ `rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
12
+ `wsl.exe` model can only approach it. The plan therefore lands a resident
13
+ in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
14
+ current paths as the documented fallback (`wsl.agent: 'off'` must equal
15
+ today's behaviour byte for byte).
16
+
17
+ Not applicable, recorded so the gap is a decision and not an omission:
18
+
19
+ - IntelliSense, debuggers, the extension marketplace: editor features. The
20
+ DSH analogue is tool latency and correctness (Phases 1–3).
21
+ - Settings Sync, Remote-SSH tunnel chaining, Codespaces: composition-layer
22
+ responsibilities, not this plugin's.
23
+
24
+ ## Contract audit (Phase 0)
25
+
26
+ Findings from reading the harness packages inside the app bundle; each one
27
+ shaped a phase of the plan.
28
+
29
+ 1. **`ctx.fs.watch` is a coarse invalidation callback, not an event stream.**
30
+ `FileSystem.watch(target, changed, signal)` (dsh-fs) resolves once
31
+ observation is active and returns an async close function; `changed` takes
32
+ no event payload. So the in-distro watcher does not need to classify
33
+ rename vs. modify — any inotify (or polling) hit may call `changed()`.
34
+ Phase 1's watcher is accordingly simpler than planned.
35
+ 2. **Search is not an `fs` seam.** `dsh-tool-fs-search` spawns the PACKAGED
36
+ ripgrep binary (`@vscode/ripgrep`) through `ctx.subprocess` with a plain
37
+ argv vector and no shell; raw stdout is parsed by the tool. The correct
38
+ interception point is therefore the plugin's `subprocess-wsl` subclass:
39
+ when `argv[0]` is the packaged rg binary and the spec's cwd is inside this
40
+ distro's share, rewrite the spawn to run a distro-side `rg` (falling back
41
+ to `grep -rE` + `find` when absent) with identical stdout conventions.
42
+ Under the agent, that exec rides the agent's `exec` capability.
43
+ 3. **`dsh-fs-local` watches with chokidar over the share** — the behaviour
44
+ this plugin replaces: chokidar on 9p is the unreliability that made
45
+ `WslFileSystem.watch` refuse. The in-distro watcher replaces it wholesale.
46
+ 4. **`dsh-bash-local` carries the timeout machinery** (`clampTimeout`,
47
+ `deadline`, `timeoutOf`, `BASH_TIMEOUT`), which the WSL executor already
48
+ inherits. Migrating exec onto the agent keeps that shape: the deadline
49
+ arms on the host as today, but the SIGTERM→SIGKILL grace fires inside the
50
+ distro via the agent instead of against the Windows process tree.
51
+
52
+ ## Phase checklist
53
+
54
+ | Phase | Deliverable | Parity row |
55
+ | --- | --- | --- |
56
+ | 0 | This document; contract audit above | baseline recorded |
57
+ | 1 | `lib/agent/` + `wsl-agent` resident process: NDJSON-over-stdio protocol, capability handshake, lazy start, idle shutdown, crash-rebuild-once, permanent fallback | resident server |
58
+ | 2 | real `watch()` SHIPPED (in-distro poll, `lib/watcher.js`); the native-ext4 migration of the remaining fs operations (and with it the workaround deletions) continues alongside Phase 3 | native file semantics, inotify — watch + publication done |
59
+ | 3 | DONE — `WslShellExecutor` exec via agent; in-distro TERM→KILL; handle shape preserved (deviation: no streaming, no spill files) | native exec latency |
60
+ | 4 | DONE (except the agent/plugin version handshake, which lands with the first agent-behaviour change after release) — `npm run bootstrap`, family-aware install command, `SANDBOX_UNAVAILABLE` names it | open-and-it-works |
61
+ | 5 | DONE (routing half) — per-session routing by workspace coordinates (`terminal-route.js`, `hostSessions`); activity-based idle reclaim of distro terminals remains a documented limitation (PTY activity stops at `wsl.exe`) | terminal per window/session — routing done |
62
+ | 6 | DONE — `DSH_WSL_PORTS` (agent-scanned `/proc/net/tcp{,6}`, timer-refreshed); localhost reachability (WSL2 localhost forwarding is platform behaviour) documented in README | port forwarding visibility |
63
+ | 7 | DONE — recipes section in both READMEs: credential sharing, `/mnt/c` guidance, `WSLENV` policy | developer-loop odds and ends |
64
+
65
+ ## Fallback invariant
66
+
67
+ Every phase must keep `wsl.agent: 'off'` (and, per capability, any runtime
68
+ fallback) behaviourally identical to the pre-phase release. Probes assert
69
+ this: `agent.sh` runs the same operation with the agent off and on and
70
+ compares outcomes, not internals.
package/docs/README.zh.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm](https://img.shields.io/npm/v/dsh-plugin-wsl-env)](https://www.npmjs.com/package/dsh-plugin-wsl-env)
7
7
  [![license](https://img.shields.io/npm/l/dsh-plugin-wsl-env)](../LICENSE)
8
8
 
9
- 这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 发行版当作工作环境:命令在发行版里执行,模型的文件工具读写发行版里的真实文件,文件夹选择器可以直接打开发行版目录,GUI 终端也开在发行版里。
9
+ 这个插件让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把 WSL 子系统当作工作环境:命令在子系统里执行,模型的文件工具读写子系统里的真实文件,文件夹选择器可以直接打开子系统目录,GUI 终端也开在子系统里。
10
10
 
11
11
  **只支持 Windows + WSL2。** 安装只需一条命令,插件自身没有依赖。环境按会话生效:打开 Windows 文件夹的会话继续使用原来的 Windows 环境。
12
12
 
@@ -26,9 +26,9 @@ dsh --profile wsl # 4. 启动
26
26
 
27
27
  第 3 步应该打印出名为 `# == dsh-plugin-wsl-env` 的层,并且 `- id: terminal-controller` 上应出现 `shell: { path: wsl.exe, name: WSL }`。这个包是 DSH bundle,所以第 2 步会把 [`cordis.patch.yml`](../cordis.patch.yml) 作为配置层施加进去,没有任何需要手工合并的补丁。
28
28
 
29
- 然后在 GUI 里打开 `\\wsl.localhost\<发行版>\...` 下的文件夹。选择器会在根一级列出每个已安装的发行版,**New terminal** 会在发行版里打开 shell。
29
+ 然后在 GUI 里打开 `\\wsl.localhost\<子系统>\...` 下的文件夹。选择器会在根一级列出每个已安装的子系统,**New terminal** 会在子系统里打开 shell。
30
30
 
31
- 还需要在发行版里安装 **bubblewrap**:`sudo apt install bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
31
+ 还需要在子系统里安装 **bubblewrap**:运行 `npm run bootstrap -- <子系统> --install`(只读检测去掉 `--install`),或直接执行 `wsl.exe -d <子系统> -u root -- apt-get install -y bubblewrap`。没有它每条命令都会失败关闭,见[沙箱](#沙箱)。
32
32
 
33
33
  卸载:`dsh plugin --profile wsl remove dsh-plugin-wsl-env`。升级:再执行一次同样的 `add` 命令。
34
34
 
@@ -36,12 +36,13 @@ dsh --profile wsl # 4. 启动
36
36
 
37
37
  ## 使用
38
38
 
39
- 把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在发行版里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
39
+ 把 `\\wsl.localhost\ubuntu\home\you\project` 作为工作区打开,然后问模型"我在什么内核上,`/etc/os-release` 里有什么"。它会在子系统里执行 `uname -r` 并读取该文件,不经过 `/mnt/c`,也不复制文件。
40
40
 
41
- - **发行版文件夹自动获得发行版环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
42
- - **命令**以 `wsl.exe -d <发行版> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
43
- - **文件就是发行版的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
44
- - **终端**(右侧栏 → *New terminal*)在发行版里打开会话目录下的 shell。
41
+ - **子系统文件夹自动获得子系统环境。** 创建会话时就会绑定 `wsl` preset,所以第一次工具调用就已经正确。
42
+ - **命令**以 `wsl.exe -d <子系统> --cd <Linux 目录> --exec <你的登录 shell> -lc <命令>` 执行,所以你的 `PATH`、`nvm`、`cargo`、`pyenv` 和 rc 配置都生效。shell 不是硬编码的 bash。
43
+ - **文件就是子系统的真实文件。** `/home/you/x` 与 `\\wsl.localhost\ubuntu\home\you\x` 是同一个文件,`/mnt/c/...` 通向 Windows 磁盘。
44
+ - **终端**(右侧栏 → *New terminal*)在子系统里打开会话目录下的 shell。
45
+ - **端口可见性**:子系统里监听中的端口经 `DSH_WSL_PORTS` 暴露给模型(约每 10 秒刷新);子系统内启动的 dev server 可由模型直接告知确切 URL(WSL2 的 localhost 转发是平台行为,Windows 侧直接可达)。
45
46
  - **模型能看到自己的 shell 环境。** 插件向受管的 `DSH_*` 命名空间注册 `DSH_WSL_DISTRO`、`DSH_WSL_SHELL` 和 `DSH_WSL_HOME`。
46
47
  - **权限与 Linux 主机一致。** 权限选择器在 `read-only`、`workspace-write`(默认)和 `danger-full-access` 之间切换。被拒绝的命令或写入会带回"放宽权限"的提议;批准后,这一次调用不加沙箱执行。
47
48
 
@@ -51,44 +52,52 @@ dsh --profile wsl # 4. 启动
51
52
 
52
53
  | 行 | 键 | 默认值 | 说明 |
53
54
  |---|---|---|---|
54
- | `wsl-shell` | `distro` | `''` | 发行版名;空表示用 WSL 的默认发行版 |
55
+ | `wsl-shell` | `distro` | `''` | 子系统名;空表示用 WSL 的默认子系统 |
55
56
  | | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
56
57
  | `wsl-fs` | `distro` | `''` | 同上 |
57
- | | `restrictToDistro` | `true` | 拒绝指向**其它发行版**共享的路径;`/mnt/c` 属于本发行版内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
58
+ | | `restrictToDistro` | `true` | 拒绝指向**其它子系统**共享的路径;`/mnt/c` 属于本子系统内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
58
59
  | | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
59
60
  | `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
60
- | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个发行版 |
61
+ | `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个子系统 |
61
62
 
62
63
  [`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) 是一份可照抄的本机层。
63
64
 
64
65
  ## 沙箱
65
66
 
66
- 命令由**发行版内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
67
+ 命令由**子系统内的 `bubblewrap`** 约束,文件写入按同一份策略检查。Windows ACL 沙箱在这里用不了:它的受限令牌完全到不了 WSL。
67
68
 
68
- | 模式 | 发行版里的命令可以做什么 |
69
+ | 模式 | 子系统里的命令可以做什么 |
69
70
  |---|---|
70
- | `read-only` | 整个发行版只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
71
+ | `read-only` | 整个子系统只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
71
72
  | `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
72
73
  | `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
73
74
 
74
75
  **bubblewrap 是必需项,而且失败是关闭的。** 没有它时,每条受限命令都报 `SANDBOX_UNAVAILABLE`,而不是不受约束地运行。要退出约束就在任一 provider 上设 `sandbox: false`,工具层会如实告诉模型这些操作没有沙箱。
75
76
 
76
- **上报的强制程度是 `partial` 而不是 `full`。** 发行版里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
77
+ **上报的强制程度是 `partial` 而不是 `full`。** 子系统里的进程仍可经 WSL interop 执行 Windows 程序(例如 `/mnt/c/.../*.exe`),bubblewrap 管不到它。`npm run probe:sandbox` 会在你的机器上演示这条边界。
77
78
 
78
79
  设计见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#sandbox),插件不做的事情见 [docs/LIMITATIONS.md](LIMITATIONS.md)。
79
80
 
81
+ ## 配方
82
+
83
+ - **Git 凭据共享**:让子系统里的 git 使用 Windows 侧的 Git Credential Manager,避免每次输密码:
84
+ `git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe"`
85
+ (路径按 Windows 侧 Git 的安装位置调整;WSL2 的 localhost 转发是平台行为,子系统内监听的端口 Windows 直接可达。)
86
+ - **路径与性能**:模型看到并操作的是子系统内的 Linux 路径(`/home/...`),写入子系统自身的 ext4;`/mnt/c` 通向 Windows 磁盘但走 9p,大批量小文件操作明显慢——重 IO 的项目请放在子系统文件系统内。`npm run bootstrap -- <子系统>` 会一并报告 ripgrep / inotifywait(搜索与监视的后端)是否就位。
87
+ - **WSLENV 透传**:WSL 只导入 `WSLENV` 中列出的变量。本插件按前缀放行托管的 `DSH_*` 命名空间,其中带 Windows 路径的两个(`DSH_HOME`、`DSH_PROFILE_DIR`)加 `/p` 让 WSL 翻译成 `/mnt/c/...`。`PATH` 故意不透传——否则 Windows 的 PATH 会覆盖子系统自身的 PATH。
88
+
80
89
  ## 常见问题
81
90
 
82
91
  | 现象 | 原因 | 处理 |
83
92
  |---|---|---|
84
- | 每条命令都报 `SANDBOX_UNAVAILABLE` | 发行版里没有 `bubblewrap` | 执行 `sudo apt install bubblewrap`,或在两个 provider 上都设 `sandbox: false` |
93
+ | 每条命令都报 `SANDBOX_UNAVAILABLE` | 子系统里没有 `bubblewrap` | 执行 `npm run bootstrap -- <子系统> --install`,或在两个 provider 上都设 `sandbox: false` |
85
94
  | 命令或写入在会话目录之外被拒绝 | `workspace-write` 的预期行为 | 接受工具给出的放宽权限提示,或把会话直接开在需要的目录上 |
86
95
  | 连工作区内的写入也被拒绝 | 会处在 `read-only` 模式 | 切换权限选择器 |
87
96
  | `dsh plugin add` 提示"没有激活任何层" | 依赖之前已经装过,`add` 没有需要记录的内容 | 先执行 `dsh plugin --profile wsl remove dsh-plugin-wsl-env`,再装一次 |
88
97
  | 终端打开后仍然是 `cmd.exe` | 这一层里的 `terminal-controller` 行没有生效 | 用 `dsh --profile wsl --dump-config` 确认能看到 `shell: { path: wsl.exe, name: WSL }` |
89
98
  | 改了 `lib/` 但不生效 | ES module 缓存 | 重启应用 |
90
- | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在发行版内开发时用运行时镜像,见[开发](#开发) |
91
- | `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用发行版内的工具 |
99
+ | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在子系统内开发时用运行时镜像,见[开发](#开发) |
100
+ | `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用子系统内的工具 |
92
101
  | 终端活动显示 `unknown` | 官方 shell 集成只对 POSIX 主机上直接启动的 `bash`/`zsh` 生效 | 关闭标签页以释放进程;空闲回收不会对它触发 |
93
102
  | 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
94
103
 
@@ -97,8 +106,8 @@ dsh --profile wsl # 4. 启动
97
106
  ```bash
98
107
  npm test # 风格与打包检查、语法检查、单元测试
99
108
  npm run test:coverage # 带覆盖率阈值的单元测试(需 Node 22.8+)
100
- npm run probe:sandbox # 在发行版里实测 bubblewrap 能约束什么、不能约束什么
101
- npm run probe # 文件系统探针,需要真实发行版(仅 Windows + WSL)
109
+ npm run probe:sandbox # 在子系统里实测 bubblewrap 能约束什么、不能约束什么
110
+ npm run probe # 文件系统探针,需要真实子系统(仅 Windows + WSL)
102
111
  npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
103
112
  npm run probe:terminal # 通过终端 provider 打开一个 PTY
104
113
  npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The error a {@link WslAgent} rejects with when the resident process is out
3
+ * for this host process — the executor's cue to fall back to the one-shot
4
+ * `wsl.exe` path. Deliberately its own class: callers must not confuse
5
+ * "the agent cannot serve" with a failure of the command itself.
6
+ *
7
+ * @module dsh-plugin-wsl/agent-errors
8
+ */
9
+ export class AgentUnavailableError extends Error {
10
+ /**
11
+ * @param {string} message - why the agent is out, for the log.
12
+ */
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "AgentUnavailableError";
16
+ }
17
+ }
18
+
19
+ /**
20
+ * The request's workdir could not be entered inside the distro. Carries the
21
+ * Linux path so a caller can synthesize the same diagnostic the one-shot
22
+ * `wsl.exe` path produces from the relay's `chdir(...) failed` text.
23
+ */
24
+ export class CwdError extends Error {
25
+ /**
26
+ * @param {string} linuxPath - the path that could not be entered.
27
+ */
28
+ constructor(linuxPath) {
29
+ super(`could not enter working directory "${linuxPath}"`);
30
+ this.name = "CwdError";
31
+ this.linuxPath = linuxPath;
32
+ }
33
+ }