dsh-plugin-wsl-env 0.7.2 → 0.7.4
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 +87 -0
- package/agent/wsl-agent.sh +111 -10
- package/docs/LIMITATIONS.md +7 -0
- package/lib/agent-confined.js +3 -3
- package/lib/agent-protocol.js +10 -5
- package/lib/agent-shared.js +14 -1
- package/lib/agent.js +52 -5
- package/lib/file-reference-wsl.js +284 -0
- package/lib/fs-substrate.js +10 -1
- package/package.json +20 -18
- package/scripts/bootstrap.sh +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,93 @@ reference below points at that record's numbering.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.7.4] - 2026-10-05
|
|
16
|
+
|
|
17
|
+
One theme: the 0.2.0 review's engineering debt closes out - the five
|
|
18
|
+
open groups land with tests, the bootstrap remedy ships with the
|
|
19
|
+
package, and the real-distro probes re-run green on the protocol v4
|
|
20
|
+
stack.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **The bootstrap remedy is shipped with the package.** The refused-write
|
|
25
|
+
and sandbox messages point at `scripts/bootstrap.sh <distro> --install`,
|
|
26
|
+
which the `files` list never packaged - on an npm install the advice was
|
|
27
|
+
a dead end. The script rides in the tarball now (the other two files in
|
|
28
|
+
`scripts/` are CI-only and stay unpackaged). The real-distro probes were
|
|
29
|
+
re-run against the protocol v4 stack: agent 11/11, substrate 8/8, exec
|
|
30
|
+
5/5, watch 6/6.
|
|
31
|
+
|
|
32
|
+
- **The review's remaining engineering items close out.** A `SETENV` key
|
|
33
|
+
that is not a POSIX identifier is refused with a protocol error instead
|
|
34
|
+
of exporting silently into every later request; an EXEC frame whose argv
|
|
35
|
+
payload fails to decode refuses the request instead of running the
|
|
36
|
+
command with a silent empty word where the host sent text, and a frame
|
|
37
|
+
that ends before its declared argument count exits instead of running
|
|
38
|
+
the op on a partial list; a non-numeric exit code reads as the sentinel
|
|
39
|
+
`-1` (a value no `wait` can produce) instead of `NaN`; an unknown FS op
|
|
40
|
+
maps to `FS_IO_ERROR` - naming the contract breach - instead of posing
|
|
41
|
+
as `FS_NOT_FOUND`; `editText` on a file that never existed reads as
|
|
42
|
+
`FS_NOT_FOUND` (the read path's dialect) while an edit that *lost* its
|
|
43
|
+
file after a read stays `FS_STALE_VERSION`; a symlinked directory as a
|
|
44
|
+
list target answers with the target's children (`find` used to skip its
|
|
45
|
+
starting point and answer empty, exit 0); and the Linux-filename
|
|
46
|
+
backslash identity limit is documented in LIMITATIONS instead of
|
|
47
|
+
waiting as an undocumented wrong answer.
|
|
48
|
+
|
|
49
|
+
## [0.7.3] - 2026-10-05
|
|
50
|
+
|
|
51
|
+
One theme: the resident agent stops taking its world on faith - the
|
|
52
|
+
handshake now verifies the deployment's content, the agent's lifetime
|
|
53
|
+
survives a wedged host only by lease, and the one 9P consumer no row
|
|
54
|
+
replacement could reach ships its seam-ready traversal.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **The resident agent verifies its own deployment, and outlives a wedged
|
|
59
|
+
host only by lease.** The agent script is read in place from the
|
|
60
|
+
distro-visible path - in the deployed layout a `/mnt/c` mirror - so a
|
|
61
|
+
stale or half-synced runtime copy used to pass the version handshake
|
|
62
|
+
while drifting in behavior. The HELLO line now carries the sha256 of the
|
|
63
|
+
file the distro actually read (protocol v4), and both resident factories
|
|
64
|
+
pin the hash of the copy the package shipped: a mismatch is refused like
|
|
65
|
+
a version mismatch, with the resync command in the message. The agent
|
|
66
|
+
also terminates itself when its host goes silent without ever delivering
|
|
67
|
+
EOF - the wedged-relay case stdin EOF cannot cover: the host forwards a
|
|
68
|
+
client lease (`idleMs` + grace) through the managed `DSH_` namespace, and
|
|
69
|
+
an in-distro watchdog polls an in-flight marker so a long command's
|
|
70
|
+
silence never counts against it (dash has no `read -t`; a marker file is
|
|
71
|
+
the portable form). A boot sweep removes sibling `wsl-agent.*` temp dirs
|
|
72
|
+
no request has touched for an hour - the residue of agents whose host
|
|
73
|
+
died before their exit trap could run.
|
|
74
|
+
- **The `@` completion's distro traversal is built and waiting for its seam.**
|
|
75
|
+
The one 9P consumer the takeover series could not reach - the GUI's `@`
|
|
76
|
+
file completion - funnels all workspace traversal through three
|
|
77
|
+
module-internal functions of the upstream `file-reference-local` service,
|
|
78
|
+
so no row replacement can reach them. The plugin now ships the consumer
|
|
79
|
+
side of RFC addendum ① (`lib/file-reference-wsl.js`): a traversal strategy
|
|
80
|
+
that lists a distro workspace through the resident agent with
|
|
81
|
+
`find -printf '%y\t%f'` - keeping readdir's Dirent fidelity, where
|
|
82
|
+
symlinks report neither kind and are skipped instead of resolved through
|
|
83
|
+
like `ls -L` would - replaces the upstream per-segment lstat walk with one
|
|
84
|
+
agent exec, and returns absolutes in the root's own coordinate system,
|
|
85
|
+
which the upstream scan joins children with. Deliberately not wired into
|
|
86
|
+
the composition: with the seam absent, reaching the funnels would mean
|
|
87
|
+
re-implementing the fuzzy ranking engine, the behavior-regression path
|
|
88
|
+
the RFC rules out. The module ships tested; the takeover lands the day
|
|
89
|
+
upstream takes the ~10-line parameter. The RFC addendum now carries the
|
|
90
|
+
concrete interface proposal.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **The Windows CI legs are green again.** The fs boot test's end-to-end
|
|
95
|
+
legs read `platform is win32` as `the real-distro topology exists`, but
|
|
96
|
+
the CI Windows runners ship wsl.exe with no distro installed - every
|
|
97
|
+
Windows leg since the boot test landed failed there, which kept the
|
|
98
|
+
release gates red through 0.7.0-0.7.2 even after the missing-module
|
|
99
|
+
fix. The guard now asks WSL for its distro list and skips unless the
|
|
100
|
+
test's distro is actually installed.
|
|
101
|
+
|
|
15
102
|
## [0.7.2] - 2026-10-05
|
|
16
103
|
|
|
17
104
|
One theme: the root-plane routing lands - and the composition that
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
# one thing a distro is guaranteed to have is a shell.
|
|
13
13
|
#
|
|
14
14
|
# Handshake — the first line the agent writes:
|
|
15
|
-
# HELLO|wsl-agent|<protocol version>
|
|
15
|
+
# HELLO|wsl-agent|<protocol version>|<sha256 of this script file>
|
|
16
|
+
# The digest lets the host verify that the script it is about to rely on is
|
|
17
|
+
# byte-identical to the copy it shipped. The script is read in place — a
|
|
18
|
+
# /mnt/c mirror in the deployed layout — so a stale or half-synced runtime
|
|
19
|
+
# copy would otherwise pass the version handshake while drifting in behavior.
|
|
16
20
|
# Requests the agent accepts, one per line on stdin:
|
|
17
21
|
# PING -> PONG
|
|
18
22
|
# SHUTDOWN -> exits 0
|
|
19
23
|
# KILL|<id> -> SIGTERM the in-flight request with that id
|
|
20
|
-
# SETENV|<key>|<b64 value> -> export the variable for later requests
|
|
24
|
+
# SETENV|<key>|<b64 value> -> export the variable for later requests;
|
|
25
|
+
# <key> must be a POSIX identifier, else
|
|
26
|
+
# ERR|protocol
|
|
21
27
|
# EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>
|
|
22
28
|
# followed by <n args> lines of base64, one argv word each
|
|
23
29
|
# -> ACK|<id> the moment the request is dequeued for execution — the host
|
|
@@ -57,11 +63,55 @@
|
|
|
57
63
|
|
|
58
64
|
set -u
|
|
59
65
|
|
|
60
|
-
PROTO_VERSION=
|
|
66
|
+
PROTO_VERSION=4
|
|
61
67
|
TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
|
|
62
68
|
OUT_FILE="$TMPDIR_AGENT/out"
|
|
63
69
|
ERR_FILE="$TMPDIR_AGENT/err"
|
|
64
|
-
trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; exit 0' EXIT
|
|
70
|
+
trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; [ -z "${WATCHDOG_PID:-}" ] || kill "$WATCHDOG_PID" 2>/dev/null; exit 0' EXIT
|
|
71
|
+
|
|
72
|
+
# The content identity of this script file, reported in the handshake: the
|
|
73
|
+
# host hashes its own shipped copy and refuses an agent whose file differs.
|
|
74
|
+
# cut with an explicit space: sha256sum separates hash and file with spaces,
|
|
75
|
+
# and the default tab delimiter would leave the whole line in the field.
|
|
76
|
+
AGENT_DIGEST=$(sha256sum -- "$0" 2>/dev/null | cut -d' ' -f1)
|
|
77
|
+
|
|
78
|
+
# Bounded cleanup: temp dirs from agents whose host died without giving them
|
|
79
|
+
# an EXIT (a `wsl --shutdown`, a force-killed wsl.exe). Own pattern only, and
|
|
80
|
+
# only when nothing inside moved for an hour — a live agent's dir mtime moves
|
|
81
|
+
# on every request (the inflight marker in run_capture is created and
|
|
82
|
+
# removed), so a sibling being served right now is never touched. A confined
|
|
83
|
+
# agent's /tmp is a private tmpfs: its own dir vanishes with it and it sees
|
|
84
|
+
# no siblings, so the sweep is a no-op there.
|
|
85
|
+
for stale in "${TMPDIR:-/tmp}"/wsl-agent.*; do
|
|
86
|
+
[ -d "$stale" ] || continue
|
|
87
|
+
[ "$stale" = "$TMPDIR_AGENT" ] && continue
|
|
88
|
+
[ -z "$(find "$stale" -maxdepth 1 -mmin -60 -print -quit 2>/dev/null)" ] && rm -rf -- "$stale" 2>/dev/null
|
|
89
|
+
done
|
|
90
|
+
|
|
91
|
+
# The client lease: when the host goes silent without closing the pipe (a
|
|
92
|
+
# wedged relay, not a dead one — EOF exits the read loop), the agent
|
|
93
|
+
# terminates itself after the lease window instead of lingering with its
|
|
94
|
+
# temp dir. The window rides DSH_AGENT_LEASE_MS from the host's pinned
|
|
95
|
+
# environment, set above the host's own idle shutdown, so the lease only
|
|
96
|
+
# fires when the host is gone in every way that matters. A request in
|
|
97
|
+
# flight pauses the lease (the inflight marker) — a long command with no
|
|
98
|
+
# traffic is silence, not absence. Unset (an older host, a manual run)
|
|
99
|
+
# disables the lease: the host keeps the lifetime it always had.
|
|
100
|
+
LEASE_S=0
|
|
101
|
+
case ${DSH_AGENT_LEASE_MS:-} in
|
|
102
|
+
''|*[!0-9]*) ;;
|
|
103
|
+
*) if [ "$DSH_AGENT_LEASE_MS" -gt 0 ]; then
|
|
104
|
+
LEASE_S=$(( (DSH_AGENT_LEASE_MS + 999) / 1000 ))
|
|
105
|
+
(
|
|
106
|
+
while :; do
|
|
107
|
+
sleep "$LEASE_S" || exit 0
|
|
108
|
+
kill -0 "$$" 2>/dev/null || exit 0
|
|
109
|
+
[ -f "$TMPDIR_AGENT/inflight" ] || kill -s TERM "$$"
|
|
110
|
+
done
|
|
111
|
+
) &
|
|
112
|
+
WATCHDOG_PID=$!
|
|
113
|
+
fi ;;
|
|
114
|
+
esac
|
|
65
115
|
|
|
66
116
|
# The request currently in flight, for KILL and for the exit trap.
|
|
67
117
|
CURRENT_PID=
|
|
@@ -76,6 +126,16 @@ b64dec() {
|
|
|
76
126
|
printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 --decode 2>/dev/null || :
|
|
77
127
|
}
|
|
78
128
|
|
|
129
|
+
# Strict decode for payloads whose emptiness would LIE: an argv word that
|
|
130
|
+
# fails to decode must refuse the request, not run the command with a silent
|
|
131
|
+
# empty word where the host sent text (an empty payload is legitimate and
|
|
132
|
+
# decodes fine — only malformed base64 lands here). Sets DEC on success.
|
|
133
|
+
b64dec_strict() {
|
|
134
|
+
DEC=$(printf '%s' "$1" | base64 -d 2>/dev/null) && return 0
|
|
135
|
+
DEC=$(printf '%s' "$1" | base64 --decode 2>/dev/null) && return 0
|
|
136
|
+
return 1
|
|
137
|
+
}
|
|
138
|
+
|
|
79
139
|
b64enc_file() {
|
|
80
140
|
# `tr` strips the 76-column wraps GNU and BusyBox base64 both emit; the
|
|
81
141
|
# protocol is one line per payload, so a wrapped encode would tear a large
|
|
@@ -150,6 +210,10 @@ run_capture() {
|
|
|
150
210
|
: >"$OUT_FILE"
|
|
151
211
|
: >"$ERR_FILE"
|
|
152
212
|
CURRENT_ID=$req_id
|
|
213
|
+
# The inflight marker pauses the lease watchdog for this request and
|
|
214
|
+
# refreshes the temp dir's mtime for the boot sweep; removing it after the
|
|
215
|
+
# answer re-arms both.
|
|
216
|
+
: >"$TMPDIR_AGENT/inflight"
|
|
153
217
|
if [ "$mode" = subshell ]; then
|
|
154
218
|
( "$@" ) >"$OUT_FILE" 2>"$ERR_FILE" &
|
|
155
219
|
elif [ -n "$SETSID" ]; then
|
|
@@ -192,6 +256,7 @@ run_capture() {
|
|
|
192
256
|
printf 'RES|%s|%s|%s|%s|%s|%s\n' "$req_id" "$rc" \
|
|
193
257
|
"$(b64enc_file "$OUT_FILE")" "$(b64enc_file "$ERR_FILE")" 0 0
|
|
194
258
|
fi
|
|
259
|
+
rm -f "$TMPDIR_AGENT/inflight"
|
|
195
260
|
}
|
|
196
261
|
|
|
197
262
|
# --- filesystem substrate (FS frames) -------------------------------------
|
|
@@ -254,6 +319,15 @@ fs_list() {
|
|
|
254
319
|
# records (records split on NUL, names taken after the 6th TAB).
|
|
255
320
|
local d err
|
|
256
321
|
d=$(b64dec "$1")
|
|
322
|
+
# The LIST TARGET follows symlinks, like every read path: `find` does not
|
|
323
|
+
# traverse its starting point, so a symlink-to-directory used to list EMPTY
|
|
324
|
+
# with exit 0 - a silent wrong answer where the peer's readdir answers with
|
|
325
|
+
# the target's children. Resolve first, then enumerate; the entries keep
|
|
326
|
+
# their own (lstat) types.
|
|
327
|
+
d=$(realpath -L -- "$d" 2>"$ERR_FILE") || {
|
|
328
|
+
err=$(cat "$ERR_FILE")
|
|
329
|
+
fs_fail "$(fs_classify "$err")" "$err"
|
|
330
|
+
}
|
|
257
331
|
find "$d" -mindepth 1 -maxdepth 1 -printf '%y\t%s\t%D\t%i\t%T@\t%C@\t%p\0' 2>"$ERR_FILE" || {
|
|
258
332
|
err=$(cat "$ERR_FILE")
|
|
259
333
|
fs_fail "$(fs_classify "$err")" "$err"
|
|
@@ -484,11 +558,13 @@ fs_dispatch() {
|
|
|
484
558
|
realpath) fs_realpath "$1" ;;
|
|
485
559
|
read) fs_read "$1" "$2" "$3" ;;
|
|
486
560
|
write) fs_write "$1" "$2" "$3" "$4" "$5" ;;
|
|
487
|
-
|
|
561
|
+
# A host/agent contract breach is an I/O-class failure, not a file that
|
|
562
|
+
# was not found: the mapped reason reads FS_IO_ERROR host-side.
|
|
563
|
+
*) fs_fail protocol "unknown fs op" ;;
|
|
488
564
|
esac
|
|
489
565
|
}
|
|
490
566
|
|
|
491
|
-
printf 'HELLO|wsl-agent|%s\n' "$PROTO_VERSION"
|
|
567
|
+
printf 'HELLO|wsl-agent|%s|%s\n' "$PROTO_VERSION" "$AGENT_DIGEST"
|
|
492
568
|
|
|
493
569
|
while IFS= read -r line; do
|
|
494
570
|
case $line in
|
|
@@ -508,6 +584,15 @@ while IFS= read -r line; do
|
|
|
508
584
|
rest=${line#SETENV|}
|
|
509
585
|
key=${rest%%|*}
|
|
510
586
|
b64=${rest#*|}
|
|
587
|
+
# The key exports into EVERY later request's environment, so it must be
|
|
588
|
+
# a POSIX identifier: anything else would be a silent no-op for the
|
|
589
|
+
# command (or a name collision with a shell special). Refused loudly.
|
|
590
|
+
case $key in
|
|
591
|
+
""|[0-9]*|*[!A-Za-z0-9_]*)
|
|
592
|
+
printf 'ERR||protocol|%s\n' "$(printf '%s' "SETENV key is not a POSIX identifier" | base64 | tr -d '\n')"
|
|
593
|
+
continue
|
|
594
|
+
;;
|
|
595
|
+
esac
|
|
511
596
|
# Command substitution strips trailing newlines from the decoded value;
|
|
512
597
|
# acceptable for environment values, documented in the protocol.
|
|
513
598
|
decoded=$(b64dec "$b64")
|
|
@@ -528,15 +613,29 @@ while IFS= read -r line; do
|
|
|
528
613
|
printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
|
|
529
614
|
continue
|
|
530
615
|
fi
|
|
531
|
-
# Build the argv from the following base64 lines.
|
|
616
|
+
# Build the argv from the following base64 lines. Two refusal paths
|
|
617
|
+
# before anything runs: a payload that does not decode (it would
|
|
618
|
+
# silently become an EMPTY argv word — a different command than the
|
|
619
|
+
# host sent), and a frame that ends before its declared argument count
|
|
620
|
+
# (a truncated frame must never execute; EOF also means the host is
|
|
621
|
+
# gone, and the exit trap cleans up).
|
|
532
622
|
# shellcheck disable=SC2086
|
|
533
623
|
set --
|
|
534
624
|
n=0
|
|
625
|
+
bad_decode=0
|
|
535
626
|
while [ "$n" -lt "$nargs" ]; do
|
|
536
|
-
IFS= read -r arg_b64 ||
|
|
627
|
+
IFS= read -r arg_b64 || exit 0
|
|
537
628
|
n=$((n + 1))
|
|
538
|
-
|
|
629
|
+
if ! b64dec_strict "$arg_b64"; then
|
|
630
|
+
bad_decode=1
|
|
631
|
+
continue
|
|
632
|
+
fi
|
|
633
|
+
set -- "$@" "$DEC"
|
|
539
634
|
done
|
|
635
|
+
if [ "$bad_decode" -ne 0 ]; then
|
|
636
|
+
printf 'ERR|%s|protocol|%s\n' "$req_id" "$(printf '%s' "an argv payload failed to decode" | base64 | tr -d '\n')"
|
|
637
|
+
continue
|
|
638
|
+
fi
|
|
540
639
|
# Dispatched: tell the host now, so its watchdog measures execution and
|
|
541
640
|
# not the queue wait this request just went through.
|
|
542
641
|
printf 'ACK|%s\n' "$req_id"
|
|
@@ -552,10 +651,12 @@ while IFS= read -r line; do
|
|
|
552
651
|
nargs=${rest#*|}
|
|
553
652
|
# Arguments stay base64 (write's content must never become a shell
|
|
554
653
|
# variable of raw bytes); handlers decode what they treat as a path.
|
|
654
|
+
# A frame that ends before its declared argument count must never run
|
|
655
|
+
# its op on a partial argument list — and EOF means the host is gone.
|
|
555
656
|
set --
|
|
556
657
|
n=0
|
|
557
658
|
while [ "$n" -lt "$nargs" ]; do
|
|
558
|
-
IFS= read -r arg_b64 ||
|
|
659
|
+
IFS= read -r arg_b64 || exit 0
|
|
559
660
|
n=$((n + 1))
|
|
560
661
|
set -- "$@" "$arg_b64"
|
|
561
662
|
done
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -29,6 +29,13 @@ crosses the 9p share. What to know about the one substrate there is:
|
|
|
29
29
|
syscall before the rename: a concurrent writer wins and the stale write
|
|
30
30
|
refuses with `FS_STALE_VERSION`. The residual stat-then-rename pair is the
|
|
31
31
|
same sliver a Linux-host session's own rename has.
|
|
32
|
+
- **A Linux filename containing a backslash cannot be addressed.** The UNC
|
|
33
|
+
display form (`\\wsl.localhost\<distro>\...`) is the file's identity
|
|
34
|
+
everywhere - the session header, the GUI, the caches - and the backslash is
|
|
35
|
+
the UNC's separator, so `a\b.ts` in the distro round-trips to `a/b.ts`: the
|
|
36
|
+
tools would silently address a different path. This is the coordinate
|
|
37
|
+
system's own limit (the peer's UNC provider has it too), not a defect of
|
|
38
|
+
the substrate; a rename away from backslash names is the workaround.
|
|
32
39
|
- **Stage two routes mutations through a confined resident.** A write or edit
|
|
33
40
|
under a confined policy runs on its own long-lived agent, spawned inside the
|
|
34
41
|
bwrap profile that binds exactly what the mode grants — so the kernel, not
|
package/lib/agent-confined.js
CHANGED
|
@@ -27,12 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* @module dsh-plugin-wsl/agent-confined
|
|
29
29
|
*/
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
31
30
|
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
32
31
|
import { toLinuxPath } from "./paths.js";
|
|
32
|
+
import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
|
|
33
33
|
import { WslAgent } from "./agent.js";
|
|
34
34
|
|
|
35
|
-
const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
|
|
36
35
|
const confinedAgents = new Map();
|
|
37
36
|
|
|
38
37
|
/**
|
|
@@ -63,7 +62,8 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
63
62
|
agent = new WslAgent({
|
|
64
63
|
distro,
|
|
65
64
|
wslPath,
|
|
66
|
-
scriptPath: toLinuxPath(
|
|
65
|
+
scriptPath: toLinuxPath(agentScriptPath),
|
|
66
|
+
expectedDigest: agentScriptDigest,
|
|
67
67
|
// The prefix is a whole command from the distro's point of view — program,
|
|
68
68
|
// profile, `--` — because the transport inserts it between
|
|
69
69
|
// `wsl.exe --exec` and the `sh <script>` pair (`lib/agent.js`'s
|
package/lib/agent-protocol.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/** The protocol version this host side speaks; the agent must match. */
|
|
14
|
-
export const PROTOCOL_VERSION =
|
|
14
|
+
export const PROTOCOL_VERSION = 4;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Upper bound on one `read` op's payload, set by the HOST looping over windowed
|
|
@@ -133,7 +133,9 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
|
|
|
133
133
|
* Parse one line of the agent's stdout.
|
|
134
134
|
* @param {string} line - a line without its terminator.
|
|
135
135
|
* @returns {object} one of:
|
|
136
|
-
* - `{ type: "hello", name, version }`
|
|
136
|
+
* - `{ type: "hello", name, version, digest }` — `digest` is the sha256 of
|
|
137
|
+
* the script file the agent is executing (empty when it could not hash
|
|
138
|
+
* it); the host compares it against its own shipped copy
|
|
137
139
|
* - `{ type: "pong" }`
|
|
138
140
|
* - `{ type: "ack", id }` — the agent dequeued the request for execution;
|
|
139
141
|
* the host arms its stuck-request watchdog here, so queue time never
|
|
@@ -148,17 +150,20 @@ export function encodeFsFrame({ id, op, args, timeoutMs = 0 }) {
|
|
|
148
150
|
*/
|
|
149
151
|
export function parseAgentLine(line) {
|
|
150
152
|
if (line.startsWith("HELLO|")) {
|
|
151
|
-
const [, name, version] = line.split("|");
|
|
152
|
-
return { type: "hello", name, version: Number(version) };
|
|
153
|
+
const [, name, version, digest] = line.split("|");
|
|
154
|
+
return { type: "hello", name, version: Number(version), digest: digest ?? "" };
|
|
153
155
|
}
|
|
154
156
|
if (line === "PONG") return { type: "pong" };
|
|
155
157
|
if (line.startsWith("ACK|")) return { type: "ack", id: line.slice(4) };
|
|
156
158
|
if (line.startsWith("RES|")) {
|
|
157
159
|
const [, id, exitCode, stdout, stderr, outTruncated, errTruncated] = line.split("|");
|
|
160
|
+
// A non-numeric code is protocol garbage, not a success: -1 is a value
|
|
161
|
+
// no `wait` can produce, so a caller can tell it from every real code.
|
|
162
|
+
const code = Number(exitCode);
|
|
158
163
|
return {
|
|
159
164
|
type: "result",
|
|
160
165
|
id,
|
|
161
|
-
exitCode: Number(
|
|
166
|
+
exitCode: Number.isSafeInteger(code) ? code : -1,
|
|
162
167
|
stdout: decodeB64(stdout ?? ""),
|
|
163
168
|
stderr: decodeB64(stderr ?? ""),
|
|
164
169
|
// The capture-cap flags the agent appends when the EXEC frame carried a
|
package/lib/agent-shared.js
CHANGED
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* @module dsh-plugin-wsl/agent-shared
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
13
15
|
import { fileURLToPath } from "node:url";
|
|
14
16
|
import { toLinuxPath } from "./paths.js";
|
|
15
17
|
import { WslAgent } from "./agent.js";
|
|
16
18
|
|
|
17
19
|
const AGENT_SCRIPT_PATH = fileURLToPath(new URL("../agent/wsl-agent.sh", import.meta.url));
|
|
20
|
+
// The script the distro reads must be byte-identical to the copy this
|
|
21
|
+
// package shipped: the deployed layout reads it from the /mnt/c mirror, and
|
|
22
|
+
// the handshake digest is what catches a stale or half-synced runtime copy.
|
|
23
|
+
// Both resident factories (plain and confined) pin it.
|
|
24
|
+
const AGENT_SCRIPT_DIGEST = createHash("sha256").update(readFileSync(AGENT_SCRIPT_PATH)).digest("hex");
|
|
18
25
|
const sharedAgents = new Map();
|
|
19
26
|
|
|
27
|
+
/** The agent script as a host-side path, for factories that need both spellings. */
|
|
28
|
+
export const agentScriptPath = AGENT_SCRIPT_PATH;
|
|
29
|
+
|
|
30
|
+
/** The sha256 of the shipped agent script; every resident verifies its HELLO against it. */
|
|
31
|
+
export const agentScriptDigest = AGENT_SCRIPT_DIGEST;
|
|
32
|
+
|
|
20
33
|
/**
|
|
21
34
|
* The shared agent for one distro, created on first use.
|
|
22
35
|
* @param distro - the distro name.
|
|
@@ -25,7 +38,7 @@ const sharedAgents = new Map();
|
|
|
25
38
|
export function sharedAgent(distro) {
|
|
26
39
|
let agent = sharedAgents.get(distro);
|
|
27
40
|
if (agent === undefined) {
|
|
28
|
-
agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH) });
|
|
41
|
+
agent = new WslAgent({ distro, scriptPath: toLinuxPath(AGENT_SCRIPT_PATH), expectedDigest: AGENT_SCRIPT_DIGEST });
|
|
29
42
|
sharedAgents.set(distro, agent);
|
|
30
43
|
}
|
|
31
44
|
return agent;
|
package/lib/agent.js
CHANGED
|
@@ -44,6 +44,11 @@ const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
|
44
44
|
/** Extra time the host watchdog waits past the request's own in-distro timeout. */
|
|
45
45
|
const WATCHDOG_GRACE_MS = 13000;
|
|
46
46
|
|
|
47
|
+
/** How far past its host's idle shutdown the agent's own lease runs. The
|
|
48
|
+
* lease fires only when the host is gone in every way that matters (a wedged
|
|
49
|
+
* relay that never delivers EOF); the host's close() normally wins the race. */
|
|
50
|
+
const LEASE_GRACE_MS = 120000;
|
|
51
|
+
|
|
47
52
|
/** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
|
|
48
53
|
const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
|
|
49
54
|
|
|
@@ -95,11 +100,34 @@ export function pinnedWindowsEnv(parent) {
|
|
|
95
100
|
* @param {string} options.scriptPath - the agent script as a Linux path inside
|
|
96
101
|
* the distro (a `/mnt/<drive>/...` translation of the package's
|
|
97
102
|
* `agent/wsl-agent.sh`).
|
|
103
|
+
* @param {number} [options.leaseMs] - the agent's own client lease, forwarded
|
|
104
|
+
* through the managed `DSH_` namespace so the script can self-terminate
|
|
105
|
+
* when a wedged relay stops carrying traffic without ever delivering EOF.
|
|
98
106
|
* @returns {import("node:child_process").ChildProcess} the spawned process.
|
|
99
107
|
*/
|
|
100
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The spawn environment for one resident, with the client lease folded in.
|
|
110
|
+
* The pinned environment forwards only the managed `DSH_` namespace, and it
|
|
111
|
+
* forwards exactly the names the parent `WSLENV` lists — so the lease rides
|
|
112
|
+
* both: the value on the variable, the name on `WSLENV`. A non-positive
|
|
113
|
+
* lease leaves the environment as the pin built it (the script treats an
|
|
114
|
+
* unset variable as no lease).
|
|
115
|
+
* @param {Record<string, string>} env - the pinned environment.
|
|
116
|
+
* @param {number} leaseMs - the agent's client lease in ms; 0 disables.
|
|
117
|
+
* @returns {Record<string, string>} the environment to spawn with.
|
|
118
|
+
*/
|
|
119
|
+
export function agentLeaseEnv(env, leaseMs) {
|
|
120
|
+
if (!(leaseMs > 0)) return env;
|
|
121
|
+
return {
|
|
122
|
+
...env,
|
|
123
|
+
DSH_AGENT_LEASE_MS: String(leaseMs),
|
|
124
|
+
WSLENV: [env.WSLENV, "DSH_AGENT_LEASE_MS"].filter(Boolean).join(":"),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [], leaseMs = 0 }) {
|
|
101
129
|
return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
|
|
102
|
-
env: pinnedWindowsEnv(process.env),
|
|
130
|
+
env: agentLeaseEnv(pinnedWindowsEnv(process.env), leaseMs),
|
|
103
131
|
stdio: ["pipe", "pipe", "pipe"],
|
|
104
132
|
windowsHide: true,
|
|
105
133
|
});
|
|
@@ -114,9 +142,13 @@ export class WslAgent {
|
|
|
114
142
|
* @param {string} config.distro - the distro this agent serves.
|
|
115
143
|
* @param {string} config.scriptPath - the agent script as a Linux path
|
|
116
144
|
* inside the distro (see {@link spawnDefault}).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
* @param {string} [config.wslPath] - the `wsl.exe` path.
|
|
146
|
+
* @param {number} [config.idleMs] - idle shutdown delay; 0 disables.
|
|
147
|
+
* @param {string} [config.expectedDigest] - the sha256 of the agent script
|
|
148
|
+
* as this package shipped it; a HELLO whose digest differs is refused the
|
|
149
|
+
* same way a version mismatch is (the script is read in place, so a stale
|
|
150
|
+
* or half-synced runtime copy is the failure this catches).
|
|
151
|
+
* @param {string[]} [config.argvPrefix] - arguments inserted between
|
|
120
152
|
* `wsl.exe --exec` and the `sh script` pair — the confinement wrapper
|
|
121
153
|
* (`bwrap … --`) a confined agent runs its whole lifetime under, so every
|
|
122
154
|
* filesystem op it performs is kernel-enforced. Empty for the plain agent.
|
|
@@ -129,6 +161,7 @@ export class WslAgent {
|
|
|
129
161
|
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
130
162
|
this.argvPrefix = config.argvPrefix ?? [];
|
|
131
163
|
this.idleMs = config.idleMs ?? 600000;
|
|
164
|
+
this.expectedDigest = config.expectedDigest;
|
|
132
165
|
/** Hard ceiling on one stdout protocol line; a longer line is a broken or
|
|
133
166
|
* hostile agent, not data to hold — readline used to accumulate it all. */
|
|
134
167
|
this.frameCapBytes = config.frameCapBytes ?? MAX_FRAME_BYTES;
|
|
@@ -179,6 +212,7 @@ export class WslAgent {
|
|
|
179
212
|
distro: this.distro,
|
|
180
213
|
scriptPath: this.scriptPath,
|
|
181
214
|
argvPrefix: this.argvPrefix,
|
|
215
|
+
leaseMs: this.idleMs > 0 ? this.idleMs + LEASE_GRACE_MS : 0,
|
|
182
216
|
}));
|
|
183
217
|
const handshakeTimer = setTimeout(() => {
|
|
184
218
|
this.lastError = `agent handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`;
|
|
@@ -252,9 +286,22 @@ export class WslAgent {
|
|
|
252
286
|
switch (message.type) {
|
|
253
287
|
case "hello": {
|
|
254
288
|
if (message.name !== AGENT_NAME || message.version !== PROTOCOL_VERSION) {
|
|
289
|
+
// Settle BEFORE killing: the exit event must not overwrite the real
|
|
290
|
+
// reason with the generic handshake message (the fake transports
|
|
291
|
+
// emit exit synchronously inside kill(); a real wsl.exe races it).
|
|
255
292
|
this.lastError = `agent handshake mismatch: got ${message.name}@${message.version}, want ${AGENT_NAME}@${PROTOCOL_VERSION}`;
|
|
293
|
+
settleStart(new Error(this.lastError));
|
|
256
294
|
this.process?.kill();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (this.expectedDigest !== undefined && message.digest !== this.expectedDigest) {
|
|
298
|
+
// The script is read in place from the distro-visible path — in the
|
|
299
|
+
// deployed layout a /mnt/c mirror — so a digest that differs from
|
|
300
|
+
// the copy this package shipped means the runtime copy is stale,
|
|
301
|
+
// half-synced, or edited. Fail closed with the fix in the message.
|
|
302
|
+
this.lastError = `agent script digest mismatch: the file the distro read at ${this.scriptPath} hashes to ${message.digest || "(nothing)"}, but this package shipped ${this.expectedDigest} — resync the runtime copy (npm run sync:windows) or reinstall the plugin`;
|
|
257
303
|
settleStart(new Error(this.lastError));
|
|
304
|
+
this.process?.kill();
|
|
258
305
|
return;
|
|
259
306
|
}
|
|
260
307
|
clearTimeout(handshakeTimer);
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The distro-side traversal strategy for `@` completion — the plugin's
|
|
3
|
+
* ready answer to RFC addendum ① (docs/upstream/rfc-wsl-workspaces.md).
|
|
4
|
+
*
|
|
5
|
+
* ## The seam this consumes
|
|
6
|
+
*
|
|
7
|
+
* `@deepseek-ai/dsh-file-reference-local` funnels every workspace traversal
|
|
8
|
+
* through three module-internal functions: `readWorkspaceRoot` and
|
|
9
|
+
* `readDirectory` (both `node:fs/promises` readdir with `withFileTypes`) and
|
|
10
|
+
* `resolveDisplayDirectory` (a lexical resolve plus a per-segment `lstat`
|
|
11
|
+
* walk that refuses symlink and non-directory components). Everything else —
|
|
12
|
+
* the fuzzy ranking, the bounded index, the generation/invalidation dance,
|
|
13
|
+
* the excluded-directory list — is pure logic over the entries those three
|
|
14
|
+
* return. Addendum ① proposes exposing the three as an injectable strategy
|
|
15
|
+
* with the current implementations as defaults; this module is the consumer
|
|
16
|
+
* side, built and tested against that interface so the takeover lands the
|
|
17
|
+
* day the seam does.
|
|
18
|
+
*
|
|
19
|
+
* It is deliberately NOT wired into cordis.patch.yml yet: with the seam
|
|
20
|
+
* absent, reaching the funnels would mean subclassing past module-internal
|
|
21
|
+
* functions or re-implementing the ranking engine — the behavior-regression
|
|
22
|
+
* path the RFC rules out. The module ships, tested, until upstream speaks.
|
|
23
|
+
*
|
|
24
|
+
* ## Semantics, held faithful where the seam allows
|
|
25
|
+
*
|
|
26
|
+
* - Entries keep upstream's Dirent shape: `{name, isDirectory(), isFile()}`.
|
|
27
|
+
* The distro listing uses `find -printf '%y\t%f'` rather than `ls -1ALp`
|
|
28
|
+
* precisely for this: `%y` reports `l` for symlinks, which the parse skips
|
|
29
|
+
* the way a readdir Dirent is neither file nor directory. `ls -1ALp` would
|
|
30
|
+
* resolve links through and change which candidates exist.
|
|
31
|
+
* - `resolveDisplayDirectory` refuses a symlink or non-directory component
|
|
32
|
+
* with one agent exec that walks the (lexically normalized) segments —
|
|
33
|
+
* the same verdict upstream reaches with N sequential lstats, and unlike
|
|
34
|
+
* `realpath`, refusing is the point, not resolving.
|
|
35
|
+
* - A directory the traversal cannot read yields `[]` (upstream's readdir
|
|
36
|
+
* catch); an aborted caller propagates the abort instead.
|
|
37
|
+
* - The returned absolutes stay in the root's own coordinate system (UNC for
|
|
38
|
+
* a distro workspace), because upstream joins children with the host
|
|
39
|
+
* `node:path` — a POSIX path from this layer would be mangled by it.
|
|
40
|
+
*
|
|
41
|
+
* @module dsh-plugin-wsl/file-reference-wsl
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { isWslUnc, posixToUnc, uncToPosix } from "./paths.js";
|
|
45
|
+
|
|
46
|
+
/** Upstream's default cap on one workspace index; the per-directory listing
|
|
47
|
+
* rides the same bound, since one level can't usefully exceed the budget. */
|
|
48
|
+
const DEFAULT_MAX_ENTRIES = 50000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lexically normalize a `/`-separated display directory against its root.
|
|
52
|
+
* Upstream resolves `resolve(root, displayDirectory)` and refuses a result
|
|
53
|
+
* outside the root, so `a/../b` lands on `b` while `..` past the root is
|
|
54
|
+
* refused outright — the walk below never sees a `..` segment.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} displayDirectory - the query text before the fragment, possibly "".
|
|
57
|
+
* @returns {string[] | undefined} the normalized segments, or undefined when
|
|
58
|
+
* the display directory escapes the root.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeDisplaySegments(displayDirectory) {
|
|
61
|
+
const segments = [];
|
|
62
|
+
for (const segment of String(displayDirectory).split("/")) {
|
|
63
|
+
if (segment === "" || segment === ".") continue;
|
|
64
|
+
if (segment === "..") {
|
|
65
|
+
if (segments.length === 0) return undefined;
|
|
66
|
+
segments.pop();
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
segments.push(segment);
|
|
70
|
+
}
|
|
71
|
+
return segments;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The distro argv for one directory listing: `find -printf '%y\t%f'` over the
|
|
76
|
+
* contained directory. The containment guard is the same shape the picker and
|
|
77
|
+
* workspace-files use — `realpath` the requested directory, refuse anything
|
|
78
|
+
* the root does not contain — and the `%y` type char is what keeps symlink
|
|
79
|
+
* semantics identical to a readdir Dirent (skipped, not followed).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} linuxRoot - the workspace root, POSIX spelling.
|
|
82
|
+
* @param {string} linuxDir - the directory to list, POSIX spelling.
|
|
83
|
+
* @param {number} maxEntries - the listing cap (head -n bound).
|
|
84
|
+
* @returns {string[]} the agent exec argv.
|
|
85
|
+
*/
|
|
86
|
+
export function traversalArgv(linuxRoot, linuxDir, maxEntries) {
|
|
87
|
+
const script = [
|
|
88
|
+
"root=$1 dir=$2 max=$3",
|
|
89
|
+
'real=$(realpath -L -- "$dir") || exit 2',
|
|
90
|
+
'case "$real" in "$root") ;; "$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac',
|
|
91
|
+
'[ -d "$real" ] || { echo "NOTDIR:$real" >&2; exit 4; }',
|
|
92
|
+
'find "$real" -mindepth 1 -maxdepth 1 -printf \'%y\\t%f\\n\' | head -n "$max"',
|
|
93
|
+
].join("\n");
|
|
94
|
+
return ["sh", "-c", script, "sh", linuxRoot, linuxDir, maxEntries];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse one `%y\t%f` listing into upstream's Dirent-like entries.
|
|
99
|
+
* `d` is a directory and `f` a file; every other type char — `l` for
|
|
100
|
+
* symlinks, devices, sockets — is skipped, matching a readdir Dirent.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} stdout - the raw listing.
|
|
103
|
+
* @returns {{name: string, isDirectory: () => boolean, isFile: () => boolean}[]}
|
|
104
|
+
*/
|
|
105
|
+
export function parseTraversalListing(stdout) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const line of String(stdout).split("\n")) {
|
|
108
|
+
const tab = line.indexOf("\t");
|
|
109
|
+
if (tab <= 0) continue;
|
|
110
|
+
const kind = line.slice(0, tab);
|
|
111
|
+
const name = line.slice(tab + 1);
|
|
112
|
+
if (name.length === 0) continue;
|
|
113
|
+
if (kind !== "d" && kind !== "f") continue;
|
|
114
|
+
entries.push({
|
|
115
|
+
name,
|
|
116
|
+
isDirectory: () => kind === "d",
|
|
117
|
+
isFile: () => kind === "f",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The argv for the per-segment walk behind `resolveDisplayDirectory`: each
|
|
125
|
+
* segment must exist, be a directory, and not be a symlink — upstream's
|
|
126
|
+
* lstat verdicts, checked in one exec. Segments ride as argv elements, never
|
|
127
|
+
* interpolated into the script.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} linuxRoot - the workspace root, POSIX spelling.
|
|
130
|
+
* @param {string[]} segments - the normalized display segments below the root.
|
|
131
|
+
* @returns {string[]} the agent exec argv.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveDirectoryArgv(linuxRoot, segments) {
|
|
134
|
+
const script = [
|
|
135
|
+
"root=$1",
|
|
136
|
+
'cur="$root"',
|
|
137
|
+
'for seg in "${@:2}"; do',
|
|
138
|
+
' cur="$cur/$seg"',
|
|
139
|
+
' if [ -L "$cur" ]; then echo "SYMLINK:$cur" >&2; exit 5; fi',
|
|
140
|
+
' [ -d "$cur" ] || { echo "NOTDIR:$cur" >&2; exit 4; }',
|
|
141
|
+
"done",
|
|
142
|
+
'printf \'%s\\n\' "$cur"',
|
|
143
|
+
].join("\n");
|
|
144
|
+
return ["sh", "-c", script, "sh", linuxRoot, ...segments];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The host-default traversal — the node:fs implementations the seam keeps
|
|
149
|
+
* as upstream's defaults, replicated here so the prototype answers host
|
|
150
|
+
* workspaces without the seam and so tests can inject substitutes for it.
|
|
151
|
+
* (Upstream owns the real defaults; this copy exists only until the takeover.)
|
|
152
|
+
*
|
|
153
|
+
* @param {object} deps - the node:fs/promises functions, injectable for tests.
|
|
154
|
+
* @returns the strategy object with the upstream default semantics.
|
|
155
|
+
*/
|
|
156
|
+
export function hostFileReferenceTraversal(deps) {
|
|
157
|
+
const read = async (absolute, signal) => {
|
|
158
|
+
signal.throwIfAborted();
|
|
159
|
+
try {
|
|
160
|
+
const entries = await deps.readdir(absolute, { withFileTypes: true });
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
return entries;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
signal.throwIfAborted();
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
readWorkspaceRoot: read,
|
|
170
|
+
readDirectory: async (absolute, signal) => {
|
|
171
|
+
signal.throwIfAborted();
|
|
172
|
+
try {
|
|
173
|
+
const entries = await deps.readdir(absolute, { withFileTypes: true });
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
return entries;
|
|
176
|
+
} catch {
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
resolveDisplayDirectory: async (root, displayDirectory, signal) => {
|
|
182
|
+
const segments = normalizeDisplaySegments(displayDirectory);
|
|
183
|
+
if (segments === undefined) return undefined;
|
|
184
|
+
let current = root;
|
|
185
|
+
for (const segment of segments) {
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
current = `${current}${current.endsWith("/") ? "" : "/"}${segment}`;
|
|
188
|
+
try {
|
|
189
|
+
const status = await deps.lstat(current);
|
|
190
|
+
signal.throwIfAborted();
|
|
191
|
+
if (status.isSymbolicLink() || !status.isDirectory()) return undefined;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
signal.throwIfAborted();
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return current;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The distro-routed traversal strategy. Each method routes by coordinate:
|
|
204
|
+
* a `\\wsl.localhost\<distro>` UNC rides the resident agent (the search
|
|
205
|
+
* root and every absolute upstream passes stay in the UNC form it joined
|
|
206
|
+
* them with); anything else defers to the default traversal. The search
|
|
207
|
+
* root doubles as the containment root for the distro listings' guard, so
|
|
208
|
+
* the seam wires one strategy per `WorkspaceFileSearch` — exactly where
|
|
209
|
+
* `WorkspaceFileSearch(root, config, traversal)` would take it.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} options
|
|
212
|
+
* @param {string} options.root - the search root, the UNC form upstream passes.
|
|
213
|
+
* @param {(distro: string) => object} options.runnerFor - the agent runner
|
|
214
|
+
* for one distro; tests substitute a fake (a real wiring passes sharedAgent).
|
|
215
|
+
* @param {object} options.defaultTraversal - the host-default strategy.
|
|
216
|
+
* @param {number} [options.maxEntries] - the listing cap, upstream's index budget.
|
|
217
|
+
* @returns the strategy object the seam's `WorkspaceFileSearch` would take.
|
|
218
|
+
*/
|
|
219
|
+
export function wslFileReferenceTraversal({ root, runnerFor, defaultTraversal, maxEntries = DEFAULT_MAX_ENTRIES }) {
|
|
220
|
+
if (!isWslUnc(root)) return defaultTraversal;
|
|
221
|
+
const rootCoords = uncToPosix(root);
|
|
222
|
+
const rootLinux = rootCoords.linuxPath;
|
|
223
|
+
|
|
224
|
+
const listDistro = async (absolute, signal, onRefusal) => {
|
|
225
|
+
signal.throwIfAborted();
|
|
226
|
+
const { linuxPath } = uncToPosix(absolute);
|
|
227
|
+
try {
|
|
228
|
+
const result = await runnerFor(rootCoords.distro).exec({
|
|
229
|
+
cwd: "/",
|
|
230
|
+
argv: traversalArgv(rootLinux, linuxPath, maxEntries),
|
|
231
|
+
maxOutputBytes: 4_000_000,
|
|
232
|
+
timeoutMs: 30_000,
|
|
233
|
+
signal,
|
|
234
|
+
});
|
|
235
|
+
signal.throwIfAborted();
|
|
236
|
+
if (result.exitCode !== 0) {
|
|
237
|
+
return onRefusal(new Error(`listing "${linuxPath}" failed: ${String(result.stderr).trim() || `exit ${result.exitCode}`}`));
|
|
238
|
+
}
|
|
239
|
+
return parseTraversalListing(result.stdout);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
return onRefusal(error);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
readWorkspaceRoot(absolute, signal) {
|
|
248
|
+
if (!isWslUnc(absolute)) return defaultTraversal.readWorkspaceRoot(absolute, signal);
|
|
249
|
+
return listDistro(absolute, signal, (error) => {
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
readDirectory(absolute, signal) {
|
|
255
|
+
if (!isWslUnc(absolute)) return defaultTraversal.readDirectory(absolute, signal);
|
|
256
|
+
return listDistro(absolute, signal, () => []);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async resolveDisplayDirectory(rootOf, displayDirectory, signal) {
|
|
260
|
+
signal.throwIfAborted();
|
|
261
|
+
if (!isWslUnc(rootOf)) return defaultTraversal.resolveDisplayDirectory(rootOf, displayDirectory, signal);
|
|
262
|
+
const segments = normalizeDisplaySegments(displayDirectory);
|
|
263
|
+
if (segments === undefined) return undefined;
|
|
264
|
+
if (segments.length === 0) return root;
|
|
265
|
+
try {
|
|
266
|
+
const result = await runnerFor(rootCoords.distro).exec({
|
|
267
|
+
cwd: "/",
|
|
268
|
+
argv: resolveDirectoryArgv(rootLinux, segments),
|
|
269
|
+
maxOutputBytes: 4_000_000,
|
|
270
|
+
timeoutMs: 30_000,
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
273
|
+
signal.throwIfAborted();
|
|
274
|
+
if (result.exitCode !== 0) return undefined;
|
|
275
|
+
return posixToUnc(rootCoords.distro, result.stdout.trim());
|
|
276
|
+
} catch {
|
|
277
|
+
signal.throwIfAborted();
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default wslFileReferenceTraversal;
|
package/lib/fs-substrate.js
CHANGED
|
@@ -261,7 +261,16 @@ export class AgentSubstrate {
|
|
|
261
261
|
return this.lock(target.targetKey, async () => {
|
|
262
262
|
const linuxPath = this.linuxOf(target);
|
|
263
263
|
const existing = await this.fs.stat(linuxPath, { signal });
|
|
264
|
-
if (!existing)
|
|
264
|
+
if (!existing) {
|
|
265
|
+
// A file that is not there NOW splits by evidence: an edit grounded
|
|
266
|
+
// in a read (it carries the expected version) lost its file, so the
|
|
267
|
+
// guard is stale; an edit with no read behind it names a file that
|
|
268
|
+
// never answered the stat - the read path's missing-file dialect.
|
|
269
|
+
if (expected) {
|
|
270
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
271
|
+
}
|
|
272
|
+
throw new FsCodedError(`cannot edit "${target.displayPath}": file does not exist`, "FS_NOT_FOUND");
|
|
273
|
+
}
|
|
265
274
|
if (existing.type !== "f") {
|
|
266
275
|
throw new FsCodedError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
267
276
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wsl-env",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -14,28 +14,29 @@
|
|
|
14
14
|
"main": "lib/shell.js",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./lib/shell.js",
|
|
17
|
-
"./
|
|
17
|
+
"./agent": "./lib/agent.js",
|
|
18
|
+
"./auto-preset": "./lib/auto-preset.js",
|
|
19
|
+
"./auto-preset/locale/en.json": "./locale/auto-preset/en.json",
|
|
20
|
+
"./auto-preset/locale/zh.json": "./locale/auto-preset/zh.json",
|
|
21
|
+
"./file-reference-wsl": "./lib/file-reference-wsl.js",
|
|
18
22
|
"./fs": "./lib/fs.js",
|
|
19
|
-
"./subprocess": "./lib/subprocess.js",
|
|
20
|
-
"./picker": "./lib/picker.js",
|
|
21
|
-
"./workspace-files-wsl": "./lib/workspace-files-wsl.js",
|
|
22
23
|
"./fs-routing": "./lib/fs-routing.js",
|
|
23
|
-
"./auto-preset": "./lib/auto-preset.js",
|
|
24
|
-
"./shell-env": "./lib/shell-env.js",
|
|
25
|
-
"./wsl": "./lib/wsl.js",
|
|
26
|
-
"./paths": "./lib/paths.js",
|
|
27
|
-
"./agent": "./lib/agent.js",
|
|
28
|
-
"./package.json": "./package.json",
|
|
29
24
|
"./locale/en.json": "./locale/en.json",
|
|
30
25
|
"./locale/zh.json": "./locale/zh.json",
|
|
31
|
-
"./
|
|
32
|
-
"./
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./paths": "./lib/paths.js",
|
|
28
|
+
"./picker": "./lib/picker.js",
|
|
33
29
|
"./picker/locale/en.json": "./locale/picker/en.json",
|
|
34
30
|
"./picker/locale/zh.json": "./locale/picker/zh.json",
|
|
31
|
+
"./shell": "./lib/shell.js",
|
|
32
|
+
"./shell-env": "./lib/shell-env.js",
|
|
35
33
|
"./shell-env/locale/en.json": "./locale/shell-env/en.json",
|
|
36
34
|
"./shell-env/locale/zh.json": "./locale/shell-env/zh.json",
|
|
37
|
-
"./
|
|
38
|
-
"./
|
|
35
|
+
"./subprocess": "./lib/subprocess.js",
|
|
36
|
+
"./subprocess/locale/en.json": "./locale/subprocess/en.json",
|
|
37
|
+
"./subprocess/locale/zh.json": "./locale/subprocess/zh.json",
|
|
38
|
+
"./workspace-files-wsl": "./lib/workspace-files-wsl.js",
|
|
39
|
+
"./wsl": "./lib/wsl.js"
|
|
39
40
|
},
|
|
40
41
|
"dsh": {
|
|
41
42
|
"bundle": {
|
|
@@ -61,13 +62,14 @@
|
|
|
61
62
|
"CONTRIBUTING.md",
|
|
62
63
|
"SECURITY.md",
|
|
63
64
|
"SUPPORT.md",
|
|
64
|
-
"LICENSE"
|
|
65
|
+
"LICENSE",
|
|
66
|
+
"scripts/bootstrap.sh"
|
|
65
67
|
],
|
|
66
68
|
"scripts": {
|
|
67
69
|
"test": "npm run lint:style && npm run test:syntax && npm run test:unit",
|
|
68
|
-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/fs.boot.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
|
|
70
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/fs.boot.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs",
|
|
69
71
|
"test:syntax": "node test/syntax.mjs",
|
|
70
|
-
"test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs",
|
|
72
|
+
"test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/workspace-files-wsl.test.mjs test/composition.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs",
|
|
71
73
|
"lint:style": "node test/style.mjs",
|
|
72
74
|
"prepublishOnly": "npm test",
|
|
73
75
|
"probe": "bash test/probe/run.sh",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prepare a distro for this plugin: bubblewrap (required, commands fail closed
|
|
3
|
+
# without it) plus the optional tools later phases use — ripgrep for in-distro
|
|
4
|
+
# search, git for workspace-changes snapshots, inotifywait for the watcher's
|
|
5
|
+
# primary backend.
|
|
6
|
+
#
|
|
7
|
+
# scripts/bootstrap.sh [distro] detect, print what is missing
|
|
8
|
+
# scripts/bootstrap.sh [distro] --install ALSO run the install commands
|
|
9
|
+
#
|
|
10
|
+
# The install runs as the distro's root through `wsl.exe -d <distro> -u root --`,
|
|
11
|
+
# so it never touches the Windows side and never needs a sudo prompt inside the
|
|
12
|
+
# distro. Without --install the script is strictly read-only: it reports and
|
|
13
|
+
# prints the exact commands, runnable as-is.
|
|
14
|
+
#
|
|
15
|
+
# Exit status: 0 when everything REQUIRED is present (or was installed), 1 when
|
|
16
|
+
# bubblewrap is still missing.
|
|
17
|
+
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
DISTRO="${1:-}"
|
|
21
|
+
INSTALL="no"
|
|
22
|
+
for arg in "$@"; do
|
|
23
|
+
case $arg in
|
|
24
|
+
--install) INSTALL="yes" ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
# A leading distro argument is positional; strip it from a re-scan. (The loop
|
|
28
|
+
# above only looks for the flag.)
|
|
29
|
+
if [ "${1:-}" = "--install" ]; then
|
|
30
|
+
DISTRO="${WSL_DISTRO_NAME:-}"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
if [ -z "$DISTRO" ]; then
|
|
34
|
+
DISTRO="${WSL_DISTRO_NAME:-}"
|
|
35
|
+
fi
|
|
36
|
+
if [ -z "$DISTRO" ]; then
|
|
37
|
+
echo "bootstrap: no distro named and WSL_DISTRO_NAME is unset" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
run_in_distro() {
|
|
42
|
+
wsl.exe -d "$DISTRO" --exec sh -c "$1" sh "$2"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# The package family decides the install command; a distro with none reported
|
|
46
|
+
# gets the apt spelling, which covers the default Ubuntu images.
|
|
47
|
+
family() {
|
|
48
|
+
run_in_distro 'for f in apt-get dnf pacman zypper; do command -v "$f" >/dev/null 2>&1 && { echo "$f"; exit 0; }; done; echo unknown' ""
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
install_command() {
|
|
52
|
+
case "$(family)" in
|
|
53
|
+
apt-get) echo 'apt-get install -y bubblewrap git ripgrep inotify-tools' ;;
|
|
54
|
+
dnf) echo 'dnf install -y bubblewrap git ripgrep inotify-tools' ;;
|
|
55
|
+
pacman) echo 'pacman -Sy --noconfirm bubblewrap git ripgrep inotify-tools' ;;
|
|
56
|
+
zypper) echo 'zypper --non-interactive install bubblewrap git ripgrep inotify-tools' ;;
|
|
57
|
+
*) echo 'apt-get install -y bubblewrap git ripgrep inotify-tools' ;;
|
|
58
|
+
esac
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
present() {
|
|
62
|
+
run_in_distro 'for t in "$@"; do command -v "$t" >/dev/null 2>&1 || exit 1; done' "$1"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
report() {
|
|
66
|
+
local label="$1" tool="$2"
|
|
67
|
+
if run_in_distro "command -v $tool >/dev/null 2>&1" ""; then
|
|
68
|
+
echo "ok $label ($tool)"
|
|
69
|
+
else
|
|
70
|
+
echo "MISSING $label ($tool)"
|
|
71
|
+
fi
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
echo "distro: $DISTRO"
|
|
75
|
+
|
|
76
|
+
BW_OK="yes"
|
|
77
|
+
if present "bwrap"; then
|
|
78
|
+
report "bubblewrap (required; commands fail closed without it)" "bwrap"
|
|
79
|
+
else
|
|
80
|
+
BW_OK="no"
|
|
81
|
+
echo "MISSING bubblewrap (REQUIRED; every command fails closed without it)"
|
|
82
|
+
fi
|
|
83
|
+
report "ripgrep (in-distro search backend)" "rg"
|
|
84
|
+
report "git (workspace-changes snapshot backend)" "git"
|
|
85
|
+
report "inotifywait (watcher primary backend)" "inotifywait"
|
|
86
|
+
|
|
87
|
+
CMD="$(install_command)"
|
|
88
|
+
echo
|
|
89
|
+
echo "install command:"
|
|
90
|
+
echo " wsl.exe -d $DISTRO -u root -- $CMD"
|
|
91
|
+
|
|
92
|
+
if [ "$BW_OK" = "yes" ]; then
|
|
93
|
+
echo
|
|
94
|
+
echo "bubblewrap is present; nothing required is missing."
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
if [ "$INSTALL" != "yes" ]; then
|
|
99
|
+
echo
|
|
100
|
+
echo "bubblewrap is missing. Re-run with --install to run the command above, or paste it yourself." >&2
|
|
101
|
+
exit 1
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
echo
|
|
105
|
+
echo "installing..."
|
|
106
|
+
wsl.exe -d "$DISTRO" -u root -- $CMD
|
|
107
|
+
|
|
108
|
+
if present "bwrap"; then
|
|
109
|
+
echo "bubblewrap installed successfully."
|
|
110
|
+
exit 0
|
|
111
|
+
fi
|
|
112
|
+
echo "bootstrap: bubblewrap is still missing after the install; check the output above" >&2
|
|
113
|
+
exit 1
|