dsh-plugin-wsl-env 0.16.0 → 0.17.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
@@ -14,7 +14,63 @@ document: the 0.1.0 entry's `design-history §` references are to
14
14
  keeps the superseded designs under the section numbers they had while they
15
15
  were part of the record.
16
16
 
17
- ## [Unreleased]
17
+ ## [0.17.0] - 2026-11-01
18
+
19
+ ### Changed
20
+
21
+ - **Git snapshot spawns are now agent-first with a one-shot fallback.**
22
+ Protocol v6 adds a stdin payload to the EXEC frame (a seventh header field
23
+ flagging one trailing base64 line), which is what lets `workspace-changes`'
24
+ snapshot calls — including `check-ignore -z --stdin` — run on the warm
25
+ resident agent instead of a one-shot `wsl.exe`. Measured on `ubuntu`
26
+ (2026-11-01): ~232 ms per one-shot git call (nearly all `wsl.exe` process
27
+ startup) against ~1.6 ms on the resident — a 6-8 call snapshot saves well
28
+ over a second. The per-stream caps map onto the agent's single wire cap with
29
+ a client-side per-stream slice (`lib/git-exec.js`), and the environment
30
+ rides the agent's per-request `env` (`env --` argv prefix, no `SETENV`
31
+ pollution) carrying only the translated `GIT_*` names. Both agents speak v6
32
+ (the shell agent decodes the payload through command substitution, which
33
+ strips trailing newlines — NUL separators survive; the native binary
34
+ delivers bytes verbatim), so host library and shipped binaries upgrade
35
+ together; the doctor's handshake reports any mismatch.
36
+
37
+ - **WSL change review no longer requires Windows git.** When
38
+ `resolveExecutable('git')` fails on the host, the provider answers with a
39
+ distro sentinel (`/usr/bin/git`); every workspace-changes spawn — the
40
+ `rev-parse` discovery included — then routes into the distro, and the
41
+ snapshot facade translates the discovery call's absolute-path output lines
42
+ into UNC form (`translateDiscoveryOutput`). A machine with Windows git is
43
+ unchanged (host discovery + `safe.directory` whitelist). The
44
+ `resolveExecutable` override is scoped to the one name and only fires on
45
+ the super's failure.
46
+
47
+ - **The doctor probes the Windows git trust.** A new `git.win-safe` check
48
+ reports the `safe.directory` whitelist state through interop `git.exe`:
49
+ `ok` when the `\\wsl.localhost` share is whitelisted, a warn with the
50
+ copy-paste remedy when Windows git does not trust it, and an info (nothing
51
+ to do) when there is no Windows git — the distro fallback covers WSL change
52
+ review entirely.
53
+
54
+ ### Documentation
55
+
56
+ - The architecture and upstream-promotion documents reflect the v6 protocol
57
+ and the shell-menu candidates; the promotion map records the measurement
58
+ that superseded the git one-shot decision.
59
+ - The full real-machine suite re-ran 15/0 on 2026-11-01 after the protocol
60
+ change landed, and again after the documentation refresh. The safe.directory
61
+ prerequisite for WSL change review is now documented with its one-time
62
+ remedy.
63
+
64
+ - **The shell menu now offers host shells instead of a single entry.**
65
+ `terminal-controller`'s `shellCandidates` grows from `[]` to
66
+ `[wsl.exe, powershell, cmd]` while the default profile stays
67
+ `shell: { path: wsl.exe, name: WSL }`. `wsl.exe` dedupes into the default's
68
+ `wsl` kind; `powershell` and `cmd` are launches this terminal really starts —
69
+ host sessions already run PowerShell outright (`hostSessions`), and a manually
70
+ selected shell is titled after itself, which moves the tab-title workaround
71
+ from a user-layer edit into the plugin's own composition
72
+ ([#8769](https://github.com/deepseek-ai/deepseek-harness/discussions/8769)
73
+ remains the real fix for the default entry's title).
18
74
 
19
75
  ## [0.16.0] - 2026-10-31
20
76
 
@@ -24,8 +24,12 @@
24
24
  # SETENV|<key>|<b64 value> -> export the variable for later requests;
25
25
  # <key> must be a POSIX identifier, else
26
26
  # ERR|protocol
27
- # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>
28
- # followed by <n args> lines of base64, one argv word each
27
+ # EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>|<stdin flag|0>
28
+ # followed by <n args> lines of base64, one argv word each, and — when
29
+ # the stdin flag is 1 — one more base64 line: the child's entire stdin.
30
+ # The agent feeds those bytes to the child and closes its stdin, so the
31
+ # child sees EOF at the payload's end. Absent or 0 means the child reads
32
+ # an empty stdin and immediate EOF (the v5 shape).
29
33
  # -> ACK|<id> the moment the request is dequeued for execution — the host
30
34
  # arms its stuck-request watchdog on this line, so time spent queued
31
35
  # behind an earlier request never counts against a budget
@@ -62,7 +66,14 @@
62
66
 
63
67
  set -u
64
68
 
65
- PROTO_VERSION=5
69
+ # v6: EXEC gains an optional seventh header field flagging one stdin payload
70
+ # line after the argv words. One documented limitation, the same one SETENV
71
+ # and every argv word already carry: command substitution strips TRAILING
72
+ # NEWLINES from the decoded payload, so a stdin feed ending in `\n` reaches
73
+ # the child without it (NUL bytes — `check-ignore -z` separators — survive).
74
+ # Every known consumer parses line-oriented stdin that tolerates a missing
75
+ # final newline.
76
+ PROTO_VERSION=6
66
77
  TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
67
78
  OUT_FILE="$TMPDIR_AGENT/out"
68
79
  ERR_FILE="$TMPDIR_AGENT/err"
@@ -254,16 +265,23 @@ run_capture() {
254
265
  fi
255
266
  fi
256
267
  CURRENT_ID=$req_id
268
+ # Protocol v6 stdin: when the EXEC dispatch staged a payload file, the child
269
+ # reads its bytes and sees EOF at the file's end; otherwise /dev/null keeps
270
+ # the child off the agent's own stdin (the host pipe).
271
+ spawn_in=/dev/null
272
+ if [ -n "${EXEC_STDIN:-}" ]; then
273
+ spawn_in=$EXEC_STDIN
274
+ fi
257
275
  # The inflight marker pauses the lease watchdog for this request and
258
276
  # refreshes the temp dir's mtime for the boot sweep; removing it after the
259
277
  # answer re-arms both.
260
278
  : >"$TMPDIR_AGENT/inflight"
261
279
  if [ "$mode" = subshell ]; then
262
- ( "$@" ) >"$spawn_out" 2>"$spawn_err" &
280
+ ( "$@" ) >"$spawn_out" 2>"$spawn_err" <"$spawn_in" &
263
281
  elif [ -n "$SETSID" ]; then
264
- ( exec setsid "$@" ) >"$spawn_out" 2>"$spawn_err" &
282
+ ( exec setsid "$@" ) >"$spawn_out" 2>"$spawn_err" <"$spawn_in" &
265
283
  else
266
- "$@" >"$spawn_out" 2>"$spawn_err" &
284
+ "$@" >"$spawn_out" 2>"$spawn_err" <"$spawn_in" &
267
285
  fi
268
286
  CURRENT_PID=$!
269
287
  # Dispatched: the child exists now, so the host may arm its execution
@@ -675,7 +693,18 @@ while IFS= read -r line; do
675
693
  timeout_s=${rest%%|*}
676
694
  rest=${rest#*|}
677
695
  nargs=${rest%%|*}
678
- maxout=${rest#*|}
696
+ rest=${rest#*|}
697
+ maxout=${rest%%|*}
698
+ # Protocol v6: an optional seventh field flags one stdin payload line
699
+ # after the argv words. Absent (a stale peer) or 0 keeps the v5 shape —
700
+ # the child reads an empty stdin and immediate EOF.
701
+ stdin_flag=0
702
+ case $rest in
703
+ *\|*) stdin_flag=${rest#*|} ;;
704
+ *) stdin_flag=0 ;;
705
+ esac
706
+ case $stdin_flag in ''|*[!01]*) stdin_flag=0 ;; esac
707
+ EXEC_STDIN=
679
708
  cwd=$(b64dec "$cwd_b64")
680
709
  if ! cd "$cwd" 2>/dev/null; then
681
710
  printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
@@ -704,9 +733,20 @@ while IFS= read -r line; do
704
733
  printf 'ERR|%s|protocol|%s\n' "$req_id" "$(printf '%s' "an argv payload failed to decode" | base64 | tr -d '\n')"
705
734
  continue
706
735
  fi
736
+ if [ "$stdin_flag" = 1 ]; then
737
+ IFS= read -r stdin_b64 || exit 0
738
+ if ! b64dec_strict "$stdin_b64"; then
739
+ printf 'ERR|%s|protocol|%s\n' "$req_id" "$(printf '%s' "the stdin payload failed to decode" | base64 | tr -d '\n')"
740
+ continue
741
+ fi
742
+ EXEC_STDIN="$TMPDIR_AGENT/stdin.$req_id"
743
+ printf '%s' "$DEC" >"$EXEC_STDIN"
744
+ fi
707
745
  # Dispatched: run_capture prints the ACK once the child exists, so the
708
746
  # host's watchdog and USR1 cancel both target a real process.
709
747
  run_capture "$req_id" "$timeout_s" "$maxout" exec "$@"
748
+ [ -n "$EXEC_STDIN" ] && rm -f -- "$EXEC_STDIN"
749
+ EXEC_STDIN=
710
750
  ;;
711
751
  FS\|*)
712
752
  header=${line#FS|}
package/cordis.patch.yml CHANGED
@@ -36,17 +36,20 @@
36
36
  # `wsl.exe -d <distro> --cd <linux dir>` for the directory the Session
37
37
  # supplies. Ordinary `spawn()` — every command, the host ripgrep search,
38
38
  # every other consumer — reaches the shipped implementation untouched;
39
- # 2. the controller is given the one shell profile it should resolve, so the
40
- # window's default is a distro shell rather than `cmd.exe` inside a UNC
41
- # workspace. Its shell menu is reduced to that single entry on purpose:
42
- # shell discovery also runs against the root provider and receives no
43
- # directory, so offering host executables there would advertise shells this
44
- # terminal never starts.
39
+ # 2. the controller is given the composition's default shell profile (the
40
+ # distro shell, so a directory-less launch lands in the distro rather than
41
+ # `cmd.exe` inside a UNC workspace) plus host candidates. The default must
42
+ # stay static — the controller resolves it without a Session — but the
43
+ # candidates make the shell menu honest: `wsl.exe` dedupes into the
44
+ # default's `wsl` kind, and `powershell`/`cmd` are launches this terminal
45
+ # really starts (host sessions run PowerShell outright, and a manually
46
+ # selected shell is titled after itself — the sanctioned bridge over the
47
+ # fixed tab title, see docs/UPSTREAM-TERMINAL-TITLE.md).
45
48
  #
46
49
  # Consequence: a terminal's EXECUTION WORLD follows the Session — a WSL-folder
47
50
  # 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
51
+ # its own directory (`hostSessions` on `subprocess-wsl`) — while the DEFAULT
52
+ # entry and its title stay the composition's single `WSL` profile, which no
50
53
  # Session identity reaches. The title that implies is documented in
51
54
  # docs/LIMITATIONS.md and bridged upstream in docs/UPSTREAM-TERMINAL-TITLE.md.
52
55
  # ═════════════════════════════════════════════════════════════════════════════
@@ -60,7 +63,10 @@
60
63
  path: wsl.exe
61
64
  name: WSL
62
65
  args: []
63
- shellCandidates: []
66
+ shellCandidates:
67
+ - wsl.exe
68
+ - powershell
69
+ - cmd
64
70
 
65
71
  - insert:
66
72
  - id: subprocess-wsl
@@ -109,9 +109,15 @@ So the layer disables the shipped `subprocess` row and inserts
109
109
  `dsh-plugin-wsl-env/subprocess` in its place at the app level. The subclass
110
110
  overrides two methods: `spawnTerminal` rewrites a `wsl.exe` launch into
111
111
  `wsl.exe -d <distro> --cd <linux dir>`, and `spawn()` rewrites two kinds of
112
- launches into the distro — the file-search tool's packaged ripgrep (agent-first,
113
- with the one-shot `wsl.exe --exec` handle as the fallback when the agent is out)
114
- and git snapshot commands. Both spawn decisions are pure coordinate functions
112
+ launches into the distro — the file-search tool's packaged ripgrep and git
113
+ snapshot commands (each agent-first on the warm resident — protocol v6 carries
114
+ a git call's stdin, and a one-shot call costs ~230 ms of `wsl.exe` startup
115
+ against the resident's ~2 ms — with the one-shot `wsl.exe --exec` handle as
116
+ the fallback when the agent is out). When the HOST has no git,
117
+ `resolveExecutable` answers git with a distro sentinel path, and the snapshot
118
+ command's discovery call (`rev-parse --show-toplevel ...`) routes too, its
119
+ absolute-path output translated back into UNC form — so full WSL change review
120
+ works without Windows git. Both spawn decisions are pure coordinate functions
115
121
  (`lib/search-route.js`, `lib/git-route.js`); every other `spawn()` — including
116
122
  the one-shot fallback's own `wsl.exe -lc <command>` (the default agent path
117
123
  carries the command over the resident instead of spawning) — reaches the shipped
@@ -120,9 +126,11 @@ implementation untouched, as do the pwsh executor and the LSP host.
120
126
  The terminal's execution world follows the session (`hostSessions`, default on):
121
127
  a WSL-folder session runs the distro shell, a Windows-folder session runs
122
128
  `powershell.exe` in its own directory. What still follows the app configuration
123
- is the shell menu and the tab title, both derived from the composition's single
124
- `WSL` shell profile — so a host session's tab reads `WSL` over a PowerShell
125
- process. See [LIMITATIONS.md](LIMITATIONS.md) and
129
+ is the shell menu's DEFAULT entry and its title, both derived from the
130
+ composition's single `WSL` shell profile — so a new tab reads `WSL` whatever it
131
+ runs, including a host session's PowerShell. The menu also lists host
132
+ candidates (`powershell`, `cmd`; `wsl.exe` dedupes into the default), and a
133
+ manually selected shell is titled after itself. See [LIMITATIONS.md](LIMITATIONS.md) and
126
134
  [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
127
135
 
128
136
  ## Path coordinates
@@ -154,6 +154,27 @@ With `agent: true`, captured output is limited inside the distro and no spill
154
154
  file is written. `maxSpillBytes` and `graceMs` apply when `agent: false` or
155
155
  when the resident agent is unavailable and execution uses one-shot `wsl.exe`.
156
156
 
157
+ ### zsh/fnm recipe for model-run commands
158
+
159
+ Commands run as `<shell> -lc`, so `~/.zshrc` is never read — see
160
+ [LIMITATIONS](LIMITATIONS.md#shell-startup-semantics-wsl-shell) for why. Put
161
+ fnm's hook in `~/.zprofile` instead, with a fallback for the sandbox, where
162
+ `/run` is read-only and fnm cannot create its multishell symlink directory:
163
+
164
+ ```zsh
165
+ # ~/.zprofile — fnm for non-interactive login shells (mirrors the ~/.zshrc hook)
166
+ path=("$HOME/.local/share/fnm" $path) # the fnm binary itself
167
+ _fnm_env="$(command -v fnm &>/dev/null && fnm env --shell zsh 2>/dev/null)" && eval "$_fnm_env"
168
+ if [[ -z "${FNM_MULTISHELL_PATH:-}" || ! -e "${FNM_MULTISHELL_PATH}/bin/node" ]]; then
169
+ path=("$HOME/.local/share/fnm/aliases/default/bin" $path) # sandbox fallback
170
+ fi
171
+ unset _fnm_env
172
+ ```
173
+
174
+ Interactive terminals keep the hook in `~/.zshrc` and are unaffected; the
175
+ `aliases/default` fallback only takes over where the multishell link cannot
176
+ exist.
177
+
157
178
  ## Environment forwarding
158
179
 
159
180
  WSL imports a host environment variable only when its name is listed in
@@ -225,6 +225,34 @@ operations do not use the Windows-side 9p share. What to know about the substrat
225
225
  a separate case and is now reported as an error naming that directory, because
226
226
  `wsl.exe` would otherwise run the command in `/` and exit 0.
227
227
 
228
+ ## Shell startup semantics (`wsl-shell`)
229
+
230
+ Commands run as `<shell> -lc <command>` — a login, **non-interactive** shell.
231
+ The login flag is deliberate: it sources the profile scripts, which is what
232
+ makes a version manager's PATH (nvm, cargo, pyenv) visible to model-run
233
+ commands. Non-interactivity is equally deliberate: interactive startup files
234
+ print prompts and plugin output into the streams the model reads, define
235
+ aliases that shadow real executables, and can keep the shell alive after the
236
+ command finishes. What follows from that split:
237
+
238
+ - **`~/.zshrc` and `~/.bashrc` are never read.** A login shell sources
239
+ `/etc/profile` and the user's profile file — `~/.profile` or `~/.zprofile`
240
+ for bash and zsh — and nothing else. zsh's ecosystem convention of putting
241
+ every hook in `.zshrc` therefore leaves model-run commands without it: a
242
+ tool installed through an interactive-only hook exists in the user's
243
+ terminal and does not exist in the model's. Move machine-level PATH setup
244
+ (`eval "$(fnm env)"`, nvm, cargo) into `~/.zprofile` or `~/.zshenv`, which
245
+ login shells do read; keep prompt and completion plugins in `.zshrc`.
246
+ - **The confined sandbox mounts `/run` read-only, so fnm's multishell PATH
247
+ cannot form there.** `fnm env` works by creating a per-shell symlink
248
+ directory under `/run/user/<uid>/fnm_multishells/`; inside `bwrap` that
249
+ creation fails, and with it the PATH entry `fnm env` exists to provide.
250
+ A profile that must work in both worlds should fall back to fnm's stable
251
+ alias directory when the multishell link did not materialize — see the
252
+ recipe in [Configuration](CONFIGURATION.md#zshfnm-recipe-for-model-run-commands).
253
+ The plugin does not relax the sandbox for this: a writable `/run` inside the
254
+ fence would be a real weakening of it, traded for one tool's convenience.
255
+
228
256
  ## Agent diagnostics
229
257
 
230
258
  - **The doctor's `agent.handshake` check probes a fresh resident, not the live
@@ -292,18 +320,31 @@ operations do not use the Windows-side 9p share. What to know about the substrat
292
320
 
293
321
  - Terminal routing is per-session by workspace coordinates (`subprocess-wsl.hostSessions`,
294
322
  default on): a WSL-folder session gets the distro shell, a Windows-folder session gets
295
- `powershell.exe` in its own directory. The shell MENU remains the single configured
296
- profile, and a directory-less launch still lands in the distro.
297
- - A host session's TAB TITLE still reads `WSL`, the composition's shell profile
298
- name, over a PowerShell process. The controller titles a tab from that profile
299
- before the provider rewrites the launch, and a plugin cannot correct it: a
300
- patch layer asserts rather than renames a row's module, and the row's id is
301
- load-bearing for the web composition (disabling it fails web boot with
302
- "waiting for service: webTerminals"). The full account and the proposed
303
- upstream change are in [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
304
- Meanwhile, double-click a tab's title to rename it, or add `shellCandidates`
305
- to the `terminal-controller` row in your own profile layer — a manually
306
- selected shell is titled after itself.
323
+ `powershell.exe` in its own directory. The shell MENU's default entry remains the
324
+ configured profile, and a directory-less launch still lands in the distro.
325
+ - **The Windows-side git must trust the `\\wsl.localhost` share** (`safe.directory`),
326
+ or the change-review pipeline silently degrades: the discovery call answers
327
+ `fatal: detected dubious ownership`, the recorder falls back to
328
+ "git is unavailable", and changed-files cards never list a WSL workspace's
329
+ diffs. Remedy (one-time, Windows side):
330
+ `git config --global --add safe.directory "//wsl.localhost/*"`, then restart
331
+ the app (the resolution is memoized for the process lifetime). Host-side
332
+ (`C:\...`) workspaces are unaffected. A future doctor check could probe this
333
+ directly. The prerequisite is also OPTIONAL: on a machine without Windows
334
+ git the plugin resolves git to a distro sentinel and routes the discovery
335
+ call too, translating its output into UNC form — full WSL change review
336
+ with no Windows git and no whitelist.
337
+ - A NEW tab's title still reads `WSL`, the composition's shell profile name, whatever
338
+ it runs — including a host session's PowerShell. The controller titles a tab from
339
+ that profile before the provider rewrites the launch, and a plugin cannot correct
340
+ the default: a patch layer asserts rather than renames a row's module, and the
341
+ row's id is load-bearing for the web composition (disabling it fails web boot with
342
+ "waiting for service: webTerminals"). The full account and the proposed upstream
343
+ change are in [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
344
+ The plugin now ships host `shellCandidates` (`wsl.exe` dedupes into the default's
345
+ kind; `powershell`/`cmd` appear as themselves), and a manually selected shell is
346
+ titled after itself — pick PowerShell from the shell menu and the tab reads
347
+ `powershell`. Double-clicking a title to rename also works.
307
348
  - Distro terminals ARE reclaimed when idle — the observation is distro-side:
308
349
  each terminal is launched with a `DSH_TERMINAL_ID` marker, and the handle's
309
350
  `inspectActivity` counts the marked processes through the resident agent
@@ -107,17 +107,15 @@ next touched:
107
107
 
108
108
  ## Workarounds until then
109
109
 
110
+ - The plugin now ships the candidate list itself: `shellCandidates` is
111
+ `[wsl.exe, powershell, cmd]` in [`cordis.patch.yml`](../cordis.patch.yml),
112
+ with the `shell` block kept verbatim. `wsl.exe` dedupes into the default's
113
+ `wsl` kind; selecting `powershell` or `cmd` starts a shell the terminal
114
+ really launches (host sessions do outright) and titles the tab after itself.
115
+ Nothing remains for a user layer to add unless it wants different entries.
110
116
  - Double-click a tab's title to rename it (a shipped client feature; the
111
117
  controller's `rename` Remote API accepts 1–120 characters).
112
- - Re-declare the `terminal-controller` row in a user layer with a non-empty
113
- `shellCandidates`: a manually selected shell is titled after itself. A patch
114
- config replaces the whole config rather than deep-merging it
115
- (`packages/boot/app-boot/src/config-schema/document.ts`), so the override has
116
- to carry this plugin's `shell` block verbatim — `path: wsl.exe`, `name: WSL`,
117
- `args: []` — or the terminal stops being a distro terminal. Expect the menu
118
- to grow the host shells discovery finds on the root provider, which receives
119
- no directory: executables this terminal never starts (the second bullet of
120
- the GUI-terminal comment in [`cordis.patch.yml`](../cordis.patch.yml)).
121
- [LIMITATIONS.md](LIMITATIONS.md) still carries the older one-line form of
122
- this tip, which drops the `shell` block; the version above is the one that
123
- works.
118
+
119
+ Both cover only manually selected shells. The DEFAULT entry — what a new tab
120
+ gets before anyone chooses — is still titled from the static profile, which is
121
+ exactly the gap the upstream change closes.
@@ -40,6 +40,11 @@ export interface AgentExecRequest {
40
40
  env?: Record<string, string>;
41
41
  /** Per-stream capture ceiling. */
42
42
  maxOutputBytes?: number;
43
+ /**
44
+ * The child's entire stdin (protocol v6); the child sees EOF at the
45
+ * payload's end. Omitted or empty keeps the v5 shape.
46
+ */
47
+ stdin?: Uint8Array;
43
48
  /** The caller's cancellation. */
44
49
  signal?: AbortSignal;
45
50
  }
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export declare const PROTOCOL_VERSION = 5;
17
+ export declare const PROTOCOL_VERSION = 6;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -65,17 +65,30 @@ export interface ExecFrameRequest {
65
65
  * default) keeps the historical unbounded capture.
66
66
  */
67
67
  maxOutputBytes?: number;
68
+ /**
69
+ * The child's entire stdin, delivered before the first read. Protocol v6:
70
+ * when present the EXEC header gains a `1` seventh field and one extra
71
+ * base64 line follows the argv words; the agent feeds those bytes to the
72
+ * child and closes its stdin (the child sees EOF at the payload's end). The
73
+ * payload rides one protocol line, so it is bounded by `MAX_FRAME_BYTES` —
74
+ * the shape of every known consumer (`git check-ignore --stdin` pathspecs),
75
+ * none of which streams an unbounded feed. Absent or empty means the child
76
+ * reads an empty stdin and immediate EOF, exactly the v5 behaviour.
77
+ */
78
+ stdin?: Uint8Array;
68
79
  }
69
80
  /**
70
81
  * Build the request lines for one EXEC frame: a header line naming the cwd,
71
- * the whole-request timeout in whole seconds (0 = none), the argv word count
72
- * and the per-stream output capture budget in bytes (0 = unlimited), followed
73
- * by one base64 line per argv word.
82
+ * the whole-request timeout in whole seconds (0 = none), the argv word count,
83
+ * the per-stream output capture budget in bytes (0 = unlimited) and — since
84
+ * protocol v6 — the stdin flag (1 when a payload line follows the argv
85
+ * words), followed by one base64 line per argv word and, when flagged, one
86
+ * base64 stdin payload line.
74
87
  *
75
88
  * @param request - the execution request.
76
89
  * @returns the lines to write to the agent's stdin, in order.
77
90
  */
78
- export declare function encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes }: ExecFrameRequest): string[];
91
+ export declare function encodeExecFrame({ id, cwd, argv, timeoutMs, maxOutputBytes, stdin }: ExecFrameRequest): string[];
79
92
  /**
80
93
  * Build a SETENV request line. Note the agent's decode strips trailing
81
94
  * newlines from the value — the same leniency the shell itself applies.
@@ -14,7 +14,7 @@
14
14
  * @module dsh-plugin-wsl/agent-protocol
15
15
  */
16
16
  /** The protocol version this host side speaks; the agent must match. */
17
- export const PROTOCOL_VERSION = 5;
17
+ export const PROTOCOL_VERSION = 6;
18
18
  /**
19
19
  * Upper bound on one `read` op's payload, set by the HOST looping over windowed
20
20
  * requests. Keeping each frame's decoded payload here means the agent's RES
@@ -54,20 +54,25 @@ export function decodeB64(value) {
54
54
  }
55
55
  /**
56
56
  * Build the request lines for one EXEC frame: a header line naming the cwd,
57
- * the whole-request timeout in whole seconds (0 = none), the argv word count
58
- * and the per-stream output capture budget in bytes (0 = unlimited), followed
59
- * by one base64 line per argv word.
57
+ * the whole-request timeout in whole seconds (0 = none), the argv word count,
58
+ * the per-stream output capture budget in bytes (0 = unlimited) and — since
59
+ * protocol v6 — the stdin flag (1 when a payload line follows the argv
60
+ * words), followed by one base64 line per argv word and, when flagged, one
61
+ * base64 stdin payload line.
60
62
  *
61
63
  * @param request - the execution request.
62
64
  * @returns the lines to write to the agent's stdin, in order.
63
65
  */
64
- export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0, maxOutputBytes = 0 }) {
66
+ export function encodeExecFrame({ id, cwd, argv, timeoutMs = 0, maxOutputBytes = 0, stdin }) {
65
67
  const timeoutSeconds = timeoutMs > 0 ? Math.ceil(timeoutMs / 1000) : 0;
66
68
  const cap = maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0;
69
+ const hasStdin = stdin !== undefined && stdin.length > 0;
67
70
  const lines = [
68
- `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}|${cap}`,
71
+ `EXEC|${id}|${encodeB64(cwd)}|${timeoutSeconds}|${argv.length}|${cap}|${hasStdin ? 1 : 0}`,
69
72
  ...argv.map((word) => encodeB64(word)),
70
73
  ];
74
+ if (hasStdin)
75
+ lines.push(encodeB64(stdin));
71
76
  return lines;
72
77
  }
73
78
  /**
package/lib/agent.d.ts CHANGED
@@ -431,7 +431,7 @@ export declare class WslAgent {
431
431
  * @returns the settled result.
432
432
  * @throws {AgentUnavailableError} when the agent is out for this process.
433
433
  */
434
- exec({ cwd, argv, timeoutMs, maxOutputBytes, env, signal }: {
434
+ exec({ cwd, argv, timeoutMs, maxOutputBytes, env, stdin, signal }: {
435
435
  /** Linux path to run in. */
436
436
  cwd: string;
437
437
  /** The argv, already bwrap-wrapped when the caller confines. */
@@ -453,6 +453,14 @@ export declare class WslAgent {
453
453
  * keeps the unbounded capture.
454
454
  */
455
455
  maxOutputBytes?: number;
456
+ /**
457
+ * The child's entire stdin, delivered before its first read (protocol
458
+ * v6); the child sees EOF at the payload's end. Omitted or empty keeps
459
+ * the v5 shape — an empty stdin and immediate EOF. Note the shell agent
460
+ * decodes the payload through command substitution, which strips
461
+ * trailing newlines; the native agent delivers the bytes verbatim.
462
+ */
463
+ stdin?: Uint8Array;
456
464
  /** Cancels the request, sending KILL if it was already dispatched. */
457
465
  signal?: AbortSignal;
458
466
  }): Promise<{
package/lib/agent.js CHANGED
@@ -780,7 +780,7 @@ export class WslAgent {
780
780
  * @returns the settled result.
781
781
  * @throws {AgentUnavailableError} when the agent is out for this process.
782
782
  */
783
- async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal }) {
783
+ async exec({ cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, stdin, signal }) {
784
784
  const assignments = Object.entries(env ?? {}).map(([key, value]) => {
785
785
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || value.includes("\0")) {
786
786
  throw new Error(`invalid environment variable: ${key}`);
@@ -793,7 +793,7 @@ export class WslAgent {
793
793
  const id = `r${this.nextRequestId++}`;
794
794
  const commandArgv = argv[0]?.includes("=") ? ["/bin/sh", "-c", "exec \"$@\"", "env", ...argv] : argv;
795
795
  const requestArgv = assignments.length > 0 ? ["env", "--", ...assignments, ...commandArgv] : argv;
796
- const lines = encodeExecFrame({ id, cwd, argv: requestArgv, timeoutMs, maxOutputBytes });
796
+ const lines = encodeExecFrame({ id, cwd, argv: requestArgv, timeoutMs, maxOutputBytes, stdin });
797
797
  return new Promise((resolve, reject) => {
798
798
  this.track({ id, kind: "exec", lines, resolve, reject, timeoutMs, signal });
799
799
  });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The agent-backed execution handle for the git-snapshot spawn.
3
+ *
4
+ * `workspace-changes`' `GitRunner` consumes a small slice of the subprocess
5
+ * handle: `handle.done` for `{exitCode, signal}`, then
6
+ * `handle.collected.stdout/stderr.readFrom(0)` for `{text, lossy}` — with
7
+ * per-stream caps applied by the caller's `stdio` spec (a generous stdout
8
+ * budget and a 16 KiB stderr tail). This module builds exactly that surface
9
+ * around one resident-agent `exec`, so a distro snapshot pays the warm agent's
10
+ * ~2 ms round trip per git call instead of the one-shot `wsl.exe`'s ~230 ms
11
+ * process startup — measured 2026-11-01 on `ubuntu`; a full snapshot's 6-8
12
+ * calls (plus per-file `cat-file`) used to spend over a second inside
13
+ * `wsl.exe` alone.
14
+ *
15
+ * The caps map onto the agent's single per-request capture ceiling as the
16
+ * larger of the two stream budgets; the reader then slices each stream to its
17
+ * own budget and reports `lossy` from that slice, so the caller's contract
18
+ * (per-stream cut flags) holds even though the wire carries one cap.
19
+ *
20
+ * Environment rides the agent's per-request `env` (an `env --` argv prefix —
21
+ * no `SETENV` global pollution), holding only the translated `GIT_*` names:
22
+ * the one-shot path forwards exactly those through `WSLENV`, and forwarding a
23
+ * host `PATH` into the distro would be wrong, not just different.
24
+ *
25
+ * The agent being out is the fallback cue, exactly as `search-exec.ts`:
26
+ * `spawnFallback` (the caller's one-shot `wsl.exe` handle) is consulted
27
+ * lazily inside `done`, and from that point the facade proxies the delegate
28
+ * verbatim. Cancellation shares one channel: the caller's `signal` and
29
+ * `kill()` abort the agent exec, settling `done` as killed.
30
+ *
31
+ * Pure shaping over injected collaborators, unit-testable without a distro.
32
+ *
33
+ * This is a TypeScript source built to `lib/git-exec.js`; edit THIS file and
34
+ * run `pnpm run build` — the artifact under `lib/` is generated, and
35
+ * `pnpm test` fails when it drifts.
36
+ *
37
+ * @module dsh-plugin-wsl/git-exec
38
+ */
39
+ import type { AgentExecRunner } from "./agent-exec.js";
40
+ /** One collected-stream reader with the seam's standard shape. */
41
+ export interface GitStreamReader {
42
+ /** Read the stream from a byte offset; `lossy` says the cap cut it. */
43
+ readFrom: (from: number) => {
44
+ text: string;
45
+ lossy: boolean;
46
+ nextOffset: number;
47
+ };
48
+ }
49
+ /**
50
+ * The slice of the subprocess handle `GitRunner` consumes: settle on `done`,
51
+ * then read the collected streams.
52
+ */
53
+ export interface GitExecutionHandle {
54
+ /** Settles with the exit code and signal; rejects on a genuine failure. */
55
+ done: Promise<{
56
+ exitCode: number | null;
57
+ signal: string | null;
58
+ }>;
59
+ /** The collected stream readers; a delegated handle's own on fallback. */
60
+ collected: {
61
+ stdout?: GitStreamReader | undefined;
62
+ stderr?: GitStreamReader | undefined;
63
+ };
64
+ /** Cooperative kill; the runner relies on its `signal` for cancellation. */
65
+ kill?: () => boolean | void;
66
+ }
67
+ /** Per-stream capture budgets the caller declared on its `stdio` spec. */
68
+ export interface GitStreamCaps {
69
+ /** The stdout budget in bytes; the snapshot's `outputMaxBytes`. */
70
+ stdout: number;
71
+ /** The stderr tail budget in bytes. */
72
+ stderr: number;
73
+ }
74
+ /**
75
+ * Run one git command through the agent and present it as a git-snapshot
76
+ * handle.
77
+ *
78
+ * @param object - the handle's collaborators.
79
+ * @returns the snapshot handle (`done`, `collected`, `kill`).
80
+ */
81
+ export declare function gitExecutionHandle({ agent, cwd, argv, env, stdin, caps, distro, discovery, signal, spawnFallback }: {
82
+ /** The {@link WslAgent}-like runner; throws `AgentUnavailableError` when out. */
83
+ agent: AgentExecRunner;
84
+ /** The Linux working directory to snapshot from. */
85
+ cwd: string;
86
+ /** The distro-side argv (`git` and its arguments). */
87
+ argv: string[];
88
+ /**
89
+ * The distro name; required when `discovery` is set (absolute path lines
90
+ * become `\\wsl.localhost\<distro><path>`).
91
+ */
92
+ distro?: string;
93
+ /**
94
+ * True for a SENTINEL discovery call (`--show-toplevel` /
95
+ * `--absolute-git-dir` / `--git-path` answered by the distro git): the
96
+ * captured stdout's absolute Linux path lines are translated into UNC form
97
+ * before the caller reads them. Snapshot calls leave this unset.
98
+ */
99
+ discovery?: boolean;
100
+ /**
101
+ * Per-request environment: the caller's entries with path-shaped values
102
+ * translated into distro form. Empty entries are dropped; nothing but
103
+ * `GIT_*` names should reach here.
104
+ */
105
+ env: Record<string, string>;
106
+ /** The child's entire stdin, or undefined for an empty one. */
107
+ stdin?: Uint8Array;
108
+ /** Per-stream capture budgets from the caller's `stdio` spec. */
109
+ caps: GitStreamCaps;
110
+ /** The caller's cancellation (its own timeout races in through `AbortSignal.any`). */
111
+ signal?: AbortSignal;
112
+ /**
113
+ * Builds the one-shot handle the facade delegates to when the agent is out;
114
+ * called at most once, lazily.
115
+ */
116
+ spawnFallback: () => GitExecutionHandle;
117
+ }): GitExecutionHandle;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The agent-backed execution handle for the git-snapshot spawn.
3
+ *
4
+ * `workspace-changes`' `GitRunner` consumes a small slice of the subprocess
5
+ * handle: `handle.done` for `{exitCode, signal}`, then
6
+ * `handle.collected.stdout/stderr.readFrom(0)` for `{text, lossy}` — with
7
+ * per-stream caps applied by the caller's `stdio` spec (a generous stdout
8
+ * budget and a 16 KiB stderr tail). This module builds exactly that surface
9
+ * around one resident-agent `exec`, so a distro snapshot pays the warm agent's
10
+ * ~2 ms round trip per git call instead of the one-shot `wsl.exe`'s ~230 ms
11
+ * process startup — measured 2026-11-01 on `ubuntu`; a full snapshot's 6-8
12
+ * calls (plus per-file `cat-file`) used to spend over a second inside
13
+ * `wsl.exe` alone.
14
+ *
15
+ * The caps map onto the agent's single per-request capture ceiling as the
16
+ * larger of the two stream budgets; the reader then slices each stream to its
17
+ * own budget and reports `lossy` from that slice, so the caller's contract
18
+ * (per-stream cut flags) holds even though the wire carries one cap.
19
+ *
20
+ * Environment rides the agent's per-request `env` (an `env --` argv prefix —
21
+ * no `SETENV` global pollution), holding only the translated `GIT_*` names:
22
+ * the one-shot path forwards exactly those through `WSLENV`, and forwarding a
23
+ * host `PATH` into the distro would be wrong, not just different.
24
+ *
25
+ * The agent being out is the fallback cue, exactly as `search-exec.ts`:
26
+ * `spawnFallback` (the caller's one-shot `wsl.exe` handle) is consulted
27
+ * lazily inside `done`, and from that point the facade proxies the delegate
28
+ * verbatim. Cancellation shares one channel: the caller's `signal` and
29
+ * `kill()` abort the agent exec, settling `done` as killed.
30
+ *
31
+ * Pure shaping over injected collaborators, unit-testable without a distro.
32
+ *
33
+ * This is a TypeScript source built to `lib/git-exec.js`; edit THIS file and
34
+ * run `pnpm run build` — the artifact under `lib/` is generated, and
35
+ * `pnpm test` fails when it drifts.
36
+ *
37
+ * @module dsh-plugin-wsl/git-exec
38
+ */
39
+ import { AgentUnavailableError } from "./agent-errors.js";
40
+ import { translateDiscoveryOutput } from "./git-route.js";
41
+ /**
42
+ * Run one git command through the agent and present it as a git-snapshot
43
+ * handle.
44
+ *
45
+ * @param object - the handle's collaborators.
46
+ * @returns the snapshot handle (`done`, `collected`, `kill`).
47
+ */
48
+ export function gitExecutionHandle({ agent, cwd, argv, env, stdin, caps, distro, discovery, signal, spawnFallback }) {
49
+ const state = { delegate: undefined, stdout: undefined, stderr: undefined };
50
+ const controller = new AbortController();
51
+ if (signal?.aborted)
52
+ controller.abort(signal.reason);
53
+ else
54
+ signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
55
+ // The agent enforces ONE cap per request; declare the larger budget on the
56
+ // wire and cut each stream to its own budget here, so the caller's
57
+ // per-stream contract holds and a stream under its cap is never flagged.
58
+ const wireCap = Math.max(caps.stdout, caps.stderr);
59
+ const reader = (slot) => {
60
+ const cap = slot === "stdout" ? caps.stdout : caps.stderr;
61
+ return {
62
+ readFrom(from) {
63
+ const buffer = state[slot] ?? Buffer.alloc(0);
64
+ const lossy = buffer.length > cap;
65
+ const visible = lossy ? buffer.subarray(0, cap) : buffer;
66
+ const start = Math.min(from, visible.length);
67
+ return { text: visible.subarray(start).toString("utf8"), lossy, nextOffset: visible.length };
68
+ },
69
+ };
70
+ };
71
+ const handle = {
72
+ get collected() {
73
+ if (state.delegate !== undefined)
74
+ return state.delegate.collected;
75
+ return { stdout: reader("stdout"), stderr: reader("stderr") };
76
+ },
77
+ done: (async () => {
78
+ let result;
79
+ try {
80
+ result = await agent.exec({ cwd, argv, env, stdin, maxOutputBytes: wireCap, signal: controller.signal });
81
+ state.stdout = discovery ? translateDiscoveryOutput(distro ?? "", result.stdout) : result.stdout;
82
+ state.stderr = result.stderr;
83
+ }
84
+ catch (error) {
85
+ if (!(error instanceof AgentUnavailableError)) {
86
+ if (controller.signal.aborted) {
87
+ // Our kill() or the caller's cancellation: the killed outcome the
88
+ // one-shot path reports, not a failure.
89
+ return { exitCode: null, signal: "SIGTERM" };
90
+ }
91
+ throw error;
92
+ }
93
+ const delegate = spawnFallback();
94
+ state.delegate = delegate;
95
+ return delegate.done;
96
+ }
97
+ return { exitCode: result.exitCode, signal: null };
98
+ })(),
99
+ kill() {
100
+ controller.abort(new Error("killed"));
101
+ state.delegate?.kill?.();
102
+ },
103
+ };
104
+ return handle;
105
+ }
@@ -13,19 +13,28 @@
13
13
  * ## What is routed, and what deliberately is not
14
14
  *
15
15
  * A spawn is routed only when the working directory names the distro (a
16
- * `\\wsl.localhost\<distro>` UNC) and the argv carries none of the discovery
17
- * flags (`--show-toplevel`, `--absolute-git-dir`, `--git-path`). Those three
18
- * print **absolute paths**, which `workspace-changes` resolves against the
19
- * UNC workspace (`git.ts` `resolve(cwd, line)`) — a distro git would answer
20
- * in Linux paths and corrupt that resolution. Host git answers in UNC form,
21
- * which is exactly what the caller wants, and the discovery call is one
22
- * cheap command rather than a work-tree scan. Everything else — `add`,
23
- * `write-tree`, `ls-tree`, `cat-file`, `diff-tree`, `ls-files`,
24
- * `check-ignore` — is safe to route: snapshot calls run in an environment
25
- * fully isolated from the user's git state (`GIT_INDEX_FILE` /
26
- * `GIT_OBJECT_DIRECTORY` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the
27
- * tool's private scratch), so the distro git mutates scratch, never the
28
- * repository.
16
+ * `\\wsl.localhost\\<distro>` UNC). Inside that, two cases:
17
+ *
18
+ * - **Snapshot calls** (`add`, `write-tree`, `ls-tree`, `cat-file`,
19
+ * `diff-tree`, `ls-files`, `check-ignore`) route whenever the program is
20
+ * git — the runs are fully isolated from the user's git state
21
+ * (`GIT_INDEX_FILE` / `GIT_OBJECT_DIRECTORY` /
22
+ * `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the tool's private
23
+ * scratch), so the distro git mutates scratch, never the repository.
24
+ * - **Discovery calls** (`--show-toplevel`, `--absolute-git-dir`, `--git-path`)
25
+ * print absolute paths that the caller resolves against the UNC workspace,
26
+ * so a distro answer in Linux paths would corrupt that resolution. They
27
+ * route only when the program is the SENTINEL path `DISTRO_GIT_SENTINEL` —
28
+ * the guard `resolveExecutable` returns when the HOST has no git — and the
29
+ * facade translates the output lines into UNC form before the caller sees
30
+ * them (`translateDiscoveryOutput`). A host-resolved git keeps the shipped
31
+ * host-side discovery, which answers UNC natively (and needs the documented
32
+ * `safe.directory` whitelist for WSL shares).
33
+ *
34
+ * The split means a machine without Windows git still gets full WSL change
35
+ * review: the sentinel resolves, every spawn routes, nothing touches the
36
+ * host. See `lib/git-exec.js` for the facade side.
37
+ *
29
38
  *
30
39
  * ## Environment translation
31
40
  *
@@ -44,6 +53,14 @@
44
53
  *
45
54
  * @module dsh-plugin-wsl/git-route
46
55
  */
56
+ /**
57
+ * The program path `resolveExecutable` returns for git when the HOST has no
58
+ * git: a Linux absolute path that only makes sense inside the distro, so any
59
+ * spawn carrying it must route (and a discovery spawn's output must come back
60
+ * translated). Chosen to match a standard distro install so the argv stays
61
+ * honest even where it is read verbatim.
62
+ */
63
+ export declare const DISTRO_GIT_SENTINEL = "/usr/bin/git";
47
64
  /**
48
65
  * Whether a spawn's program is git.
49
66
  *
@@ -97,4 +114,17 @@ export declare function gitSpawnRewrite({ argv, cwd, wslPath }: {
97
114
  linuxCwd: string;
98
115
  gitArgv: string[];
99
116
  wslArgv: string[];
117
+ discovery?: boolean;
100
118
  } | undefined;
119
+ /**
120
+ * Translate one routed discovery call's stdout into the caller's UNC
121
+ * coordinate system: every absolute Linux path line becomes
122
+ * `\\wsl.localhost\<distro><path>`; relative lines (`--git-path` answers
123
+ * `.git/objects` when the repository is not bare) pass through untouched,
124
+ * because the caller resolves them against the UNC cwd natively.
125
+ *
126
+ * @param distro - the distro the discovery ran in.
127
+ * @param stdout - the distro git's captured stdout.
128
+ * @returns the stdout with absolute path lines rewritten.
129
+ */
130
+ export declare function translateDiscoveryOutput(distro: string, stdout: Buffer): Buffer;
package/lib/git-route.js CHANGED
@@ -13,19 +13,28 @@
13
13
  * ## What is routed, and what deliberately is not
14
14
  *
15
15
  * A spawn is routed only when the working directory names the distro (a
16
- * `\\wsl.localhost\<distro>` UNC) and the argv carries none of the discovery
17
- * flags (`--show-toplevel`, `--absolute-git-dir`, `--git-path`). Those three
18
- * print **absolute paths**, which `workspace-changes` resolves against the
19
- * UNC workspace (`git.ts` `resolve(cwd, line)`) — a distro git would answer
20
- * in Linux paths and corrupt that resolution. Host git answers in UNC form,
21
- * which is exactly what the caller wants, and the discovery call is one
22
- * cheap command rather than a work-tree scan. Everything else — `add`,
23
- * `write-tree`, `ls-tree`, `cat-file`, `diff-tree`, `ls-files`,
24
- * `check-ignore` — is safe to route: snapshot calls run in an environment
25
- * fully isolated from the user's git state (`GIT_INDEX_FILE` /
26
- * `GIT_OBJECT_DIRECTORY` / `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the
27
- * tool's private scratch), so the distro git mutates scratch, never the
28
- * repository.
16
+ * `\\wsl.localhost\\<distro>` UNC). Inside that, two cases:
17
+ *
18
+ * - **Snapshot calls** (`add`, `write-tree`, `ls-tree`, `cat-file`,
19
+ * `diff-tree`, `ls-files`, `check-ignore`) route whenever the program is
20
+ * git — the runs are fully isolated from the user's git state
21
+ * (`GIT_INDEX_FILE` / `GIT_OBJECT_DIRECTORY` /
22
+ * `GIT_ALTERNATE_OBJECT_DIRECTORIES` pointing at the tool's private
23
+ * scratch), so the distro git mutates scratch, never the repository.
24
+ * - **Discovery calls** (`--show-toplevel`, `--absolute-git-dir`, `--git-path`)
25
+ * print absolute paths that the caller resolves against the UNC workspace,
26
+ * so a distro answer in Linux paths would corrupt that resolution. They
27
+ * route only when the program is the SENTINEL path `DISTRO_GIT_SENTINEL` —
28
+ * the guard `resolveExecutable` returns when the HOST has no git — and the
29
+ * facade translates the output lines into UNC form before the caller sees
30
+ * them (`translateDiscoveryOutput`). A host-resolved git keeps the shipped
31
+ * host-side discovery, which answers UNC natively (and needs the documented
32
+ * `safe.directory` whitelist for WSL shares).
33
+ *
34
+ * The split means a machine without Windows git still gets full WSL change
35
+ * review: the sentinel resolves, every spawn routes, nothing touches the
36
+ * host. See `lib/git-exec.js` for the facade side.
37
+ *
29
38
  *
30
39
  * ## Environment translation
31
40
  *
@@ -47,7 +56,15 @@
47
56
  import { isWslUnc, uncToPosix, windowsToLinuxMount } from "./paths.js";
48
57
  /** The one program this rewrite passes through to the distro. */
49
58
  const GIT_PROGRAM = "git";
50
- /** Discovery flags print absolute paths — host git keeps that contract. */
59
+ /**
60
+ * The program path `resolveExecutable` returns for git when the HOST has no
61
+ * git: a Linux absolute path that only makes sense inside the distro, so any
62
+ * spawn carrying it must route (and a discovery spawn's output must come back
63
+ * translated). Chosen to match a standard distro install so the argv stays
64
+ * honest even where it is read verbatim.
65
+ */
66
+ export const DISTRO_GIT_SENTINEL = "/usr/bin/git";
67
+ /** Discovery flags print absolute paths — the caller resolves them. */
51
68
  const DISCOVERY_FLAGS = ["--show-toplevel", "--absolute-git-dir", "--git-path"];
52
69
  /**
53
70
  * Whether a spawn's program is git.
@@ -125,7 +142,8 @@ export function translateGitEnv(env) {
125
142
  export function gitSpawnRewrite({ argv, cwd, wslPath }) {
126
143
  if (!Array.isArray(argv) || !isGitProgram(argv[0]))
127
144
  return undefined;
128
- if (namesDiscoveryPaths(argv))
145
+ const sentinel = argv[0] === DISTRO_GIT_SENTINEL;
146
+ if (namesDiscoveryPaths(argv) && !sentinel)
129
147
  return undefined;
130
148
  const named = uncToPosix(cwd);
131
149
  if (named === undefined)
@@ -137,5 +155,26 @@ export function gitSpawnRewrite({ argv, cwd, wslPath }) {
137
155
  linuxCwd,
138
156
  gitArgv,
139
157
  wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...gitArgv],
158
+ discovery: sentinel && namesDiscoveryPaths(argv),
140
159
  };
141
160
  }
161
+ /**
162
+ * Translate one routed discovery call's stdout into the caller's UNC
163
+ * coordinate system: every absolute Linux path line becomes
164
+ * `\\wsl.localhost\<distro><path>`; relative lines (`--git-path` answers
165
+ * `.git/objects` when the repository is not bare) pass through untouched,
166
+ * because the caller resolves them against the UNC cwd natively.
167
+ *
168
+ * @param distro - the distro the discovery ran in.
169
+ * @param stdout - the distro git's captured stdout.
170
+ * @returns the stdout with absolute path lines rewritten.
171
+ */
172
+ export function translateDiscoveryOutput(distro, stdout) {
173
+ const text = stdout.toString("utf8");
174
+ if (!text.includes("\n") && !text.startsWith("/"))
175
+ return stdout;
176
+ const translated = text.split("\n").map((line) => line.startsWith("/")
177
+ ? `\\\\wsl.localhost\\${distro}${line.replaceAll("/", "\\")}`
178
+ : line);
179
+ return Buffer.from(translated.join("\n"), "utf8");
180
+ }
@@ -60,13 +60,17 @@
60
60
  *
61
61
  * ## What the operator must pair it with
62
62
  *
63
- * `terminalEnvironment()` and `resolveExecutable()` are also resolved from the
64
- * root realm, and neither receives a Session or a directory, so this provider
65
- * deliberately leaves them alone: rewriting them would change executable lookup
66
- * for every other root consumer (the pwsh executor, the LSP host, the file
67
- * search) to make the *shell menu* of one window prettier. The profile instead
68
- * configures the terminal controller with the single shell profile that matters:
69
- * `shell: { path: wsl.exe, name: WSL }` and `shellCandidates: []`.
63
+ * `terminalEnvironment()` is also resolved from the root realm and receives no
64
+ * Session or directory, so this provider deliberately leaves it alone:
65
+ * rewriting it would change environment lookup for every other root consumer
66
+ * to make the *shell menu* of one window prettier. `resolveExecutable()` gets
67
+ * ONE scoped exception — a git fallback when the host has none (see the
68
+ * override below); every other name's lookup is untouched. The profile also
69
+ * configures the terminal controller with the distro shell as the default
70
+ * profile plus deliberate host candidates: `shell: { path: wsl.exe, name: WSL }`
71
+ * and `shellCandidates: [wsl.exe, powershell, cmd]` — the first dedupes into
72
+ * the default's `wsl` kind, and the other two are launches this terminal really
73
+ * starts (host sessions, or an explicit user selection, titled after itself).
70
74
  *
71
75
  * Consequence worth stating plainly: the terminal is a property of the
72
76
  * composition, not of the Session. A Session whose workspace is a Windows folder
@@ -297,6 +301,16 @@ export declare class WslSubprocessRuntime extends LocalSubprocessRuntime {
297
301
  * @returns the live search handle.
298
302
  */
299
303
  spawn(spec: SubprocessSpawnSpec): SubprocessHandle;
304
+ /**
305
+ * Guarded git fallback: when the HOST has no git, resolve the name to the
306
+ * distro sentinel so the change-review pipeline's every spawn (discovery
307
+ * included) routes into the distro instead of dying at the executable
308
+ * lookup. Scoped to the one name — every other consumer's lookup (pwsh,
309
+ * LSP, ripgrep) is untouched — and only on the super's failure, so a
310
+ * machine with Windows git behaves exactly as shipped. See
311
+ * `git-route.ts`'s `DISTRO_GIT_SENTINEL` for the routing side.
312
+ */
313
+ resolveExecutable(command: string, env: Record<string, string> | undefined, signal: AbortSignal | undefined): Promise<string>;
300
314
  /**
301
315
  * The environment one `wsl.exe` launch forwards: the safe list plus every
302
316
  * managed `DSH_*` name, admitted through `WSLENV` and never `PATH` — see the
package/lib/subprocess.js CHANGED
@@ -60,13 +60,17 @@
60
60
  *
61
61
  * ## What the operator must pair it with
62
62
  *
63
- * `terminalEnvironment()` and `resolveExecutable()` are also resolved from the
64
- * root realm, and neither receives a Session or a directory, so this provider
65
- * deliberately leaves them alone: rewriting them would change executable lookup
66
- * for every other root consumer (the pwsh executor, the LSP host, the file
67
- * search) to make the *shell menu* of one window prettier. The profile instead
68
- * configures the terminal controller with the single shell profile that matters:
69
- * `shell: { path: wsl.exe, name: WSL }` and `shellCandidates: []`.
63
+ * `terminalEnvironment()` is also resolved from the root realm and receives no
64
+ * Session or directory, so this provider deliberately leaves it alone:
65
+ * rewriting it would change environment lookup for every other root consumer
66
+ * to make the *shell menu* of one window prettier. `resolveExecutable()` gets
67
+ * ONE scoped exception — a git fallback when the host has none (see the
68
+ * override below); every other name's lookup is untouched. The profile also
69
+ * configures the terminal controller with the distro shell as the default
70
+ * profile plus deliberate host candidates: `shell: { path: wsl.exe, name: WSL }`
71
+ * and `shellCandidates: [wsl.exe, powershell, cmd]` — the first dedupes into
72
+ * the default's `wsl` kind, and the other two are launches this terminal really
73
+ * starts (host sessions, or an explicit user selection, titled after itself).
70
74
  *
71
75
  * Consequence worth stating plainly: the terminal is a property of the
72
76
  * composition, not of the Session. A Session whose workspace is a Windows folder
@@ -79,13 +83,15 @@
79
83
  * @module dsh-plugin-wsl/subprocess
80
84
  */
81
85
  import { randomUUID } from "node:crypto";
86
+ import { Buffer } from "node:buffer";
82
87
  import z from "@deepseek-ai/schemastery";
83
88
  import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
84
89
  import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
85
90
  import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
86
91
  import { searchSpawnRewrite } from "./search-route.js";
87
- import { gitSpawnRewrite, translateGitEnv } from "./git-route.js";
92
+ import { DISTRO_GIT_SENTINEL, gitSpawnRewrite, translateGitEnv } from "./git-route.js";
88
93
  import { searchExecutionHandle } from "./search-exec.js";
94
+ import { gitExecutionHandle } from "./git-exec.js";
89
95
  import { sharedAgent } from "./agent-shared.js";
90
96
  import { parseTerminalActivity, TERMINAL_ID_ENV, terminalActivityProbe, wrapTerminalHandle } from "./terminal-activity.js";
91
97
  import { AgentUnavailableError } from "./agent-errors.js";
@@ -341,12 +347,40 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
341
347
  // object store, work tree, and refs stay untouched by design — the
342
348
  // snapshot runs in the tool's private scratch.
343
349
  const { env, wslenv } = translateGitEnv(spec.env);
344
- return super.spawn({
350
+ const oneShot = () => super.spawn({
345
351
  ...spec,
346
352
  argv: gitRewrite.wslArgv,
347
353
  cwd: this.hostCwd(),
348
354
  env: wslenv.length > 0 ? { ...env, WSLENV: wslEnvValue(wslenv) } : env,
349
355
  });
356
+ // Agent-first: the warm resident answers a git call in ~2 ms where a
357
+ // one-shot `wsl.exe` spends ~230 ms of process startup, and a snapshot
358
+ // makes 6-8 of those calls plus per-file `cat-file`. Only the translated
359
+ // GIT_* names ride the agent's per-request environment — a host PATH
360
+ // must not leak into the distro, and the one-shot forwards exactly those
361
+ // names through WSLENV too. The agent being out falls back to the
362
+ // one-shot handle lazily (see git-exec.ts).
363
+ const agentEnv = {};
364
+ for (const [name, value] of Object.entries(env)) {
365
+ if (name.startsWith("GIT_") && typeof value === "string")
366
+ agentEnv[name] = value;
367
+ }
368
+ const stdinSpec = spec.stdio.stdin;
369
+ const stdinData = typeof stdinSpec === "object" && stdinSpec !== null && typeof stdinSpec.data === "string" ? stdinSpec.data : undefined;
370
+ const stdoutCap = spec.stdio.stdout?.maxBytes ?? 0;
371
+ const stderrCap = spec.stdio.stderr?.maxBytes ?? 0;
372
+ return gitExecutionHandle({
373
+ agent: sharedAgent(gitRewrite.distro),
374
+ cwd: gitRewrite.linuxCwd,
375
+ argv: gitRewrite.gitArgv,
376
+ env: agentEnv,
377
+ stdin: stdinData === undefined ? undefined : Buffer.from(stdinData, "utf8"),
378
+ caps: { stdout: stdoutCap, stderr: stderrCap },
379
+ distro: gitRewrite.distro,
380
+ discovery: gitRewrite.discovery === true,
381
+ signal: spec.signal,
382
+ spawnFallback: oneShot,
383
+ });
350
384
  }
351
385
  const rewrite = searchSpawnRewrite({ argv: spec.argv, cwd: spec.cwd, wslPath: this.config.wslPath });
352
386
  if (rewrite === undefined)
@@ -372,6 +406,26 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
372
406
  }),
373
407
  });
374
408
  }
409
+ /**
410
+ * Guarded git fallback: when the HOST has no git, resolve the name to the
411
+ * distro sentinel so the change-review pipeline's every spawn (discovery
412
+ * included) routes into the distro instead of dying at the executable
413
+ * lookup. Scoped to the one name — every other consumer's lookup (pwsh,
414
+ * LSP, ripgrep) is untouched — and only on the super's failure, so a
415
+ * machine with Windows git behaves exactly as shipped. See
416
+ * `git-route.ts`'s `DISTRO_GIT_SENTINEL` for the routing side.
417
+ */
418
+ async resolveExecutable(command, env, signal) {
419
+ try {
420
+ return await super.resolveExecutable(command, env, signal);
421
+ }
422
+ catch (error) {
423
+ const base = command.slice(Math.max(command.lastIndexOf("/"), command.lastIndexOf("\\")) + 1).toLowerCase().replace(/\.exe$/u, "");
424
+ if (base === "git")
425
+ return DISTRO_GIT_SENTINEL;
426
+ throw error;
427
+ }
428
+ }
375
429
  /**
376
430
  * The environment one `wsl.exe` launch forwards: the safe list plus every
377
431
  * managed `DSH_*` name, admitted through `WSLENV` and never `PATH` — see the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.16.0",
4
- "description": "在 WSL 的 Linux 实例中打开项目工作区,在对应 Linux 空间中进行命令执行、运行 shell 脚本和文件访问,以获得全套 Linux Shell 工具链收益,并通过 bubblewrap 对命令进行沙箱隔离",
3
+ "version": "0.17.0",
4
+ "description": "\u5728 WSL \u7684 Linux \u5b9e\u4f8b\u4e2d\u6253\u5f00\u9879\u76ee\u5de5\u4f5c\u533a\uff0c\u5728\u5bf9\u5e94 Linux \u7a7a\u95f4\u4e2d\u8fdb\u884c\u547d\u4ee4\u6267\u884c\u3001\u8fd0\u884c shell \u811a\u672c\u548c\u6587\u4ef6\u8bbf\u95ee\uff0c\u4ee5\u83b7\u5f97\u5168\u5957 Linux Shell \u5de5\u5177\u94fe\u6536\u76ca\uff0c\u5e76\u901a\u8fc7 bubblewrap \u5bf9\u547d\u4ee4\u8fdb\u884c\u6c99\u7bb1\u9694\u79bb",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -394,6 +394,26 @@ async function main(options) {
394
394
  if (m === null) return [];
395
395
  return (m[1] ?? "").trim().split(/\s+/).filter((p) => p.length > 0);
396
396
  });
397
+ // ---- Windows git trust (WSL change review) ------------------------------
398
+ // The change-review pipeline's discovery call runs the WINDOWS git against
399
+ // a \\wsl.localhost UNC when Windows git is installed; git's
400
+ // dubious-ownership check refuses the share unless it is whitelisted, and
401
+ // the pipeline silently degrades to "git is unavailable" (no diff cards for
402
+ // WSL workspaces). A machine without Windows git is fine too — the provider
403
+ // resolves git to a distro sentinel and routes everything. What the doctor
404
+ // can probe cheaply from here is interop git.exe and its whitelist.
405
+ const winGitVersion = await run(["git.exe", "--version"]);
406
+ if (!winGitVersion.ok) {
407
+ check("git.win-safe", "info", "Windows git trust", "Windows git not found through interop — WSL change review uses the distro fallback; host (C:\\...) workspaces would need git for their own review");
408
+ } else {
409
+ const entries = (await run(["git.exe", "config", "--global", "--get-all", "safe.directory"])).stdout.split(/\r?\n/).filter(Boolean);
410
+ const whitelisted = entries.some((entry) => /wsl\.localhost/i.test(entry));
411
+ check("git.win-safe", whitelisted ? "ok" : "warn", "Windows git trust",
412
+ whitelisted ? `the \\\\wsl.localhost share is whitelisted (${entries.length} entr${entries.length === 1 ? "y" : "ies"})`
413
+ : "Windows git does not trust the \\\\wsl.localhost share; WSL workspaces' change review silently degrades to file-tool edits",
414
+ whitelisted ? undefined : 'on Windows: git config --global --add safe.directory "//wsl.localhost/*", then restart the app');
415
+ }
416
+
397
417
  // ---- resident handshake --------------------------------------------------
398
418
  const agentScript = join(ROOT, "agent", "wsl-agent.sh");
399
419
  if (!agentScript.startsWith("/")) {