dsh-plugin-wsl-env 0.5.0 → 0.7.2
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 +256 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +9 -6
- package/agent/wsl-agent.sh +41 -8
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +36 -22
- package/docs/CONFIGURATION.md +3 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -0
- package/docs/README.zh.md +10 -7
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +92 -306
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/sandbox-core.js +157 -0
- package/lib/sandbox.js +11 -131
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/watcher.js +63 -12
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +24 -41
- package/package.json +23 -3
- package/lib/fs-publish.js +0 -42
package/lib/picker.js
CHANGED
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
* the entry point rather than something you must know a UNC path for;
|
|
32
32
|
* - the breadcrumb "WSL" row jumps back to that distro list;
|
|
33
33
|
* - the dialog's Home affordance targets the preferred distro's Linux `$HOME`;
|
|
34
|
-
* - every other level is
|
|
35
|
-
*
|
|
34
|
+
* - every other level inside a distro is listed BY THE DISTRO — the resident
|
|
35
|
+
* agent's `ls` when it is up, a one-shot `wsl.exe --exec ls` when it is
|
|
36
|
+
* out — because the host's own listing used to walk the 9p share in its
|
|
37
|
+
* slow direction, one metadata round trip per entry; a Windows path keeps
|
|
38
|
+
* the host's own walk, which is native there.
|
|
36
39
|
*
|
|
37
40
|
* Because the client surface is unchanged, this row composes with the **shipped**
|
|
38
41
|
* `@deepseek-ai/dsh-client-ui-directory-picker-browse` dialog: no client code,
|
|
@@ -46,9 +49,11 @@ import { homedir } from "node:os";
|
|
|
46
49
|
import { join, resolve } from "node:path";
|
|
47
50
|
import z from "@deepseek-ai/schemastery";
|
|
48
51
|
import { DirectoryPicker, DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker";
|
|
49
|
-
import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot } from "./paths.js";
|
|
50
|
-
import { breadcrumbs, boundedInsert, fullyQualified } from "./listing.js";
|
|
51
|
-
import { listDistros, linuxHome } from "./wsl.js";
|
|
52
|
+
import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot, isWslUnc, uncToPosix } from "./paths.js";
|
|
53
|
+
import { breadcrumbs, boundedInsert, fullyQualified, lsListingArgv, parseLsListing } from "./listing.js";
|
|
54
|
+
import { listDistros, linuxHome, runCapture } from "./wsl.js";
|
|
55
|
+
import { sharedAgent } from "./agent-shared.js";
|
|
56
|
+
import { AgentUnavailableError } from "./agent-errors.js";
|
|
52
57
|
|
|
53
58
|
/** How long a resolved distro list stays fresh before it is queried again. */
|
|
54
59
|
const DEFAULT_DISTRO_CACHE_MS = 5_000;
|
|
@@ -269,6 +274,74 @@ export class WslDirectoryPicker extends DirectoryPicker {
|
|
|
269
274
|
throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
|
|
270
275
|
}
|
|
271
276
|
const target = resolve(path);
|
|
277
|
+
const preferred = await this.preferredDistro(signal);
|
|
278
|
+
const level = isWslUnc(target) ? await this.listDistroLevel(target, signal) : await this.listHostLevel(target, signal);
|
|
279
|
+
return {
|
|
280
|
+
path: target,
|
|
281
|
+
home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
|
|
282
|
+
crumbs: this.crumbsFor(target),
|
|
283
|
+
entries: level.rows,
|
|
284
|
+
truncated: level.truncated,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* List one level inside the distro, where the files live.
|
|
290
|
+
*
|
|
291
|
+
* The resident agent answers in one round trip; when it is out — distro
|
|
292
|
+
* stopped, or the resident's rebuild budget spent — the same `ls` runs
|
|
293
|
+
* through a one-shot `wsl.exe`. Neither path touches the 9p share, which is
|
|
294
|
+
* what the host walk it replaces used: one metadata round trip per entry,
|
|
295
|
+
* in the share's slow direction, plus one more per symlink.
|
|
296
|
+
*
|
|
297
|
+
* @param target - the absolute UNC directory to list.
|
|
298
|
+
* @param signal - caller lifetime.
|
|
299
|
+
* @returns the level's rows and truncation flag.
|
|
300
|
+
* @throws DirectoryPickerError with code `directory-unreadable`.
|
|
301
|
+
*/
|
|
302
|
+
async listDistroLevel(target, signal) {
|
|
303
|
+
const parsed = uncToPosix(target);
|
|
304
|
+
const linuxDir = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
|
|
305
|
+
let stdout;
|
|
306
|
+
try {
|
|
307
|
+
const result = await sharedAgent(parsed.distro).exec({
|
|
308
|
+
cwd: linuxDir,
|
|
309
|
+
argv: lsListingArgv(linuxDir),
|
|
310
|
+
timeoutMs: 15_000,
|
|
311
|
+
signal,
|
|
312
|
+
});
|
|
313
|
+
if (result.exitCode !== 0) {
|
|
314
|
+
const detail = result.stderr.toString("utf8").trim();
|
|
315
|
+
throw new DirectoryPickerError(
|
|
316
|
+
"directory-unreadable",
|
|
317
|
+
target,
|
|
318
|
+
`cannot list ${target}: ${detail.length > 0 ? detail : `ls exited ${result.exitCode}`}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
stdout = result.stdout;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
324
|
+
try {
|
|
325
|
+
stdout = await runCapture(
|
|
326
|
+
[this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...lsListingArgv(linuxDir)],
|
|
327
|
+
signal,
|
|
328
|
+
);
|
|
329
|
+
} catch (fallbackError) {
|
|
330
|
+
throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(fallbackError)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return parseLsListing(stdout, target, this.config.maxEntries);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* List one Windows level with the host's own walk — native I/O on a native
|
|
338
|
+
* filesystem, unchanged from the shipped browse behaviour.
|
|
339
|
+
* @param target - the absolute Windows directory to list.
|
|
340
|
+
* @param signal - caller lifetime.
|
|
341
|
+
* @returns the level's rows and truncation flag.
|
|
342
|
+
* @throws DirectoryPickerError with code `directory-unreadable`.
|
|
343
|
+
*/
|
|
344
|
+
async listHostLevel(target, signal) {
|
|
272
345
|
const maxEntries = this.config.maxEntries;
|
|
273
346
|
const keep = maxEntries + 1;
|
|
274
347
|
const window = [];
|
|
@@ -295,27 +368,19 @@ export class WslDirectoryPicker extends DirectoryPicker {
|
|
|
295
368
|
if (level !== undefined) await level.close().catch(() => {});
|
|
296
369
|
}
|
|
297
370
|
|
|
298
|
-
const
|
|
371
|
+
const rows = [];
|
|
299
372
|
let truncated = evicted;
|
|
300
373
|
for (const candidate of window) {
|
|
301
374
|
signal?.throwIfAborted();
|
|
302
375
|
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
|
|
303
376
|
if (row === null) continue;
|
|
304
|
-
if (
|
|
377
|
+
if (rows.length === maxEntries) {
|
|
305
378
|
truncated = true;
|
|
306
379
|
break;
|
|
307
380
|
}
|
|
308
|
-
|
|
381
|
+
rows.push(row);
|
|
309
382
|
}
|
|
310
|
-
|
|
311
|
-
const preferred = await this.preferredDistro(signal);
|
|
312
|
-
return {
|
|
313
|
-
path: target,
|
|
314
|
-
home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
|
|
315
|
-
crumbs: this.crumbsFor(target),
|
|
316
|
-
entries,
|
|
317
|
-
truncated,
|
|
318
|
-
};
|
|
383
|
+
return { rows, truncated };
|
|
319
384
|
}
|
|
320
385
|
|
|
321
386
|
/**
|
|
@@ -334,6 +399,7 @@ export class WslDirectoryPicker extends DirectoryPicker {
|
|
|
334
399
|
throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
|
|
335
400
|
}
|
|
336
401
|
const target = join(parent, name);
|
|
402
|
+
if (isWslUnc(parent)) return this.createDistroDirectory(parent, name, target);
|
|
337
403
|
try {
|
|
338
404
|
await mkdir(target);
|
|
339
405
|
return target;
|
|
@@ -344,6 +410,44 @@ export class WslDirectoryPicker extends DirectoryPicker {
|
|
|
344
410
|
throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
|
|
345
411
|
}
|
|
346
412
|
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create one child directory inside the distro — the resident agent's
|
|
416
|
+
* `mkdir` when it is up, a one-shot `wsl.exe --exec mkdir` when it is out;
|
|
417
|
+
* the host's `mkdir` over the 9p share is what this replaces.
|
|
418
|
+
*
|
|
419
|
+
* @param parent - the absolute UNC parent directory.
|
|
420
|
+
* @param name - the validated single path segment to create.
|
|
421
|
+
* @param target - the UNC path the caller receives on success.
|
|
422
|
+
* @returns the created directory's absolute path.
|
|
423
|
+
* @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
|
|
424
|
+
*/
|
|
425
|
+
async createDistroDirectory(parent, name, target) {
|
|
426
|
+
const parsed = uncToPosix(parent);
|
|
427
|
+
const linuxParent = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
|
|
428
|
+
try {
|
|
429
|
+
const result = await sharedAgent(parsed.distro).exec({ cwd: linuxParent, argv: ["mkdir", "--", name], timeoutMs: 10_000 });
|
|
430
|
+
if (result.exitCode === 0) return target;
|
|
431
|
+
const detail = result.stderr.toString("utf8");
|
|
432
|
+
if (/File exists/i.test(detail)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
|
|
433
|
+
throw new DirectoryPickerError(
|
|
434
|
+
"directory-create-failed",
|
|
435
|
+
target,
|
|
436
|
+
`cannot create ${target}: ${detail.trim().length > 0 ? detail.trim() : `mkdir exited ${result.exitCode}`}`,
|
|
437
|
+
);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (error instanceof DirectoryPickerError) throw error;
|
|
440
|
+
if (!(error instanceof AgentUnavailableError)) throw error;
|
|
441
|
+
try {
|
|
442
|
+
await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxParent, "--exec", "mkdir", "--", name]);
|
|
443
|
+
return target;
|
|
444
|
+
} catch (fallbackError) {
|
|
445
|
+
const message = messageOf(fallbackError);
|
|
446
|
+
if (/File exists/i.test(message)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
|
|
447
|
+
throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${message}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
347
451
|
}
|
|
348
452
|
|
|
349
453
|
export default WslDirectoryPicker;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox's probe and confinement logic, free of DSH peers.
|
|
3
|
+
*
|
|
4
|
+
* `lib/sandbox.js` binds this core to the peer's `SandboxUnavailableError` —
|
|
5
|
+
* the class identity `instanceof` consumers match — which is the ONLY thing
|
|
6
|
+
* that kept the whole policy out of unit tests: a checkout without the peers
|
|
7
|
+
* could not even import the module, so CI skipped every check of the probe
|
|
8
|
+
* caching policy and the argv composition. The core takes the error class as
|
|
9
|
+
* the one injected dependency; the tests inject a stand-in with the same
|
|
10
|
+
* contract (`code: "SANDBOX_UNAVAILABLE"`), and what CI runs is the code that
|
|
11
|
+
* ships.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-wsl/sandbox-core
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createProbeCache } from "./probe-cache.js";
|
|
17
|
+
import { toLinuxPath } from "./paths.js";
|
|
18
|
+
import { runCapture } from "./wsl.js";
|
|
19
|
+
import { bwrapArgvPrefix } from "./bwrap.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
23
|
+
* whose message is "Read-only file system". Classified case-insensitively.
|
|
24
|
+
*/
|
|
25
|
+
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runner-side diagnostics. Bubblewrap reports its own failures with a `bwrap: `
|
|
29
|
+
* prefix and exits 1; exit status alone never proves runner failure (the
|
|
30
|
+
* upstream rule is signature-only for exactly that reason), so a confined
|
|
31
|
+
* command that merely prints the signature is not misread as "never ran".
|
|
32
|
+
*/
|
|
33
|
+
const RUNNER_FAILURE_RULES = [{ fatalSignatures: ["bwrap: "] }];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
37
|
+
*
|
|
38
|
+
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
39
|
+
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
40
|
+
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
41
|
+
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
42
|
+
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
43
|
+
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
44
|
+
* the honest `partial` and README states where the hole is.
|
|
45
|
+
*/
|
|
46
|
+
const ENFORCEMENT = "partial";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one mitigation the profile can offer, and its honest ceiling.
|
|
50
|
+
*
|
|
51
|
+
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
52
|
+
* files vanish from the command's view, which takes the drive's data off the
|
|
53
|
+
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
54
|
+
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
55
|
+
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
56
|
+
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
57
|
+
* sandbox regardless of where the file sits. The only complete closures are
|
|
58
|
+
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
59
|
+
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
60
|
+
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
61
|
+
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
66
|
+
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
67
|
+
* where it is unit-testable without the peers this module needs.
|
|
68
|
+
*/
|
|
69
|
+
const probeVerdicts = createProbeCache();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the sandbox class, bound to one unavailable-error implementation.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} deps - the injected peer surface.
|
|
75
|
+
* @param {new (mode: string, detail: string) => Error} deps.SandboxUnavailableError
|
|
76
|
+
* the failure type `confine` throws; production binds the peer's class so
|
|
77
|
+
* `instanceof` consumers keep matching, tests bind a stand-in with the same
|
|
78
|
+
* `code`.
|
|
79
|
+
* @returns {typeof WslSandboxCore} the sandbox class (see `lib/sandbox.js` for
|
|
80
|
+
* the shipped binding).
|
|
81
|
+
*/
|
|
82
|
+
export function createSandboxCore({ SandboxUnavailableError }) {
|
|
83
|
+
return class WslSandboxCore {
|
|
84
|
+
/**
|
|
85
|
+
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
86
|
+
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
87
|
+
* profile this sandbox builds, hiding the Windows drive from confined
|
|
88
|
+
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
89
|
+
* does and does not close).
|
|
90
|
+
*/
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
93
|
+
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
98
|
+
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
99
|
+
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
100
|
+
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
101
|
+
* than silently run unconfined.
|
|
102
|
+
*
|
|
103
|
+
* A success is remembered for the process lifetime; a failure is not, so a
|
|
104
|
+
* `bubblewrap` installed while the app is running is believed on the next
|
|
105
|
+
* command.
|
|
106
|
+
*
|
|
107
|
+
* @param distro - the distro to probe.
|
|
108
|
+
* @param signal - optional cancellation.
|
|
109
|
+
* @returns true when a confined command can run there.
|
|
110
|
+
*/
|
|
111
|
+
async usable(distro, signal) {
|
|
112
|
+
const key = `${this.wslPath}\u0000${distro}`;
|
|
113
|
+
return probeVerdicts.run(key, async () => {
|
|
114
|
+
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
115
|
+
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
122
|
+
*
|
|
123
|
+
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
124
|
+
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
125
|
+
* Linux path `bwrap` must bind inside the distro.
|
|
126
|
+
*
|
|
127
|
+
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
128
|
+
* @param policy - the resolved confined policy (`mode` is never
|
|
129
|
+
* `danger-full-access`; the caller does not confine that case at all).
|
|
130
|
+
* @param options - the distro to run in and optional cancellation.
|
|
131
|
+
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
132
|
+
* seam's `ConfinedArgv` shape.
|
|
133
|
+
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
134
|
+
* requested confinement never degrades into an unconfined run.
|
|
135
|
+
*/
|
|
136
|
+
async confine(argv, policy, options) {
|
|
137
|
+
const { distro, signal } = options;
|
|
138
|
+
if (!(await this.usable(distro, signal))) {
|
|
139
|
+
throw new SandboxUnavailableError(
|
|
140
|
+
policy.mode,
|
|
141
|
+
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
142
|
+
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
143
|
+
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
147
|
+
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
148
|
+
return {
|
|
149
|
+
argv: [...prefix, ...argv],
|
|
150
|
+
enforcement: ENFORCEMENT,
|
|
151
|
+
denialSignatures: DENIAL_SIGNATURES,
|
|
152
|
+
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
153
|
+
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
package/lib/sandbox.js
CHANGED
|
@@ -23,148 +23,28 @@
|
|
|
23
23
|
* The profile arguments, the denial dialect and the runner-failure rules mirror
|
|
24
24
|
* `@deepseek-ai/dsh-sandbox-local`'s `bwrap` rung, so a denial reads the same to
|
|
25
25
|
* the tool layer whether the command ran on a Linux host or inside a distro.
|
|
26
|
-
* The one deliberate difference is
|
|
26
|
+
* The one deliberate difference is the core's `partial` enforcement: see
|
|
27
|
+
* `lib/sandbox-core.js` for what it reports and why.
|
|
28
|
+
*
|
|
29
|
+
* The class itself lives in `lib/sandbox-core.js`, free of DSH peers — this
|
|
30
|
+
* module is the shipped binding that hands it the peer's `SandboxUnavailableError`,
|
|
31
|
+
* so `instanceof` consumers keep matching the class their composition knows.
|
|
27
32
|
*
|
|
28
33
|
* @module dsh-plugin-wsl/sandbox
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
import { SandboxUnavailableError } from "@deepseek-ai/dsh-sandbox";
|
|
32
|
-
import {
|
|
33
|
-
import { toLinuxPath } from "./paths.js";
|
|
34
|
-
import { runCapture } from "./wsl.js";
|
|
35
|
-
import { bwrapArgvPrefix, bwrapProfileArgs } from "./bwrap.js";
|
|
37
|
+
import { createSandboxCore } from "./sandbox-core.js";
|
|
36
38
|
|
|
37
39
|
// The profile and the argv prefix are also what `lib/agent-confined.js` builds
|
|
38
40
|
// for its residents, so both live in the peer-free `lib/bwrap.js` and are
|
|
39
41
|
// re-exported here, where the sandbox's callers have always found them.
|
|
40
|
-
export { bwrapProfileArgs };
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* bwrap's denial dialect: a write outside the granted mounts fails with EROFS,
|
|
44
|
-
* whose message is "Read-only file system". Classified case-insensitively.
|
|
45
|
-
*/
|
|
46
|
-
const DENIAL_SIGNATURES = ["read-only file system"];
|
|
42
|
+
export { bwrapProfileArgs } from "./bwrap.js";
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* upstream rule is signature-only for exactly that reason), so a confined
|
|
52
|
-
* command that merely prints the signature is not misread as "never ran".
|
|
45
|
+
* The confinement over WSL: the probe-then-confine class bound to the peer's
|
|
46
|
+
* failure type. See the core module for the probe policy and the profile.
|
|
53
47
|
*/
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Enforcement is **partial**, not `full` like upstream's Linux rung.
|
|
58
|
-
*
|
|
59
|
-
* `SandboxMode` governs file effects. A Linux process under bwrap is fully
|
|
60
|
-
* governed — but a WSL distro process can execute a *Windows* binary through
|
|
61
|
-
* interop (`/mnt/c/**\*.exe`), and that Windows process runs outside the Linux
|
|
62
|
-
* sandbox entirely, under the user's ordinary Windows token: it can write
|
|
63
|
-
* anywhere the user can. `npm run probe:sandbox` demonstrates it. Claiming
|
|
64
|
-
* `full` would advertise a boundary that does not hold, so the provider reports
|
|
65
|
-
* the honest `partial` and README states where the hole is.
|
|
66
|
-
*/
|
|
67
|
-
const ENFORCEMENT = "partial";
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The one mitigation the profile can offer, and its honest ceiling.
|
|
71
|
-
*
|
|
72
|
-
* `maskWindowsDrive` mounts an empty tmpfs over `/mnt`: the Windows drive's
|
|
73
|
-
* files vanish from the command's view, which takes the drive's data off the
|
|
74
|
-
* table and makes the on-drive executables unreachable. It does NOT make the
|
|
75
|
-
* run `full`: binfmt interop dispatches on file CONTENT, so a command that can
|
|
76
|
-
* write the workspace (which `workspace-write` grants) can write a PE there
|
|
77
|
-
* and execute it — binfmt launches it as a Windows process outside the Linux
|
|
78
|
-
* sandbox regardless of where the file sits. The only complete closures are
|
|
79
|
-
* distro-level (`[interop] enabled=false` in `wsl.conf`, which costs every
|
|
80
|
-
* legitimate interop user their tooling) — so the provider reports `partial`
|
|
81
|
-
* either way, and the mask is a decision an operator makes to shrink the hole,
|
|
82
|
-
* recorded as the `windowsDrive: "masked"` fact the model can see.
|
|
83
|
-
*/
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Probe verdicts, keyed by `wslPath\0distro`. The policy — a success is durable, a
|
|
87
|
-
* failure is re-probed, concurrent probes are shared — lives in `probe-cache.js`,
|
|
88
|
-
* where it is unit-testable without the peers this module needs.
|
|
89
|
-
*/
|
|
90
|
-
const probeVerdicts = createProbeCache();
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Confine commands inside one distro with `bwrap`.
|
|
94
|
-
*
|
|
95
|
-
* The policy's `workspaceRoot` arrives in whichever coordinate system the
|
|
96
|
-
* caller speaks — a session workspace is a UNC path — and is translated into the
|
|
97
|
-
* Linux path `bwrap` must bind inside the distro.
|
|
98
|
-
*/
|
|
99
|
-
export class WslSandbox {
|
|
100
|
-
/**
|
|
101
|
-
* @param config - the `wsl.exe` path, overridable for a non-standard install.
|
|
102
|
-
* @param config.maskWindowsDrive - shadow `/mnt` with an empty tmpfs in every
|
|
103
|
-
* profile this sandbox builds, hiding the Windows drive from confined
|
|
104
|
-
* commands (see the module doc above {@link ENFORCEMENT} for what that
|
|
105
|
-
* does and does not close).
|
|
106
|
-
*/
|
|
107
|
-
constructor(config = {}) {
|
|
108
|
-
this.wslPath = config.wslPath ?? "wsl.exe";
|
|
109
|
-
this.maskWindowsDrive = config.maskWindowsDrive ?? false;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Whether `bwrap` can create a profile inside this distro. The probe mirrors
|
|
114
|
-
* upstream's: apply the real read-only profile around `true`, and treat a zero
|
|
115
|
-
* exit as the kernel having accepted and enforced it. A missing `bwrap` fails the
|
|
116
|
-
* spawn and probes unusable, which is what makes the provider fail closed rather
|
|
117
|
-
* than silently run unconfined.
|
|
118
|
-
*
|
|
119
|
-
* A success is remembered for the process lifetime; a failure is not, so a
|
|
120
|
-
* `bubblewrap` installed while the app is running is believed on the next
|
|
121
|
-
* command.
|
|
122
|
-
*
|
|
123
|
-
* @param distro - the distro to probe.
|
|
124
|
-
* @param signal - optional cancellation.
|
|
125
|
-
* @returns true when a confined command can run there.
|
|
126
|
-
*/
|
|
127
|
-
async usable(distro, signal) {
|
|
128
|
-
const key = `${this.wslPath}\u0000${distro}`;
|
|
129
|
-
return probeVerdicts.run(key, async () => {
|
|
130
|
-
const prefix = bwrapArgvPrefix({ mode: "read-only", workspaceRoot: "/" });
|
|
131
|
-
await runCapture([this.wslPath, "-d", distro, "--exec", ...prefix, "true"], signal);
|
|
132
|
-
return true;
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Wrap one distro-side argv in the bwrap profile the policy asks for.
|
|
138
|
-
*
|
|
139
|
-
* @param argv - the argv to run INSIDE the distro (shell, its flags, command).
|
|
140
|
-
* @param policy - the resolved confined policy (`mode` is never
|
|
141
|
-
* `danger-full-access`; the caller does not confine that case at all).
|
|
142
|
-
* @param options - the distro to run in and optional cancellation.
|
|
143
|
-
* @returns the wrapped argv plus the settlement-classification facts, in the
|
|
144
|
-
* seam's `ConfinedArgv` shape.
|
|
145
|
-
* @throws SandboxUnavailableError when the distro has no usable `bwrap`, so a
|
|
146
|
-
* requested confinement never degrades into an unconfined run.
|
|
147
|
-
*/
|
|
148
|
-
async confine(argv, policy, options) {
|
|
149
|
-
const { distro, signal } = options;
|
|
150
|
-
if (!(await this.usable(distro, signal))) {
|
|
151
|
-
throw new SandboxUnavailableError(
|
|
152
|
-
policy.mode,
|
|
153
|
-
`bwrap is not usable inside distro "${distro}". Install it with ` +
|
|
154
|
-
`"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
|
|
155
|
-
`wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
|
|
159
|
-
const prefix = bwrapArgvPrefix({ ...policy, workspaceRoot }, { maskWindowsDrive: this.maskWindowsDrive });
|
|
160
|
-
return {
|
|
161
|
-
argv: [...prefix, ...argv],
|
|
162
|
-
enforcement: ENFORCEMENT,
|
|
163
|
-
denialSignatures: DENIAL_SIGNATURES,
|
|
164
|
-
runnerFailureRules: RUNNER_FAILURE_RULES,
|
|
165
|
-
windowsDrive: this.maskWindowsDrive ? "masked" : "visible",
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
}
|
|
48
|
+
export const WslSandbox = createSandboxCore({ SandboxUnavailableError });
|
|
169
49
|
|
|
170
50
|
export default WslSandbox;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-backed execution handle for the file-search spawn.
|
|
3
|
+
*
|
|
4
|
+
* `dsh-tool-fs-search` consumes a deliberately small slice of the subprocess
|
|
5
|
+
* handle: it awaits `handle.done` for `{exitCode, signal}`, then reads
|
|
6
|
+
* `handle.collected.stdout/stderr.readFrom(0)` — nothing else. This module
|
|
7
|
+
* builds exactly that surface around one resident-agent `exec`, so a distro
|
|
8
|
+
* search pays one round trip on the warm agent (~10 ms) instead of a one-shot
|
|
9
|
+
* `wsl.exe` spawn (~400 ms of process startup for a few ms of rg).
|
|
10
|
+
*
|
|
11
|
+
* The agent being out is not an error condition here — it is the fallback
|
|
12
|
+
* cue. `spawnFallback` (the shipped one-shot handle the caller builds) is
|
|
13
|
+
* consulted lazily inside `done`; from that point the facade proxies the
|
|
14
|
+
* delegate's `collected` and `done` verbatim. Neither transport ever touches
|
|
15
|
+
* the 9p share.
|
|
16
|
+
*
|
|
17
|
+
* Cancellation shares one channel: the caller's `signal` and `kill()` abort
|
|
18
|
+
* the agent exec, which settles `done` as killed (`{exitCode: null, signal:
|
|
19
|
+
* "SIGTERM"}`) — the same shape the one-shot path reports for a killed search.
|
|
20
|
+
* A genuine agent failure (anything but unavailability) propagates through
|
|
21
|
+
* `done` as a rejection, which the tool classifies as a search failure.
|
|
22
|
+
*
|
|
23
|
+
* Pure shaping over injected collaborators, so the handle semantics are
|
|
24
|
+
* unit-testable without a distro.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-plugin-wsl/search-exec
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { AgentUnavailableError } from "./agent-errors.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The output-stream reader the search tool consumes: `handle.collected.stdout`
|
|
33
|
+
* / `stderr` expose `readFrom(offset)` with the seam's standard shape.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {Object} SearchCollectedReader
|
|
36
|
+
* @property {(from: number) => {text: string, lossy: boolean, nextOffset: number}} readFrom
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The slice of the subprocess handle the file-search tool consumes
|
|
41
|
+
* (`dsh-tool-fs-search` `runRipgrep`): settle on `done`, then read the
|
|
42
|
+
* collected streams. Nothing else of the handle is contract.
|
|
43
|
+
*
|
|
44
|
+
* @typedef {Object} SearchExecutionHandle
|
|
45
|
+
* @property {Promise<{exitCode: number|null, signal: string|null}>} done
|
|
46
|
+
* @property {{stdout: SearchCollectedReader, stderr: SearchCollectedReader}} collected
|
|
47
|
+
* @property {() => boolean|void} [kill] - optional cooperative kill; the tool
|
|
48
|
+
* relies on its `signal` for cancellation instead.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run one search command through the agent and present it as a search handle.
|
|
53
|
+
*
|
|
54
|
+
* @param object - the handle's collaborators.
|
|
55
|
+
* @param {object} object.agent - the {@link WslAgent}-like runner: `exec`
|
|
56
|
+
* accepting `{cwd, argv, maxOutputBytes, signal}` and resolving
|
|
57
|
+
* `{exitCode, stdout, stderr}`; throws `AgentUnavailableError` when out.
|
|
58
|
+
* @param {string} object.cwd - the Linux working directory to search from.
|
|
59
|
+
* @param {string[]} object.argv - the distro-side argv (`rg` and its arguments).
|
|
60
|
+
* @param {number} [object.maxOutputBytes] - per-stream capture ceiling the
|
|
61
|
+
* agent enforces; the tool's own raw-output budget rides here.
|
|
62
|
+
* @param {AbortSignal} [object.signal] - the caller's cancellation.
|
|
63
|
+
* @param {() => object} object.spawnFallback - builds the one-shot handle the
|
|
64
|
+
* facade delegates to when the agent is out; called at most once, lazily.
|
|
65
|
+
* @returns {object} the search handle (`done`, `collected`, `kill`).
|
|
66
|
+
*/
|
|
67
|
+
export function searchExecutionHandle({ agent, cwd, argv, maxOutputBytes = 0, signal, spawnFallback }) {
|
|
68
|
+
const state = { delegate: undefined, stdout: undefined, stderr: undefined };
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
if (signal?.aborted) controller.abort(signal.reason);
|
|
71
|
+
else signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
72
|
+
|
|
73
|
+
const reader = (slot) => ({
|
|
74
|
+
readFrom(from) {
|
|
75
|
+
const buffer = state[slot] ?? Buffer.alloc(0);
|
|
76
|
+
const start = Math.min(from, buffer.length);
|
|
77
|
+
return { text: buffer.subarray(start).toString("utf8"), lossy: false, nextOffset: buffer.length };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** @type {object} */
|
|
82
|
+
const handle = {
|
|
83
|
+
get collected() {
|
|
84
|
+
if (state.delegate !== undefined) return state.delegate.collected;
|
|
85
|
+
return { stdout: reader("stdout"), stderr: reader("stderr") };
|
|
86
|
+
},
|
|
87
|
+
done: (async () => {
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await agent.exec({ cwd, argv, maxOutputBytes, signal: controller.signal });
|
|
91
|
+
state.stdout = result.stdout;
|
|
92
|
+
state.stderr = result.stderr;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!(error instanceof AgentUnavailableError)) {
|
|
95
|
+
if (controller.signal.aborted) {
|
|
96
|
+
// Our kill() or the caller's cancellation: the killed outcome the
|
|
97
|
+
// one-shot path reports, not a failure.
|
|
98
|
+
return { exitCode: null, signal: "SIGTERM" };
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
const delegate = spawnFallback();
|
|
103
|
+
state.delegate = delegate;
|
|
104
|
+
return delegate.done;
|
|
105
|
+
}
|
|
106
|
+
return { exitCode: result.exitCode, signal: null };
|
|
107
|
+
})(),
|
|
108
|
+
kill() {
|
|
109
|
+
controller.abort(new Error("killed"));
|
|
110
|
+
state.delegate?.kill?.();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return handle;
|
|
114
|
+
}
|