dsh-plugin-wsl-env 0.1.5 → 0.1.7

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,81 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.1.7] - 2026-10-02
16
+
17
+ One correctness fix and the error-code reference the closure review asked for.
18
+
19
+ ### Fixed
20
+
21
+ - A command whose working directory could not be entered was reported as a success.
22
+ `wsl.exe --cd <missing>` does not fail: it writes a relay error to stderr, runs the
23
+ command in `/`, and exits 0 — measured directly, and visible in the shell probe's own
24
+ transcript. A build or a write could therefore act on the wrong tree while every
25
+ signal said it worked. `WslShellExecutor` now matches that relay line and fails with
26
+ the directory and the fallback. Both markers are required (the relay prefix and
27
+ `CreateProcessCommon`), so a command printing its own `chdir(...)` message cannot be
28
+ mistaken for it; the matcher also finds the line when it arrives UTF-16 encoded.
29
+ `npm run probe:sandbox-shell` asserts the behaviour end to end, and
30
+ `test/shell.test.mjs` covers the matcher, including a false-positive case.
31
+ - The shell probe's own setup ran into this on the first run: it created the workspace
32
+ root while that not-yet-existing root was its workdir, which had "worked" only because
33
+ `mkdir -p` takes an absolute path. The setup now runs from the distro home, and the
34
+ new assertion pins the failure it used to hide.
35
+
36
+ ### Documentation
37
+
38
+ - `docs/ARCHITECTURE.md` gains an **Error codes** table: what raises each `FS_*` code
39
+ and `SANDBOX_UNAVAILABLE`, what each one means, and what clears it — including that
40
+ `FS_OUTSIDE_DISTRO` cannot be lifted by a wider permission. It also documents the two
41
+ failures that come from `wsl.exe` itself: a missing, stopped or unregistered distro (a
42
+ `WSL_E_*` code on stdout, UTF-16 on some builds, with an empty stderr) and an
43
+ unenterable working directory. Both READMEs' troubleshooting tables now point at it.
44
+ - `docs/LIMITATIONS.md` separates the two workdir cases a deleted directory can produce:
45
+ the bind that bubblewrap refuses, and the directory that cannot be entered.
46
+
47
+
48
+ ## [0.1.6] - 2026-10-02
49
+
50
+ Three probes that close coverage gaps a closure review named, the documentation of a
51
+ `/tmp` asymmetry, and one clearer error message. No behaviour changed otherwise.
52
+
53
+ ### Added
54
+
55
+ - `npm run probe:picker` drives the directory picker in a throwaway boot: the root
56
+ level's crumbs, the Windows home, every installed distro, the documented
57
+ `directory-unreadable` refusal for a relative path and for a directory that does not
58
+ exist, and `maxEntries` with its `truncated` flag. Nothing ran `lib/picker.js` before
59
+ this — the unit tests import only `lib/listing.js`.
60
+ - `npm run probe:missing-wsl` boots a profile whose `wslPath` cannot start and pins how
61
+ that is reported: the executable is named, `wslPath` is suggested, the failure is not
62
+ dressed up as a sandbox refusal, and no sandbox facts are claimed for a command that
63
+ never ran. The other fail-closed state — `wsl.exe` works but the distro has no
64
+ `bubblewrap` — is asserted in `test/sandbox.test.mjs`, which drives
65
+ `WslSandbox.confine` against a failing probe, because driving that end to end needs a
66
+ distro without `bubblewrap`.
67
+ - `npm run probe:mode` runs `test/probe/mode-probe.mjs`, which measures which POSIX-mode
68
+ facts survive the share. The file was cited by `docs/LIMITATIONS.md` and wired into
69
+ no script at all.
70
+ - `test/preset-choice.test.mjs` covers the session-preset decision, now that the rule
71
+ is the pure `lib/preset-choice.js`. The frame timing that applies the decision still
72
+ needs the harness, and the test says so.
73
+
74
+ ### Changed
75
+
76
+ - `runCapture` names the cause when the executable itself cannot start: the message now
77
+ points at `wslPath` and at WSL being installed, the same way it already did for a
78
+ token denied by the Windows sandbox. `npm run probe:missing-wsl` asserts it.
79
+
80
+ ### Documentation
81
+
82
+ - `docs/LIMITATIONS.md` states the `/tmp` split: a confined command's `/tmp` is a fresh
83
+ tmpfs while the file tools' fence grants the distro's *real* `/tmp`, so both sides can
84
+ write but neither sees the other's files. Hand a file between them through the session
85
+ workspace. `docs/ARCHITECTURE.md` describes the same distinction where it explains the
86
+ fence, including why the Windows temp directory is deliberately not granted to a
87
+ distro session.
88
+
89
+
15
90
  ## [0.1.5] - 2026-10-02
