dsh-plugin-wsl-env 0.11.0 → 0.13.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTRIBUTING.md +29 -7
  3. package/README.md +124 -138
  4. package/SECURITY.md +11 -1
  5. package/SUPPORT.md +29 -2
  6. package/cordis.patch.yml +17 -19
  7. package/docs/ARCHITECTURE.md +30 -29
  8. package/docs/CONFIGURATION.md +81 -21
  9. package/docs/LIMITATIONS.md +71 -6
  10. package/docs/MIGRATION.md +98 -0
  11. package/docs/PARITY.md +1 -1
  12. package/docs/PERFORMANCE.md +60 -0
  13. package/docs/QUICKSTART.md +125 -0
  14. package/docs/README.zh.md +117 -137
  15. package/docs/ROADMAP-1.0.md +780 -0
  16. package/docs/ROADMAP.md +4 -0
  17. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
  18. package/docs/UPSTREAM-LSP-SEAM.md +90 -0
  19. package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
  20. package/lib/agent-errors.d.ts +19 -0
  21. package/lib/agent-errors.js +22 -0
  22. package/lib/agent-exec.js +2 -2
  23. package/lib/agent.d.ts +68 -5
  24. package/lib/agent.js +129 -22
  25. package/lib/distro-route.d.ts +75 -0
  26. package/lib/distro-route.js +86 -0
  27. package/lib/fs-routing.d.ts +4 -1
  28. package/lib/index.d.ts +166 -37
  29. package/lib/index.js +353 -75
  30. package/lib/paths.d.ts +15 -0
  31. package/lib/paths.js +28 -0
  32. package/lib/preflight.d.ts +46 -3
  33. package/lib/preflight.js +83 -14
  34. package/lib/shell-env.d.ts +9 -3
  35. package/lib/shell-env.js +121 -43
  36. package/lib/subprocess.d.ts +12 -3
  37. package/lib/subprocess.js +21 -3
  38. package/package.json +23 -15
  39. package/scripts/diagnose.sh +9 -88
  40. package/scripts/doctor.mjs +548 -0
  41. package/scripts/profile-preset.mjs +254 -0
  42. package/lib/workspace-files-route.d.ts +0 -136
  43. package/lib/workspace-files-route.js +0 -225
  44. package/lib/workspace-files-wsl.d.ts +0 -257
  45. package/lib/workspace-files-wsl.js +0 -418
package/lib/index.js CHANGED
@@ -46,10 +46,11 @@ import { FsError } from "@deepseek-ai/dsh-fs";
46
46
  import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
