dsh-plugin-wsl-env 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,871 @@
1
+ /**
2
+ * dsh-plugin-wsl — run a DeepSeek Harness session against a WSL distro.
3
+ *
4
+ * DeepSeek Harness is a Cordis application whose capabilities are exposed as
5
+ * service seams: `ctx.fs` (filesystem), `ctx.shell` (command execution),
6
+ * `ctx.sandbox` (confinement), `ctx.directoryPicker`, `ctx.workspaceRegistry`.
7
+ * The model-facing tools (`dsh-tool-fs`, `dsh-tool-fs-search`, `dsh-tool-bash`)
8
+ * consume those seams and never talk to a filesystem or a shell directly. A WSL
9
+ * integration is therefore a seam provider, not a new tool.
10
+ *
11
+ * This package provides two of them:
12
+ *
13
+ * - {@link WslShellExecutor} registers `ctx.shell` and runs every command
14
+ * inside the distro as `wsl.exe -d <distro> --cd <linux dir> --exec <shell> -lc <cmd>`,
15
+ * where `<shell>` is the distro user's login shell (not a hardcoded bash).
16
+ * - {@link WslFileSystem} registers `ctx.fs` and maps Linux paths onto the
17
+ * distro's UNC share so the host's own fs stack (including the packaged
18
+ * ripgrep used by `dsh-tool-fs-search`) reads and writes real distro files.
19
+ *
20
+ * ## The one constraint that shapes everything
21
+ *
22
+ * On Windows, DSH confines commands with `dsh-sandbox-windows-acl`: a
23
+ * restricted, low-integrity token plus a capability-SID write allowlist. That
24
+ * token cannot reach WSL at all — `wsl.exe` fails with `Wsl/E_ACCESSDENIED` and
25
+ * the UNC share `\\wsl.localhost\<distro>` reports access denied. Both work
26
+ * normally outside the sandbox.
27
+ *
28
+ * The confinement is applied by the *sandboxing* executor (`pwsh-sandbox`),
29
+ * which wraps the argv through `ctx.sandbox.confine`. This plugin therefore
30
+ * subclasses the NON-sandboxing `LocalBashExecutor`, and inherits
31
+ * `sandboxMode === undefined` from the seam — which is the honest capability
32
+ * fact: the tool layer reads it and advertises that these commands are not
33
+ * confined. WSL access is inherently outside the harness's file sandbox; do not
34
+ * paper over that by reporting a mode this provider does not enforce.
35
+ *
36
+ * @module dsh-plugin-wsl
37
+ */
38
+
39
+ import { rename } from "node:fs/promises";
40
+ import z from "@deepseek-ai/schemastery";
41
+ import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
42
+ import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
43
+ import { FsError } from "@deepseek-ai/dsh-fs";
44
+ import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
45
+ import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } from "./paths.js";
46
+ import { WslSandbox } from "./sandbox.js";
47
+ // `runCapture` and distro discovery live in ./wsl.js so the directory picker
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";
50
+
51
+ /**
52
+ * Every model shell call gets a rebuilt managed `DSH_*` namespace (see
53
+ * `dsh-shell-env` / `ctx.shellEnv`). Those names exist only in the Windows-side
54
+ * process, so unless they are listed in WSLENV they never reach the distro and
55
+ * the model sees none of its environment facts.
56
+ */
57
+ /**
58
+ * Of the managed facts, these two carry Windows paths. WSL's `WSLENV` `/p` flag
59
+ * translates them on the way in, so a command inside the distro gets a usable
60
+ * `/mnt/c/...` path instead of `C:\...`. `DSH_WSL_HOME` is deliberately absent:
61
+ * it is already a POSIX path, and translating it would corrupt it.
62
+ */
63
+ const DSH_ENV_PREFIX = "DSH_";
64
+ const PATH_TRANSLATED = new Set([`${DSH_ENV_PREFIX}HOME`, `${DSH_ENV_PREFIX}PROFILE_DIR`]);
65
+
66
+
67
+ /** Env names that are meaningful on the Linux side and safe to forward. */
68
+ const SAFE_FORWARD = ["NO_COLOR", "TERM", "PAGER", "GIT_PAGER", "LANG", "LC_ALL"];
69
+
70
+ /**
71
+ * The configured default workdir, or an empty string when the operator left it
72
+ * unset. `cwd` is a volatile field on both providers, so it is read through
73
+ * `.get()`, and it may be absent entirely.
74
+ *
75
+ * @param config - a provider's config.
76
+ * @returns the configured directory, trimmed, or `""`.
77
+ */
78
+ function configuredCwd(config) {
79
+ return String(config.cwd?.get?.() ?? "").trim();
80
+ }
81
+
82
+ /**
83
+ * The distro user's home as a Linux path, cached on the provider instance whose
84
+ * `distro`/`wslPath` config it belongs to.
85
+ *
86
+ * An empty `cwd` cannot mean the shipped default (`process.cwd()`, a Windows
87
+ * directory no `wsl.exe --cd` accepts) and it must not mean a hardcoded author
88
+ * path either, so both providers agree on this instead: the distro's own home,
89
+ * asked of the distro once per instance.
90
+ *
91
+ * @param owner - a WslShellExecutor or WslFileSystem.
92
+ * @returns an absolute Linux path.
93
+ */
94
+ async function distroHome(owner) {
95
+ if (owner.resolvedHome === undefined) {
96
+ const distro = await owner.distro();
97
+ owner.resolvedHome = await linuxHomePath(distro, { wslPath: owner.config.wslPath });
98
+ }
99
+ return owner.resolvedHome;
100
+ }
101
+
102
+ /**
103
+ * The roots a `workspace-write` policy may write under, in the vocabulary this
104
+ * backend's targets are spelled in.
105
+ *
106
+ * Upstream's {@link writableRoots} grants the workspace plus the platform temp
107
+ * areas (`/tmp`, `os.tmpdir()`). A session whose workspace lives inside a
108
+ * distro speaks UNC, where neither host spelling matches anything, and the temp
109
+ * area that world actually exposes is the distro's own `/tmp` — so that is what
110
+ * is granted instead, and the Windows temp dir deliberately is NOT: it lies
111
+ * outside the execution world the policy describes. A session whose workspace
112
+ * is a Windows folder keeps upstream's grant verbatim.
113
+ *
114
+ * @param policy - the per-call file-effect policy.
115
+ * @param distro - the distro a UNC workspace belongs to.
116
+ * @returns canonical writable roots; empty exactly under `read-only`.
117
+ */
118
+ function wslWritableRoots(policy, distro) {
119
+ if (policy.mode !== "workspace-write") return [];
120
+ const roots = new Set([canonicalPath(policy.workspaceRoot)]);
121
+ if (isWslUnc(policy.workspaceRoot)) {
122
+ roots.add(canonicalPath(posixToUnc(distro, "/tmp")));
123
+ } else {
124
+ for (const root of writableRoots(policy)) roots.add(root);
125
+ }
126
+ return [...roots];
127
+ }
128
+
129
+ /**
130
+ * Bash executor that runs every command inside a WSL distro.
131
+ *
132
+ * Extends the unconfined `LocalBashExecutor`, so command defaulting, deadline
133
+ * handling, output caps, spill files and background-process management are the
134
+ * shipped implementation's; this subclass replaces only the argv (prefix with
135
+ * `wsl.exe`) and the process working directory (wsl.exe must be started from a
136
+ * Windows directory, while the Linux directory rides on `--cd`).
137
+ */
138
+ export class WslShellExecutor extends LocalBashExecutor {
139
+ static inject = ["subprocess", "sandboxPolicy"];
140
+
141
+ static Config = z.object({
142
+ /** Distro name; empty means WSL's own default distro. */
143
+ distro: z.string().default(""),
144
+ /** Path to `wsl.exe`; overridable for a non-standard install. */
145
+ wslPath: z.string().default("wsl.exe"),
146
+ /**
147
+ * The shell commands run in. Empty means "resolve the login shell of the
148
+ * distro's user" — hardcoding bash silently ignores a user whose passwd
149
+ * entry points at zsh, fish, dash, or anything else, losing their PATH and
150
+ * rc setup. Set an absolute path inside the distro to pin one.
151
+ */
152
+ shell: z.string().default(""),
153
+ /**
154
+ * Login-shell semantics (`<shell> -lc`) source the profile scripts, which is
155
+ * what makes an interactive WSL setup's PATH (nvm, cargo, pyenv) visible.
156
+ * Set false for a fast, fully deterministic `<shell> -c`. Applies only to the
157
+ * POSIX family — see `shellArgs` for why other shells get a bare `-c`.
158
+ */
159
+ loginShell: z.boolean().default(true),
160
+ /**
161
+ * Confine every command inside the distro with `bwrap`, and report the
162
+ * policy's mode through {@link sandboxMode}. Fails closed: a distro without
163
+ * a usable `bwrap` produces `SANDBOX_UNAVAILABLE` rather than an unconfined
164
+ * run. `false` restores the pre-sandbox behaviour exactly — commands run
165
+ * unconfined and `sandboxMode` is `undefined`, so the model is told so.
166
+ */
167
+ sandbox: z.boolean().default(true),
168
+ /**
169
+ * Default workdir for a request that names none; accepts `/home/andy/proj`,
170
+ * a UNC path, or `C:\...`. Empty — the default — means the distro user's
171
+ * home, resolved inside the distro.
172
+ */
173
+ cwd: z.string().volatile(),
174
+ /** Windows directory the `wsl.exe` process itself starts in. */
175
+ hostCwd: z.string().volatile(),
176
+ /** Extra env names to forward into the distro through WSLENV. */
177
+ forwardEnv: z.array(z.string()).default([]),
178
+ timeoutMs: z.number().default(12e4).volatile(),
179
+ maxTimeoutMs: z.number().default(6e5).volatile(),
180
+ maxOutputBytes: z.number().default(64e3).volatile(),
181
+ maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
182
+ graceMs: z.number().default(3e3).volatile(),
183
+ });
184
+
185
+ constructor(ctx, config) {
186
+ super(ctx, config);
187
+ /** Cached default distro, resolved once from `wsl.exe -l -q`. */
188
+ this.resolvedDistro = config.distro;
189
+ /** Cached distro-user login shell, resolved once. */
190
+ this.resolvedShell = config.shell;
191
+ /** Cached distro-user home, the workdir when `cwd` is empty. */
192
+ this.resolvedHome = undefined;
193
+ /** The default mode — the capability fact the tool layer reads. */
194
+ this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
195
+ /** The distro-side confinement, or nothing when the operator opted out. */
196
+ this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
197
+ /**
198
+ * Per-process confinement facts retained until settlement. Overlapping calls
199
+ * can carry different policies, so a shared latest-wrap value would classify
200
+ * a process against the wrong facts. Unconfined processes have no entry.
201
+ */
202
+ this.processFacts = new Map();
203
+ }
204
+
205
+ /**
206
+ * The mode this executor confines with, or `undefined` when it does not
207
+ * confine at all. `dsh-tool-bash` reads exactly this to decide whether to
208
+ * advertise the escalation field, so it must disappear together with the
209
+ * enforcement — never claim a boundary that is not applied.
210
+ * @returns the deployment default mode, or `undefined` when unconfined.
211
+ */
212
+ get sandboxMode() {
213
+ return this.mode;
214
+ }
215
+
216
+ /**
217
+ * The distro every command runs in, resolving WSL's default on first use.
218
+ * @returns the distro name.
219
+ */
220
+ async distro() {
221
+ if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
222
+ return this.resolvedDistro;
223
+ }
224
+
225
+ /**
226
+ * The shell every command runs in: the configured one, else the login shell of
227
+ * the distro user, resolved once and cached.
228
+ * @returns an absolute path to the shell inside the distro.
229
+ */
230
+ async shellName() {
231
+ // Consumed by the shell tool after upstream change A: it names and describes
232
+ // itself from the executor, so a zsh distro is never presented as bash.
233
+ const shell = await this.shell();
234
+ const base = String(shell ?? "").split("/").pop() ?? "";
235
+ const name = base.replace(/\.exe$/i, "").replace(/^[-.]+/, "");
236
+ return /^[a-z0-9_+-]+$/i.test(name) ? name : "bash";
237
+ }
238
+
239
+ async shell() {
240
+ if (this.resolvedShell.length === 0) {
241
+ const distro = await this.distro();
242
+ this.resolvedShell = await defaultShell(distro, { wslPath: this.config.wslPath });
243
+ }
244
+ return this.resolvedShell;
245
+ }
246
+
247
+ /** Windows directory the `wsl.exe` process is started from. */
248
+ hostCwd() {
249
+ return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
250
+ }
251
+
252
+ /**
253
+ * The argv that runs INSIDE the distro for one spec: the resolved shell, its
254
+ * login/command flags, and the command string. This is the argv a
255
+ * distro-side sandbox wraps, which is why it is built separately from the
256
+ * `wsl.exe` invocation around it.
257
+ *
258
+ * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
259
+ * @param shell - the resolved distro shell.
260
+ * @returns the distro-side argv.
261
+ */
262
+ distroArgv(spec, shell) {
263
+ return [shell, ...shellArgs(shell, this.config.loginShell), spec.command];
264
+ }
265
+
266
+ /**
267
+ * Build the `wsl.exe` argv for one resolved spec. The command string is
268
+ * passed as a single argv element to the resolved shell, so there is no
269
+ * quoting layer of our own; Node's argv array reaches `wsl.exe` verbatim.
270
+ *
271
+ * `--exec` is load-bearing — see the comment inside.
272
+ *
273
+ * @param spec - the resolved spec from {@link LocalBashExecutor.resolve}.
274
+ * @param distro - the resolved distro name.
275
+ * @param shell - the resolved distro shell.
276
+ * @param inner - the already-confined distro argv, when confinement applies.
277
+ * @returns the argv to spawn.
278
+ */
279
+ argv(spec, distro, shell, inner) {
280
+ const distroArgs = distro.length > 0 ? ["-d", distro] : [];
281
+ return [
282
+ this.config.wslPath,
283
+ ...distroArgs,
284
+ "--cd",
285
+ toLinuxPath(spec.workdir, { distro }),
286
+ // `--exec` is essential, not decoration. Without it wsl.exe hands the
287
+ // command line to the distro's DEFAULT SHELL, which expands it once before
288
+ // our own shell ever sees it: `x=1; echo "$x"` arrives as `x=1; echo ""`,
289
+ // `$?` becomes the outer shell's status, and `$(...)`, `${...}`, `$1` and
290
+ // backticks are eaten the same way. With `--exec` the argv is passed
291
+ // straight through, so the command string reaches the shell verbatim.
292
+ //
293
+ // Note the shell is named explicitly here even though `--exec` plus the
294
+ // distro's default shell would look equivalent: without `--exec` we get the
295
+ // double parse above, so the shell has to be resolved by us instead.
296
+ "--exec",
297
+ ...(inner ?? this.distroArgv(spec, shell)),
298
+ ];
299
+ }
300
+
301
+ /**
302
+ * Override only the spawn's working directory and environment. `spec.workdir`
303
+ * is a Linux path, which a Windows CreateProcess call cannot use as `cwd`.
304
+ *
305
+ * @param spec - resolved execution settings.
306
+ * @param argv - the argv from {@link WslShellExecutor.argv}.
307
+ * @param stdoutMaxBytes - per-call stdout cap.
308
+ * @param signal - the spawn cancellation signal.
309
+ * @returns the subprocess spawn spec.
310
+ */
311
+ spawnSpec(spec, argv, stdoutMaxBytes, signal) {
312
+ const base = super.spawnSpec(spec, argv, stdoutMaxBytes, signal);
313
+ const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
314
+ const forwarded = Object.keys(base.env ?? {}).filter(
315
+ (name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
316
+ );
317
+ return {
318
+ ...base,
319
+ cwd: this.hostCwd(),
320
+ env: {
321
+ ...base.env,
322
+ // WSL imports only the names listed in WSLENV. The managed DSH_* facts are
323
+ // included by prefix so every contributed variable rides along. PATH is deliberately
324
+ // never listed: putting it here would replace the distro's PATH with
325
+ // the Windows one instead of appending the interop entries WSL adds
326
+ // on its own.
327
+ ...(forwarded.length > 0 ? { WSLENV: forwarded.map((name) => (PATH_TRANSLATED.has(name) ? `${name}/p` : name)).join(":") } : {}),
328
+ },
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Resolve a request into a spec, replacing the parent's `process.cwd()`
334
+ * fallback with this provider's distro-home marker and stamping the per-call
335
+ * sandbox policy.
336
+ *
337
+ * The seam requires this step to be synchronous — the tool layer calls
338
+ * `ctx.shell.execute(ctx.shell.resolve(…))` with no `await` between them — so
339
+ * neither the home nor a confinement decision can be made here. An empty
340
+ * `workdir` is the home marker, filled in {@link withDefaultWorkdir}, and the
341
+ * policy travels on the spec to {@link execute}, which awaits the wrap.
342
+ *
343
+ * @param request - the caller's request.
344
+ * @returns the fully-resolved spec.
345
+ */
346
+ resolve(request) {
347
+ const spec = super.resolve(request);
348
+ const sandboxPolicy = this.sandbox === undefined ? undefined : (request?.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve());
349
+ const needsHome = request?.workdir === undefined && configuredCwd(this.config).length === 0;
350
+ if (!needsHome && sandboxPolicy === undefined) return spec;
351
+ return {
352
+ ...spec,
353
+ ...(needsHome ? { workdir: "" } : {}),
354
+ ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * The spec to execute, with an empty `workdir` filled from the distro user's
360
+ * home. That is one `sh -c 'printf %s "$HOME"'` per provider instance, cached,
361
+ * and it happens only for a request that named no directory and had no
362
+ * configured default.
363
+ *
364
+ * @param spec - the resolved spec.
365
+ * @returns the spec to execute.
366
+ */
367
+ async withDefaultWorkdir(spec) {
368
+ if (String(spec?.workdir ?? "").trim().length > 0) return spec;
369
+ return { ...spec, workdir: await distroHome(this) };
370
+ }
371
+
372
+ /**
373
+ * Decorate a handle's foreground projection in place, memoized once. The
374
+ * handle keeps its identity — never wrapped in a second object — because the
375
+ * per-process facts and `onProcessDone` key on the exact instance.
376
+ *
377
+ * @param ex - the execution handle to decorate.
378
+ * @param map - maps the settled result.
379
+ * @param mapError - maps a rejection, when the caller has one.
380
+ * @returns the same handle.
381
+ */
382
+ static decorateResult(ex, map, mapError) {
383
+ const base = ex.result.bind(ex);
384
+ let decorated;
385
+ ex.result = () => {
386
+ decorated ??= base().then(map, mapError);
387
+ return decorated;
388
+ };
389
+ return ex;
390
+ }
391
+
392
+ /**
393
+ * Stamp the per-process sandbox facts before `done` settles, so a background
394
+ * process reports the same mode, denial and enforcement a foreground run
395
+ * would. Signal deaths are not denials.
396
+ */
397
+ onProcessDone(proc, stderr, providerRejected, providerError) {
398
+ const facts = this.processFacts.get(proc);
399
+ if (facts !== undefined) {
400
+ this.processFacts.delete(proc);
401
+ // A provider rejection exposes no public failure stage; attribute it to
402
+ // the confinement runner only when the error independently names argv[0].
403
+ const runnerFailed = providerRejected
404
+ ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
405
+ : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined;
406
+ proc.sandbox = {
407
+ mode: facts.mode,
408
+ denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
409
+ enforcement: facts.enforcement,
410
+ ...(runnerFailed ? { runnerFailed } : {}),
411
+ };
412
+ }
413
+ super.onProcessDone(proc, stderr, providerRejected, providerError);
414
+ }
415
+
416
+ /**
417
+ * Execute one resolved spec inside the distro, confined by the per-call policy
418
+ * when there is one.
419
+ *
420
+ * Confinement wraps the DISTRO-side argv (`<shell> -lc <command>`) in `bwrap`
421
+ * and hands the result to `wsl.exe --exec`, so the sandbox is built inside the
422
+ * distro while the process that spawns it stays the ordinary Windows one. The
423
+ * `danger-full-access` escalation — and an operator's `sandbox: false` — skip
424
+ * the wrap entirely and run the command as before.
425
+ *
426
+ * @param spec - resolved execution settings.
427
+ * @returns the live shell process handle.
428
+ */
429
+ async execute(spec) {
430
+ const distro = await this.distro();
431
+ const shell = await this.shell();
432
+ const resolved = await this.withDefaultWorkdir(spec);
433
+ const policy = resolved.sandboxPolicy;
434
+ if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
435
+ return this.executeArgv(resolved, () => this.argv(resolved, distro, shell));
436
+ }
437
+ const mode = policy.mode;
438
+ let confined;
439
+ const execution = await this.executeArgv(
440
+ resolved,
441
+ async (signal) => {
442
+ // Preparation is async, so it runs under executeArgv's deadline and
443
+ // cancellation handling rather than before them. A distro without a
444
+ // usable runner throws here, before anything is spawned.
445
+ confined = await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode }, { distro, signal });
446
+ signal.throwIfAborted();
447
+ return this.argv(resolved, distro, shell, confined.argv);
448
+ },
449
+ (process) => {
450
+ this.processFacts.set(process, {
451
+ mode,
452
+ enforcement: confined.enforcement,
453
+ denialSignatures: confined.denialSignatures,
454
+ runnerFailureRules: confined.runnerFailureRules,
455
+ runnerProgram: confined.argv[0],
456
+ workdir: resolved.workdir,
457
+ });
458
+ },
459
+ );
460
+ return WslShellExecutor.decorateResult(execution, (result) => {
461
+ if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
462
+ const { enforcement, denialSignatures, runnerFailureRules } = confined;
463
+ // Runner failure outranks denial because the command did not run at all.
464
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
465
+ if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
466
+ return {
467
+ ...result,
468
+ sandbox: {
469
+ mode,
470
+ denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
471
+ enforcement,
472
+ },
473
+ };
474
+ }, (error) => {
475
+ // An upstream abort remains cancellation even when it prevents spawn.
476
+ if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
477
+ if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], resolved.workdir)) {
478
+ throw new SandboxUnavailableError(mode, String(error));
479
+ }
480
+ throw error;
481
+ });
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Filesystem backend over a WSL distro's UNC share.
487
+ *
488
+ * Reads, listings, stats and searches reuse the host backend verbatim: the
489
+ * share is a real filesystem to Node, and the packaged ripgrep walks it too.
490
+ * Two things are deliberately replaced.
491
+ *
492
+ * **Resolution** accepts Linux paths (`/home/andy/proj/src/a.ts`) and shows them
493
+ * back as Linux paths, while `targetKey` stays the UNC path the host I/O uses —
494
+ * the seam treats `targetKey` as opaque and reserves `displayPath` for model and
495
+ * UI output, so this rewrite is contract-legal.
496
+ *
497
+ * **Mutation guards are hoisted.** The host backend publishes a guarded
498
+ * *creation* by hard-linking a staged file into place, and the WSL UNC share
499
+ * rejects hard links with `ENOTSUP`; it also refuses `symlink` with `EPERM`.
500
+ * Guarded creation through the share would therefore fail with `FS_IO_ERROR` on
501
+ * the most common write there is — creating a new file. This backend checks the
502
+ * caller's guard itself and then delegates the unguarded publication, which the
503
+ * share does support. The residual window is the same check-then-act race
504
+ * `dsh-fs-sandbox` already documents and accepts.
505
+ *
506
+ * **Publication is replaced.** Unguarded is not enough on its own: the host
507
+ * backend's Windows branch also copies the replaced file's security descriptor
508
+ * and publishes with `ReplaceFileW`, and neither exists on a 9p share — the
509
+ * descriptor read fails every write to an existing file outright. See the
510
+ * constructor for what runs instead.
511
+ */
512
+ export class WslFileSystem extends LocalFileSystem {
513
+ static inject = ["sandboxPolicy"];
514
+
515
+ static Config = z.object({
516
+ /** Distro name; empty resolves WSL's default on first use. */
517
+ distro: z.string().default(""),
518
+ /**
519
+ * Default workdir for relative input; accepts a Linux path, a UNC path, or
520
+ * `C:\...`. Empty — the default — means the distro user's home, resolved
521
+ * inside the distro.
522
+ */
523
+ cwd: z.string().default(""),
524
+ /** Path to `wsl.exe`, used only to resolve the default distro. */
525
+ wslPath: z.string().default("wsl.exe"),
526
+ /** UTF-8 byte limit per overwrite-diff side, mirroring the host backend. */
527
+ diffBasisMaxBytes: z.number().default(10 * 1024 * 1024),
528
+ /**
529
+ * Fence mutations by `ctx.sandboxPolicy`, and report the mode through
530
+ * {@link sandboxMode}. `false` restores the pre-sandbox behaviour exactly —
531
+ * an unfenced backend that reports `undefined` — for an operator who wants
532
+ * the old posture and accepts what it means: the model is told these
533
+ * operations are unconfined.
534
+ */
535
+ sandbox: z.boolean().default(true),
536
+ /**
537
+ * Refuse any path that leaves the pinned distro. A WSL filesystem is a
538
+ * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
539
+ * this the backend would silently widen the session far beyond the
540
+ * workspace the operator selected.
541
+ */
542
+ restrictToDistro: z.boolean().default(true),
543
+ /**
544
+ * When a read-side operation reports `FS_NOT_FOUND` on a Linux path, ask the
545
+ * distro for that path's canonical form and retry once. This is what makes
546
+ * symlinked paths usable at all: the UNC share cannot traverse Linux
547
+ * symlinks, so `/etc/os-release`, `/bin` and `/lib` would otherwise be
548
+ * unreachable from the model's read tools. Costs one `wsl.exe` call per
549
+ * distinct failing path, cached.
550
+ */
551
+ resolveSymlinks: z.boolean().default(true),
552
+ });
553
+
554
+ constructor(ctx, config) {
555
+ super(ctx, config);
556
+ this.resolvedDistro = config.distro;
557
+ /** Cached distro-user home, the base for relative input when `cwd` is empty. */
558
+ this.resolvedHome = undefined;
559
+ /** The deployment default mode — the capability fact the tool layer reads. */
560
+ this.defaultMode = ctx.sandboxPolicy.defaultMode;
561
+ /** targetKey -> canonical replacement target, for symlinked paths. */
562
+ this.canonicalTargets = new Map();
563
+ // `dsh-fs-local` takes a Windows branch whenever the destination already
564
+ // exists — which is how it knows to preserve the replaced file's security
565
+ // descriptor, and what makes every write to an existing file fail here:
566
+ //
567
+ // * it reads that descriptor with `GetFileSecurityW` and applies it to the
568
+ // staged temp. A 9p share carries no Windows security descriptor, so the
569
+ // call fails with `EIO (Win32 1)` and takes the whole write with it —
570
+ // `edit` and every overwrite, not just an exotic path.
571
+ // * it publishes with `ReplaceFileW`, which is not a 9p primitive.
572
+ //
573
+ // Both are replaced with their POSIX equivalents: the staged temp gets the
574
+ // target's real mode from inside the distro (see `copyModeInDistro` — the
575
+ // share ignores a host-side chmod), and publication is the plain atomic
576
+ // rename the share does support.
577
+ //
578
+ // Trade-off worth knowing: those two hooks are the only DACL handling in the
579
+ // inherited backend, so a *Windows* path served by this provider loses its
580
+ // descriptor preservation. This provider exists to serve distro paths, where
581
+ // no DACL can exist in the first place.
582
+ this.internals = {
583
+ ...this.internals,
584
+ copyFileDacl: async () => {},
585
+ replaceFile: (replaced, replacement) => this.publish(replaced, replacement),
586
+ };
587
+ }
588
+
589
+ /**
590
+ * The mode this backend fences mutations with, or `undefined` when the
591
+ * operator turned the fence off. This is the capability fact `dsh-tool-fs`
592
+ * reads to advertise the escalation fields honestly, so it must disappear
593
+ * together with the enforcement — never claim a boundary that is not applied.
594
+ * @returns the deployment default mode, or `undefined` when unfenced.
595
+ */
596
+ get sandboxMode() {
597
+ return this.config.sandbox ? this.defaultMode : undefined;
598
+ }
599
+
600
+ /**
601
+ * Publish a staged temp file over its target the way a 9p share supports:
602
+ * give the temp the target's POSIX mode inside the distro, then rename it into
603
+ * place.
604
+ *
605
+ * The mode is applied before the rename on purpose. The share drops a mode set
606
+ * from the Windows side, but a rename preserves the inode's mode — so setting
607
+ * it on the staged temp is what makes the published file keep the bits it had,
608
+ * without a second in-distro call after publication and without the version
609
+ * the write returns going stale behind a late `chmod`.
610
+ *
611
+ * @param replaced - the existing file being replaced (a UNC path).
612
+ * @param replacement - the staged temp file holding the new content.
613
+ */
614
+ async publish(replaced, replacement) {
615
+ const source = uncToPosix(replaced);
616
+ const staged = uncToPosix(replacement);
617
+ if (source !== undefined && staged !== undefined) {
618
+ await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
619
+ }
620
+ await rename(replacement, replaced);
621
+ }
622
+
623
+ /**
624
+ * The pinned distro, resolving WSL's default on first use.
625
+ * @returns the distro name.
626
+ */
627
+ async distro() {
628
+ if (this.resolvedDistro.length === 0) this.resolvedDistro = await defaultDistro({ wslPath: this.config.wslPath });
629
+ return this.resolvedDistro;
630
+ }
631
+
632
+ /**
633
+ * Translate one caller path into the world path, enforcing the distro fence.
634
+ * @param path - a Linux, UNC or Windows path.
635
+ * @param cwd - the caller's override of the configured default workdir.
636
+ * @returns the host path to operate on.
637
+ */
638
+ async worldPath(path, cwd) {
639
+ const distro = await this.distro();
640
+ let base = cwd ?? this.config.cwd;
641
+ if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(path)) {
642
+ // An empty `cwd` means the distro user's home. Only relative input
643
+ // consults the base at all, which is also the only case that has to pay
644
+ // for the `wsl.exe` query — an absolute path never does.
645
+ base = await distroHome(this);
646
+ }
647
+ const world = toWorldPath(path, { distro, cwd: base });
648
+ if (this.config.restrictToDistro && isWslUnc(world) && !isUnderDistro(world, distro)) {
649
+ throw new FsError(`"${path}" is outside distro "${distro}"`, "FS_SANDBOX_DENIED");
650
+ }
651
+ return world;
652
+ }
653
+
654
+ /**
655
+ * Resolve a path to a target whose `targetKey` is the UNC identity the host
656
+ * I/O stack uses and whose `displayPath` is the Linux path the model sees.
657
+ * @param path - a Linux, UNC or Windows path.
658
+ * @param opts - optional `cwd` override and `signal`.
659
+ * @returns the resolved `FsTarget`.
660
+ */
661
+ async resolve(path, opts) {
662
+ const distro = await this.distro();
663
+ const target = await super.resolve(await this.worldPath(path, opts?.cwd), opts);
664
+ return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
665
+ }
666
+
667
+ /**
668
+ * Enforce the per-call file-effect policy against one target and return the
669
+ * EXACT target the mutation must use, so the checked identity is the mutated
670
+ * one (no check-here-write-there window).
671
+ *
672
+ * `read-only` denies outright. `workspace-write` re-resolves the target NOW
673
+ * (reflecting a symlink ancestor swapped since the tool resolved it), requires
674
+ * containment under a writable root — the workspace plus the temp area of the
675
+ * world the workspace lives in — and hands back that fresh target.
676
+ * `danger-full-access` is the approved escalation and is not fenced. Refusal
677
+ * is the structured `FS_SANDBOX_DENIED` the tool layer maps to its
678
+ * model-facing `[sandbox: …]` marker and escalation hint.
679
+ *
680
+ * @param target - the resolved target to mutate.
681
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
682
+ * deployment policy.
683
+ * @returns the target the mutation must use.
684
+ * @throws FsError with `FS_SANDBOX_DENIED` when the policy refuses it.
685
+ */
686
+ async checkedTarget(target, sandboxPolicy) {
687
+ if (!this.config.sandbox) return target;
688
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
689
+ const { mode } = policy;
690
+ if (mode === "danger-full-access") return target;
691
+ if (mode === "read-only") {
692
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, "FS_SANDBOX_DENIED");
693
+ }
694
+ const fresh = await this.resolve(target.displayPath);
695
+ const distro = await this.distro();
696
+ for (const root of wslWritableRoots(policy, distro)) {
697
+ if (isWorldPathUnder(fresh.targetKey, root, { distro })) return fresh;
698
+ }
699
+ throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, "FS_SANDBOX_DENIED");
700
+ }
701
+
702
+ /**
703
+ * The canonical replacement target for a Linux symlink this share cannot
704
+ * traverse, or undefined when the path is not one.
705
+ * @param target - the resolved target that failed a read-side operation.
706
+ * @param signal - optional cancellation.
707
+ * @returns the replacement target, or undefined.
708
+ */
709
+ async canonicalTarget(target, signal) {
710
+ if (!this.config.resolveSymlinks) return undefined;
711
+ const cached = this.canonicalTargets.get(target.targetKey);
712
+ if (cached !== undefined) return cached;
713
+ const parsed = uncToPosix(target.targetKey);
714
+ if (parsed === undefined) return undefined;
715
+ const canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
716
+ if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
717
+ const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
718
+ this.canonicalTargets.set(target.targetKey, replacement);
719
+ return replacement;
720
+ }
721
+
722
+ /**
723
+ * Run one read-side operation, retrying once through the distro's canonical
724
+ * path when the share cannot follow a symlink. `displayPath` is preserved, so
725
+ * the model still sees the path it asked for while `targetKey` names the file
726
+ * the host can actually open.
727
+ *
728
+ * @param target - the resolved target.
729
+ * @param signal - optional cancellation.
730
+ * @param operation - the inherited operation to attempt.
731
+ * @returns the operation's result.
732
+ */
733
+ async withCanonicalRetry(target, signal, operation) {
734
+ let value;
735
+ try {
736
+ value = await operation(target);
737
+ } catch (error) {
738
+ if (!(error instanceof FsError) || error.code !== "FS_NOT_FOUND") throw error;
739
+ const canonical = await this.canonicalTarget(target, signal);
740
+ if (canonical === undefined) throw error;
741
+ return operation(canonical);
742
+ }
743
+ // `stat` reports an absent file by RETURNING undefined rather than throwing,
744
+ // and an untraversable symlink takes exactly that path over this share. The
745
+ // model-facing read tool branches on precisely that undefined — it resolves,
746
+ // stats, and renders "not found" itself — so retrying only the throw is not
747
+ // enough: the silent miss has to be retried here too.
748
+ if (value === undefined) {
749
+ const canonical = await this.canonicalTarget(target, signal);
750
+ if (canonical !== undefined) return operation(canonical);
751
+ }
752
+ return value;
753
+ }
754
+
755
+ async stat(target, signal) {
756
+ return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
757
+ }
758
+
759
+ async readText(target, signal) {
760
+ return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
761
+ }
762
+
763
+ async streamText(target, signal) {
764
+ return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
765
+ }
766
+
767
+ async readBytes(target, signal, maxBytes) {
768
+ return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
769
+ }
770
+
771
+ async readByteRange(target, range, signal) {
772
+ return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
773
+ }
774
+
775
+ /**
776
+ * List one level, rewriting each child's display path into the distro's
777
+ * coordinate system — the host backend reports children with UNC display paths,
778
+ * which would leak `\\wsl.localhost\...` into model-facing output.
779
+ *
780
+ * @param target - the resolved directory.
781
+ * @param signal - optional cancellation.
782
+ * @returns the directory rows.
783
+ */
784
+ async listDir(target, signal) {
785
+ const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
786
+ const distro = await this.distro();
787
+ return entries.map((entry) => ({
788
+ ...entry,
789
+ target: { targetKey: entry.target.targetKey, displayPath: toDisplayPath(entry.target.targetKey, distro) },
790
+ }));
791
+ }
792
+
793
+ /**
794
+ * Map an absolute host path into this execution world. POSIX input is a Linux
795
+ * path inside the distro; anything else keeps the host backend's behaviour.
796
+ * @param hostPath - an absolute path.
797
+ * @returns the process path for the same file, or undefined when unmappable.
798
+ */
799
+ async processPathFromHostPath(hostPath) {
800
+ return this.worldPath(hostPath);
801
+ }
802
+
803
+ /**
804
+ * Watching a 9p share is neither reliable nor cheap, so this provider refuses
805
+ * rather than arming a watcher that may never fire — the seam's documented
806
+ * failure mode for a provider that cannot watch.
807
+ * @returns a rejection carrying `FS_IO_ERROR`.
808
+ */
809
+ watch() {
810
+ return Promise.reject(new FsError("Filesystem watching is not supported over the WSL UNC share.", "FS_IO_ERROR"));
811
+ }
812
+
813
+ /**
814
+ * Check the caller's mutation guard immediately before publication.
815
+ * @param target - the resolved target.
816
+ * @param expected - the write intent, or undefined for an unconditional write.
817
+ * @param signal - optional cancellation.
818
+ * @throws FsError with the same codes the host backend raises.
819
+ */
820
+ async assertGuard(target, expected, signal) {
821
+ if (expected === undefined) return;
822
+ const existing = await this.stat(target, signal);
823
+ if (expected.kind === "replaceIfVersion") {
824
+ if (existing === undefined) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, "FS_STALE_VERSION");
825
+ if (existing.version !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
826
+ return;
827
+ }
828
+ if (expected.kind === "createIfAbsent" && existing !== undefined) {
829
+ throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Fence the write by the per-call policy, then keep the guard's observable
835
+ * behaviour.
836
+ * @param target - the resolved target.
837
+ * @param content - the full new file content.
838
+ * @param expected - the write intent.
839
+ * @param signal - optional cancellation.
840
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
841
+ * deployment policy.
842
+ * @returns the write outcome.
843
+ */
844
+ async writeText(target, content, expected, signal, sandboxPolicy) {
845
+ // The fence runs first and its fresh target is the one checked and written,
846
+ // so a refusal costs no I/O and cannot be raced by a swapped symlink.
847
+ const fenced = await this.checkedTarget(target, sandboxPolicy);
848
+ await this.assertGuard(fenced, expected, signal);
849
+ // The guard is deliberately not forwarded: the host backend would publish a
850
+ // guarded creation with a hard link, which this filesystem rejects. Once
851
+ // past the guard, publication is the overridden `publish` (see the
852
+ // constructor), not the inherited Windows one.
853
+ return super.writeText(fenced, content, undefined, signal);
854
+ }
855
+
856
+ /**
857
+ * Fence the edit by the same policy, keeping the guard's observable behaviour.
858
+ * @param target - the resolved target.
859
+ * @param edit - the literal search/replace request.
860
+ * @param expected - the version guard.
861
+ * @param signal - optional cancellation.
862
+ * @param sandboxPolicy - the per-call mode and workspace root; omit for the
863
+ * deployment policy.
864
+ * @returns the edit outcome.
865
+ */
866
+ async editText(target, edit, expected, signal, sandboxPolicy) {
867
+ const fenced = await this.checkedTarget(target, sandboxPolicy);
868
+ await this.assertGuard(fenced, expected, signal);
869
+ return super.editText(fenced, edit, undefined, signal);
870
+ }
871
+ }