dsh-plugin-wsl-env 0.7.3 → 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 +34 -0
- package/agent/wsl-agent.sh +54 -6
- package/docs/LIMITATIONS.md +7 -0
- package/lib/agent-protocol.js +4 -1
- package/lib/fs-substrate.js +10 -1
- package/package.json +3 -2
- package/scripts/bootstrap.sh +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,40 @@ 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
|
+
|
|
15
49
|
## [0.7.3] - 2026-10-05
|
|
16
50
|
|
|
17
51
|
One theme: the resident agent stops taking its world on faith - the
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
# PING -> PONG
|
|
22
22
|
# SHUTDOWN -> exits 0
|
|
23
23
|
# KILL|<id> -> SIGTERM the in-flight request with that id
|
|
24
|
-
# 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
|
|
25
27
|
# EXEC|<id>|<b64 cwd>|<timeout seconds|0>|<n args>|<output cap bytes|0>
|
|
26
28
|
# followed by <n args> lines of base64, one argv word each
|
|
27
29
|
# -> ACK|<id> the moment the request is dequeued for execution — the host
|
|
@@ -124,6 +126,16 @@ b64dec() {
|
|
|
124
126
|
printf '%s' "$1" | base64 -d 2>/dev/null || printf '%s' "$1" | base64 --decode 2>/dev/null || :
|
|
125
127
|
}
|
|
126
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
|
+
|
|
127
139
|
b64enc_file() {
|
|
128
140
|
# `tr` strips the 76-column wraps GNU and BusyBox base64 both emit; the
|
|
129
141
|
# protocol is one line per payload, so a wrapped encode would tear a large
|
|
@@ -307,6 +319,15 @@ fs_list() {
|
|
|
307
319
|
# records (records split on NUL, names taken after the 6th TAB).
|
|
308
320
|
local d err
|
|
309
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
|
+
}
|
|
310
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" || {
|
|
311
332
|
err=$(cat "$ERR_FILE")
|
|
312
333
|
fs_fail "$(fs_classify "$err")" "$err"
|
|
@@ -537,7 +558,9 @@ fs_dispatch() {
|
|
|
537
558
|
realpath) fs_realpath "$1" ;;
|
|
538
559
|
read) fs_read "$1" "$2" "$3" ;;
|
|
539
560
|
write) fs_write "$1" "$2" "$3" "$4" "$5" ;;
|
|
540
|
-
|
|
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" ;;
|
|
541
564
|
esac
|
|
542
565
|
}
|
|
543
566
|
|
|
@@ -561,6 +584,15 @@ while IFS= read -r line; do
|
|
|
561
584
|
rest=${line#SETENV|}
|
|
562
585
|
key=${rest%%|*}
|
|
563
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
|
|
564
596
|
# Command substitution strips trailing newlines from the decoded value;
|
|
565
597
|
# acceptable for environment values, documented in the protocol.
|
|
566
598
|
decoded=$(b64dec "$b64")
|
|
@@ -581,15 +613,29 @@ while IFS= read -r line; do
|
|
|
581
613
|
printf 'ERR|%s|cwd|%s\n' "$req_id" "$(printf '%s' "$cwd" | base64 | tr -d '\n')"
|
|
582
614
|
continue
|
|
583
615
|
fi
|
|
584
|
-
# 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).
|
|
585
622
|
# shellcheck disable=SC2086
|
|
586
623
|
set --
|
|
587
624
|
n=0
|
|
625
|
+
bad_decode=0
|
|
588
626
|
while [ "$n" -lt "$nargs" ]; do
|
|
589
|
-
IFS= read -r arg_b64 ||
|
|
627
|
+
IFS= read -r arg_b64 || exit 0
|
|
590
628
|
n=$((n + 1))
|
|
591
|
-
|
|
629
|
+
if ! b64dec_strict "$arg_b64"; then
|
|
630
|
+
bad_decode=1
|
|
631
|
+
continue
|
|
632
|
+
fi
|
|
633
|
+
set -- "$@" "$DEC"
|
|
592
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
|
|
593
639
|
# Dispatched: tell the host now, so its watchdog measures execution and
|
|
594
640
|
# not the queue wait this request just went through.
|
|
595
641
|
printf 'ACK|%s\n' "$req_id"
|
|
@@ -605,10 +651,12 @@ while IFS= read -r line; do
|
|
|
605
651
|
nargs=${rest#*|}
|
|
606
652
|
# Arguments stay base64 (write's content must never become a shell
|
|
607
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.
|
|
608
656
|
set --
|
|
609
657
|
n=0
|
|
610
658
|
while [ "$n" -lt "$nargs" ]; do
|
|
611
|
-
IFS= read -r arg_b64 ||
|
|
659
|
+
IFS= read -r arg_b64 || exit 0
|
|
612
660
|
n=$((n + 1))
|
|
613
661
|
set -- "$@" "$arg_b64"
|
|
614
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-protocol.js
CHANGED
|
@@ -157,10 +157,13 @@ export function parseAgentLine(line) {
|
|
|
157
157
|
if (line.startsWith("ACK|")) return { type: "ack", id: line.slice(4) };
|
|
158
158
|
if (line.startsWith("RES|")) {
|
|
159
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);
|
|
160
163
|
return {
|
|
161
164
|
type: "result",
|
|
162
165
|
id,
|
|
163
|
-
exitCode: Number(
|
|
166
|
+
exitCode: Number.isSafeInteger(code) ? code : -1,
|
|
164
167
|
stdout: decodeB64(stdout ?? ""),
|
|
165
168
|
stderr: decodeB64(stderr ?? ""),
|
|
166
169
|
// The capture-cap flags the agent appends when the EXEC frame carried a
|
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",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"CONTRIBUTING.md",
|
|
63
63
|
"SECURITY.md",
|
|
64
64
|
"SUPPORT.md",
|
|
65
|
-
"LICENSE"
|
|
65
|
+
"LICENSE",
|
|
66
|
+
"scripts/bootstrap.sh"
|
|
66
67
|
],
|
|
67
68
|
"scripts": {
|
|
68
69
|
"test": "npm run lint:style && npm run test:syntax && npm run test:unit",
|
|
@@ -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
|