47
47
  import { isAnotherDistrosUnc, isRelativeWorldPath, isWorldPathUnder, isWslUnc, posixToUnc, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
48
48
  import { isConfinedMutation, mutationModeRefusal, outsideDistroRefusal, shareSubstrateRefusal, substrateFailure, workspaceWriteDenial } from "./fs-decisions.js";
49
+ import { distroOfUnc, isDefaultDistro, pinnedDistro, selectDistro } from "./distro-route.js";
49
50
  import { WslSandbox } from "./sandbox.js";
50
51
  import { bwrapFailure, missingBwrapRemedy, withBwrapHint } from "./sandbox-core.js";
51
52
  import { armDistroWatcher } from "./watcher.js";
52
- import { AgentUnavailableError } from "./agent-errors.js";
53
+ import { AgentUnavailableError, SpawnError } from "./agent-errors.js";
53
54
  import { agentExecutionHandle } from "./agent-exec.js";
54
55
  import { sharedAgent } from "./agent-shared.js";
55
56
  import { AgentSubstrate } from "./fs-substrate.js";
@@ -102,20 +103,32 @@ function configuredCwd(config) {
102
103
  * An empty `cwd` cannot mean the shipped default (`process.cwd()`, a Windows
103
104
  * directory no `wsl.exe --cd` accepts) and it must not mean a hardcoded author
104
105
  * path either, so both providers agree on this instead: the distro's own home,
105
- * asked of the distro once per instance.
106
+ * asked of the distro once per (provider, distro) pair.
106
107
  *
107
108
  * @param owner - a WslShellExecutor or WslFileSystem.
109
+ * @param distro - the distro whose home is wanted.
108
110
  * @returns an absolute Linux path.
109
111
  */
110
- async function distroHome(owner) {
111
- if (owner.resolvedHome === undefined) {
112
- // Both providers carry `wslPath` in their own config; the base classes'
113
- // `config` types do not know the key, so the shape is asserted here.
114
- const config = owner.config;
115
- const distro = await owner.distro();
116
- owner.resolvedHome = await linuxHomePath(distro, { wslPath: config.wslPath });
117
- }
118
- return owner.resolvedHome;
112
+ async function distroHome(owner, distro) {
113
+ const cached = owner.resolvedHomes.get(distro);
114
+ if (cached !== undefined)
115
+ return cached;
116
+ // The single-home field predates the per-distro map and is still the test
117
+ // substitute for "a home is already known"; honour it before spawning.
118
+ if (owner.resolvedHome !== undefined && owner.resolvedDistro.length > 0 && distro === owner.resolvedDistro) {
119
+ owner.resolvedHomes.set(distro, owner.resolvedHome);
120
+ return owner.resolvedHome;
121
+ }
122
+ // Both providers carry `wslPath` in their own config; the base classes'
123
+ // `config` types do not know the key, so the shape is asserted here.
124
+ const config = owner.config;
125
+ const home = await linuxHomePath(distro, { wslPath: config.wslPath });
126
+ owner.resolvedHomes.set(distro, home);
127
+ // The legacy single-home cache is still what `processPathFromHostPath` and
128
+ // the existing tests read; keep it warm for the resolved default/pin.
129
+ if (owner.resolvedDistro.length > 0 && distro === owner.resolvedDistro)
130
+ owner.resolvedHome = home;
131
+ return home;
119
132
  }
120
133
  /**
121
134
  * The roots a `workspace-write` policy may write under, in the vocabulary this
@@ -147,6 +160,48 @@ function wslWritableRoots(policy, distro) {
147
160
  return [...roots];
148
161
  }
149
162
  const inheritedSpawnSpec = LocalBashExecutor.prototype.spawnSpec;
163
+ /**
164
+ * How long an `auto` distro that could not host a session runtime stays
165
+ * downgraded before the probe is retried.
166
+ */
167
+ const AUTO_SCOPE_RECHECK_MS = 60_000;
168
+ /**
169
+ * Whether a provider should use the session runtime for one distro.
170
+ *
171
+ * - `false` is off;
172
+ * - `true` refuses with the preflight remedy when the platform cannot host it;
173
+ * - `"auto"` downgrades to the per-command sandbox and reports that narrower
174
+ * scope in the result, re-probing a failed verdict at most once a minute so
175
+ * following the remedy (systemd, Linger) upgrades without an app restart.
176
+ *
177
+ * @param value - the configured `sessionSandbox` value.
178
+ * @param distro - the resolved distro.
179
+ * @param ensure - the provider's preflight, which throws the remedy on failure.
180
+ * @param cache - the provider's per-distro `auto` verdicts.
181
+ * @returns whether the session runtime should be used.
182
+ */
183
+ async function sessionRuntimeWanted(value, distro, ensure, cache) {
184
+ if (value === false)
185
+ return false;
186
+ if (value === true) {
187
+ await ensure();
188
+ return true;
189
+ }
190
+ const cached = cache.get(distro);
191
+ if (cached?.usable === true)
192
+ return true;
193
+ if (cached !== undefined && Date.now() - cached.checkedAt < AUTO_SCOPE_RECHECK_MS)
194
+ return false;
195
+ try {
196
+ await ensure();
197
+ cache.set(distro, { usable: true, checkedAt: Date.now() });
198
+ return true;
199
+ }
200
+ catch {
201
+ cache.set(distro, { usable: false, checkedAt: Date.now() });
202
+ return false;
203
+ }
204
+ }
150
205
  /**
151
206
  * Bash executor that runs every command inside a WSL distro.
152
207
  *
@@ -160,7 +215,10 @@ const inheritedSpawnSpec = LocalBashExecutor.prototype.spawnSpec;
160
215
  export class WslShellExecutor extends LocalBashExecutor {
161
216
  static inject = ["subprocess", "sandboxPolicy"];
162
217
  static Config = z.object({
163
- /** Distro name; empty means WSL's own default distro. */
218
+ /**
219
+ * Distro mode: empty/`"auto"` follows the request workdir, `"default"`
220
+ * forces WSL's default, and an explicit name pins every command.
221
+ */
164
222
  distro: z.string().default(""),
165
223
  /** Path to `wsl.exe`; overridable for a non-standard install. */
166
224
  wslPath: z.string().default("wsl.exe"),
@@ -233,7 +291,7 @@ export class WslShellExecutor extends LocalBashExecutor {
233
291
  * `Linger`; the preflight refuses with the remedy when one is missing, and
234
292
  * only a success is cached. Off by default; requires `agent: true`.
235
293
  */
236
- sessionSandbox: z.boolean().default(false),
294
+ sessionSandbox: z.union([z.boolean(), z.const("auto")]).default(false),
237
295
  });
238
296
  /**
239
297
  * @param ctx - the composition context.
@@ -242,16 +300,21 @@ export class WslShellExecutor extends LocalBashExecutor {
242
300
  constructor(ctx, config) {
243
301
  super(ctx, config);
244
302
  configureAgentBackend(config.agentBackend ?? "auto");
245
- this.resolvedDistro = config.distro;
303
+ this.resolvedDistro = pinnedDistro(config.distro) ?? "";
246
304
  this.resolvedShell = config.shell;
305
+ this.resolvedShells = new Map();
247
306
  this.resolvedHome = undefined;
307
+ this.resolvedHomes = new Map();
248
308
  this.mode = config.sandbox ? ctx.sandboxPolicy.defaultMode : undefined;
249
- this.sandbox = config.sandbox ? new WslSandbox({ wslPath: config.wslPath }) : undefined;
309
+ this.sandbox = config.sandbox
310
+ ? new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive })
311
+ : undefined;
250
312
  this.processFacts = new Map();
251
313
  this.sessionRuntimes = sessionRuntimes;
252
314
  this.sessionRuntimeEntries = new Map();
253
315
  this.sessionScopeProbe = (argv) => runCapture(argv);
254
- this.sessionRuntimePreflight = "unknown";
316
+ this.sessionRuntimePreflight = new Set();
317
+ this.autoSessionScope = new Map();
255
318
  // The preset realm disposes when the session ends; the scope must go with
256
319
  // it, or a dev server outlives the session that started it.
257
320
  ctx.effect(() => () => this.disposeSessionRuntimes().catch(() => { }), "wsl session runtimes");
@@ -268,7 +331,8 @@ export class WslShellExecutor extends LocalBashExecutor {
268
331
  return this.mode;
269
332
  }
270
333
  /**
271
- * The distro every command runs in, resolving WSL's default on first use.
334
+ * The distro used when no path supplies evidence: the pinned name, WSL's
335
+ * default, or the default for the automatic modes.
272
336
  *
273
337
  * @returns the distro name.
274
338
  */
@@ -279,10 +343,33 @@ export class WslShellExecutor extends LocalBashExecutor {
279
343
  return this.resolvedDistro;
280
344
  }
281
345
  /**
282
- * The shell every command runs in: the configured one, else the login shell of
283
- * the distro user, resolved once and cached.
346
+ * The distro one command belongs to: an explicit pin wins; otherwise the
347
+ * request workdir, then the configured `cwd`, may name a distro; both
348
+ * absent falls back to WSL's default.
284
349
  *
285
- * @returns an absolute path to the shell inside the distro.
350
+ * @param workdir - the request's workdir, in any coordinate system.
351
+ * @param cwd - the configured default workdir, when the caller has it.
352
+ * @returns the distro name.
353
+ */
354
+ async distroFor(workdir, cwd) {
355
+ const config = this.config;
356
+ const pinned = pinnedDistro(config.distro);
357
+ if (pinned !== undefined)
358
+ return pinned;
359
+ if (!isDefaultDistro(config.distro)) {
360
+ for (const candidate of [workdir, cwd ?? configuredCwd(this.config)]) {
361
+ const named = distroOfUnc(candidate);
362
+ if (named !== undefined)
363
+ return named;
364
+ }
365
+ }
366
+ return this.distro();
367
+ }
368
+ /**
369
+ * The shell name for the default/pinned distro, as the shell tool presents
370
+ * it.
371
+ *
372
+ * @returns the basename of the shell, or `bash` when it cannot be named.
286
373
  */
287
374
  async shellName() {
288
375
  // Consumed by the shell tool after upstream change A: it names and describes
@@ -293,8 +380,30 @@ export class WslShellExecutor extends LocalBashExecutor {
293
380
  return /^[a-z0-9_+-]+$/i.test(name) ? name : "bash";
294
381
  }
295
382
  /**
296
- * The shell every command runs in, resolving the distro login shell on
297
- * first use.
383
+ * The shell for one distro: the configured shell, else that distro's login
384
+ * shell. The default/pinned distro keeps the legacy {@link resolvedShell}
385
+ * cache; every other distro gets an entry in {@link resolvedShells}.
386
+ *
387
+ * @param distro - the distro name.
388
+ * @returns an absolute path to the shell inside the distro.
389
+ */
390
+ async shellFor(distro) {
391
+ const config = this.config;
392
+ if (config.shell.length > 0)
393
+ return config.shell;
394
+ if (this.resolvedDistro.length > 0 && distro === this.resolvedDistro)
395
+ return this.shell();
396
+ const cached = this.resolvedShells.get(distro);
397
+ if (cached !== undefined)
398
+ return cached;
399
+ const resolved = await defaultShell(distro, { wslPath: config.wslPath });
400
+ if (resolved !== undefined)
401
+ this.resolvedShells.set(distro, resolved);
402
+ return resolved ?? "bash";
403
+ }
404
+ /**
405
+ * The shell for the default/pinned distro: the configured one, else the
406
+ * login shell, resolved once and cached.
298
407
  *
299
408
  * @returns an absolute path to the shell inside the distro.
300
409
  */
@@ -374,14 +483,26 @@ export class WslShellExecutor extends LocalBashExecutor {
374
483
  * @throws {Error} with the remedy when it cannot.
375
484
  */
376
485
  async ensureSessionRuntimeUsable(distro) {
377
- if (this.sessionRuntimePreflight === "ready")
486
+ if (this.sessionRuntimePreflight.has(distro))
378
487
  return;
379
488
  const config = this.config;
380
489
  const problem = await probeSessionRuntime({ wslPath: config.wslPath, distro, run: this.sessionScopeProbe });
381
490
  if (problem !== null)
382
491
  throw new Error(problem);
383
492
  await ensureNativeSessionAgent({ wslPath: config.wslPath, distro, setNative: setSessionNativeAgent });
384
- this.sessionRuntimePreflight = "ready";
493
+ this.sessionRuntimePreflight.add(distro);
494
+ }
495
+ /**
496
+ * Whether this executor should use the session runtime for one distro, with
497
+ * the `auto` downgrade and its bounded re-probe. `true` keeps the refusing
498
+ * behavior; `false` stays off.
499
+ *
500
+ * @param distro - the resolved distro.
501
+ * @returns whether to acquire the per-session scope.
502
+ */
503
+ async wantsSessionRuntime(distro) {
504
+ const config = this.config;
505
+ return sessionRuntimeWanted(config.sessionSandbox, distro, () => this.ensureSessionRuntimeUsable(distro), this.autoSessionScope);
385
506
  }
386
507
  /**
387
508
  * Release every runtime this executor holds, stopping each scope. Called
@@ -530,10 +651,11 @@ export class WslShellExecutor extends LocalBashExecutor {
530
651
  const tail = typeof spec?.relativeWorkdir === "string" ? spec.relativeWorkdir : undefined;
531
652
  if (String(spec?.workdir ?? "").trim().length > 0 && tail === undefined)
532
653
  return spec;
533
- const home = await distroHome(this);
654
+ const distro = await this.distroFor(spec.workdir, configuredCwd(this.config));
655
+ const home = await distroHome(this, distro);
534
656
  if (tail === undefined || tail.length === 0)
535
657
  return { ...spec, workdir: home };
536
- return { ...spec, workdir: toLinuxPath(tail, { distro: await this.distro(), cwd: home }) };
658
+ return { ...spec, workdir: toLinuxPath(tail, { distro, cwd: home }) };
537
659
  }
538
660
  /**
539
661
  * Decorate a handle's foreground projection in place, memoized once. The
@@ -582,6 +704,7 @@ export class WslShellExecutor extends LocalBashExecutor {
582
704
  denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
583
705
  enforcement: facts.enforcement,
584
706
  ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
707
+ sessionScope: facts.sessionScope,
585
708
  ...(runnerFailed ? { runnerFailed } : {}),
586
709
  };
587
710
  }
@@ -645,9 +768,9 @@ export class WslShellExecutor extends LocalBashExecutor {
645
768
  */
646
769
  async execute(spec) {
647
770
  const config = this.config;
648
- const distro = await this.distro();
649
- const shell = await this.shell();
650
771
  const resolved = await this.withDefaultWorkdir(spec);
772
+ const distro = await this.distroFor(resolved.workdir, configuredCwd(config));
773
+ const shell = await this.shellFor(distro);
651
774
  this.throwIfWorkdirInAnotherDistro(resolved.workdir, distro);
652
775
  if (config.agent) {
653
776
  try {
@@ -698,9 +821,8 @@ export class WslShellExecutor extends LocalBashExecutor {
698
821
  ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro, signal: resolved.signal })
699
822
  : undefined;
700
823
  const mode = policy?.mode;
701
- if (config.sessionSandbox)
702
- await this.ensureSessionRuntimeUsable(distro);
703
- const runtime = this.sessionRuntimeFor(policy, resolved, distro);
824
+ const useScope = await this.wantsSessionRuntime(distro);
825
+ const runtime = useScope ? this.sessionRuntimeFor(policy, resolved, distro) : undefined;
704
826
  const agent = runtime !== undefined ? runtime.agent : await this.executionAgent(distro);
705
827
  // A session runtime's own bwrap profile applies the mounts, so the
706
828
  // command must NOT be wrapped a second time: the per-command probe still
@@ -740,6 +862,7 @@ export class WslShellExecutor extends LocalBashExecutor {
740
862
  runnerProgram: profile.argv[0] ?? "",
741
863
  workdir: resolved.workdir,
742
864
  windowsDrive: profile.windowsDrive,
865
+ sessionScope: useScope ? "session" : "per-command",
743
866
  });
744
867
  }
745
868
  : undefined,
@@ -750,7 +873,7 @@ export class WslShellExecutor extends LocalBashExecutor {
750
873
  if (this.sandbox === undefined)
751
874
  return result;
752
875
  if (confined === undefined) {
753
- return ({ ...result, sandbox: { mode, denied: false } });
876
+ return ({ ...result, sandbox: { mode, denied: false, sessionScope: useScope ? "session" : "per-command" } });
754
877
  }
755
878
  const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
756
879
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
@@ -767,11 +890,19 @@ export class WslShellExecutor extends LocalBashExecutor {
767
890
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
768
891
  enforcement,
769
892
  windowsDrive,
893
+ sessionScope: useScope ? "session" : "per-command",
770
894
  },
771
895
  });
772
896
  }, (error) => {
773
897
  if (resolved.signal?.aborted === true)
774
898
  resolved.signal.throwIfAborted();
899
+ // The native agent reports a missing workdir as a spawn failure without
900
+ // naming it. Retry that command on the one-shot path, whose `--cd`
901
+ // warning names the directory — the failure path pays the extra spawn,
902
+ // the hot path does not.
903
+ if (error instanceof SpawnError) {
904
+ return this.executeOneShot(resolved, distro, shell).then((fallback) => fallback.result());
905
+ }
775
906
  throw error;
776
907
  });
777
908
  // The agent handle IS the upstream execution shape at runtime; its typedef
@@ -829,13 +960,14 @@ export class WslShellExecutor extends LocalBashExecutor {
829
960
  runnerProgram: profile.argv[0] ?? "",
830
961
  workdir: resolved.workdir,
831
962
  windowsDrive: profile.windowsDrive,
963
+ sessionScope: "per-command",
832
964
  });
