dsh-plugin-wsl-env 0.1.6 → 0.1.8

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,61 @@ reference below points at that record's numbering.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.1.8] - 2026-10-02
16
+
17
+ A probe assertion for the one item the closure review had to record as unverifiable.
18
+ No runtime change.
19
+
20
+ ### Added
21
+
22
+ - `npm run probe:sandbox-shell` asserts that a command past its deadline is reported as
23
+ timed out **and leaves no process behind in the distro** (15 checks, up from 13). The
24
+ escalated path is used on purpose: a confined command also dies with its `bwrap`
25
+ parent, so only the unconfined path shows whether the subprocess service's kill
26
+ actually reaches the distro. It does — measured through `ctx.shell` here, and
27
+ directly by killing a `wsl.exe` relay and watching the distro-side `sleep` disappear
28
+ with it (1 process before, 0 after).
29
+
30
+ ### Documentation
31
+
32
+ - The review's "unverified: orphaned processes after a timeout" item is closed. The
33
+ earlier attempt could not see the process at all, and said so: the agent's own shell
34
+ ran in a separate PID namespace, where `ps` cannot see the distro's processes.
35
+
36
+
37
+ ## [0.1.7] - 2026-10-02
38
+
39
+ One correctness fix and the error-code reference the closure review asked for.
40
+
41
+ ### Fixed
42
+
43
+ - A command whose working directory could not be entered was reported as a success.
44
+ `wsl.exe --cd <missing>` does not fail: it writes a relay error to stderr, runs the
45
+ command in `/`, and exits 0 — measured directly, and visible in the shell probe's own
46
+ transcript. A build or a write could therefore act on the wrong tree while every
47
+ signal said it worked. `WslShellExecutor` now matches that relay line and fails with
48
+ the directory and the fallback. Both markers are required (the relay prefix and
49
+ `CreateProcessCommon`), so a command printing its own `chdir(...)` message cannot be
50
+ mistaken for it; the matcher also finds the line when it arrives UTF-16 encoded.
51
+ `npm run probe:sandbox-shell` asserts the behaviour end to end, and
52
+ `test/shell.test.mjs` covers the matcher, including a false-positive case.
53
+ - The shell probe's own setup ran into this on the first run: it created the workspace
54
+ root while that not-yet-existing root was its workdir, which had "worked" only because
55
+ `mkdir -p` takes an absolute path. The setup now runs from the distro home, and the
56
+ new assertion pins the failure it used to hide.
57
+
58
+ ### Documentation
59
+
60
+ - `docs/ARCHITECTURE.md` gains an **Error codes** table: what raises each `FS_*` code
61
+ and `SANDBOX_UNAVAILABLE`, what each one means, and what clears it — including that
62
+ `FS_OUTSIDE_DISTRO` cannot be lifted by a wider permission. It also documents the two
63
+ failures that come from `wsl.exe` itself: a missing, stopped or unregistered distro (a
64
+ `WSL_E_*` code on stdout, UTF-16 on some builds, with an empty stderr) and an
65
+ unenterable working directory. Both READMEs' troubleshooting tables now point at it.
66
+ - `docs/LIMITATIONS.md` separates the two workdir cases a deleted directory can produce:
67
+ the bind that bubblewrap refuses, and the directory that cannot be entered.
68
+
69
+
15
70
  ## [0.1.6] - 2026-10-02
16
71
 
17
72
  Three probes that close coverage gaps a closure review named, the documentation of a
package/CONTRIBUTING.md CHANGED
@@ -147,11 +147,11 @@ On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
147
147
  outside the session workspace is refused inside the distro and the following
148
148
  wider-permission request succeeds.
149
149
 
150
- Counts: 55 unit assertions on a bare checkout (50 plus the 5 preset-decision
151
- checks), plus 5 in `test/sandbox.test.mjs` which need the `dsh-sandbox` peer and skip
152
- themselves without it; 19 filesystem-probe assertions; 12 shell-probe checks; 9 picker
153
- checks; 6 missing-executable checks; 5 mode checks and 10 sandbox expectations plus the
154
- 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; 15 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.
155
155
 
156
156
  ## Commit and pull request conventions
157
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
 
@@ -165,6 +165,30 @@ code, because a wider permission cannot create a distro.
165
165
  Set `sandbox: false` on either provider to opt out. Commands then run unconfined,
166
166
  and `sandboxMode` returns `undefined`.
167
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
+
168
192
  ## The runtime mirror
169
193
 
170
194
  The checkout lives inside the distro. A DSH profile can only link a Windows path,
@@ -19,9 +19,11 @@ boundary; one that is not is a bug report waiting to happen.
19
19
  the file tools can only report a file-level error, because the share itself is
20
20
  gone.
21
21
  - `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
22
- bind whose source directory does not exist. If a session's workspace directory is
23
- deleted, commands fail with a runner error; the plugin does not recreate the
24
- 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.
25
27
  - New files get the distro's umask default (0644). A `chmod` from the Windows side on
26
28
  a share path is silently ignored; overwrites and edits keep the original permission
27
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
 
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.
package/lib/wsl.js CHANGED
@@ -194,6 +194,29 @@ const POSIX_LOGIN_SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "ksh93",
194
194
  * @param login - whether login-shell semantics were requested.
195
195
  * @returns the flags to place before the command string.
196
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
+
197
220
  export function shellArgs(shellPath, login) {
198
221
  const name = String(shellPath).slice(String(shellPath).lastIndexOf("/") + 1).toLowerCase();
199
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.6",
3
+ "version": "0.1.8",
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",