16
91
 
17
92
  Three defects from a design-closure review. Two are cases where a documented remedy
package/CONTRIBUTING.md CHANGED
@@ -69,6 +69,9 @@ terminal if you would rather not see the prompt.
69
69
  | `npm run probe:sandbox` | what bubblewrap confines and what it does not | no |
70
70
  | `npm run probe:sandbox-shell` | the confined executor through a real boot | yes |
71
71
  | `npm run probe:terminal` | the terminal provider | yes |
72
+ | `npm run probe:missing-wsl` | how a `wslPath` that cannot start is reported | yes |
73
+ | `npm run probe:picker` | the picker's root level, its refusals and its cap | yes |
74
+ | `npm run probe:mode` | which POSIX-mode facts survive the share | no; Windows Node |
72
75
 
73
76
  Every script above is wired in `package.json`. `npm test` runs `lint:style`,
74
77
  `test:syntax` and `test:unit` in that order. `prepublishOnly` runs `npm test` again
@@ -144,10 +147,11 @@ On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
144
147
  outside the session workspace is refused inside the distro and the following
145
148
  wider-permission request succeeds.
146
149
 
147
- Counts: 50 unit assertions on a bare checkout, plus 5 in `test/sandbox.test.mjs`
148
- which need the `dsh-sandbox` peer and skip themselves without it; 19
149
- filesystem-probe assertions; 12 shell-probe checks; 10 sandbox expectations plus
150
- the recorded escape; and 3 terminal assertions.
150
+ Counts: 57 unit assertions on a bare checkout, plus 5 in `test/sandbox.test.mjs` which
151
+ need the `dsh-sandbox` peer and skip themselves without it; 19 filesystem-probe
152
+ assertions; 13 shell-probe checks; 9 picker checks; 6 missing-executable checks; 5 mode
153
+ checks and 10 sandbox expectations plus the recorded escape; and 3 terminal
154
+ assertions.
151
155
 
152
156
  ## Commit and pull request conventions
153
157
 