833
965
  });
834
966
  return WslShellExecutor.decorateResult(execution, (result) => {
835
967
  this.throwIfDistroUnavailable(result, distro);
836
968
  this.throwIfWorkdirMissing(result, resolved.workdir);
837
969
  if (confined === undefined) {
838
- return ({ ...result, sandbox: { mode, denied: false } });
970
+ return ({ ...result, sandbox: { mode, denied: false, sessionScope: "per-command" } });
839
971
  }
840
972
  const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
841
973
  // Runner failure outranks denial because the command did not run at all.
@@ -849,6 +981,7 @@ export class WslShellExecutor extends LocalBashExecutor {
849
981
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
850
982
  enforcement,
851
983
  windowsDrive,
984
+ sessionScope: "per-command",
852
985
  },
853
986
  });
854
987
  }, (error) => {
@@ -865,7 +998,10 @@ export class WslShellExecutor extends LocalBashExecutor {
865
998
  export class WslFileSystem extends LocalFileSystem {
866
999
  static inject = ["sandboxPolicy"];
867
1000
  static Config = z.object({
868
- /** Distro name; empty resolves WSL's default on first use. */
1001
+ /**
1002
+ * Distro mode: empty/`"auto"` follows the target path, `"default"` forces
1003
+ * WSL's default, and an explicit name pins the filesystem.
1004
+ */
869
1005
  distro: z.string().default(""),
870
1006
  /**
871
1007
  * Default workdir for relative input; accepts a Linux path, a UNC path, or
@@ -895,7 +1031,7 @@ export class WslFileSystem extends LocalFileSystem {
895
1031
  * `sessionSandbox`; the preflight refuses with the remedy. Off for direct
896
1032
  * constructions; on in the shipped preset.
897
1033
  */
898
- sessionSandbox: z.boolean().default(false),
1034
+ sessionSandbox: z.union([z.boolean(), z.const("auto")]).default(false),
899
1035
  /**
900
1036
  * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
901
1037
  * Windows drive from confined commands — same meaning and same honest
@@ -949,16 +1085,20 @@ export class WslFileSystem extends LocalFileSystem {
949
1085
  if (refusal !== null)
950
1086
  throw new Error(refusal.message);
951
1087
  super(ctx, config);
952
- this.resolvedDistro = config.distro;
1088
+ this.resolvedDistro = pinnedDistro(config.distro) ?? "";
953
1089
  this.resolvedHome = undefined;
1090
+ this.resolvedHomes = new Map();
954
1091
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
955
1092
  this.agentSubstrateInstance = undefined;
1093
+ this.agentSubstrates = new Map();
956
1094
  this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
957
1095
  this.agentInstance = undefined;
1096
+ this.agentInstances = new Map();
958
1097
  this.sessionRuntimes = sessionRuntimes;
959
1098
  this.sessionRuntimeEntries = new Map();
960
1099
  this.sessionScopeProbe = (argv) => runCapture(argv);
961
- this.sessionRuntimePreflight = "unknown";
1100
+ this.sessionRuntimePreflight = new Set();
1101
+ this.autoSessionScope = new Map();
962
1102
  // The preset's disposer releases the runtime; the shell executor's effect
963
1103
  // releases its own reference, so the scope stops after the last one.
964
1104
  ctx.effect(() => () => this.disposeSessionRuntimes().catch(() => { }), "wsl fs session runtimes");
@@ -976,11 +1116,10 @@ export class WslFileSystem extends LocalFileSystem {
976
1116
  return config.sandbox ? this.defaultMode : undefined;
977
1117
  }
978
1118
  /**
979
- * The resident agent serving this filesystem's in-distro side work, created
980
- * on first use and kept for the provider's lifetime (its own idle timer
981
- * retires the process; a later call starts a fresh one).
1119
+ * The resident agent serving the default/pinned distro's in-distro side
1120
+ * work, created on first use and kept for the provider's lifetime.
982
1121
  *
983
- * @returns the agent for the pinned distro.
1122
+ * @returns the agent for the default/pinned distro.
984
1123
  */
985
1124
  async publicationAgent() {
986
1125
  if (this.agentInstance === undefined) {
@@ -989,8 +1128,26 @@ export class WslFileSystem extends LocalFileSystem {
989
1128
  return this.agentInstance;
990
1129
  }
991
1130
  /**
992
- * The agent-backed substrate, built on first use over the same resident
993
- * agent every other distro side work shares.
1131
+ * The resident agent for one distro. The default/pinned distro keeps the
1132
+ * legacy {@link publicationAgent} cache; every other distro gets its own
1133
+ * entry, because the shared agent is a per-distro process singleton.
1134
+ *
1135
+ * @param distro - the distro name.
1136
+ * @returns the agent for that distro.
1137
+ */
1138
+ async publicationAgentFor(distro) {
1139
+ if (this.resolvedDistro.length > 0 && distro === this.resolvedDistro)
1140
+ return this.publicationAgent();
1141
+ const cached = this.agentInstances.get(distro);
1142
+ if (cached !== undefined)
1143
+ return cached;
1144
+ const agent = sharedAgent(distro);
1145
+ this.agentInstances.set(distro, agent);
1146
+ return agent;
1147
+ }
1148
+ /**
1149
+ * The agent-backed substrate for the default/pinned distro, built on first
1150
+ * use over the shared resident agent.
994
1151
  *
995
1152
  * @returns the substrate.
996
1153
  */
@@ -1006,6 +1163,40 @@ export class WslFileSystem extends LocalFileSystem {
1006
1163
  }
1007
1164
  return this.agentSubstrateInstance;
1008
1165
  }
1166
+ /**
1167
+ * The agent-backed substrate for one distro. The default/pinned distro keeps
1168
+ * the legacy {@link agentSubstrate} accessor (and its test override); every
1169
+ * other distro gets a substrate that mints its own UNC identities and
1170
+ * addresses its own resident.
1171
+ *
1172
+ * @param distro - the distro name.
1173
+ * @returns the substrate for that distro.
1174
+ */
1175
+ async agentSubstrateFor(distro) {
1176
+ if (this.resolvedDistro.length > 0 && distro === this.resolvedDistro)
1177
+ return this.agentSubstrate();
1178
+ const cached = this.agentSubstrates.get(distro);
1179
+ if (cached !== undefined)
1180
+ return cached;
1181
+ const config = this.config;
1182
+ const substrate = new AgentSubstrate({
1183
+ agent: await this.publicationAgentFor(distro),
1184
+ distro,
1185
+ diffBasisMaxBytes: config.diffBasisMaxBytes,
1186
+ agentFor: (policy) => this.mutationAgent(policy, distro),
1187
+ });
1188
+ this.agentSubstrates.set(distro, substrate);
1189
+ return substrate;
1190
+ }
1191
+ /**
1192
+ * The substrate for one resolved target, following the target's UNC identity.
1193
+ *
1194
+ * @param target - the resolved target.
1195
+ * @returns the substrate for the target's distro.
1196
+ */
1197
+ async substrateForTarget(target) {
1198
+ return this.agentSubstrateFor(await this.distroForTarget(target));
1199
+ }
1009
1200
  /**
1010
1201
  * The persistent session runtime for one confined mutation, when
1011
1202
  * `sessionSandbox` is on. The key is the shell executor's key for the same
@@ -1044,14 +1235,26 @@ export class WslFileSystem extends LocalFileSystem {
1044
1235
  * @throws {Error} with the remedy when it cannot.
1045
1236
  */
1046
1237
  async ensureSessionRuntimeUsable(distro) {
1047
- if (this.sessionRuntimePreflight === "ready")
1238
+ if (this.sessionRuntimePreflight.has(distro))
1048
1239
  return;
1049
1240
  const config = this.config;
1050
1241
  const problem = await probeSessionRuntime({ wslPath: config.wslPath, distro, run: this.sessionScopeProbe });
1051
1242
  if (problem !== null)
1052
1243
  throw new Error(problem);
1053
1244
  await ensureNativeSessionAgent({ wslPath: config.wslPath, distro, setNative: setSessionNativeAgent });
1054
- this.sessionRuntimePreflight = "ready";
1245
+ this.sessionRuntimePreflight.add(distro);
1246
+ }
1247
+ /**
1248
+ * Whether this provider should use the session runtime for one distro, with
1249
+ * the `auto` downgrade and its bounded re-probe. `true` keeps the refusing
1250
+ * behavior; `false` stays off.
1251
+ *
1252
+ * @param distro - the resolved distro.
1253
+ * @returns whether to acquire the per-session scope.
1254
+ */
1255
+ async wantsSessionRuntime(distro) {
1256
+ const config = this.config;
1257
+ return sessionRuntimeWanted(config.sessionSandbox, distro, () => this.ensureSessionRuntimeUsable(distro), this.autoSessionScope);
1055
1258
  }
1056
1259
  /**
1057
1260
  * Release every runtime this provider holds. Called from the preset's
@@ -1076,26 +1279,27 @@ export class WslFileSystem extends LocalFileSystem {
1076
1279
  *
1077
1280
  * @param policy - the resolved file-effect policy, or undefined when the
1078
1281
  * caller pre-checked.
1282
+ * @param forDistro - the distro the mutation belongs to; derived from the
1283
+ * policy when the caller does not know it.
1079
1284
  * @returns the agent to publish through.
1080
1285
  */
1081
- async mutationAgent(policy) {
1286
+ async mutationAgent(policy, forDistro) {
1082
1287
  const config = this.config;
1288
+ const distro = forDistro ?? await this.distroForPolicy(policy);
1083
1289
  if (!isConfinedMutation({ policy, sandboxEnabled: config.sandbox })) {
1084
- return this.publicationAgent();
1290
+ return this.publicationAgentFor(distro);
1085
1291
  }
1086
1292
  // The untyped guard decided this is a confined policy but cannot narrow
1087
1293
  // the parameter, so the confined shape is asserted once here (type-level
1088
1294
  // only; `confinedPolicy` is the same value as `policy`).
1089
1295
  const confinedPolicy = policy;
1090
- const distro = await this.distro();
1091
1296
  if (!(await this.sandbox.usable(distro))) {
1092
1297
  // The probe's classified remedy — missing vs present-but-broken, with the
1093
1298
  // distro's own package family — is what every other refusal surfaces;
1094
1299
  // this path has the same failure, so it carries the same text.
1095
1300
  throw new SandboxUnavailableError(confinedPolicy.mode, bwrapFailure(config.wslPath, distro) ?? missingBwrapRemedy(distro));
1096
1301
  }
1097
- if (config.sessionSandbox) {
1098
- await this.ensureSessionRuntimeUsable(distro);
1302
+ if (await this.wantsSessionRuntime(distro)) {
1099
1303
  const runtime = this.sessionRuntimeFor(confinedPolicy, distro);
1100
1304
  if (runtime !== undefined)
1101
1305
  return runtime.agent;
@@ -1131,7 +1335,8 @@ export class WslFileSystem extends LocalFileSystem {
1131
1335
  return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
1132
1336
  }
1133
1337
  /**
1134
- * The pinned distro, resolving WSL's default on first use.
1338
+ * The distro used when no path supplies evidence: the pinned name, WSL's
1339
+ * default, or the default for the automatic modes.
1135
1340
  *
1136
1341
  * @returns the distro name.
1137
1342
  */
@@ -1141,6 +1346,60 @@ export class WslFileSystem extends LocalFileSystem {
1141
1346
  this.resolvedDistro = await defaultDistro({ wslPath: config.wslPath });
1142
1347
  return this.resolvedDistro;
1143
1348
  }
1349
+ /**
1350
+ * The distro one path belongs to: an explicit pin wins; otherwise the path
1351
+ * itself, then the cwd or configured cwd, may name a distro; both absent
1352
+ * falls back to WSL's default.
1353
+ *
1354
+ * @param path - the caller path, in any coordinate system.
1355
+ * @param cwd - the caller's optional cwd override.
1356
+ * @returns the distro name.
1357
+ */
1358
+ async distroForPath(path, cwd) {
1359
+ const config = this.config;
1360
+ const pinned = pinnedDistro(config.distro);
1361
+ if (pinned !== undefined)
1362
+ return pinned;
1363
+ if (!isDefaultDistro(config.distro)) {
1364
+ for (const candidate of [path, cwd ?? config.cwd]) {
1365
+ const named = distroOfUnc(candidate);
1366
+ if (named !== undefined)
1367
+ return named;
1368
+ }
1369
+ }
1370
+ return this.distro();
1371
+ }
1372
+ /**
1373
+ * The distro a resolved target belongs to. A UNC identity names it outright;
1374
+ * a host-coordinate target falls back to the configured/default distro.
1375
+ *
1376
+ * @param target - the resolved target.
1377
+ * @returns the distro name.
1378
+ */
1379
+ async distroForTarget(target) {
1380
+ return uncToPosix(target.targetKey)?.distro ?? this.distroForPath(target.displayPath);
1381
+ }
1382
+ /**
1383
+ * The distro a mutation's policy belongs to: the workspace root is the
1384
+ * strongest evidence, then the configured cwd, then the default.
1385
+ *
1386
+ * @param policy - the resolved policy, when the caller has one.
1387
+ * @returns the distro name.
1388
+ */
1389
+ async distroForPolicy(policy) {
1390
+ const config = this.config;
1391
+ const pinned = pinnedDistro(config.distro);
1392
+ if (pinned !== undefined)
1393
+ return pinned;
1394
+ if (!isDefaultDistro(config.distro)) {
1395
+ for (const candidate of [policy?.workspaceRoot, config.cwd]) {
1396
+ const named = distroOfUnc(candidate);
1397
+ if (named !== undefined)
1398
+ return named;
1399
+ }
1400
+ }
1401
+ return this.distro();
1402
+ }
1144
1403
  /**
1145
1404
  * Translate one caller path into the world path, enforcing the distro fence.
1146
1405
  *
@@ -1150,13 +1409,13 @@ export class WslFileSystem extends LocalFileSystem {
1150
1409
  */
1151
1410
  async worldPath(path, cwd) {
1152
1411
  const config = this.config;
1153
- const distro = await this.distro();
1412
+ const distro = await this.distroForPath(path, cwd);
1154
1413
  let base = cwd ?? config.cwd;
1155
1414
  if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(path)) {
1156
1415
  // An empty `cwd` means the distro user's home. Only relative input
1157
1416
  // consults the base at all, which is also the only case that has to pay
1158
1417
  // for the `wsl.exe` query — an absolute path never does.
1159
- base = await distroHome(this);
1418
+ base = await distroHome(this, distro);
1160
1419
  }
1161
1420
  const world = toWorldPath(path, { distro, cwd: base });
1162
1421
  const refusal = outsideDistroRefusal({ path, world, distro, restrictToDistro: config.restrictToDistro });
@@ -1181,10 +1440,11 @@ export class WslFileSystem extends LocalFileSystem {
1181
1440
  const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1182
1441
  if (linux === undefined)
1183
1442
  throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1443
+ const distro = uncToPosix(world)?.distro ?? await this.distroForPath(path, opts?.cwd);
1184
1444
  try {
1185
1445
  // The substrate's resolved shape carries plain-string keys; the seam's
1186
1446
  // branded tokens ride one type-level cast (runtime shape unchanged).
1187
- return (await (await this.agentSubstrate()).resolve(linux, opts?.signal));
1447
+ return (await (await this.agentSubstrateFor(distro)).resolve(linux, opts?.signal));
1188
1448
  }
1189
1449
  catch (error) {
1190
1450
  throw this.peerError(error);
@@ -1220,8 +1480,8 @@ export class WslFileSystem extends LocalFileSystem {
1220
1480
  }
1221
1481
  if (policy.mode !== "workspace-write")
1222
1482
  return target;
1223
- const fresh = await this.resolve(target.displayPath);
1224
- const distro = await this.distro();
1483
+ const distro = await this.distroForTarget(target);
1484
+ const fresh = await this.resolve(target.targetKey);
1225
1485
  for (const root of wslWritableRoots(policy, distro)) {
1226
1486
  if (isWorldPathUnder(fresh.targetKey, root, { distro }))
1227
1487
  return fresh;
@@ -1238,7 +1498,7 @@ export class WslFileSystem extends LocalFileSystem {
1238
1498
  */
1239
1499
  async stat(target, signal) {
1240
1500
  try {
1241
- return (await (await this.agentSubstrate()).stat(target, signal));
1501
+ return (await (await this.substrateForTarget(target)).stat(target, signal));
1242
1502
  }
1243
1503
  catch (error) {
1244
1504
  throw this.peerError(error);
@@ -1258,8 +1518,9 @@ export class WslFileSystem extends LocalFileSystem {
1258
1518
  const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1259
1519
  if (linux === undefined)
1260
1520
  throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1521
+ const distro = uncToPosix(world)?.distro ?? await this.distroForPath(path, opts?.cwd);
1261
1522
  try {
1262
- return (await (await this.agentSubstrate()).lstat(linux, signal));
1523
+ return (await (await this.agentSubstrateFor(distro)).lstat(linux, signal));
1263
1524
  }
1264
1525
  catch (error) {
1265
1526
  throw this.peerError(error);
@@ -1274,7 +1535,7 @@ export class WslFileSystem extends LocalFileSystem {
1274
1535
  */
1275
1536
  async readText(target, signal) {
1276
1537
  try {
1277
- return await (await this.agentSubstrate()).readText(target, signal);
1538
+ return await (await this.substrateForTarget(target)).readText(target, signal);
1278
1539
  }
1279
1540
  catch (error) {
1280
1541
  throw this.peerError(error);
@@ -1289,7 +1550,7 @@ export class WslFileSystem extends LocalFileSystem {
1289
1550
  */
1290
1551
  async streamText(target, signal) {
1291
1552
  try {
1292
- return await (await this.agentSubstrate()).streamText(target, signal);
1553
+ return await (await this.substrateForTarget(target)).streamText(target, signal);
1293
1554
  }
1294
1555
  catch (error) {
1295
1556
  throw this.peerError(error);
@@ -1305,7 +1566,7 @@ export class WslFileSystem extends LocalFileSystem {
1305
1566
  */
1306
1567
  async readBytes(target, signal, maxBytes) {
1307
1568
  try {
1308
- return await (await this.agentSubstrate()).readBytes(target, signal, maxBytes);
1569
+ return await (await this.substrateForTarget(target)).readBytes(target, signal, maxBytes);
1309
1570
  }
1310
1571
  catch (error) {
1311
1572
  throw this.peerError(error);
@@ -1321,7 +1582,7 @@ export class WslFileSystem extends LocalFileSystem {
1321
1582
  */
1322
1583
  async readByteRange(target, range, signal) {
1323
1584
  try {
1324
- return await (await this.agentSubstrate()).readByteRange(target, range, signal);
1585
+ return await (await this.substrateForTarget(target)).readByteRange(target, range, signal);
1325
1586
  }
1326
1587
  catch (error) {
1327
1588
  throw this.peerError(error);
@@ -1337,7 +1598,7 @@ export class WslFileSystem extends LocalFileSystem {
1337
1598
  */
1338
1599
  async listDir(target, signal) {
1339
1600
  try {
1340
- return (await (await this.agentSubstrate()).listDir(target, signal));
1601
+ return (await (await this.substrateForTarget(target)).listDir(target, signal));
1341
1602
  }
1342
1603
  catch (error) {
1343
1604
  throw this.peerError(error);
@@ -1350,10 +1611,10 @@ export class WslFileSystem extends LocalFileSystem {
1350
1611
  * plain callback over image attachments, and `packages/api/session-controller`
1351
1612
  * plus the UI's open/reveal path compare the return value against `undefined`
1352
1613
  * without awaiting. The mapping is therefore built from facts that are already
1353
- * known — the distro, once `distro()` has resolved it (the pinned config, or
1354
- * the default `wsl.exe` answered earlier: the first filesystem operation
1355
- * resolves it), and the configured `cwd`, or the resolved home for a relative
1356
- * path. A call that would have to ask `wsl.exe` — a cold default distro, or a
1614
+ * known — a UNC host path names its own distro outright; a non-UNC path uses
1615
+ * the pinned/config default once `distro()` has resolved it, and the
1616
+ * configured `cwd`, or the resolved home for a relative path. A call that
1617
+ * would have to ask `wsl.exe` — a cold default distro with no UNC, or a
1357
1618
  * relative path before the home is known — answers `undefined`, which every
1358
1619
  * consumer reads as "no mapping", the same answer the ssh provider gives for
1359
1620
  * a path it cannot map.
@@ -1371,16 +1632,21 @@ export class WslFileSystem extends LocalFileSystem {
1371
1632
  */
1372
1633
  processPathFromHostPath(hostPath) {
1373
1634
  const config = this.config;
1374
- if (this.resolvedDistro.length === 0)
1635
+ const choice = selectDistro({
1636
+ configured: config.distro,
1637
+ candidates: [hostPath, config.cwd],
1638
+ fallback: this.resolvedDistro,
1639
+ });
1640
+ if (choice.distro.length === 0)
1375
1641
  return undefined;
1376
1642
  let base = config.cwd;
1377
1643
  if (String(base ?? "").trim().length === 0 && isRelativeWorldPath(hostPath)) {
1378
- if (this.resolvedHome === undefined)
1644
+ base = this.resolvedHomes.get(choice.distro) ?? (choice.distro === this.resolvedDistro ? this.resolvedHome : undefined);
1645
+ if (base === undefined)
1379
1646
  return undefined;
1380
- base = this.resolvedHome;
1381
1647
  }
1382
- const world = toWorldPath(hostPath, { distro: this.resolvedDistro, cwd: base });
1383
- const refusal = outsideDistroRefusal({ path: hostPath, world, distro: this.resolvedDistro, restrictToDistro: config.restrictToDistro });
1648
+ const world = toWorldPath(hostPath, { distro: choice.distro, cwd: base });
1649
+ const refusal = outsideDistroRefusal({ path: hostPath, world, distro: choice.distro, restrictToDistro: config.restrictToDistro });
1384
1650
  return refusal === null ? world : undefined;
1385
1651
  }
1386
1652
  /**
@@ -1407,7 +1673,19 @@ export class WslFileSystem extends LocalFileSystem {
1407
1673
  if (parsed === undefined) {
1408
1674
  throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
1409
1675
  }
1410
- const distro = await this.distro();
1676
+ const pinned = pinnedDistro(config.distro);
1677
+ const configuredName = pinned ?? (isDefaultDistro(config.distro) ? await this.distro() : undefined);
1678
+ if (configuredName !== undefined) {
1679
+ const refusal = outsideDistroRefusal({
1680
+ path: target.displayPath,
1681
+ world: target.targetKey,
1682
+ distro: configuredName,
1683
+ restrictToDistro: config.restrictToDistro,
1684
+ });
1685
+ if (refusal !== null)
1686
+ throw new FsError(refusal.message, refusal.code);
1687
+ }
1688
+ const distro = parsed.distro;
1411
1689
  return armDistroWatcher({
1412
1690
  wslPath: config.wslPath,
1413
1691
  distro,
@@ -1437,7 +1715,7 @@ export class WslFileSystem extends LocalFileSystem {
1437
1715
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1438
1716
  const fenced = await this.checkedTarget(target, policy);
1439
1717
  try {
1440
- return (await (await this.agentSubstrate()).writeText(fenced, content, expected, signal, policy));
1718
+ return (await (await this.substrateForTarget(fenced)).writeText(fenced, content, expected, signal, policy));
1441
1719
  }
1442
1720
  catch (error) {
1443
1721
  throw this.peerError(error);
@@ -1459,7 +1737,7 @@ export class WslFileSystem extends LocalFileSystem {
1459
1737
  const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1460
1738
  const fenced = await this.checkedTarget(target, policy);
1461
1739
  try {
1462
- return (await (await this.agentSubstrate()).editText(fenced, edit, expected, signal, policy));
1740
+ return (await (await this.substrateForTarget(fenced)).editText(fenced, edit, expected, signal, policy));
1463
1741
  }
1464
1742
  catch (error) {
1465
1743
  throw this.peerError(error);