dsh-plugin-wsl-env 0.1.4 → 0.1.5
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 +62 -0
- package/CONTRIBUTING.md +10 -5
- package/README.md +2 -2
- package/docs/ARCHITECTURE.md +16 -3
- package/docs/CONFIGURATION.md +6 -2
- package/docs/LIMITATIONS.md +7 -1
- package/docs/README.zh.md +2 -2
- package/lib/index.js +75 -14
- package/lib/paths.js +9 -4
- package/lib/probe-cache.js +48 -0
- package/lib/sandbox.js +19 -18
- package/lib/wsl.js +19 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,68 @@ reference below points at that record's numbering.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.1.5] - 2026-10-02
|
|
16
|
+
|
|
17
|
+
Three defects from a design-closure review. Two are cases where a documented remedy
|
|
18
|
+
could not work, and one where the cause of a failure was invisible.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A failed sandbox probe was remembered for the process lifetime, so installing
|
|
23
|
+
`bubblewrap` after the app had started changed nothing until a restart — while the
|
|
24
|
+
error told the user to install exactly that. Only a success is cached now; a failure
|
|
25
|
+
is re-probed on the next command, and concurrent first commands share one probe. The
|
|
26
|
+
policy lives in [`lib/probe-cache.js`](lib/probe-cache.js), which is pure, so the
|
|
27
|
+
regression is pinned on every platform including CI
|
|
28
|
+
([`test/probe-cache.test.mjs`](test/probe-cache.test.mjs)).
|
|
29
|
+
- `restrictToDistro` refused another distro's share with `FS_SANDBOX_DENIED`. The
|
|
30
|
+
file-tool layer turns that code into a denial marker plus an escalation offer
|
|
31
|
+
(`mapError` in `dsh-tool-fs`), and no wider permission can lift this fence: it is a
|
|
32
|
+
configuration choice, not a sandbox decision. It now reports `FS_OUTSIDE_DISTRO`,
|
|
33
|
+
which the tool layer passes through unchanged, and the message says what to do.
|
|
34
|
+
- A command that never started because the distro was missing, stopped or
|
|
35
|
+
unregistered reached the model as a bare non-zero exit. `wsl.exe` writes
|
|
36
|
+
`Wsl/Service/WSL_E_DISTRO_NOT_FOUND` to **stdout** — UTF-16LE on some builds — with
|
|
37
|
+
an empty stderr, so every signature in the plugin missed it. `WslShellExecutor` now
|
|
38
|
+
reads that code (NUL-stripped, so either encoding is found), pins `WSL_UTF8` on its
|
|
39
|
+
own spawn so the text is readable, and fails with the distro name and the remedy.
|
|
40
|
+
Deliberately not a sandbox code: a wider permission cannot create a distro.
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- Two statements were wrong and are corrected against measurement. `read-only` does
|
|
45
|
+
not leave `/dev/null` as the only writable path: `bwrap --dev /dev` mounts a fresh
|
|
46
|
+
`/dev`, so `/dev/shm` is writable scratch and only `/tmp` stays read-only. And
|
|
47
|
+
`restrictToDistro` does not refuse `/mnt/c`, which is a directory *inside* the
|
|
48
|
+
distro — it refuses another distro's share.
|
|
49
|
+
- The shell row's inherited output budgets (`maxOutputBytes`, `maxSpillBytes`,
|
|
50
|
+
`graceMs`) are listed in [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md); the
|
|
51
|
+
fail-closed bullet in [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md) matches the
|
|
52
|
+
re-probing behaviour, and what the file tools do when a distro stops mid-session is
|
|
53
|
+
stated there too.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- `test:unit` gained `test/probe-cache.test.mjs` and `test/sandbox.test.mjs`;
|
|
58
|
+
`test:coverage` gained the pure one. The sandbox test skips itself where the
|
|
59
|
+
`dsh-sandbox` peer is absent, which is the CI case, and
|
|
60
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md) explains when a test belongs in which list.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- A shell request that names its workdir only **relatively** now lands on the
|
|
66
|
+
distro user's home, joined under it, when no `cwd` is configured — instead of
|
|
67
|
+
riding on the parent's `process.cwd()` fallback, a Windows directory no
|
|
68
|
+
`wsl.exe --cd` accepts. The empty-workdir case already defaulted to the home;
|
|
69
|
+
the relative tail was the one shape the marker missed. Absolute workdirs in
|
|
70
|
+
any coordinate system are unchanged.
|
|
71
|
+
- A Windows UNC path outside the WSL share (`\\server\share\...`) is now
|
|
72
|
+
treated as the world path it already is, instead of being joined onto the
|
|
73
|
+
default workdir as if it were relative — which named a file that existed
|
|
74
|
+
nowhere. It also no longer triggers a `wsl.exe` home query it can never need:
|
|
75
|
+
`isRelativeWorldPath` now reports every UNC form, WSL or not, as absolute.
|
|
76
|
+
|
|
15
77
|
## [0.1.4] - 2026-10-02
|
|
16
78
|
|
|
17
79
|
Documentation only: a shorter README, and the reference documents it links to.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -92,9 +92,12 @@ Choose the layer by what the test needs.
|
|
|
92
92
|
| Unit test | `test/*.test.mjs` | pure logic: path translation, listing, shell argument building, terminal helpers |
|
|
93
93
|
| Probe | `test/probe/` | behaviour that needs a real distro, a real profile, or bubblewrap |
|
|
94
94
|
|
|
95
|
-
Unit tests run under `node --test
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
Unit tests run under `node --test`. A test that imports only this repository goes in
|
|
96
|
+
both the `test:unit` and the `test:coverage` lists in `package.json`, which name
|
|
97
|
+
their files explicitly. A test that needs a DSH peer — `test/sandbox.test.mjs`
|
|
98
|
+
imports `lib/sandbox.js`, which imports `@deepseek-ai/dsh-sandbox` — goes in
|
|
99
|
+
`test:unit` only and must skip itself when the peer is missing, because CI installs
|
|
100
|
+
nothing: try the import, print a `SKIP` line, exit 0.
|
|
98
101
|
|
|
99
102
|
Probes are shell scripts that mount a throwaway profile, plus the small plugin
|
|
100
103
|
files they load. Those plugin files are the `*-probe.mjs` and `*-probe.yml` files
|
|
@@ -141,8 +144,10 @@ On Windows 11 + WSL2 (Ubuntu 26.04) the following has been verified:
|
|
|
141
144
|
outside the session workspace is refused inside the distro and the following
|
|
142
145
|
wider-permission request succeeds.
|
|
143
146
|
|
|
144
|
-
Counts:
|
|
145
|
-
|
|
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.
|
|
146
151
|
|
|
147
152
|
## Commit and pull request conventions
|
|
148
153
|
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Override a row by id in `$DSH_HOME/profiles/<name>/cordis.patch.yml`. The keys w
|
|
|
54
54
|
| `wsl-shell` | `distro` | `''` | distro name; empty means WSL's default distro |
|
|
55
55
|
| | `sandbox` | `true` | confine commands with `bubblewrap`; `false` disables the sandbox |
|
|
56
56
|
| `wsl-fs` | `distro` | `''` | as above |
|
|
57
|
-
| | `restrictToDistro` | `true` | refuse
|
|
57
|
+
| | `restrictToDistro` | `true` | refuse a path in **another distro**'s share; `/mnt/c` is inside this distro and is not affected. Refused with `FS_OUTSIDE_DISTRO`, which is not a sandbox denial and cannot be lifted by wider permissions |
|
|
58
58
|
| | `sandbox` | `true` | check `writeText` and `editText` against the policy |
|
|
59
59
|
| `directory-picker-wsl` | `includeHostHome` | `true` | also list the Windows home directory |
|
|
60
60
|
| `subprocess-wsl` | `distro` | `''` | which distro the GUI terminal opens in |
|
|
@@ -67,7 +67,7 @@ Commands are confined by `bubblewrap` inside the distro, and file writes are che
|
|
|
67
67
|
|
|
68
68
|
| Mode | What a command inside the distro can do |
|
|
69
69
|
|---|---|
|
|
70
|
-
| `read-only` | read the whole distro;
|
|
70
|
+
| `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
|
|
71
71
|
| `workspace-write` | the above, plus the session workspace is writable and `/tmp` is a temporary mount |
|
|
72
72
|
| `danger-full-access` | no sandbox; used for an approved wider-permission request |
|
|
73
73
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -120,14 +120,17 @@ behaviour and the error messages match a Linux host. The function
|
|
|
120
120
|
|
|
121
121
|
| Mode | What a command inside the distro can do |
|
|
122
122
|
|---|---|
|
|
123
|
-
| `read-only` | read the whole distro;
|
|
123
|
+
| `read-only` | read the whole distro; a fresh `/dev` is mounted writable, so `/dev/null` and `/dev/shm` work, and nothing else does |
|
|
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
127
|
`WslFileSystem` checks writes against the same policy and the same writable list.
|
|
128
128
|
That avoids a mismatch where `bash` could write `/tmp` but the write tool could
|
|
129
|
-
not.
|
|
130
|
-
|
|
129
|
+
not. The fence is complete for the model's reach: `writeText` and `editText` are
|
|
130
|
+
the only mutation calls the model-facing file tools make on the filesystem seam,
|
|
131
|
+
so checking those two checks everything the model can mutate. Both providers
|
|
132
|
+
report their mode through `sandboxMode`. The tool layer and the Permissions
|
|
133
|
+
selector read that value.
|
|
131
134
|
|
|
132
135
|
### Reported enforcement
|
|
133
136
|
|
|
@@ -146,6 +149,16 @@ throws `SandboxUnavailableError`, and every confined command reports
|
|
|
146
149
|
`read-only file system` is classified as a denial, so the tool layer can offer the
|
|
147
150
|
wider-permission path.
|
|
148
151
|
|
|
152
|
+
A failed probe is **not** remembered: only a success is cached for the process
|
|
153
|
+
lifetime, so a `bubblewrap` installed while the app runs is believed on the next
|
|
154
|
+
command. See [`lib/probe-cache.js`](../lib/probe-cache.js).
|
|
155
|
+
|
|
156
|
+
A command that never started because the distro itself is missing, stopped or
|
|
157
|
+
unregistered is reported separately: `wsl.exe` puts a `WSL_E_*` code on stdout with
|
|
158
|
+
an empty stderr, and `WslShellExecutor` turns that into an error naming the distro
|
|
159
|
+
and the remedy instead of a bare non-zero exit. It is deliberately not a sandbox
|
|
160
|
+
code, because a wider permission cannot create a distro.
|
|
161
|
+
|
|
149
162
|
Set `sandbox: false` on either provider to opt out. Commands then run unconfined,
|
|
150
163
|
and `sandboxMode` returns `undefined`.
|
|
151
164
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -18,7 +18,7 @@ are schema defaults, listed because they are the ones worth knowing.
|
|
|
18
18
|
| | `cwd` | `''` | default working directory; empty means the distro user's home |
|
|
19
19
|
| | `timeoutMs` / `maxTimeoutMs` | `120000` / `600000` *(shipped)* | per-call time limit, and the ceiling a call may ask for |
|
|
20
20
|
| `wsl-fs` | `distro` | `''` *(shipped)* | as above |
|
|
21
|
-
| | `restrictToDistro` | `true` *(shipped)* | refuse
|
|
21
|
+
| | `restrictToDistro` | `true` *(shipped)* | refuse a path in **another distro**'s share. `/mnt/c` is a directory *inside* the pinned distro, so it is not affected. The refusal carries `FS_OUTSIDE_DISTRO`; that is not a sandbox decision, so the file tools do not offer a wider permission for it. Set `false` to allow another distro's share |
|
|
22
22
|
| | `sandbox` | `true` | check `writeText` and `editText` against the policy |
|
|
23
23
|
| | `resolveSymlinks` | `true` | follow Linux symlinks that the share cannot traverse, such as `/etc/os-release` and `/bin` |
|
|
24
24
|
| | `cwd` | `''` | base directory for relative paths; empty means the distro user's home |
|
|
@@ -31,7 +31,11 @@ are schema defaults, listed because they are the ones worth knowing.
|
|
|
31
31
|
|
|
32
32
|
You can override the other keys in the same way; they keep their defaults. Those are
|
|
33
33
|
`wslPath`, `hostCwd` and `forwardEnv` on the shell and terminal rows,
|
|
34
|
-
`diffBasisMaxBytes` on `wsl-fs`, and `distroCacheMs` on the picker.
|
|
34
|
+
`diffBasisMaxBytes` on `wsl-fs`, and `distroCacheMs` on the picker. The shell row
|
|
35
|
+
also inherits the shipped output budgets — `maxOutputBytes` (64000 bytes per
|
|
36
|
+
stream before the rest spills to a temporary file), `maxSpillBytes` (67108864) and
|
|
37
|
+
`graceMs` (3000, the SIGTERM-to-SIGKILL grace) — from `dsh-bash-local`, which
|
|
38
|
+
documents them in full.
|
|
35
39
|
|
|
36
40
|
## Which distro is used
|
|
37
41
|
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -6,7 +6,13 @@ boundary; one that is not is a bug report waiting to happen.
|
|
|
6
6
|
- **The sandbox does not govern WSL interop.** A confined command can still run a
|
|
7
7
|
Windows program, which escapes the Linux-side boundary. The plugin reports this as
|
|
8
8
|
`enforcement: partial`. See [ARCHITECTURE.md](ARCHITECTURE.md#reported-enforcement).
|
|
9
|
-
- **`bubblewrap` must be installed**, or both providers fail immediately.
|
|
9
|
+
- **`bubblewrap` must be installed**, or both providers fail immediately. The
|
|
10
|
+
usability probe re-runs after a failure, so installing bubblewrap while the app
|
|
11
|
+
runs is believed on the next command; only a success is cached.
|
|
12
|
+
- A distro that is stopped or unregistered while the app runs leaves the UNC share
|
|
13
|
+
unreachable: the shell tools report `wsl.exe`'s `WSL_E_*` code with the reason, but
|
|
14
|
+
the file tools can only report a file-level error, because the share itself is
|
|
15
|
+
gone.
|
|
10
16
|
- `workspace-write` binds the workspace root as writable, and bubblewrap refuses a
|
|
11
17
|
bind whose source directory does not exist. If a session's workspace directory is
|
|
12
18
|
deleted, commands fail with a runner error; the plugin does not recreate the
|
package/docs/README.zh.md
CHANGED
|
@@ -54,7 +54,7 @@ dsh --profile wsl # 4. 启动
|
|
|
54
54
|
| `wsl-shell` | `distro` | `''` | 发行版名;空表示用 WSL 的默认发行版 |
|
|
55
55
|
| | `sandbox` | `true` | 用 `bubblewrap` 约束命令;`false` 表示不用沙箱 |
|
|
56
56
|
| `wsl-fs` | `distro` | `''` | 同上 |
|
|
57
|
-
| | `restrictToDistro` | `true` |
|
|
57
|
+
| | `restrictToDistro` | `true` | 拒绝指向**其它发行版**共享的路径;`/mnt/c` 属于本发行版内部,不受影响。拒绝码为 `FS_OUTSIDE_DISTRO`,它不是沙箱拒绝,放宽权限也无法解除 |
|
|
58
58
|
| | `sandbox` | `true` | 写入时按策略检查 `writeText` 和 `editText` |
|
|
59
59
|
| `directory-picker-wsl` | `includeHostHome` | `true` | 同时列出 Windows 家目录 |
|
|
60
60
|
| `subprocess-wsl` | `distro` | `''` | GUI 终端开在哪个发行版 |
|
|
@@ -67,7 +67,7 @@ dsh --profile wsl # 4. 启动
|
|
|
67
67
|
|
|
68
68
|
| 模式 | 发行版里的命令可以做什么 |
|
|
69
69
|
|---|---|
|
|
70
|
-
| `read-only` |
|
|
70
|
+
| `read-only` | 整个发行版只读;`/dev` 是新建的可写挂载(`/dev/null` 与 `/dev/shm` 可用),其余不可写 |
|
|
71
71
|
| `workspace-write` | 在上一行基础上,把会话工作区绑定为可写,并把 `/tmp` 挂成临时目录 |
|
|
72
72
|
| `danger-full-access` | 不加沙箱;用于批准后的放宽权限请求 |
|
|
73
73
|
|
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 } from "./wsl.js";
|
|
49
|
+
import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, wslErrorCode } from "./wsl.js";
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Every model shell call gets a rebuilt managed `DSH_*` namespace (see
|
|
@@ -319,6 +319,11 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
319
319
|
cwd: this.hostCwd(),
|
|
320
320
|
env: {
|
|
321
321
|
...base.env,
|
|
322
|
+
// wsl.exe writes its own diagnostics as UTF-16LE unless this is set, and a
|
|
323
|
+
// UTF-16 message read as UTF-8 is mojibake — including the `WSL_E_*` codes
|
|
324
|
+
// that say why a command could not start. The distro-missing check below
|
|
325
|
+
// reads those codes, so the encoding has to be pinned here.
|
|
326
|
+
WSL_UTF8: "1",
|
|
322
327
|
// WSL imports only the names listed in WSLENV. The managed DSH_* facts are
|
|
323
328
|
// included by prefix so every contributed variable rides along. PATH is deliberately
|
|
324
329
|
// never listed: putting it here would replace the distro's PATH with
|
|
@@ -336,9 +341,14 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
336
341
|
*
|
|
337
342
|
* The seam requires this step to be synchronous — the tool layer calls
|
|
338
343
|
* `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
|
|
339
|
-
* neither the home nor a confinement decision can be made here.
|
|
340
|
-
*
|
|
341
|
-
*
|
|
344
|
+
* neither the home nor a confinement decision can be made here. Two request
|
|
345
|
+
* shapes default to the home when no `cwd` is configured: one that names no
|
|
346
|
+
* `workdir` at all, and one that names it only RELATIVELY — the parent
|
|
347
|
+
* resolves the first against `process.cwd()` (a Windows directory) and has
|
|
348
|
+
* nothing of its own to join the second onto. Both are stamped as the empty
|
|
349
|
+
* marker, with a relative tail carried verbatim in `relativeWorkdir`;
|
|
350
|
+
* {@link withDefaultWorkdir} resolves the pair against the home. The policy
|
|
351
|
+
* travels on the spec to {@link execute}, which awaits the wrap.
|
|
342
352
|
*
|
|
343
353
|
* @param request - the caller's request.
|
|
344
354
|
* @returns the fully-resolved spec.
|
|
@@ -346,27 +356,33 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
346
356
|
resolve(request) {
|
|
347
357
|
const spec = super.resolve(request);
|
|
348
358
|
const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
|
|
349
|
-
const
|
|
359
|
+
const workdir = typeof request?.workdir === "string" ? request.workdir : undefined;
|
|
360
|
+
const needsHome =
|
|
361
|
+
configuredCwd(this.config).length === 0 && (workdir === undefined || isRelativeWorldPath(workdir));
|
|
350
362
|
if (!needsHome && sandboxPolicy === undefined) return spec;
|
|
351
363
|
return {
|
|
352
364
|
...spec,
|
|
353
|
-
...(needsHome ? { workdir: "" } : {}),
|
|
365
|
+
...(needsHome ? { workdir: "", relativeWorkdir: workdir } : {}),
|
|
354
366
|
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
355
367
|
};
|
|
356
368
|
}
|
|
357
369
|
|
|
358
370
|
/**
|
|
359
|
-
* The spec to execute, with
|
|
360
|
-
* home
|
|
361
|
-
*
|
|
362
|
-
*
|
|
371
|
+
* The spec to execute, with the home marker resolved against the distro
|
|
372
|
+
* user's home: `workdir: ""` becomes the home itself, and a relative tail
|
|
373
|
+
* carried by {@link WslShellExecutor.resolve} is joined under it. That is one
|
|
374
|
+
* `sh -c 'printf %s "$HOME"'` per provider instance, cached, and it happens
|
|
375
|
+
* only for a request that defaulted — an absolute workdir never reaches here.
|
|
363
376
|
*
|
|
364
377
|
* @param spec - the resolved spec.
|
|
365
378
|
* @returns the spec to execute.
|
|
366
379
|
*/
|
|
367
380
|
async withDefaultWorkdir(spec) {
|
|
368
|
-
|
|
369
|
-
|
|
381
|
+
const tail = typeof spec?.relativeWorkdir === "string" ? spec.relativeWorkdir : undefined;
|
|
382
|
+
if (String(spec?.workdir ?? "").trim().length > 0 && tail === undefined) return spec;
|
|
383
|
+
const home = await distroHome(this);
|
|
384
|
+
if (tail === undefined || tail.length === 0) return { ...spec, workdir: home };
|
|
385
|
+
return { ...spec, workdir: toLinuxPath(tail, { distro: await this.distro(), cwd: home }) };
|
|
370
386
|
}
|
|
371
387
|
|
|
372
388
|
/**
|
|
@@ -426,13 +442,50 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
426
442
|
* @param spec - resolved execution settings.
|
|
427
443
|
* @returns the live shell process handle.
|
|
428
444
|
*/
|
|
445
|
+
/**
|
|
446
|
+
* Reject a settled command that failed because the distro itself is missing,
|
|
447
|
+
* stopped or unregistered.
|
|
448
|
+
*
|
|
449
|
+
* `wsl.exe` exits non-zero for that with an EMPTY stderr and its diagnostic on
|
|
450
|
+
* stdout, so nothing else here can see it: the denial signatures read stderr, and
|
|
451
|
+
* the result would otherwise reach the model as a bare non-zero code with no
|
|
452
|
+
* cause. The message names the remedy and says plainly that a wider permission
|
|
453
|
+
* cannot help — a sandbox flag does not create a distro.
|
|
454
|
+
*
|
|
455
|
+
* @param result - the settled shell result.
|
|
456
|
+
* @param distro - the distro the command asked for.
|
|
457
|
+
* @throws Error carrying the `WSL_E_*` code and the remedy.
|
|
458
|
+
*/
|
|
459
|
+
throwIfDistroUnavailable(result, distro) {
|
|
460
|
+
if (result?.exitCode === 0) return;
|
|
461
|
+
const code = wslErrorCode(result?.stdout?.text, result?.stderr?.text);
|
|
462
|
+
if (code === undefined) return;
|
|
463
|
+
throw new Error(
|
|
464
|
+
`wsl.exe reported ${code} for distro "${distro}": it is not installed, or not running. ` +
|
|
465
|
+
`Start it with \`wsl.exe -d ${distro}\`, check \`wsl.exe -l -v\`, or point \`distro\` at another one. ` +
|
|
466
|
+
`This is not a sandbox denial, so running with wider permissions will not help.`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
429
470
|
async execute(spec) {
|
|
430
471
|
const distro = await this.distro();
|
|
431
472
|
const shell = await this.shell();
|
|
432
473
|
const resolved = await this.withDefaultWorkdir(spec);
|
|
433
474
|
const policy = resolved.sandboxPolicy;
|
|
434
475
|
if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
|
|
435
|
-
|
|
476
|
+
// `executeArgv` resolves to the execution handle, so it has to be awaited
|
|
477
|
+
// before it can be decorated — the confined branch below does the same.
|
|
478
|
+
const execution = await this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
|
|
479
|
+
return WslShellExecutor.decorateResult(
|
|
480
|
+
execution,
|
|
481
|
+
(result) => {
|
|
482
|
+
this.throwIfDistroUnavailable(result, distro);
|
|
483
|
+
return result;
|
|
484
|
+
},
|
|
485
|
+
(error) => {
|
|
486
|
+
throw error;
|
|
487
|
+
},
|
|
488
|
+
);
|
|
436
489
|
}
|
|
437
490
|
const mode = policy.mode;
|
|
438
491
|
let confined;
|
|
@@ -458,6 +511,7 @@ export class WslShellExecutor extends LocalBashExecutor {
|
|
|
458
511
|
},
|
|
459
512
|
);
|
|
460
513
|
return WslShellExecutor.decorateResult(execution, (result) => {
|
|
514
|
+
this.throwIfDistroUnavailable(result, distro);
|
|
461
515
|
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
|
|
462
516
|
const { enforcement, denialSignatures, runnerFailureRules } = confined;
|
|
463
517
|
// Runner failure outranks denial because the command did not run at all.
|
|
@@ -646,7 +700,14 @@ export class WslFileSystem extends LocalFileSystem {
|
|
|
646
700
|
}
|
|
647
701
|
const world = toWorldPath(path, { distro, cwd: base });
|
|
648
702
|
if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
|
|
649
|
-
|
|
703
|
+
// Deliberately NOT `FS_SANDBOX_DENIED`. The file-tool layer turns that code
|
|
704
|
+
// into a denial marker plus an escalation offer, and no wider permission can
|
|
705
|
+
// lift this fence: it is a configuration choice, not a sandbox decision.
|
|
706
|
+
throw new FsError(
|
|
707
|
+
`"${path}" names a path in another WSL distro, and this profile pins its filesystem to "${distro}" ` +
|
|
708
|
+
`(restrictToDistro: true). Open a session in that distro, or set restrictToDistro: false.`,
|
|
709
|
+
"FS_OUTSIDE_DISTRO",
|
|
710
|
+
);
|
|
650
711
|
}
|
|
651
712
|
return world;
|
|
652
713
|
}
|
package/lib/paths.js
CHANGED
|
@@ -46,16 +46,17 @@ export function isPosixAbsolute(value) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Whether a path
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* Whether a path names a location only relative to a base. The fs provider
|
|
50
|
+
* consults its default workdir exactly for this input; every absolute form —
|
|
51
|
+
* POSIX, drive, WSL UNC, or any other UNC share — is a location in its own
|
|
52
|
+
* right and short-circuits before a base is ever needed.
|
|
52
53
|
* @param value - candidate path.
|
|
53
54
|
* @returns true when the path names no absolute location.
|
|
54
55
|
*/
|
|
55
56
|
export function isRelativeWorldPath(value) {
|
|
56
57
|
const text = String(value ?? "");
|
|
57
58
|
if (text.length === 0) return false;
|
|
58
|
-
return !isWslUnc(text) && !isPosixAbsolute(text) && !DRIVE_RE.test(text);
|
|
59
|
+
return !isWslUnc(text) && !isPosixAbsolute(text) && !DRIVE_RE.test(text) && !text.startsWith("\\");
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** Drop trailing separators, keeping a bare root (`C:\`, `\\wsl.localhost\x\`) meaningful. */
|
|
@@ -215,6 +216,10 @@ export function toWorldPath(value, options) {
|
|
|
215
216
|
if (isWslUnc(text)) return toWindowsSlashes(text);
|
|
216
217
|
if (isPosixAbsolute(text)) return posixToUnc(options?.distro ?? "Ubuntu", text);
|
|
217
218
|
if (DRIVE_RE.test(text)) return toWindowsSlashes(text);
|
|
219
|
+
// Any other UNC share is already a world path in its own right — the host
|
|
220
|
+
// can open it directly, and joining it onto a base would name a file that
|
|
221
|
+
// exists nowhere.
|
|
222
|
+
if (text.startsWith("\\")) return toWindowsSlashes(text);
|
|
218
223
|
const base = options?.cwd ?? process.cwd();
|
|
219
224
|
const baseWorld = toWorldPath(base, { distro: options?.distro });
|
|
220
225
|
return `${baseWorld.replace(/[\\/]+$/, "")}\\${toWindowsSlashes(text).replace(/^\.\\/, "")}`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caching policy for a functional probe: which verdicts may be remembered.
|
|
3
|
+
*
|
|
4
|
+
* A SUCCESS is durable — if `bwrap` created a profile a minute ago it will create
|
|
5
|
+
* one now. A FAILURE is not: it describes the machine as it is at that moment, and
|
|
6
|
+
* the common case is a package that is not installed *yet*. Remembering a failure
|
|
7
|
+
* would make the documented remedy (`sudo apt install bubblewrap`) silently
|
|
8
|
+
* ineffective until the app restarts, while the error message tells the user to
|
|
9
|
+
* install exactly that. So a failure is dropped and the next call probes again.
|
|
10
|
+
*
|
|
11
|
+
* Probes are I/O, and concurrent first calls would otherwise each pay for one.
|
|
12
|
+
* Storing the in-flight promise makes them share a single probe.
|
|
13
|
+
*
|
|
14
|
+
* Pure by construction — no Node API, no clock — so the policy is unit-testable
|
|
15
|
+
* without the peers `lib/sandbox.js` needs.
|
|
16
|
+
*
|
|
17
|
+
* @param {() => Promise<boolean>|boolean} probe - the probe to run on a miss. A
|
|
18
|
+
* throw is a failed probe, never a propagated error.
|
|
19
|
+
* @returns {{ run: (key: string, probe: () => Promise<boolean>|boolean) => Promise<boolean>, size: number }}
|
|
20
|
+
*/
|
|
21
|
+
export function createProbeCache() {
|
|
22
|
+
/** @type {Map<string, true|Promise<boolean>>} */
|
|
23
|
+
const verdicts = new Map();
|
|
24
|
+
return {
|
|
25
|
+
async run(key, probe) {
|
|
26
|
+
const cached = verdicts.get(key);
|
|
27
|
+
if (cached !== undefined) return cached;
|
|
28
|
+
|
|
29
|
+
const pending = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
return await probe();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
verdicts.set(key, pending);
|
|
37
|
+
|
|
38
|
+
const verdict = await pending;
|
|
39
|
+
if (verdict) verdicts.set(key, true);
|
|
40
|
+
else verdicts.delete(key);
|
|
41
|
+
return verdict;
|
|
42
|
+
},
|
|
43
|
+
/** Remembered successes, for tests and diagnostics. */
|
|
44
|
+
get size() {
|
|
45
|
+
return verdicts.size;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/lib/sandbox.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
32
|
+
import { createProbeCache } from "./probe-cache.js";
|
|
32
33
|
import { toLinuxPath } from "./paths.js";
|
|
33
34
|
import { runCapture } from "./wsl.js";
|
|
34
35
|
|
|
@@ -59,8 +60,12 @@ const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
|
|
|
59
60
|
*/
|
|
60
61
|
const ENFORCEMENT = "partial";
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
65
|
+
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
66
|
+
* where it is unit-testable without the peers this module needs.
|
|
67
|
+
*/
|
|
68
|
+
const probeVerdicts = createProbeCache();
|
|
64
69
|
|
|
65
70
|
/**
|
|
66
71
|
* Build the bwrap profile arguments for one file-effect policy, exactly as
|
|
@@ -101,12 +106,15 @@ export class WslSandbox {
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
/**
|
|
104
|
-
* Whether `bwrap` can create a profile inside this distro
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
109
|
+
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
110
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
111
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
112
|
+
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
113
|
+
* than silently run unconfined.
|
|
114
|
+
*
|
|
115
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
116
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
117
|
+
* command.
|
|
110
118
|
*
|
|
111
119
|
* @param distro - the distro to probe.
|
|
112
120
|
* @param signal - optional cancellation.
|
|
@@ -114,18 +122,11 @@ export class WslSandbox {
|
|
|
114
122
|
*/
|
|
115
123
|
async usable(distro, signal) {
|
|
116
124
|
const key = `${this.wslPath}\u0000${distro}`;
|
|
117
|
-
|
|
118
|
-
if (cached !== undefined) return cached;
|
|
119
|
-
let verdict = false;
|
|
120
|
-
try {
|
|
125
|
+
return probeVerdicts.run(key, async () => {
|
|
121
126
|
const profile = bwrapProfileArgs({ mode: "read-only", workspaceRoot: "/" });
|
|
122
127
|
await runCapture([this.wslPath, "-d", distro, "--exec", "bwrap", ...profile, "--", "true"], signal);
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
verdict = false;
|
|
126
|
-
}
|
|
127
|
-
probeVerdicts.set(key, verdict);
|
|
128
|
-
return verdict;
|
|
128
|
+
return true;
|
|
129
|
+
});
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/**
|
package/lib/wsl.js
CHANGED
|
@@ -285,6 +285,25 @@ export function wslEnvValue(names) {
|
|
|
285
285
|
* @param options - the `wsl.exe` path and optional cancellation.
|
|
286
286
|
* @returns an absolute path to the shell inside the distro.
|
|
287
287
|
*/
|
|
288
|
+
/**
|
|
289
|
+
* The `WSL_E_*` code inside `wsl.exe`'s own output, when it reported one.
|
|
290
|
+
*
|
|
291
|
+
* wsl.exe writes these diagnostics to STDOUT, and a build may encode them as
|
|
292
|
+
* UTF-16LE — a NUL byte between every ASCII character. Read as UTF-8 that leaves
|
|
293
|
+
* the code spelled with NULs in the middle, so the search strips them first and
|
|
294
|
+
* finds the code under either encoding. Verified against a missing distro:
|
|
295
|
+
* `wsl.exe -d nosuchdistro --exec …` exits 255 with an empty stderr and
|
|
296
|
+
* `Wsl/Service/WSL_E_DISTRO_NOT_FOUND` on stdout.
|
|
297
|
+
*
|
|
298
|
+
* @param stdout - a command's captured stdout.
|
|
299
|
+
* @param stderr - its captured stderr.
|
|
300
|
+
* @returns the code, for example `WSL_E_DISTRO_NOT_FOUND`, or undefined.
|
|
301
|
+
*/
|
|
302
|
+
export function wslErrorCode(stdout, stderr) {
|
|
303
|
+
const text = `${stdout ?? ""}\n${stderr ?? ""}`.replace(/\0/g, "");
|
|
304
|
+
return /WSL_E_[A-Z0-9_]+/.exec(text)?.[0];
|
|
305
|
+
}
|
|
306
|
+
|
|
288
307
|
export async function defaultShell(distro, options = {}) {
|
|
289
308
|
const wslPath = options.wslPath ?? DEFAULT_WSL_PATH;
|
|
290
309
|
const ask = async (script) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-wsl-env",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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,9 +46,9 @@
|
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
52
52
|
"lint:style": "node test/style.mjs",
|
|
53
53
|
"prepublishOnly": "npm test",
|
|
54
54
|
"probe": "bash test/probe/run.sh",
|