package/README.md CHANGED
@@ -90,6 +90,7 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#sandbox) for the design, and [do
90
90
  | `link:\\wsl.localhost\...` leaves a broken symlink | pnpm cannot link a UNC path | link a Windows path instead; developing inside the distro needs the runtime mirror, see [Development](#development) |
91
91
  | `glob` and `grep` are slow | the Windows-side ripgrep walks the 9p share | expected; narrow the path, or use `bash` to call tools inside the distro |
92
92
  | the terminal reports `unknown` activity | shell integration only covers `bash`/`zsh` started directly on a POSIX host | close the tab to release the process; idle reclamation does not run for these terminals |
93
+ | a result names an `FS_*` code | the code says what refused it, and what clears it | see the error-code table in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#error-codes) |
93
94
 
94
95
  ## Development
95
96
 
@@ -100,6 +101,9 @@ npm run probe:sandbox # measure inside the distro what bubblewrap does an
100
101
  npm run probe # filesystem probe against a real distro (Windows + WSL only)
101
102
  npm run probe:sandbox-shell # boot a real harness and drive the confined executor
102
103
  npm run probe:terminal # open a PTY through the terminal provider
104
+ npm run probe:missing-wsl # boot a profile whose wslPath cannot start (Windows + WSL only)
105
+ npm run probe:picker # list the picker's root level, refusals and its cap (Windows + WSL only)
106
+ npm run probe:mode # which POSIX-mode facts survive the share (Windows node only)
103
107
  ```
104
108
 
105
109
  There is nothing to install first: the package has no dependencies, and every module under test imports only Node builtins. CI runs `npm test` on Node 20, 22 and 24, on Linux and Windows, and `npm run test:coverage` on Node 24.
@@ -124,9 +124,12 @@ behaviour and the error messages match a Linux host. The function
124
124
  | `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
125
125
  | `danger-full-access` | no sandbox; used for an approved wider-permission request |
126
126
 
127
- `WslFileSystem` checks writes against the same policy and the same writable list.
128
- That avoids a mismatch where `bash` could write `/tmp` but the write tool could
129
- not. The fence is complete for the model's reach: `writeText` and `editText` are
127
+ `WslFileSystem` checks writes against the same policy and the same writable list,
128
+ with one deliberate translation: for a session inside a distro the temp root is the
129
+ distro's own `/tmp`, and the Windows temp directory is *not* granted, because it lies
130
+ outside the execution world the policy describes. That avoids a mismatch where `bash`
131
+ could write `/tmp` but the write tool could not. The two `/tmp`s are still different
132
+ directories — the command's is a fresh tmpfs — see [LIMITATIONS.md](LIMITATIONS.md). The fence is complete for the model's reach: `writeText` and `editText` are
130
133
  the only mutation calls the model-facing file tools make on the filesystem seam,
131
134
  so checking those two checks everything the model can mutate. Both providers
132
135
  report their mode through `sandboxMode`. The tool layer and the Permissions
@@ -162,6 +165,30 @@ code, because a wider permission cannot create a distro.
162
165
  Set `sandbox: false` on either provider to opt out. Commands then run unconfined,
163
166
  and `sandboxMode` returns `undefined`.
164
167
 
168
+ ## Error codes
169
+
170
+ The plugin raises `FsError`s with a code the tool layer maps, and `wsl.exe`'s own
171
+ failures are named as well. A model sees the code; a reader asking what refused them
172
+ finds it here.
173
+
174
+ | Code | Raised by | Meaning | What clears it |
175
+ |---|---|---|---|
176
+ | `SANDBOX_UNAVAILABLE` | `WslSandbox.confine` | the requested mode cannot be enforced: no usable `bwrap` in the distro | install `bubblewrap` there (a failed probe is re-run, so no restart is needed), or set `sandbox: false` |
177
+ | `FS_SANDBOX_DENIED` | `WslFileSystem.checkedTarget` | the file-effect policy refused the write: `read-only`, or a target outside the writable roots | a wider permission for that one call, or a session workspace that contains the target |
178
+ | `FS_OUTSIDE_DISTRO` | `WslFileSystem.worldPath` | `restrictToDistro` is on and the path names **another distro**'s share — a configuration fence, not a sandbox decision | open a session in that distro, or set `restrictToDistro: false`. A wider permission does **not** lift it |
179
+ | `FS_NOT_OBSERVED` | `WslFileSystem.assertGuard` | a guarded create (`createIfAbsent`) targeted a file that exists and had not been read first | read the file, then overwrite with the version guard or edit it |
180
+ | `FS_STALE_VERSION` | `WslFileSystem.assertGuard` | the version the caller holds no longer matches: the file changed, or is gone | read it again and retry with the fresh version |
181
+ | `FS_IO_ERROR` | `WslFileSystem.watch` | `watch()` is refused, because a 9p share cannot be watched reliably | poll, or watch from inside the distro |
182
+ | `FS_NOT_FOUND` | the host filesystem stack | the path does not exist — the ordinary answer, passed through | — |
183
+
184
+ Two failures come from `wsl.exe` itself rather than from a code of ours, and both used
185
+ to be invisible:
186
+
187
+ | Failure | How it arrives | What the plugin does |
188
+ |---|---|---|
189
+ | the distro is missing, stopped or unregistered | a `Wsl/Service/WSL_E_*` code on **stdout**, UTF-16 on some builds, with an empty stderr and exit 255 | `WslShellExecutor` reads the code with the NULs stripped and fails with the distro name and the remedy. Deliberately not a sandbox code: a wider permission cannot create a distro |
190
+ | the working directory cannot be entered | a relay line on stderr, exit **0**, and the command runs in `/` | `WslShellExecutor` fails with the directory and the fallback, instead of reporting a success that acted on the wrong tree |
191
+
165
192
  ## The runtime mirror
166
193
 
167
194
  The checkout lives inside the distro. A DSH profile can only link a Windows path,
@@ -181,7 +208,8 @@ step with the checkout, and `npm run sync:windows` runs that script.
181
208
  | Syntax pass | [`test/syntax.mjs`](../test/syntax.mjs) | no |
182
209
  | Unit tests | `test/*.test.mjs` | no |
183
210
  | Sandbox probe | [`test/probe/sandbox.sh`](../test/probe/sandbox.sh) | no; it applies the profile arguments directly |
184
- | Harness probes | [`test/probe/run.sh`](../test/probe/run.sh), `terminal.sh`, `sandbox-shell.sh` | yes: Windows, WSL2, a mounted profile, and a linked checkout |
211
+ | Harness probes | [`test/probe/run.sh`](../test/probe/run.sh), `terminal.sh`, `sandbox-shell.sh`, `picker.sh`, `missing-wsl.sh` | yes: Windows, WSL2, a mounted profile, and a linked checkout |
212
+ | Host probes | [`test/probe/mode.sh`](../test/probe/mode.sh) | Windows Node, no harness |
185
213
  | Probe plugins | the `*-probe.mjs` and `*-probe.yml` files in `test/probe/` | loaded by the scripts above |
186
214
 
187
215
  The unit tests import only Node builtins, so they run on any platform. CI runs them
@@ -9,14 +9,21 @@ boundary; one that is not is a bug report waiting to happen.
9
9
  - **`bubblewrap` must be installed**, or both providers fail immediately. The
10
10
  usability probe re-runs after a failure, so installing bubblewrap while the app
11
11
  runs is believed on the next command; only a success is cached.
12
+ - **A confined command's `/tmp` is not the write tool's `/tmp`.** `workspace-write`
13
+ mounts a fresh tmpfs at `/tmp` for the command — that is what makes it ephemeral —
14
+ while the file tools' fence grants the distro's *real* `/tmp`. Both are writable, so
15
+ neither side is refused, but a file the write tool puts in `/tmp` is invisible to
16
+ `bash` and vice versa. Hand a file between them through the session workspace.
12
17
  - A distro that is stopped or unregistered while the app runs leaves the UNC share
13
18
  unreachable: the shell tools report `wsl.exe`'s `WSL_E_*` code with the reason, but
14
19
  the file tools can only report a file-level error, because the share itself is
15
20
  gone.
16
21
  - `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
17
- bind whose source directory does not exist. If a session's workspace directory is
18
- deleted, commands fail with a runner error; the plugin does not recreate the
19
- directory.
22
+ bind whose source directory does not exist, so a session whose workspace directory was
23
+ deleted fails with a runner error; the plugin does not recreate it. A workdir that
24
+ does not exist — a configured `cwd` pointing at a deleted directory, for instance — is
25
+ a separate case and is now reported as an error naming that directory, because
26
+ `wsl.exe` would otherwise run the command in `/` and exit 0.
20
27
  - New files get the distro's umask default (0644). A `chmod` from the Windows side on
21
28
  a share path is silently ignored; overwrites and edits keep the original permission
22
29
  bits. Run `chmod +x` inside the distro when you need an executable.
package/docs/README.zh.md CHANGED
@@ -90,6 +90,7 @@ dsh --profile wsl # 4. 启动
90
90
  | 用 `link:\\wsl.localhost\...` 安装后符号链接是坏的 | pnpm 无法链接 UNC 路径 | 改成链接 Windows 路径;在发行版内开发时用运行时镜像,见[开发](#开发) |
91
91
  | `glob`/`grep` 很慢 | Windows 侧的 ripgrep 在 9p 共享上遍历 | 属于预期,收窄搜索路径,或改用 `bash` 调用发行版内的工具 |
92
92
  | 终端活动显示 `unknown` | 官方 shell 集成只对 POSIX 主机上直接启动的 `bash`/`zsh` 生效 | 关闭标签页以释放进程;空闲回收不会对它触发 |
93
+ | 结果里出现 `FS_*` 码 | 码本身说明了是谁拒绝的、以及怎样解除 | 见 [docs/ARCHITECTURE.md](ARCHITECTURE.md#error-codes) 的错误码表 |
93
94
 
94
95
  ## 开发
95
96
 
@@ -100,6 +101,9 @@ npm run probe:sandbox # 在发行版里实测 bubblewrap 能约束什么
100
101
  npm run probe # 文件系统探针,需要真实发行版(仅 Windows + WSL)
101
102
  npm run probe:sandbox-shell # 启动真实 harness,驱动受限执行器
102
103
  npm run probe:terminal # 通过终端 provider 打开一个 PTY
104
+ npm run probe:missing-wsl # 启动一个 wslPath 无法启动的 profile(仅 Windows + WSL)
105
+ npm run probe:picker # 列出选择器的根级、拒绝路径与上限(仅 Windows + WSL)
106
+ npm run probe:mode # 哪些 POSIX 权限事实能穿过共享层(仅需 Windows node)
103
107
  ```
104
108
 
105
109
  这个包没有依赖,被测模块只 import Node 内置模块,所以不需要先安装任何东西。CI 在 Linux 和 Windows 上用 Node 20、22、24 运行 `npm test`,并在 Node 24 上运行 `npm run test:coverage`。
@@ -33,6 +33,7 @@
33
33
  import { appendFileSync } from "node:fs";
34
34
  import z from "@deepseek-ai/schemastery";
35
35
  import { isWslUnc } from "./paths.js";
36
+ import { shouldAdoptWslPreset } from "./preset-choice.js";
36
37
 
37
38
  /**
38
39
  * Set `DSH_WSL_TRACE` to a file path to record every decision this plugin makes.
@@ -106,14 +107,13 @@ export async function apply(ctx, config = {}) {
106
107
  // registry default (i.e. the operator did not ask for one). `undefined` means
107
108
  // the creating caller mounted nothing at all — the headless runner does this
108
109
  // — which is likewise "no explicit choice".
109
- if (!isWslUnc(cwd)) return;
110
110
  let fallback;
111
111
  try {
112
112
  fallback = ctx.agentPresets.defaultId;
113
113
  } catch {
114
114
  fallback = void 0;
115
115
  }
116
- if (current !== fallback && current !== void 0) return;
116
+ if (!shouldAdoptWslPreset({ cwd, current, fallback })) return;
117
117
  trace(` -> acting: fallback=${String(fallback)} isWsl=${isWslUnc(cwd)}`);
118
118
 
119
119
  // Deferred on purpose — see the module comment.
package/lib/index.js CHANGED
@@ -46,7 +46,7 @@ import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixTo
46
46
  import { WslSandbox } from "./sandbox.js";
47
47
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
48
48
  // can use them without pulling this file's executor and filesystem peers in.
49
- import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, wslErrorCode } from "./wsl.js";
49
+ import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
50
50
 
51
51
  /**
52
52
  * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
@@ -467,6 +467,27 @@ export class WslShellExecutor extends LocalBashExecutor {
467
467
  );
468
468
  }
469
469
 
470
+ /**
471
+ * Reject a command that ran in the wrong directory.
472
+ *
473
+ * `wsl.exe --cd <path>` does not fail on a missing directory: it warns on stderr,
474
+ * runs the command in `/`, and exits 0. Reporting that as success would let a write
475
+ * or a build act on the wrong tree while every signal says it worked, so it becomes
476
+ * an error naming the directory that could not be entered.
477
+ *
478
+ * @param result - the settled shell result.
479
+ * @param workdir - the Linux directory the request asked for.
480
+ * @throws Error naming the directory and the fallback that happened.
481
+ */
482
+ throwIfWorkdirMissing(result, workdir) {
483
+ const missing = workdirFailure(result?.stdout?.text, result?.stderr?.text);
484
+ if (missing === undefined) return;
485
+ throw new Error(
486
+ `wsl.exe could not enter the working directory "${missing}" inside the distro, so the command ran from "/" instead. ` +
487
+ `Create it, or point \`cwd\` (or the request's \`workdir\`) at a directory that exists.`,
488
+ );
489
+ }
490
+
470
491
  async execute(spec) {
471
492
  const distro = await this.distro();
472
493
  const shell = await this.shell();
@@ -480,6 +501,7 @@ export class WslShellExecutor extends LocalBashExecutor {
480
501
  execution,
481
502
  (result) => {
482
503
  this.throwIfDistroUnavailable(result, distro);
504
+ this.throwIfWorkdirMissing(result, resolved.workdir);
483
505
  return result;
484
506
  },
485
507
  (error) => {
@@ -512,6 +534,7 @@ export class WslShellExecutor extends LocalBashExecutor {
512
534
  );
513
535
  return WslShellExecutor.decorateResult(execution, (result) => {
514
536
  this.throwIfDistroUnavailable(result, distro);
537
+ this.throwIfWorkdirMissing(result, resolved.workdir);
515
538
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
516
539
  const { enforcement, denialSignatures, runnerFailureRules } = confined;
517
540
  // Runner failure outranks denial because the command did not run at all.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Whether a newly created session should adopt the WSL preset.
3
+ *
4
+ * The rule is deliberately narrow, and it is the whole of "a session whose workspace
5
+ * is inside a distro gets the distro environment automatically":
6
+ *
7
+ * - the workspace must be a WSL UNC path, so a Windows folder keeps its own
8
+ * environment;
9
+ * - the session must not already carry an explicit choice. `undefined` counts as
10
+ * "no choice", because a caller that mounts nothing at all (the headless runner)
11
+ * is not asking for the host environment either; anything else must equal the
12
+ * registry's default id, which is what "the operator did not pick one" looks like
13
+ * after composition.
14
+ *
15
+ * Pure, so the decision is unit-testable without a boot — the frame timing that
16
+ * applies it still needs the harness.
17
+ *
18
+ * @param {{ cwd?: string, current?: string, fallback?: string }} input - the new
19
+ * session's workspace, the preset it currently has, and the registry default.
20
+ * @returns true when the WSL preset should be selected for this session.
21
+ */
22
+ import { isWslUnc } from "./paths.js";
23
+
24
+ export function shouldAdoptWslPreset({ cwd, current, fallback } = {}) {
25
+ if (!isWslUnc(cwd)) return false;
26
+ if (current === void 0) return true;
27
+ return current === fallback;
28
+ }
package/lib/wsl.js CHANGED
@@ -40,10 +40,15 @@ export function runCapture(argv, signal) {
40
40
  // is unhelpful, so name the cause when it is recognisable.
41
41
  const detail = `${stdout ?? ""}${stderr ?? ""}`.replace(/\u0000/g, "").trim();
42
42
  const denied = /E_ACCESSDENIED/i.test(detail) || error.code === "EACCES";
43
+ const missing = error.code === "ENOENT";
43
44
  error.message = `${file} failed: ${detail || error.message}${
44
45
  denied
45
46
  ? " (WSL is unreachable from a sandboxed process: DSH's Windows ACL sandbox runs commands under a restricted low-integrity token. Compose the non-sandboxing providers.)"
46
47
  : ""
48
+ }${
49
+ missing
50
+ ? ` (the executable "${file}" could not be started: check \`wslPath\` in this row, and that WSL is installed)`
51
+ : ""
47
52
  }`;
48
53
  reject(error);
49
54
  return;
@@ -189,6 +194,29 @@ const POSIX_LOGIN_SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "ksh93",
189
194
  * @param login - whether login-shell semantics were requested.
190
195
  * @returns the flags to place before the command string.
191
196
  */
197
+ /**
198
+ * The working directory `wsl.exe` could not enter, when its relay said so.
199
+ *
200
+ * `wsl.exe --cd <path>` does not fail when `<path>` is missing. It writes a relay
201
+ * error to STDERR, runs the command in `/` anyway, and exits 0 — so a caller would see
202
+ * a successful command that ran somewhere else. The shape is matched here for the
203
+ * callers to turn into a failure.
204
+ *
205
+ * Both markers are required: the relay prefix and `CreateProcessCommon`. A command is
206
+ * free to print "chdir(...) failed" itself, and a false positive would turn a correct
207
+ * run into an error. Verified against `wsl.exe -d <distro> --cd /missing --exec sh`:
208
+ * exit 0, `hi` on stdout, this shape on stderr.
209
+ *
210
+ * @param stdout - a command's captured stdout.
211
+ * @param stderr - its captured stderr.
212
+ * @returns the directory that could not be entered, or undefined.
213
+ */
214
+ export function workdirFailure(stdout, stderr) {
215
+ const text = `${stdout ?? ""}\n${stderr ?? ""}`.replace(/\0/g, "");
216
+ const match = /<3>WSL[^\n]*CreateProcessCommon[^\n]*chdir\(([^)]*)\) failed/.exec(text);
217
+ return match?.[1];
218
+ }
219
+
192
220
  export function shellArgs(shellPath, login) {
193
221
  const name = String(shellPath).slice(String(shellPath).lastIndexOf("/") + 1).toLowerCase();
194
222
  if (!POSIX_LOGIN_SHELLS.has(name)) return ["-c"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Run a DeepSeek Harness session against a WSL distro: a ctx.shell executor that runs commands inside the distro and a ctx.fs backend over its UNC share",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -46,12 +46,15 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
49
- "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",
49
+ "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",
50
50
  "test:syntax": "node test/syntax.mjs",
51
- "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/sandbox.test.mjs",
51
+ "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",
52
52
  "lint:style": "node test/style.mjs",
53
53
  "prepublishOnly": "npm test",
54
54
  "probe": "bash test/probe/run.sh",
55
+ "probe:missing-wsl": "bash test/probe/missing-wsl.sh",
56
+ "probe:mode": "bash test/probe/mode.sh",
57
+ "probe:picker": "bash test/probe/picker.sh",
55
58
  "probe:terminal": "bash test/probe/terminal.sh",
56
59
  "probe:sandbox": "bash test/probe/sandbox.sh",
57
60
  "probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",