dsh-plugin-wsl-env 0.3.0 → 0.4.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 +129 -0
- package/agent/wsl-agent.sh +63 -19
- package/docs/LIMITATIONS.md +7 -1
- package/docs/PARITY.md +5 -4
- package/lib/agent-confined.js +9 -3
- package/lib/agent.js +69 -4
- package/lib/bwrap.js +18 -1
- package/lib/fs-decisions.js +132 -0
- package/lib/fsio-agent.js +2 -2
- package/lib/index.js +36 -24
- package/lib/paths.js +14 -0
- package/lib/shell-env.js +6 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,135 @@ reference below points at that record's numbering.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.4.0] - 2026-10-03
|
|
16
|
+
|
|
17
|
+
One theme: the enterprise review's remediation cycle completes. The
|
|
18
|
+
security-decision layer gains its own peer-free module and unit tests, the
|
|
19
|
+
coverage gate measures every unit file again, the fallback invariant is probed
|
|
20
|
+
for real, and the file tools pick up the audit's fixes — a killed write no
|
|
21
|
+
longer strands state, large reads stream instead of staging, `/tmp` writes
|
|
22
|
+
land where readers read, cross-distro workdirs are refused, the resident's
|
|
23
|
+
Windows environment is pinned, and `maskWindowsDrive` offers the interop
|
|
24
|
+
mitigation.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **The filesystem provider's policy decisions are a peer-free module with
|
|
29
|
+
their own unit tests.** The security-decision layer — which paths are
|
|
30
|
+
refused for naming another distro, which mode denies a mutation outright,
|
|
31
|
+
which write guards refuse which intents, how a substrate failure maps onto
|
|
32
|
+
the surfaced refusal, and whether a mutation routes to the confined
|
|
33
|
+
resident — used to live inline in `lib/index.js`, a file no unit test can
|
|
34
|
+
import. It is now `lib/fs-decisions.js` (pure in, refusal descriptor out,
|
|
35
|
+
byte-identical messages), with `test/fs-decisions.test.mjs` covering every
|
|
36
|
+
branch, and `lib/index.js` delegating at one `FsError` choke point.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **The fallback invariant is now probed, not just promised.** PARITY.md's
|
|
41
|
+
hard rule — "`agent: false` must behave like the pre-agent release" — cited
|
|
42
|
+
a probe leg that did not exist, and spelled the key as `wsl.agent: 'off'`
|
|
43
|
+
when it is a boolean. `test/probe/agent.sh` now runs the same operations
|
|
44
|
+
through the resident AND through the one-shot `wsl.exe` path and compares
|
|
45
|
+
outcomes: exit code and stdout bytes (including `$`-metacharacters), stderr
|
|
46
|
+
and working directory, and NUL/CRLF binary safety. All three legs pass
|
|
47
|
+
against a real distro; the doc now names the key correctly and describes
|
|
48
|
+
what the probe actually compares.
|
|
49
|
+
|
|
50
|
+
- **The coverage gate measures every unit file again.** `test:coverage` listed
|
|
51
|
+
six of the sixteen test files — the thresholds (85/85/70) were met by the
|
|
52
|
+
narrowed set while the full suite sat at 82.45% branches, so branch
|
|
53
|
+
regressions anywhere outside the six were invisible to the gate that claimed
|
|
54
|
+
to guard them. The gate now runs the whole unit suite (95.83% lines / 85.95%
|
|
55
|
+
branches / 91.01% functions), which got there the honest way: new tests for
|
|
56
|
+
the seams the audit found dark — the deny-dialect classification,
|
|
57
|
+
aborted-signal mapping, the transport env pin, handshake mismatch, protocol
|
|
58
|
+
garbage, cwd rejections, pre-aborted signals, idle retirement, mid-flight
|
|
59
|
+
over-cap buffers, failed rebuilds, watcher teardown and error paths, the
|
|
60
|
+
cross-distro workdir predicate, staging sweeps, setuid publication, and the
|
|
61
|
+
real-`/tmp` consistency the file tools now have.
|
|
62
|
+
|
|
63
|
+
- **Reading a large file no longer copies its remainder to a temp file for
|
|
64
|
+
every window.** `fs_read` staged `tail -c +offset` to disk before `head`
|
|
65
|
+
took the requested bytes — so a full read of a 1 GB file performed ~1000
|
|
66
|
+
window round trips AND ~500 GB of aggregate temp-file writes inside the
|
|
67
|
+
distro, and a window that took over 30 seconds died as an opaque
|
|
68
|
+
"distro I/O failure". The remainder now streams through a `tail | head`
|
|
69
|
+
pipe (the request's own bound is the only staging), read failures still
|
|
70
|
+
classify through the same stderr dialect, and windowed reads are verified
|
|
71
|
+
byte-exact including the final partial window.
|
|
72
|
+
- **A request the transport cannot deliver now fails its caller instead of
|
|
73
|
+
stranding it.** `writeLines` used to drop a frame silently when the
|
|
74
|
+
`wsl.exe` process was gone (a thin race between exit handling and the next
|
|
75
|
+
request), leaving a pending entry nothing would ever answer — the caller
|
|
76
|
+
waited forever when it had no timeout, and the watchdog had no process
|
|
77
|
+
left to kill. An undeliverable frame now rejects with
|
|
78
|
+
`AgentUnavailableError` ("the agent exited before the request could be
|
|
79
|
+
sent; it never ran"), the rebuild relay fails undeliverable re-sends the
|
|
80
|
+
same way, and `close()` no longer writes `SHUTDOWN` into an ended stdin
|
|
81
|
+
(whose async write-after-end error could crash the process during its own
|
|
82
|
+
shutdown).
|
|
83
|
+
|
|
84
|
+
- **A confined write to `/tmp` now lands in the distro's real `/tmp`, where
|
|
85
|
+
every reader reads.** The fence grants `/tmp` as a writable root, but the
|
|
86
|
+
confined resident's profile mounted an ephemeral tmpfs over it — so the
|
|
87
|
+
write reported success into a private tmpfs, the read tools (unconfined)
|
|
88
|
+
could not see it, the post-write stat produced a `missing:` version that
|
|
89
|
+
made the next edit fail with `FS_STALE_VERSION`, and the file evaporated
|
|
90
|
+
when the resident retired. The file-writes resident's profile now binds the
|
|
91
|
+
real `/tmp` read-write (`realTmp`, on by default for the confined factory):
|
|
92
|
+
the write lands where the fence said it would, reads and edits agree, and
|
|
93
|
+
user terminals see the file. Commands keep their ephemeral per-run tmpfs —
|
|
94
|
+
the fresh-temp-area property (and its anti-poisoning guarantee) is
|
|
95
|
+
unchanged, so a command still cannot see files the write tools placed in
|
|
96
|
+
`/tmp` — the documented command-vs-tools split, now safe in both
|
|
97
|
+
directions. Verified live: confined write → plain read returns the content;
|
|
98
|
+
a confined `cat` still reports it absent, as documented.
|
|
99
|
+
|
|
100
|
+
- **A killed write no longer leaves its staging directory in your files.** A
|
|
101
|
+
`KILL`/`SIGTERM` mid-write could not be trapped, so the private 0700
|
|
102
|
+
staging dir (`.<name>.<pid>.<rand>.tmpdir`) sat in the target's own
|
|
103
|
+
directory forever — and listings showed the debris to the model. The EXIT
|
|
104
|
+
trap now removes the in-flight staging along with the agent's temp dir, and
|
|
105
|
+
each write sweeps the target directory's leftovers whose creating agent's
|
|
106
|
+
PID (it rides the name) is no longer running — a live agent's staging is
|
|
107
|
+
left alone, PID reuse included.
|
|
108
|
+
- **A write to a typo'd path refuses instead of materializing the chain.**
|
|
109
|
+
The staging `mkdir -p` silently created every missing parent directory and
|
|
110
|
+
reported success, where the peer's Node-fs publication fails `ENOENT` —
|
|
111
|
+
masking model errors as wins. Parents are no longer created: the refusal is
|
|
112
|
+
`FS_NOT_FOUND`, an existing directory stays fine, and a concurrent creator
|
|
113
|
+
of the directory still wins.
|
|
114
|
+
- **Setuid, setgid and sticky bits survive an overwrite or edit.** The mode
|
|
115
|
+
was masked to `0o777` at the stat parse and again at the write, so a
|
|
116
|
+
`4755` binary became `755` and a `1777` directory file lost its sticky bit
|
|
117
|
+
— while LIMITATIONS claimed the bits were kept. The full mode now rides
|
|
118
|
+
stat and write end-to-end (`0o7777`), matching the share substrate's
|
|
119
|
+
`chmod --reference` behaviour.
|
|
120
|
+
|
|
121
|
+
- **A shell command whose workdir names another WSL distro is refused, where
|
|
122
|
+
it used to run in the pinned distro against whatever tree shares the path.**
|
|
123
|
+
`toLinuxPath` strips a `\\wsl.localhost\<name>\…` UNC's distro name, and the
|
|
124
|
+
shell executor never compared it — so a `debian` workspace's commands ran
|
|
125
|
+
inside the pinned `ubuntu`, silently, while the fs side refused the same
|
|
126
|
+
workspace with `FS_OUTSIDE_DISTRO` ("open a session in that distro" — which
|
|
127
|
+
the user had done). The shell now refuses with the fs side's wording, and
|
|
128
|
+
the shell-env facts stay silent for a foreign-distro session instead of
|
|
129
|
+
presenting another distro's home and shell as this session's.
|
|
130
|
+
|
|
131
|
+
- **The resident agent no longer inherits the Windows environment's WSLENV
|
|
132
|
+
forwarding.** Spawned bare, `wsl.exe` carried this process's whole
|
|
133
|
+
environment with it, and whatever the user's `WSLENV` names — dev machines
|
|
134
|
+
routinely route secrets (`GITHUB_TOKEN/u` is the documented example) — was
|
|
135
|
+
forwarded into the distro, where every model command inherited it. The
|
|
136
|
+
transport's Windows env is now pinned: the essentials `wsl.exe` itself
|
|
137
|
+
needs, the managed `DSH_*` facts, `WSL_UTF8=1`, and a `WSLENV` that names
|
|
138
|
+
only the managed entries with their original flags. Verified live: a secret
|
|
139
|
+
injected on the Windows side with a forwarding flag appears zero times in
|
|
140
|
+
the distro environment. `forwardEnv` remains one-shot-only today — the
|
|
141
|
+
resident path never forwarded it, and the pin keeps that posture instead of
|
|
142
|
+
widening it silently.
|
|
143
|
+
|
|
15
144
|
### Added
|
|
16
145
|
|
|
17
146
|
- **`maskWindowsDrive` on `wsl-shell` and `wsl-fs`** — the mitigation knob for
|
package/agent/wsl-agent.sh
CHANGED
|
@@ -57,11 +57,15 @@ PROTO_VERSION=3
|
|
|
57
57
|
TMPDIR_AGENT=$(mktemp -d "${TMPDIR:-/tmp}/wsl-agent.XXXXXX")
|
|
58
58
|
OUT_FILE="$TMPDIR_AGENT/out"
|
|
59
59
|
ERR_FILE="$TMPDIR_AGENT/err"
|
|
60
|
-
trap 'rm -rf "$TMPDIR_AGENT"; exit 0' EXIT
|
|
60
|
+
trap 'rm -rf "$TMPDIR_AGENT"; [ -z "${CURRENT_STAGING:-}" ] || rm -rf -- "$CURRENT_STAGING"; exit 0' EXIT
|
|
61
61
|
|
|
62
62
|
# The request currently in flight, for KILL and for the exit trap.
|
|
63
63
|
CURRENT_PID=
|
|
64
64
|
CURRENT_ID=
|
|
65
|
+
# The staged-publication directory of an in-flight FS write. TERM/INT would
|
|
66
|
+
# otherwise orphan it inside the target's own directory; a KILL cannot be
|
|
67
|
+
# trapped, which is what the sweep in fs_write is for.
|
|
68
|
+
CURRENT_STAGING=
|
|
65
69
|
|
|
66
70
|
b64dec() {
|
|
67
71
|
# `base64 -d` with GNU and BusyBox spellings; a decode failure yields empty.
|
|
@@ -256,18 +260,22 @@ fs_realpath() {
|
|
|
256
260
|
fs_read() {
|
|
257
261
|
# $1 = base64 path, $2 = base64 byte offset, $3 = base64 max bytes. The
|
|
258
262
|
# window is the bound: at most $3 bytes leave the distro per request; the
|
|
259
|
-
# host loops.
|
|
260
|
-
|
|
263
|
+
# host loops. The remainder is STREAMED through the pipe, never staged —
|
|
264
|
+
# staging copied the whole tail to a temp file once per window, which made
|
|
265
|
+
# a full read of a large file quadratic in the file's own size. tail's
|
|
266
|
+
# stderr lands in ERR_FILE (its SIGPIPE death once `head` has had its fill
|
|
267
|
+
# writes nothing there), so a real read failure is still classified.
|
|
268
|
+
local p offset max err
|
|
261
269
|
p=$(b64dec "$1")
|
|
262
270
|
offset=$(b64dec "$2")
|
|
263
271
|
max=$(b64dec "$3")
|
|
264
272
|
[ "$max" -gt 0 ] || return 0
|
|
265
|
-
|
|
266
|
-
tail -c +"$(( offset + 1 ))" -- "$p" >"$
|
|
273
|
+
: >"$ERR_FILE"
|
|
274
|
+
tail -c +"$(( offset + 1 ))" -- "$p" 2>"$ERR_FILE" | head -c "$max" -- 2>/dev/null
|
|
275
|
+
if [ -s "$ERR_FILE" ]; then
|
|
267
276
|
err=$(cat "$ERR_FILE")
|
|
268
277
|
fs_fail "$(fs_classify "$err")" "$err"
|
|
269
|
-
|
|
270
|
-
head -c "$max" -- "$rd"
|
|
278
|
+
fi
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
norm_time() {
|
|
@@ -299,6 +307,30 @@ fs_version() {
|
|
|
299
307
|
printf '%s:%s:%s:%s:%s' "$dev" "$ino" "$size" "$(norm_time "$mtime")" "$(norm_time "$ctime")"
|
|
300
308
|
}
|
|
301
309
|
|
|
310
|
+
drop_staging() {
|
|
311
|
+
# Remove the current write's staging dir and forget it, so the EXIT trap
|
|
312
|
+
# never re-removes (or, worse, removes a newer write's staging) after it.
|
|
313
|
+
rm -rf -- "$staging"
|
|
314
|
+
CURRENT_STAGING=
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
sweep_staging() {
|
|
318
|
+
# $1 = directory, $2 = base name. Remove this directory's leftover staging
|
|
319
|
+
# dirs whose creating agent is gone — the PID rides the name, and a process
|
|
320
|
+
# that is not running cannot clean up after its own KILL. A live PID keeps
|
|
321
|
+
# its dir (the safe direction, PID reuse included).
|
|
322
|
+
local d pid
|
|
323
|
+
for d in "$1/.$2."*.*.tmpdir; do
|
|
324
|
+
[ -d "$d" ] || continue
|
|
325
|
+
pid=${d#"$1/.$2."}
|
|
326
|
+
pid=${pid%%.*}
|
|
327
|
+
case $pid in
|
|
328
|
+
''|*[!0-9]*) continue ;;
|
|
329
|
+
esac
|
|
330
|
+
kill -0 "$pid" 2>/dev/null || rm -rf -- "$d"
|
|
331
|
+
done
|
|
332
|
+
}
|
|
333
|
+
|
|
302
334
|
fs_write() {
|
|
303
335
|
# $1 = base64 path, $2 = base64 mode (`-` keeps none), $3 = base64
|
|
304
336
|
# `replace` or `no-replace`, $4 = expected version (`-` for none) — the
|
|
@@ -315,10 +347,21 @@ fs_write() {
|
|
|
315
347
|
dir=$(dirname -- "$path")
|
|
316
348
|
base=$(basename -- "$path")
|
|
317
349
|
[ -n "$base" ] && [ "$base" != "/" ] || fs_fail notfound "empty file name"
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
350
|
+
# Leftover staging dirs from a KILLed write (a KILL cannot be trapped) would
|
|
351
|
+
# sit in the target's directory forever and show up in listings. The creating
|
|
352
|
+
# agent's PID rides the name: one whose process is gone is an orphan, so
|
|
353
|
+
# sweep it before staging this write.
|
|
354
|
+
sweep_staging "$dir" "$base"
|
|
355
|
+
if [ ! -d "$dir" ]; then
|
|
356
|
+
# Deliberately NO `mkdir -p`: a typo'd parent chain must refuse (the peer's
|
|
357
|
+
# Node-fs publication fails ENOENT there), not materialize silently. An
|
|
358
|
+
# existing directory after a failed mkdir is a concurrent creator, not an
|
|
359
|
+
# error.
|
|
360
|
+
mkdir -- "$dir" 2>"$ERR_FILE" || {
|
|
361
|
+
err=$(cat "$ERR_FILE")
|
|
362
|
+
[ -d "$dir" ] || fs_fail "$(fs_classify "$err")" "$err"
|
|
363
|
+
}
|
|
364
|
+
fi
|
|
322
365
|
u=$(head -c 8 /dev/urandom 2>/dev/null | od -An -tx1 | tr -d ' \n')
|
|
323
366
|
staging="$dir/.$base.$$.${u:-0}.tmpdir"
|
|
324
367
|
mkdir -- "$staging" 2>"$ERR_FILE" || {
|
|
@@ -327,12 +370,13 @@ fs_write() {
|
|
|
327
370
|
err=$(cat "$ERR_FILE")
|
|
328
371
|
fs_fail "$(fs_classify "$err")" "$err"
|
|
329
372
|
}
|
|
373
|
+
CURRENT_STAGING=$staging
|
|
330
374
|
chmod 700 -- "$staging" 2>/dev/null
|
|
331
375
|
tmp="$staging/$base.tmp"
|
|
332
376
|
# Decode straight to the file: the base64 text is the one form that crosses
|
|
333
377
|
# a shell variable without a NUL-byte or quoting hazard.
|
|
334
378
|
printf '%s' "$5" | base64 -d >"$tmp" 2>/dev/null || {
|
|
335
|
-
|
|
379
|
+
drop_staging
|
|
336
380
|
fs_fail io "cannot stage content"
|
|
337
381
|
}
|
|
338
382
|
chmod 600 -- "$tmp"
|
|
@@ -340,7 +384,7 @@ fs_write() {
|
|
|
340
384
|
if [ "$mode" != "-" ]; then
|
|
341
385
|
chmod "$mode" -- "$tmp" 2>"$ERR_FILE" || {
|
|
342
386
|
err=$(cat "$ERR_FILE")
|
|
343
|
-
|
|
387
|
+
drop_staging
|
|
344
388
|
fs_fail io "$err"
|
|
345
389
|
}
|
|
346
390
|
fi
|
|
@@ -351,7 +395,7 @@ fs_write() {
|
|
|
351
395
|
# trips the stat/find skew a list-derived version would.
|
|
352
396
|
version=$(fs_version "$path")
|
|
353
397
|
if [ "$version" != "$expected" ]; then
|
|
354
|
-
|
|
398
|
+
drop_staging
|
|
355
399
|
if [ -n "$version" ]; then
|
|
356
400
|
fs_fail stale "cannot write \"$path\": file changed since it was read"
|
|
357
401
|
fi
|
|
@@ -363,12 +407,12 @@ fs_write() {
|
|
|
363
407
|
# it) where the peer's link(2) refuses any existing path, so the existence
|
|
364
408
|
# guard is checked before the call and the landing spot verified after it.
|
|
365
409
|
if [ -e "$path" ] || [ -L "$path" ]; then
|
|
366
|
-
|
|
410
|
+
drop_staging
|
|
367
411
|
fs_fail exists "cannot write \"$path\": it already exists"
|
|
368
412
|
fi
|
|
369
413
|
ln -- "$tmp" "$path" 2>"$ERR_FILE" || {
|
|
370
414
|
err=$(cat "$ERR_FILE")
|
|
371
|
-
|
|
415
|
+
drop_staging
|
|
372
416
|
# A concurrent creator wins the race; classify by what is on disk now.
|
|
373
417
|
if [ -e "$path" ] || [ -L "$path" ]; then
|
|
374
418
|
fs_fail exists "$err"
|
|
@@ -379,17 +423,17 @@ fs_write() {
|
|
|
379
423
|
# ln linked BESIDE the target (a directory appeared under us): undo the
|
|
380
424
|
# stray link and refuse, the guarded create did not publish.
|
|
381
425
|
rm -f -- "$path/${base}.tmp"
|
|
382
|
-
|
|
426
|
+
drop_staging
|
|
383
427
|
fs_fail exists "cannot write \"$path\": it was created concurrently"
|
|
384
428
|
fi
|
|
385
429
|
else
|
|
386
430
|
mv -f -- "$tmp" "$path" 2>"$ERR_FILE" || {
|
|
387
431
|
err=$(cat "$ERR_FILE")
|
|
388
|
-
|
|
432
|
+
drop_staging
|
|
389
433
|
fs_fail "$(fs_classify "$err")" "$err"
|
|
390
434
|
}
|
|
391
435
|
fi
|
|
392
|
-
|
|
436
|
+
drop_staging
|
|
393
437
|
}
|
|
394
438
|
|
|
395
439
|
fs_dispatch() {
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -22,6 +22,9 @@ host stack over the distro's 9p share. What differs:
|
|
|
22
22
|
rather than quietly degrading to share semantics that cannot follow symlinks
|
|
23
23
|
or keep mode bits. The share substrate dies with the distro anyway; the
|
|
24
24
|
agent only adds its own failure mode, which is the one honest way to fail.
|
|
25
|
+
The failure message names the opt-out: set `substrate: "share"` in the
|
|
26
|
+
provider config to keep working on the Windows-side share while the agent
|
|
27
|
+
is out.
|
|
25
28
|
- **The mutation guard survives to the write.** `createIfAbsent` publishes with
|
|
26
29
|
a no-replace link inside the distro, which closes the check-then-write window
|
|
27
30
|
for creates outright. An overwrite or edit carries the version it was based
|
|
@@ -90,7 +93,10 @@ Everything below applies to the share substrate unless it says otherwise.
|
|
|
90
93
|
- The check before a write is "check, then write", which is not atomic. This backend
|
|
91
94
|
checks the caller's guard itself, because the Windows backend publishes a guarded
|
|
92
95
|
new file with a hard link, and the share does not support hard links. This is the
|
|
93
|
-
race that `dsh-fs-sandbox` already documents
|
|
96
|
+
race that `dsh-fs-sandbox` already documents — and one reason the share substrate
|
|
97
|
+
is the documented FALLBACK rather than the main path: its publication rides the
|
|
98
|
+
unconfined agent, so it never receives the confined resident's kernel-enforced
|
|
99
|
+
containment. The agent substrate does.
|
|
94
100
|
- `watch()` observes from inside the distro (a `find -newer` poll loop, see
|
|
95
101
|
`lib/watcher.js`), so events are coarse invalidation, not per-file notifications,
|
|
96
102
|
and they arrive on the poll cadence, not instantly. A file created with an mtime
|
package/docs/PARITY.md
CHANGED
|
@@ -11,7 +11,7 @@ distro. Everything users love — native ext4 semantics, inotify, native
|
|
|
11
11
|
`rg`, low latency — is a derivative. This plugin's 9p-UNC plus one-shot
|
|
12
12
|
`wsl.exe` model can only approach it. The plan therefore lands a resident
|
|
13
13
|
in-distro agent and migrates the `fs` and `shell` seams onto it, keeping the
|
|
14
|
-
current paths as the documented fallback (`wsl.agent:
|
|
14
|
+
current paths as the documented fallback (`wsl.agent: false` must equal
|
|
15
15
|
today's behaviour byte for byte).
|
|
16
16
|
|
|
17
17
|
Not applicable, recorded so the gap is a decision and not an omission:
|
|
@@ -64,7 +64,8 @@ shaped a phase of the plan.
|
|
|
64
64
|
|
|
65
65
|
## Fallback invariant
|
|
66
66
|
|
|
67
|
-
Every phase must keep `wsl.agent:
|
|
67
|
+
Every phase must keep `wsl.agent: false` (and, per capability, any runtime
|
|
68
68
|
fallback) behaviourally identical to the pre-phase release. Probes assert
|
|
69
|
-
this: `agent.sh` runs the same
|
|
70
|
-
compares outcomes,
|
|
69
|
+
this: `agent.sh` runs the same operations through the resident and through
|
|
70
|
+
the one-shot `wsl.exe` path and compares outcomes — exit code, stdout bytes,
|
|
71
|
+
stderr bytes, working directory — not internals.
|
package/lib/agent-confined.js
CHANGED
|
@@ -48,10 +48,16 @@ const confinedAgents = new Map();
|
|
|
48
48
|
* @param {boolean} [request.maskWindowsDrive] - shadow `/mnt` with an empty
|
|
49
49
|
* tmpfs in the resident's profile; part of the cache key, since it changes
|
|
50
50
|
* the mount table the resident lives under.
|
|
51
|
+
* @param {boolean} [request.realTmp] - default TRUE: workspace-write binds the
|
|
52
|
+
* distro's real `/tmp` read-write into the resident's profile, so a write
|
|
53
|
+
* the fence approved lands where every reader reads. `false` restores the
|
|
54
|
+
* ephemeral tmpfs — for tests and probes, not production: a `/tmp` write
|
|
55
|
+
* behind an ephemeral mount reports success into a directory no reader can
|
|
56
|
+
* see, which is the bug this default exists to prevent.
|
|
51
57
|
* @returns {WslAgent} the confined resident for this key.
|
|
52
58
|
*/
|
|
53
|
-
export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false }) {
|
|
54
|
-
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}`;
|
|
59
|
+
export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindowsDrive = false, realTmp = true }) {
|
|
60
|
+
const key = `${wslPath}\0${distro}\0${policy.mode}\0${policy.workspaceRoot}\0${maskWindowsDrive ? "masked" : "visible"}\0${realTmp ? "real" : "ephemeral"}`;
|
|
55
61
|
let agent = confinedAgents.get(key);
|
|
56
62
|
if (agent === undefined) {
|
|
57
63
|
agent = new WslAgent({
|
|
@@ -65,7 +71,7 @@ export function confinedAgent({ distro, wslPath = "wsl.exe", policy, maskWindows
|
|
|
65
71
|
// `lib/bwrap.js`, whose module doc records what happened when they did not.
|
|
66
72
|
argvPrefix: bwrapArgvPrefix(
|
|
67
73
|
{ mode: policy.mode, workspaceRoot: toLinuxPath(policy.workspaceRoot, { distro }) },
|
|
68
|
-
{ maskWindowsDrive },
|
|
74
|
+
{ maskWindowsDrive, realTmp },
|
|
69
75
|
),
|
|
70
76
|
});
|
|
71
77
|
confinedAgents.set(key, agent);
|
package/lib/agent.js
CHANGED
|
@@ -44,6 +44,50 @@ 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
|
+
/** The Windows essentials `wsl.exe` itself needs to start and report cleanly. */
|
|
48
|
+
const WINDOWS_ESSENTIALS = /^(systemroot|systemdrive|windir|path|pathext|comspec|os|computername)$/i;
|
|
49
|
+
|
|
50
|
+
/** The managed facts prefix whose names (and only theirs) may be forwarded. */
|
|
51
|
+
const MANAGED_ENV_PREFIX = "DSH_";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Windows environment the transport starts from, pinned.
|
|
55
|
+
*
|
|
56
|
+
* Spawned bare, `wsl.exe` would inherit this process's whole environment — and
|
|
57
|
+
* the `WSLENV` variable riding along decides which of those names are
|
|
58
|
+
* FORWARDED into the distro, where every model command inherits them. Dev
|
|
59
|
+
* machines routinely route secrets that way (`WSLENV=GITHUB_TOKEN/u` is the
|
|
60
|
+
* documented example), so the resident's env is built from scratch instead:
|
|
61
|
+
* the Windows essentials `wsl.exe` itself needs, the managed `DSH_*` facts,
|
|
62
|
+
* and a `WSLENV` that names only those managed entries — preserving their
|
|
63
|
+
* original forwarding flags. Nothing else on the Windows side can reach a
|
|
64
|
+
* model command.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately NOT here: the executor's `forwardEnv` names. They ride the
|
|
67
|
+
* one-shot path's WSLENV (`spawnSpec` builds it per call); the resident path
|
|
68
|
+
* has never forwarded them, and this pin keeps that posture instead of
|
|
69
|
+
* widening it silently.
|
|
70
|
+
*
|
|
71
|
+
* @param {Record<string, string|undefined>} parent - this process's environment.
|
|
72
|
+
* @returns {Record<string, string>} the pinned environment for `wsl.exe`.
|
|
73
|
+
*/
|
|
74
|
+
export function pinnedWindowsEnv(parent) {
|
|
75
|
+
const env = {};
|
|
76
|
+
for (const [name, value] of Object.entries(parent)) {
|
|
77
|
+
if (value === undefined) continue;
|
|
78
|
+
if (WINDOWS_ESSENTIALS.test(name) || name.startsWith(MANAGED_ENV_PREFIX)) env[name] = value;
|
|
79
|
+
}
|
|
80
|
+
env.WSLENV = String(parent.WSLENV ?? "")
|
|
81
|
+
.split(":")
|
|
82
|
+
.filter((entry) => entry.startsWith(MANAGED_ENV_PREFIX))
|
|
83
|
+
.join(":");
|
|
84
|
+
// wsl.exe writes its own diagnostics as UTF-16LE without this, and a UTF-16
|
|
85
|
+
// message read as UTF-8 is mojibake — including the `WSL_E_*` codes. Same
|
|
86
|
+
// pin the one-shot path applies to every spawn.
|
|
87
|
+
env.WSL_UTF8 = "1";
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
/**
|
|
48
92
|
* @param {object} options - the spawn options.
|
|
49
93
|
* @param {string} options.wslPath - the `wsl.exe` path.
|
|
@@ -55,6 +99,7 @@ const WATCHDOG_GRACE_MS = 13000;
|
|
|
55
99
|
*/
|
|
56
100
|
function spawnDefault({ wslPath, distro, scriptPath, argvPrefix = [] }) {
|
|
57
101
|
return spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, "sh", scriptPath], {
|
|
102
|
+
env: pinnedWindowsEnv(process.env),
|
|
58
103
|
stdio: ["pipe", "pipe", "pipe"],
|
|
59
104
|
windowsHide: true,
|
|
60
105
|
});
|
|
@@ -309,7 +354,13 @@ export class WslAgent {
|
|
|
309
354
|
const id = `r${this.nextRequestId++}`;
|
|
310
355
|
pending.id = id;
|
|
311
356
|
this.pending.set(id, pending);
|
|
312
|
-
|
|
357
|
+
// The rebuilt transport can itself be gone by now; a frame that
|
|
358
|
+
// cannot be written must fail its caller, not strand the entry.
|
|
359
|
+
if (!this.writeLines(relabelFrame(pending.lines, id))) {
|
|
360
|
+
this.clearPending(pending);
|
|
361
|
+
this.pending.delete(id);
|
|
362
|
+
pending.reject(new AgentUnavailableError(`agent exited before request ${id} could be re-sent; its state is unknown`));
|
|
363
|
+
}
|
|
313
364
|
}
|
|
314
365
|
})
|
|
315
366
|
.catch((error) => {
|
|
@@ -381,7 +432,16 @@ export class WslAgent {
|
|
|
381
432
|
};
|
|
382
433
|
signal.addEventListener("abort", pending.onAbort, { once: true });
|
|
383
434
|
}
|
|
384
|
-
|
|
435
|
+
// A frame that cannot be WRITTEN must fail its caller now. Silently
|
|
436
|
+
// dropping it used to strand the request: nothing would ever answer, the
|
|
437
|
+
// watchdog (armed on an ACK that will not come) had no process to kill,
|
|
438
|
+
// and with no timeout the caller waited forever.
|
|
439
|
+
if (!this.writeLines(lines)) {
|
|
440
|
+
this.clearPending(pending);
|
|
441
|
+
this.pending.delete(pending.id);
|
|
442
|
+
reject(new AgentUnavailableError("the agent exited before the request could be sent; it never ran"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
385
445
|
this.rearmIdleTimer();
|
|
386
446
|
}
|
|
387
447
|
|
|
@@ -491,15 +551,20 @@ export class WslAgent {
|
|
|
491
551
|
/** @private */
|
|
492
552
|
writeLines(lines) {
|
|
493
553
|
const child = this.process;
|
|
494
|
-
if (!child || !child.stdin.writable) return;
|
|
554
|
+
if (!child || !child.stdin.writable) return false;
|
|
495
555
|
child.stdin.write(`${lines.join("\n")}\n`);
|
|
496
556
|
this.rearmIdleTimer();
|
|
557
|
+
return true;
|
|
497
558
|
}
|
|
498
559
|
|
|
499
560
|
/** @private */
|
|
500
561
|
writeLinesSafe(child, lines) {
|
|
562
|
+
// The writable check matters beyond politeness: a write to an ENDED stdin
|
|
563
|
+
// does not throw here — it emits an async 'error' event the caller never
|
|
564
|
+
// listens for, which crashes the process during its own shutdown.
|
|
565
|
+
if (!child.stdin || !child.stdin.writable) return;
|
|
501
566
|
try {
|
|
502
|
-
child.stdin
|
|
567
|
+
child.stdin.write(`${lines.join("\n")}\n`);
|
|
503
568
|
} catch {
|
|
504
569
|
// A dead pipe is what close() is cleaning up after.
|
|
505
570
|
}
|
package/lib/bwrap.js
CHANGED
|
@@ -40,15 +40,32 @@
|
|
|
40
40
|
* (binfmt interop dispatches on file content, not location), so `enforcement`
|
|
41
41
|
* stays `partial` — see `lib/sandbox.js`.
|
|
42
42
|
*
|
|
43
|
+
* `options.realTmp` changes what workspace-write does at `/tmp`. The default
|
|
44
|
+
* (`--tmpfs /tmp`) gives the sandbox an ephemeral, empty temp area — the right
|
|
45
|
+
* shape for a COMMAND: fresh per run, cannot touch the distro's real `/tmp`,
|
|
46
|
+
* cannot be poisoned by a previous run. `realTmp: true` binds the REAL `/tmp`
|
|
47
|
+
* read-write instead — the right shape for the FILE-WRITES resident: the
|
|
48
|
+
* fence grants `/tmp` as a writable root, so a write the fence approved must
|
|
49
|
+
* land where every reader (the plain agent, the user's own shell) reads — not
|
|
50
|
+
* inside a private tmpfs that reads cannot see and that dies with the
|
|
51
|
+
* resident. Read-only mode mounts no `/tmp` at all, so the option is inert
|
|
52
|
+
* there.
|
|
53
|
+
*
|
|
43
54
|
* @param policy - the policy to express as bwrap mounts.
|
|
44
55
|
* @param options - profile options.
|
|
45
56
|
* @param options.maskWindowsDrive - shadow `/mnt` with an empty tmpfs.
|
|
57
|
+
* @param options.realTmp - workspace-write binds the real `/tmp` read-write
|
|
58
|
+
* instead of mounting an ephemeral tmpfs over it.
|
|
46
59
|
* @returns profile arguments, before the `--` separator and the command argv.
|
|
47
60
|
*/
|
|
48
61
|
export function bwrapProfileArgs(policy, options = {}) {
|
|
49
62
|
const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
|
|
50
63
|
if (policy.mode === "workspace-write") {
|
|
51
|
-
|
|
64
|
+
if (options.realTmp) {
|
|
65
|
+
args.push("--bind", "/tmp", "/tmp");
|
|
66
|
+
} else {
|
|
67
|
+
args.push("--tmpfs", "/tmp");
|
|
68
|
+
}
|
|
52
69
|
args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
|
|
53
70
|
}
|
|
54
71
|
if (options.maskWindowsDrive) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem provider's POLICY DECISIONS, extracted from
|
|
3
|
+
* `lib/index.js` so they are unit-testable in a bare checkout: which paths are
|
|
4
|
+
* refused for naming another distro, which mode refuses a mutation outright,
|
|
5
|
+
* which write guards refuse which intents, and how a substrate failure maps
|
|
6
|
+
* onto the refusal the tool layer surfaces. Pure in, descriptor out — no I/O,
|
|
7
|
+
* no peers (the provider wraps these descriptors in `FsError` at one choke
|
|
8
|
+
* point, keeping the peer import out of this module and the decisions honest).
|
|
9
|
+
*
|
|
10
|
+
* Every message here is model-facing and must stay byte-identical to what the
|
|
11
|
+
* provider produced before the extraction — the tool layer and the model match
|
|
12
|
+
* on these strings.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-wsl/fs-decisions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAnotherDistrosUnc } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
/** A refusal descriptor the provider wraps in `FsError` at one choke point. */
|
|
20
|
+
const refusal = (message, code) => ({ message, code });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The worldPath decision: a path that resolves into another WSL distro is
|
|
24
|
+
* refused while `restrictToDistro` holds — deliberately NOT a sandbox denial,
|
|
25
|
+
* because no wider permission can lift a configuration choice.
|
|
26
|
+
* @param {object} input - the decision's inputs.
|
|
27
|
+
* @param {string} input.path - the path as the caller spelled it.
|
|
28
|
+
* @param {string} input.world - the resolved world path (a UNC for distro paths).
|
|
29
|
+
* @param {string} input.distro - the pinned distro name.
|
|
30
|
+
* @param {boolean} input.restrictToDistro - the configuration fence.
|
|
31
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
32
|
+
*/
|
|
33
|
+
export function outsideDistroRefusal({ path, world, distro, restrictToDistro }) {
|
|
34
|
+
if (!restrictToDistro || !isAnotherDistrosUnc(world, distro)) return null;
|
|
35
|
+
return refusal(
|
|
36
|
+
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
37
|
+
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
38
|
+
"FS_OUTSIDE_DISTRO",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The mutationAgent decision: does THIS mutation route to the confined
|
|
44
|
+
* resident? An absent policy (caller pre-checked), a disabled sandbox, and an
|
|
45
|
+
* approved escalation all address the plain resident — which is what those
|
|
46
|
+
* mean.
|
|
47
|
+
* @param {object} input - the decision's inputs.
|
|
48
|
+
* @param {{mode: string, workspaceRoot: string}|undefined} input.policy - the
|
|
49
|
+
* resolved file-effect policy.
|
|
50
|
+
* @param {boolean} input.sandboxEnabled - the provider's `sandbox` configuration.
|
|
51
|
+
* @returns {boolean} true when the mutation belongs to the confined resident.
|
|
52
|
+
*/
|
|
53
|
+
export function isConfinedMutation({ policy, sandboxEnabled }) {
|
|
54
|
+
return Boolean(policy) && Boolean(sandboxEnabled) && policy.mode !== "danger-full-access";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The mode-level mutation decision: `read-only` refuses every write outright;
|
|
59
|
+
* `danger-full-access` is the approved escalation and refuses nothing.
|
|
60
|
+
* `workspace-write` refuses nothing HERE — its containment check needs the
|
|
61
|
+
* fresh target and runs in the provider.
|
|
62
|
+
* @param {string} mode - the resolved policy mode.
|
|
63
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
64
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
65
|
+
*/
|
|
66
|
+
export function mutationModeRefusal(mode, displayPath) {
|
|
67
|
+
if (mode !== "read-only") return null;
|
|
68
|
+
return refusal(`cannot write "${displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The containment denial for a `workspace-write` mutation whose fresh target
|
|
73
|
+
* sat under no writable root.
|
|
74
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
75
|
+
* @returns {{message: string, code: string}} the refusal descriptor.
|
|
76
|
+
*/
|
|
77
|
+
export function workspaceWriteDenial(displayPath) {
|
|
78
|
+
return refusal(`cannot write "${displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The write/edit guard: compare the intent (`expected`) with what is on disk
|
|
83
|
+
* NOW (`existing`, a stat row or `undefined`) and refuse the stale or
|
|
84
|
+
* unobserved intent. The peer's dialect, byte for byte.
|
|
85
|
+
* @param {object|undefined} expected - the write intent (`replaceIfVersion`
|
|
86
|
+
* with a `version`, or `createIfAbsent`), or `undefined` for an unguarded write.
|
|
87
|
+
* @param {{version: string}|undefined} existing - what stat found, or undefined.
|
|
88
|
+
* @param {string} displayPath - the caller-facing path for the message.
|
|
89
|
+
* @returns {{message: string, code: string}|null} the refusal, or null to proceed.
|
|
90
|
+
*/
|
|
91
|
+
export function guardRefusal(expected, existing, displayPath) {
|
|
92
|
+
if (expected === undefined) return null;
|
|
93
|
+
if (expected.kind === "replaceIfVersion") {
|
|
94
|
+
if (existing === undefined) return refusal(`cannot write "${displayPath}": file no longer exists`, "FS_STALE_VERSION");
|
|
95
|
+
if (existing.version !== expected.version) {
|
|
96
|
+
return refusal(`cannot write "${displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
101
|
+
return refusal(`cannot overwrite existing "${displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The substrate failure mapping: what the tool layer should surface for an
|
|
108
|
+
* error that crossed the `ctx.fs` seam. A coded refusal keeps its code and
|
|
109
|
+
* message byte-for-byte; anything else is the substrate being UNAVAILABLE —
|
|
110
|
+
* the share backend's "distro is gone" case wearing the agent substrate's
|
|
111
|
+
* wording — and the message names the documented opt-out, so an operator hit
|
|
112
|
+
* by the fail-closed posture can reach the old behaviour without a doc trip.
|
|
113
|
+
* @param {unknown} error - what the substrate raised.
|
|
114
|
+
* @returns {{message: string, code: string, cause: unknown}} the refusal the
|
|
115
|
+
* provider wraps in `FsError`.
|
|
116
|
+
*/
|
|
117
|
+
export function substrateFailure(error) {
|
|
118
|
+
if (isCoded(error)) return { message: error.message, code: error.code, cause: error };
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return {
|
|
121
|
+
message:
|
|
122
|
+
`the distro file substrate is unavailable: ${message}. ` +
|
|
123
|
+
`Set "substrate: \\"share\\"" in the provider config to work on the Windows-side share while the agent is out.`,
|
|
124
|
+
code: "FS_IO_ERROR",
|
|
125
|
+
cause: error instanceof Error ? error : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A coded refusal: our `FsCodedError` duck (a message, a string `FS_*` code). */
|
|
130
|
+
function isCoded(error) {
|
|
131
|
+
return error instanceof Error && typeof error.code === "string" && error.code.startsWith("FS_");
|
|
132
|
+
}
|
package/lib/fsio-agent.js
CHANGED
|
@@ -68,7 +68,7 @@ export function parseStatRecord(stdout) {
|
|
|
68
68
|
const [type, mode, size, dev, ino, mtime, ctime] = stdout.toString("utf8").split("\t");
|
|
69
69
|
return {
|
|
70
70
|
type,
|
|
71
|
-
mode: Number.parseInt(mode, 8) &
|
|
71
|
+
mode: Number.parseInt(mode, 8) & 0o7777,
|
|
72
72
|
size: Number(size),
|
|
73
73
|
dev,
|
|
74
74
|
ino,
|
|
@@ -419,7 +419,7 @@ export class DistroFs {
|
|
|
419
419
|
"write",
|
|
420
420
|
[
|
|
421
421
|
linuxPath,
|
|
422
|
-
mode === undefined ? "-" : (mode &
|
|
422
|
+
mode === undefined ? "-" : (mode & 0o7777).toString(8),
|
|
423
423
|
createIfAbsent ? "no-replace" : "replace",
|
|
424
424
|
expectedVersion ?? "-",
|
|
425
425
|
content,
|
package/lib/index.js
CHANGED
|
@@ -43,7 +43,8 @@ import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
|
|
|
43
43
|
import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
|
|
44
44
|
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
45
45
|
import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
46
|
-
import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
46
|
+
import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
|
|
47
|
+
import { guardRefusal, isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
|
|
47
48
|
import { WslSandbox } from "./sandbox.js";
|
|
48
49
|
import { armDistroWatcher } from "./watcher.js";
|
|
49
50
|
import { WslAgent } from "./agent.js";
|
|
@@ -534,6 +535,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
534
535
|
const distro = await this.distro();
|
|
535
536
|
const shell = await this.shell();
|
|
536
537
|
const resolved = await this.withDefaultWorkdir(spec);
|
|
538
|
+
this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
|
|
537
539
|
if (this.config.agent) {
|
|
538
540
|
try {
|
|
539
541
|
return await this.executeViaAgent(resolved, distro, shell);
|
|
@@ -546,6 +548,26 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
546
548
|
return this.executeOneShot(resolved, distro, shell);
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Refuse a workdir that names another WSL distro — the shell-side twin of
|
|
553
|
+
* the filesystem's `FS_OUTSIDE_DISTRO` fence. Left unguarded, `toLinuxPath`
|
|
554
|
+
* strips the UNC's distro name and the command runs inside THIS distro
|
|
555
|
+
* against whatever tree happens to share the path, silently, while the fs
|
|
556
|
+
* side refuses the same workspace outright. The wording mirrors the fs
|
|
557
|
+
* refusal (minus `restrictToDistro`, which is an fs-only key).
|
|
558
|
+
* @param workdir - the resolved workdir (UNC, Linux path, or Windows path).
|
|
559
|
+
* @param distro - the pinned distro name.
|
|
560
|
+
* @throws Error naming both distros and both ways out.
|
|
561
|
+
*/
|
|
562
|
+
throwIfWorkdirInAnotherDistro(workdir, distro) {
|
|
563
|
+
if (!isAnotherDistrosUnc(workdir, distro)) return;
|
|
564
|
+
throw new Error(
|
|
565
|
+
`"${workdir}" names a workspace in another WSL distro, and this profile pins its commands to "${distro}" — ` +
|
|
566
|
+
`running them here would act on a different machine's tree. Open a session in that distro, ` +
|
|
567
|
+
`or point cwd/workdir at this distro.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
549
571
|
/**
|
|
550
572
|
* The agent-backed execution path: one long-lived in-distro process, the
|
|
551
573
|
* confinement argv unchanged, the handle shape unchanged. Any
|
|
@@ -929,7 +951,7 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
929
951
|
* @returns {Promise<WslAgent>} the agent to publish through.
|
|
930
952
|
*/
|
|
931
953
|
async mutationAgent(policy) {
|
|
932
|
-
if (!policy
|
|
954
|
+
if (!isConfinedMutation({ policy, sandboxEnabled: this.config.sandbox })) {
|
|
933
955
|
return this.publicationAgent();
|
|
934
956
|
}
|
|
935
957
|
const distro = await this.distro();
|
|
@@ -955,8 +977,8 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
955
977
|
peerError(error) {
|
|
956
978
|
if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
|
|
957
979
|
if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
|
|
958
|
-
const message
|
|
959
|
-
return new FsError(
|
|
980
|
+
const { message, code, cause } = substrateFailure(error);
|
|
981
|
+
return new FsError(message, code, cause === undefined ? {} : { cause });
|
|
960
982
|
}
|
|
961
983
|
|
|
962
984
|
/**
|
|
@@ -994,15 +1016,12 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
994
1016
|
base = await distroHome(this);
|
|
995
1017
|
}
|
|
996
1018
|
const world = toWorldPath(path, { distro, cwd: base });
|
|
997
|
-
|
|
1019
|
+
const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: this.config.restrictToDistro });
|
|
1020
|
+
if (refusal !== null) {
|
|
998
1021
|
// Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
|
|
999
1022
|
// into a denial marker plus an escalation offer, and no wider permission can
|
|
1000
1023
|
// lift this fence: it is a configuration choice, not a sandbox decision.
|
|
1001
|
-
throw new FsError(
|
|
1002
|
-
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
1003
|
-
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
1004
|
-
"FS_OUTSIDE_DISTRO",
|
|
1005
|
-
);
|
|
1024
|
+
throw new FsError(refusal.message, refusal.code);
|
|
1006
1025
|
}
|
|
1007
1026
|
return world;
|
|
1008
1027
|
}
|
|
@@ -1053,17 +1072,16 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1053
1072
|
async checkedTarget(target, sandboxPolicy) {
|
|
1054
1073
|
if (!this.config.sandbox) return target;
|
|
1055
1074
|
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
|
|
1056
|
-
const
|
|
1057
|
-
if (
|
|
1058
|
-
if (mode
|
|
1059
|
-
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
|
|
1060
|
-
}
|
|
1075
|
+
const modeRefusal = mutationModeRefusal(policy.mode, target.displayPath);
|
|
1076
|
+
if (modeRefusal !== null) throw new FsError(modeRefusal.message, modeRefusal.code);
|
|
1077
|
+
if (policy.mode !== "workspace-write") return target;
|
|
1061
1078
|
const fresh = await this.resolve(target.displayPath);
|
|
1062
1079
|
const distro = await this.distro();
|
|
1063
1080
|
for (const root of wslWritableRoots(policy, distro)) {
|
|
1064
1081
|
if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
|
|
1065
1082
|
}
|
|
1066
|
-
|
|
1083
|
+
const denial = workspaceWriteDenial(target.displayPath);
|
|
1084
|
+
throw new FsError(denial.message, denial.code);
|
|
1067
1085
|
}
|
|
1068
1086
|
|
|
1069
1087
|
/**
|
|
@@ -1297,14 +1315,8 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
1297
1315
|
async assertGuard(target, expected, signal) {
|
|
1298
1316
|
if (expected === undefined) return;
|
|
1299
1317
|
const existing = await this.stat(target, signal);
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
1303
|
-
return;
|
|
1304
|
-
}
|
|
1305
|
-
if (expected.kind === "createIfAbsent" && existing !== undefined) {
|
|
1306
|
-
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
1307
|
-
}
|
|
1318
|
+
const refusal = guardRefusal(expected, existing, target.displayPath);
|
|
1319
|
+
if (refusal !== null) throw new FsError(refusal.message, refusal.code);
|
|
1308
1320
|
}
|
|
1309
1321
|
|
|
1310
1322
|
/**
|
package/lib/paths.js
CHANGED
|
@@ -165,6 +165,20 @@ export function isUnderDistro(uncPath, distro) {
|
|
|
165
165
|
return parsed !== undefined && parsed.distro.toLowerCase() === String(distro).toLowerCase();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A WSL UNC that names a distro OTHER than the pinned one — the shape both
|
|
170
|
+
* shell seams refuse: the shell executor will not run commands in it (running
|
|
171
|
+
* there would mean silently switching distros on path similarity), and the
|
|
172
|
+
* shell-env facts describe only the pinned distro, so a foreign session gets
|
|
173
|
+
* no facts rather than another distro's home and shell.
|
|
174
|
+
* @param uncPath - the candidate path (any spelling; non-UNC values are not foreign).
|
|
175
|
+
* @param distro - the pinned distro name.
|
|
176
|
+
* @returns true when the value is a WSL UNC under a different distro.
|
|
177
|
+
*/
|
|
178
|
+
export function isAnotherDistrosUnc(value, distro) {
|
|
179
|
+
return isWslUnc(value) && !isUnderDistro(value, distro);
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
/**
|
|
169
183
|
* Translate a Windows drive path into its WSL interop mount.
|
|
170
184
|
* @param winPath - e.g. `C:\Users\andyz\Documents`.
|
package/lib/shell-env.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import z from "@deepseek-ai/schemastery";
|
|
27
|
-
import { isWslUnc } from "./paths.js";
|
|
27
|
+
import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
|
|
28
28
|
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
29
29
|
import { sharedAgent } from "./agent-shared.js";
|
|
30
30
|
import { listeningPorts } from "./ports.js";
|
|
@@ -82,6 +82,11 @@ export async function apply(ctx, config = {}) {
|
|
|
82
82
|
resolve: (execution) => {
|
|
83
83
|
const cwd = execution?.agent?.session?.header?.cwd;
|
|
84
84
|
if (!isWslUnc(cwd)) return {};
|
|
85
|
+
// The facts describe ONE distro. A workspace under another distro's UNC
|
|
86
|
+
// is not that distro — the shell refuses its commands outright (the
|
|
87
|
+
// cross-distro guard) — so the honest resolution is no facts at all,
|
|
88
|
+
// not another distro's home and shell presented as this session's.
|
|
89
|
+
if (isAnotherDistrosUnc(cwd, distro)) return {};
|
|
85
90
|
return {
|
|
86
91
|
DSH_WSL_DISTRO: distro,
|
|
87
92
|
DSH_WSL_SHELL: shell,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wsl-env",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
],
|
|
52
52
|
"scripts": {
|
|
53
53
|
"test": "npm run lint:style && npm run test:syntax && npm run test:unit",
|
|
54
|
-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/
|
|
54
|
+
"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-publish.test.mjs test/fs-substrate.test.mjs test/fsio-agent.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/shell.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/fs-decisions.test.mjs",
|
|
55
55
|
"test:syntax": "node test/syntax.mjs",
|
|
56
|
-
"test:unit": "node --test 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/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs",
|
|
56
|
+
"test:unit": "node --test 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/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/fs-decisions.test.mjs",
|
|
57
57
|
"lint:style": "node test/style.mjs",
|
|
58
58
|
"prepublishOnly": "npm test",
|
|
59
59
|
"probe": "bash test/probe/run.sh",
|