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 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
@@ -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
- local p offset max rd err
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
- rd="$TMPDIR_AGENT/read"
266
- tail -c +"$(( offset + 1 ))" -- "$p" >"$rd" 2>"$ERR_FILE" || {
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
- mkdir -p -- "$dir" 2>"$ERR_FILE" || {
319
- err=$(cat "$ERR_FILE")
320
- fs_fail "$(fs_classify "$err")" "$err"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
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
- rm -rf -- "$staging"
432
+ drop_staging
389
433
  fs_fail "$(fs_classify "$err")" "$err"
390
434
  }
391
435
  fi
392
- rm -rf -- "$staging"
436
+ drop_staging
393
437
  }
394
438
 
395
439
  fs_dispatch() {
@@ -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: 'off'` must equal
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: 'off'` (and, per capability, any runtime
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 operation with the agent off and on and
70
- compares outcomes, not internals.
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.
@@ -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
- this.writeLines(relabelFrame(pending.lines, id));
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
- this.writeLines(lines);
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?.write(`${lines.join("\n")}\n`);
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
- args.push("--tmpfs", "/tmp");
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) & 0o777,
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 & 0o777).toString(8),
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 || !this.config.sandbox || policy.mode === "danger-full-access") {
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 = error instanceof Error ? error.message : String(error);
959
- return new FsError(`the distro file substrate is unavailable: ${message}`, "FS_IO_ERROR", { cause: error instanceof Error ? error : undefined });
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
- if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
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 { mode } = policy;
1057
- if (mode === "danger-full-access") return target;
1058
- if (mode === "read-only") {
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
- throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
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
- if (expected.kind === "replaceIfVersion") {
1301
- if (existing === undefined) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
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.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/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",
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",