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 +57 -1
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +47 -7
- package/cordis.patch.yml +15 -9
- package/docs/ARCHITECTURE.md +14 -6
- package/docs/CONFIGURATION.md +21 -0
- package/docs/LIMITATIONS.md +53 -12
- package/docs/UPSTREAM-TERMINAL-TITLE.md +10 -12
- package/lib/agent-exec.d.ts +5 -0
- package/lib/agent-protocol.d.ts +18 -5
- package/lib/agent-protocol.js +11 -6
- package/lib/agent.d.ts +9 -1
- package/lib/agent.js +2 -2
- package/lib/git-exec.d.ts +117 -0
- package/lib/git-exec.js +105 -0
- package/lib/git-route.d.ts +43 -13
- package/lib/git-route.js +54 -15
- package/lib/subprocess.d.ts +21 -7
- package/lib/subprocess.js +63 -9
- package/package.json +2 -2
- package/scripts/doctor.mjs +20 -0
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
|
-
## [
|
|
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
|
|
|
Binary file
|
|
Binary file
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
|
49
|
-
# and
|
|
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
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
124
|
-
`WSL` shell profile — so a
|
|
125
|
-
|
|
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
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -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
|
|
296
|
-
profile, and a directory-less launch still lands in the distro.
|
|
297
|
-
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
package/lib/agent-exec.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/agent-protocol.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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.
|
package/lib/agent-protocol.js
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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;
|
package/lib/git-exec.js
ADDED
|
@@ -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
|
+
}
|
package/lib/git-route.d.ts
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/subprocess.d.ts
CHANGED
|
@@ -60,13 +60,17 @@
|
|
|
60
60
|
*
|
|
61
61
|
* ## What the operator must pair it with
|
|
62
62
|
*
|
|
63
|
-
* `terminalEnvironment()`
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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()`
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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("/")) {
|