dsh-plugin-wsl-env 0.1.10 → 0.3.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 CHANGED
@@ -37,13 +37,23 @@
37
37
  */
38
38
 
39
39
  import { rename } from "node:fs/promises";
40
+ import { fileURLToPath } from "node:url";
40
41
  import z from "@deepseek-ai/schemastery";
41
42
  import { LocalBashExecutor } from "@deepseek-ai/dsh-bash-local";
42
43
  import { LocalFileSystem } from "@deepseek-ai/dsh-fs-local";
43
44
  import { FsError } from "@deepseek-ai/dsh-fs";
44
45
  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 { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
46
47
  import { WslSandbox } from "./sandbox.js";
48
+ import { armDistroWatcher } from "./watcher.js";
49
+ import { WslAgent } from "./agent.js";
50
+ import { AgentUnavailableError } from "./agent-errors.js";
51
+ import { publicationArgv } from "./fs-publish.js";
52
+ import { agentExecutionHandle } from "./agent-exec.js";
53
+ import { sharedAgent } from "./agent-shared.js";
54
+ import { AgentSubstrate } from "./fs-substrate.js";
55
+ import { FsCodedError } from "./fsio-text.js";
56
+ import { confinedAgent } from "./agent-confined.js";
47
57
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
48
58
  // can use them without pulling this file's executor and filesystem peers in.
49
59
  import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
@@ -165,6 +175,20 @@ export class WslShellExecutor extends LocalBashExecutor {
165
175
  * unconfined and `sandboxMode` is `undefined`, so the model is told so.
166
176
  */
167
177
  sandbox: z.boolean().default(true),
178
+ /**
179
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
180
+ * Windows drive from confined commands: its files cannot be read or
181
+ * exfiltrated and its executables cannot be launched through interop —
182
+ * the hole that lets a "sandboxed" command reach your full Windows token.
183
+ * A narrowing, not a closure (a command can still write an executable
184
+ * into the workspace and run it — binfmt dispatches on content, not
185
+ * location), so enforcement stays `partial` and the sandbox fact reports
186
+ * `windowsDrive: "masked"`. Costs: confined commands cannot read or touch
187
+ * anything under `/mnt`, and tools that probe the drive degrade. The
188
+ * complete closure is distro-level: `[interop] enabled=false` in
189
+ * `wsl.conf`.
190
+ */
191
+ maskWindowsDrive: z.boolean().default(false),
168
192
  /**
169
193
  * Default workdir for a request that names none; accepts `/home/andy/proj`,
170
194
  * a UNC path, or `C:\...`. Empty — the default — means the distro user's
@@ -180,6 +204,14 @@ export class WslShellExecutor extends LocalBashExecutor {
180
204
  maxOutputBytes: z.number().default(64e3).volatile(),
181
205
  maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
182
206
  graceMs: z.number().default(3e3).volatile(),
207
+ /**
208
+ * Run commands through the resident agent when it is up (one long-lived
209
+ * in-distro process; in-distro timeout semantics), falling back to the
210
+ * one-shot `wsl.exe` path whenever the agent is out. `false` never uses
211
+ * the agent. The confinement, the output budgets' shapes and the result
212
+ * decoration are identical on both paths.
213
+ */
214
+ agent: z.boolean().default(true),
183
215
  });
184
216
 
185
217
  constructor(ctx, config) {
@@ -249,6 +281,15 @@ export class WslShellExecutor extends LocalBashExecutor {
249
281
  return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
250
282
  }
251
283
 
284
+ /**
285
+ * The shared resident agent serving this executor's distro.
286
+ * @param distro - the resolved distro name.
287
+ * @returns {WslAgent} the shared agent.
288
+ */
289
+ executionAgent(distro) {
290
+ return sharedAgent(distro);
291
+ }
292
+
252
293
  /**
253
294
  * The argv that runs INSIDE the distro for one spec: the resolved shell, its
254
295
  * login/command flags, and the command string. This is the argv a
@@ -423,6 +464,7 @@ export class WslShellExecutor extends LocalBashExecutor {
423
464
  mode: facts.mode,
424
465
  denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
425
466
  enforcement: facts.enforcement,
467
+ ...(facts.windowsDrive ? { windowsDrive: facts.windowsDrive } : {}),
426
468
  ...(runnerFailed ? { runnerFailed } : {}),
427
469
  };
428
470
  }
@@ -492,6 +534,94 @@ export class WslShellExecutor extends LocalBashExecutor {
492
534
  const distro = await this.distro();
493
535
  const shell = await this.shell();
494
536
  const resolved = await this.withDefaultWorkdir(spec);
537
+ if (this.config.agent) {
538
+ try {
539
+ return await this.executeViaAgent(resolved, distro, shell);
540
+ } catch (error) {
541
+ // Only an out-of-service agent falls back; anything else (a cwd
542
+ // refusal, a sandbox refusal) is the command's own verdict.
543
+ if (!(error instanceof AgentUnavailableError)) throw error;
544
+ }
545
+ }
546
+ return this.executeOneShot(resolved, distro, shell);
547
+ }
548
+
549
+ /**
550
+ * The agent-backed execution path: one long-lived in-distro process, the
551
+ * confinement argv unchanged, the handle shape unchanged. Any
552
+ * {@link AgentUnavailableError} propagates for the caller's fallback.
553
+ * @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
554
+ * @param distro - the resolved distro name.
555
+ * @param shell - the resolved distro shell.
556
+ * @returns the decorated execution result.
557
+ */
558
+ async executeViaAgent(resolved, distro, shell) {
559
+ const policy = resolved.sandboxPolicy;
560
+ const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
561
+ ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
562
+ : undefined;
563
+ const mode = policy?.mode;
564
+ const execution = agentExecutionHandle({
565
+ agent: await this.executionAgent(distro),
566
+ // The one-shot path converts the spec's workdir into the distro's
567
+ // coordinate system inside argv(); the agent carries the cwd as a frame
568
+ // field, so the SAME conversion happens here — a raw UNC must never
569
+ // reach `chdir` (that is the bug this line fixes).
570
+ cwd: toLinuxPath(resolved.workdir, { distro }),
571
+ argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
572
+ timeoutMs: resolved.timeoutMs,
573
+ // The agent enforces the collect budget distro-side (the EXEC frame
574
+ // carries it); `maxSpillBytes` stays one-shot-only — the agent path has
575
+ // no spill files by design, its capture is bounded instead.
576
+ maxOutputBytes: this.config.maxOutputBytes,
577
+ signal: resolved.signal,
578
+ onStarted: confined !== undefined
579
+ ? (proc) => {
580
+ this.processFacts.set(proc, {
581
+ mode,
582
+ enforcement: confined.enforcement,
583
+ denialSignatures: confined.denialSignatures,
584
+ runnerFailureRules: confined.runnerFailureRules,
585
+ runnerProgram: confined.argv[0],
586
+ workdir: resolved.workdir,
587
+ windowsDrive: confined.windowsDrive,
588
+ });
589
+ }
590
+ : undefined,
591
+ });
592
+ return WslShellExecutor.decorateResult(execution, (result) => {
593
+ this.throwIfDistroUnavailable(result, distro);
594
+ this.throwIfWorkdirMissing(result, resolved.workdir);
595
+ if (this.sandbox === undefined) return result;
596
+ if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
597
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
598
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
599
+ if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
600
+ return {
601
+ ...result,
602
+ sandbox: {
603
+ mode,
604
+ denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
605
+ enforcement,
606
+ windowsDrive,
607
+ },
608
+ };
609
+ }, (error) => {
610
+ if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
611
+ throw error;
612
+ });
613
+ }
614
+
615
+ /**
616
+ * The pre-agent execution path, verbatim: one `wsl.exe` per command through
617
+ * the inherited `executeArgv` machinery. Kept as the permanent fallback for
618
+ * an out-of-service agent and as the `agent: false` behaviour.
619
+ * @param resolved - the resolved spec.
620
+ * @param distro - the resolved distro name.
621
+ * @param shell - the resolved distro shell.
622
+ * @returns the decorated execution result.
623
+ */
624
+ async executeOneShot(resolved, distro, shell) {
495
625
  const policy = resolved.sandboxPolicy;
496
626
  if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
497
627
  // `executeArgv` resolves to the execution handle, so it has to be awaited
@@ -529,6 +659,7 @@ export class WslShellExecutor extends LocalBashExecutor {
529
659
  runnerFailureRules: confined.runnerFailureRules,
530
660
  runnerProgram: confined.argv[0],
531
661
  workdir: resolved.workdir,
662
+ windowsDrive: confined.windowsDrive,
532
663
  });
533
664
  },
534
665
  );
@@ -536,7 +667,7 @@ export class WslShellExecutor extends LocalBashExecutor {
536
667
  this.throwIfDistroUnavailable(result, distro);
537
668
  this.throwIfWorkdirMissing(result, resolved.workdir);
538
669
  if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
539
- const { enforcement, denialSignatures, runnerFailureRules } = confined;
670
+ const { enforcement, denialSignatures, runnerFailureRules, windowsDrive } = confined;
540
671
  // Runner failure outranks denial because the command did not run at all.
541
672
  const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
542
673
  if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
@@ -546,6 +677,7 @@ export class WslShellExecutor extends LocalBashExecutor {
546
677
  mode,
547
678
  denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
548
679
  enforcement,
680
+ windowsDrive,
549
681
  },
550
682
  };
551
683
  }, (error) => {
@@ -610,6 +742,14 @@ export class WslFileSystem extends LocalFileSystem {
610
742
  * operations are unconfined.
611
743
  */
612
744
  sandbox: z.boolean().default(true),
745
+ /**
746
+ * Shadow `/mnt` with an empty tmpfs in every confined profile, hiding the
747
+ * Windows drive from confined commands — same meaning and same honest
748
+ * limits as the `wsl-shell` key of the same name (its doc carries the
749
+ * full story). Confined fs mutations publish through the masked resident;
750
+ * reads and the share substrate are unaffected.
751
+ */
752
+ maskWindowsDrive: z.boolean().default(false),
613
753
  /**
614
754
  * Refuse any path that leaves the pinned distro. A WSL filesystem is a
615
755
  * whole-Linux view whose `/mnt/c` also reaches the Windows disk, so without
@@ -626,6 +766,28 @@ export class WslFileSystem extends LocalFileSystem {
626
766
  * distinct failing path, cached.
627
767
  */
628
768
  resolveSymlinks: z.boolean().default(true),
769
+ /**
770
+ * Run the in-distro side of publication (mode copy + atomic rename) through
771
+ * the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
772
+ * `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
773
+ * legacy two-step path for that call and, after the agent's own rebuild
774
+ * budget is spent, permanently — this flag never changes what is published,
775
+ * only how many round trips it costs.
776
+ */
777
+ agent: z.boolean().default(true),
778
+ /**
779
+ * Which I/O substrate serves the file tools. `"agent"` (the default) —
780
+ * the resident in-distro agent (`lib/fs-substrate.js`): reads, writes,
781
+ * identities and watches run where the files live, symlinks and mode bits
782
+ * are native, and the mutation guard survives to the write's no-replace
783
+ * publication, kernel-enforced by the confined resident when the policy is
784
+ * confined. `"share"` — the opt-out: the Windows-side host filesystem
785
+ * stack over the distro's 9p share, with the symlink-retry and publication
786
+ * hooks that compensate for what the share cannot do. Both substrates
787
+ * fence writes with the same host-side policy check; neither changes
788
+ * `sandboxMode`'s honesty.
789
+ */
790
+ substrate: z.union(["share", "agent"]).default("agent"),
629
791
  });
630
792
 
631
793
  constructor(ctx, config) {
@@ -637,6 +799,10 @@ export class WslFileSystem extends LocalFileSystem {
637
799
  this.defaultMode = ctx.sandboxPolicy.defaultMode;
638
800
  /** targetKey -> canonical replacement target, for symlinked paths. */
639
801
  this.canonicalTargets = new Map();
802
+ /** The lazy agent-backed substrate, built on first use (see `agentSubstrate`). */
803
+ this.agentSubstrateInstance = undefined;
804
+ /** The distro-side bwrap usability probe, shared with the confined agents. */
805
+ this.sandbox = new WslSandbox({ wslPath: config.wslPath, maskWindowsDrive: config.maskWindowsDrive });
640
806
  // `dsh-fs-local` takes a Windows branch whenever the destination already
641
807
  // exists — which is how it knows to preserve the replaced file's security
642
808
  // descriptor, and what makes every write to an existing file fail here:
@@ -692,11 +858,117 @@ export class WslFileSystem extends LocalFileSystem {
692
858
  const source = uncToPosix(replaced);
693
859
  const staged = uncToPosix(replacement);
694
860
  if (source !== undefined && staged !== undefined) {
861
+ if (this.config.agent) {
862
+ try {
863
+ // One in-distro round trip: mode copy + atomic rename. The staged
864
+ // temp is on the share, but the agent reaches it by its Linux path,
865
+ // where the kernel does the rename — no 9p publication involved.
866
+ const agent = await this.publicationAgent();
867
+ const result = await agent.exec({
868
+ cwd: "/",
869
+ argv: publicationArgv(source.linuxPath, staged.linuxPath),
870
+ timeoutMs: 30000,
871
+ });
872
+ if (result.exitCode === 0) return;
873
+ // A nonzero from the script is a real publication failure (chmod or
874
+ // rename); report it rather than retrying through the legacy path,
875
+ // which would fail the same way but slower.
876
+ throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
877
+ } catch (error) {
878
+ // The agent being out is the ONLY thing the legacy path exists for.
879
+ if (!(error instanceof AgentUnavailableError)) throw error;
880
+ }
881
+ }
695
882
  await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
696
883
  }
697
884
  await rename(replacement, replaced);
698
885
  }
699
886
 
887
+ /**
888
+ * The resident agent serving this filesystem's in-distro side work, created
889
+ * on first use and kept for the provider's lifetime (its own idle timer
890
+ * retires the process; a later call starts a fresh one).
891
+ * @returns {Promise<WslAgent>} the agent for the pinned distro.
892
+ */
893
+ async publicationAgent() {
894
+ if (this.agentInstance === undefined) {
895
+ this.agentInstance = sharedAgent(await this.distro());
896
+ }
897
+ return this.agentInstance;
898
+ }
899
+
900
+ /**
901
+ * The agent-backed substrate for `substrate: "agent"`, built on first use
902
+ * over the same resident agent the publication path shares.
903
+ * @returns {Promise<AgentSubstrate|undefined>} the substrate, or undefined
904
+ * when the share backend is configured.
905
+ */
906
+ async agentSubstrate() {
907
+ if (this.config.substrate !== "agent") return undefined;
908
+ if (this.agentSubstrateInstance === undefined) {
909
+ this.agentSubstrateInstance = new AgentSubstrate({
910
+ agent: await this.publicationAgent(),
911
+ distro: await this.distro(),
912
+ diffBasisMaxBytes: this.config.diffBasisMaxBytes,
913
+ agentFor: (policy) => this.mutationAgent(policy),
914
+ });
915
+ }
916
+ return this.agentSubstrateInstance;
917
+ }
918
+
919
+ /**
920
+ * The agent a MUTATION runs on under this resolved policy — stage-two
921
+ * routing. A confined policy addresses the confined resident whose bwrap
922
+ * profile binds exactly what the mode grants, so the kernel enforces what
923
+ * the check decided; an escalated write (`danger-full-access`) or a disabled
924
+ * sandbox addresses the plain resident, which is what those mean. A distro
925
+ * without a usable bwrap refuses confined mutations instead of downgrading
926
+ * them — the same closed failure the command path has.
927
+ * @param {{mode: string, workspaceRoot: string}|undefined} policy - the
928
+ * resolved file-effect policy, or undefined when the caller pre-checked.
929
+ * @returns {Promise<WslAgent>} the agent to publish through.
930
+ */
931
+ async mutationAgent(policy) {
932
+ if (!policy || !this.config.sandbox || policy.mode === "danger-full-access") {
933
+ return this.publicationAgent();
934
+ }
935
+ const distro = await this.distro();
936
+ if (!(await this.sandbox.usable(distro))) {
937
+ throw new SandboxUnavailableError(
938
+ policy.mode,
939
+ `bwrap is not usable inside distro "${distro}". Install it with ` +
940
+ `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
941
+ `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
942
+ );
943
+ }
944
+ return confinedAgent({ distro, wslPath: this.config.wslPath, policy, maskWindowsDrive: this.config.maskWindowsDrive });
945
+ }
946
+
947
+ /**
948
+ * Map a substrate error onto the peer's `FsError`, so the tool layer reads
949
+ * the same refusal dialect on both substrates. An agent that is out becomes
950
+ * an I/O error naming the cause — the share backend's "distro is gone" case
951
+ * is the same availability, but the message says which substrate failed.
952
+ * @param error - what the substrate raised.
953
+ * @returns {FsError} the error to surface.
954
+ */
955
+ peerError(error) {
956
+ if (error instanceof FsCodedError) return new FsError(error.message, error.code, { cause: error });
957
+ if (error instanceof SandboxUnavailableError) return new FsError(error.message, "FS_IO_ERROR", { cause: error });
958
+ const message = error instanceof Error ? error.message : String(error);
959
+ return new FsError(`the distro file substrate is unavailable: ${message}`, "FS_IO_ERROR", { cause: error instanceof Error ? error : undefined });
960
+ }
961
+
962
+ /**
963
+ * One resolved target's Linux path — the substrate's coordinate — falling
964
+ * back to the display spelling when the identity is not a distro share path.
965
+ * @param target - the resolved target.
966
+ * @returns the absolute Linux path.
967
+ */
968
+ linuxOf(target) {
969
+ return uncToPosix(target.targetKey)?.linuxPath ?? target.displayPath;
970
+ }
971
+
700
972
  /**
701
973
  * The pinned distro, resolving WSL's default on first use.
702
974
  * @returns the distro name.
@@ -743,8 +1015,19 @@ export class WslFileSystem extends LocalFileSystem {
743
1015
  * @returns the resolved `FsTarget`.
744
1016
  */
745
1017
  async resolve(path, opts) {
1018
+ const world = await this.worldPath(path, opts?.cwd);
1019
+ const sub = await this.agentSubstrate();
1020
+ if (sub !== undefined) {
1021
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1022
+ if (linux === undefined) throw new FsError(`cannot serve "${path}" from inside the distro`, "FS_IO_ERROR");
1023
+ try {
1024
+ return await sub.resolve(linux, opts?.signal);
1025
+ } catch (error) {
1026
+ throw this.peerError(error);
1027
+ }
1028
+ }
746
1029
  const distro = await this.distro();
747
- const target = await super.resolve(await this.worldPath(path, opts?.cwd), opts);
1030
+ const target = await super.resolve(world, opts);
748
1031
  return { targetKey: target.targetKey, displayPath: toDisplayPath(target.targetKey, distro) };
749
1032
  }
750
1033
 
@@ -796,7 +1079,23 @@ export class WslFileSystem extends LocalFileSystem {
796
1079
  if (cached !== undefined) return cached;
797
1080
  const parsed = uncToPosix(target.targetKey);
798
1081
  if (parsed === undefined) return undefined;
799
- const canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
1082
+ // The agent serves the canonical path when it is up (no extra `wsl.exe`
1083
+ // per distinct failing path); the one-shot helper is the fallback and the
1084
+ // `agent: false` path.
1085
+ let canonical;
1086
+ if (this.config.agent) {
1087
+ try {
1088
+ const agent = await this.publicationAgent();
1089
+ const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
1090
+ const resolved = result.stdout.toString("utf8").trim();
1091
+ canonical = resolved.startsWith("/") ? resolved : undefined;
1092
+ } catch (error) {
1093
+ if (!(error instanceof AgentUnavailableError)) throw error;
1094
+ }
1095
+ }
1096
+ if (canonical === undefined) {
1097
+ canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
1098
+ }
800
1099
  if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
801
1100
  const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
802
1101
  this.canonicalTargets.set(target.targetKey, replacement);
@@ -837,22 +1136,86 @@ export class WslFileSystem extends LocalFileSystem {
837
1136
  }
838
1137
 
839
1138
  async stat(target, signal) {
1139
+ const sub = await this.agentSubstrate();
1140
+ if (sub !== undefined) {
1141
+ try {
1142
+ return await sub.stat(target, signal);
1143
+ } catch (error) {
1144
+ throw this.peerError(error);
1145
+ }
1146
+ }
840
1147
  return this.withCanonicalRetry(target, signal, (t) => super.stat(t, signal));
841
1148
  }
842
1149
 
1150
+ /**
1151
+ * No-follow stat one path. The agent substrate answers from the distro,
1152
+ * where a symlink is a first-class entry; the share backend keeps the
1153
+ * inherited host behaviour.
1154
+ * @param path - a Linux, UNC or Windows path.
1155
+ * @param opts - optional `cwd` override.
1156
+ * @param signal - optional cancellation.
1157
+ * @returns the path info, or undefined when absent.
1158
+ */
1159
+ async lstat(path, opts, signal) {
1160
+ const sub = await this.agentSubstrate();
1161
+ if (sub !== undefined) {
1162
+ const world = await this.worldPath(path, opts?.cwd);
1163
+ const linux = uncToPosix(world)?.linuxPath ?? windowsToLinuxMount(world);
1164
+ if (linux === undefined) throw new FsError(`cannot inspect "${path}" from inside the distro`, "FS_IO_ERROR");
1165
+ try {
1166
+ return await sub.lstat(linux, signal);
1167
+ } catch (error) {
1168
+ throw this.peerError(error);
1169
+ }
1170
+ }
1171
+ return super.lstat(path, opts, signal);
1172
+ }
1173
+
843
1174
  async readText(target, signal) {
1175
+ const sub = await this.agentSubstrate();
1176
+ if (sub !== undefined) {
1177
+ try {
1178
+ return await sub.readText(target, signal);
1179
+ } catch (error) {
1180
+ throw this.peerError(error);
1181
+ }
1182
+ }
844
1183
  return this.withCanonicalRetry(target, signal, (t) => super.readText(t, signal));
845
1184
  }
846
1185
 
847
1186
  async streamText(target, signal) {
1187
+ const sub = await this.agentSubstrate();
1188
+ if (sub !== undefined) {
1189
+ try {
1190
+ return await sub.streamText(target, signal);
1191
+ } catch (error) {
1192
+ throw this.peerError(error);
1193
+ }
1194
+ }
848
1195
  return this.withCanonicalRetry(target, signal, (t) => super.streamText(t, signal));
849
1196
  }
850
1197
 
851
1198
  async readBytes(target, signal, maxBytes) {
1199
+ const sub = await this.agentSubstrate();
1200
+ if (sub !== undefined) {
1201
+ try {
1202
+ return await sub.readBytes(target, signal, maxBytes);
1203
+ } catch (error) {
1204
+ throw this.peerError(error);
1205
+ }
1206
+ }
852
1207
  return this.withCanonicalRetry(target, signal, (t) => super.readBytes(t, signal, maxBytes));
853
1208
  }
854
1209
 
855
1210
  async readByteRange(target, range, signal) {
1211
+ const sub = await this.agentSubstrate();
1212
+ if (sub !== undefined) {
1213
+ try {
1214
+ return await sub.readByteRange(target, range, signal);
1215
+ } catch (error) {
1216
+ throw this.peerError(error);
1217
+ }
1218
+ }
856
1219
  return this.withCanonicalRetry(target, signal, (t) => super.readByteRange(t, range, signal));
857
1220
  }
858
1221
 
@@ -866,6 +1229,16 @@ export class WslFileSystem extends LocalFileSystem {
866
1229
  * @returns the directory rows.
867
1230
  */
868
1231
  async listDir(target, signal) {
1232
+ const sub = await this.agentSubstrate();
1233
+ if (sub !== undefined) {
1234
+ // The substrate's rows already carry Linux display paths and canonical
1235
+ // identities, so no share-side rewrite follows.
1236
+ try {
1237
+ return await sub.listDir(target, signal);
1238
+ } catch (error) {
1239
+ throw this.peerError(error);
1240
+ }
1241
+ }
869
1242
  const entries = await this.withCanonicalRetry(target, signal, (t) => super.listDir(t, signal));
870
1243
  const distro = await this.distro();
871
1244
  return entries.map((entry) => ({
@@ -885,13 +1258,33 @@ export class WslFileSystem extends LocalFileSystem {
885
1258
  }
886
1259
 
887
1260
  /**
888
- * Watching a 9p share is neither reliable nor cheap, so this provider refuses
889
- * rather than arming a watcher that may never fire — the seam's documented
890
- * failure mode for a provider that cannot watch.
891
- * @returns a rejection carrying `FS_IO_ERROR`.
1261
+ * Watch from inside the distro, where the kernel can actually report change:
1262
+ * a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
1263
+ * invalidation callback. The seam asks for coarse invalidation, not events,
1264
+ * so a poll is honest — and creations and deletions are both seen, because
1265
+ * they bump the parent directory's mtime. Replaces the previous flat
1266
+ * refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
1267
+ *
1268
+ * @param target - the resolved file or directory to observe.
1269
+ * @param changed - the invalidation callback.
1270
+ * @param signal - cancels initialization; the caller closes an armed watcher.
1271
+ * @returns resolves once active, with the async close function.
892
1272
  */
893
- watch() {
894
- return Promise.reject(new FsError("Filesystem watching is not supported over the WSL UNC share.", "FS_IO_ERROR"));
1273
+ async watch(target, changed, signal) {
1274
+ signal.throwIfAborted();
1275
+ const parsed = uncToPosix(target.targetKey);
1276
+ if (parsed === undefined) {
1277
+ throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
1278
+ }
1279
+ const distro = await this.distro();
1280
+ return armDistroWatcher({
1281
+ wslPath: this.config.wslPath,
1282
+ distro,
1283
+ linuxPath: parsed.linuxPath,
1284
+ onChange: changed,
1285
+ onError: (error) => changed(error),
1286
+ signal,
1287
+ });
895
1288
  }
896
1289
 
897
1290
  /**
@@ -926,9 +1319,25 @@ export class WslFileSystem extends LocalFileSystem {
926
1319
  * @returns the write outcome.
927
1320
  */
928
1321
  async writeText(target, content, expected, signal, sandboxPolicy) {
1322
+ // The policy resolves once: the fence and the confined routing must see
1323
+ // the same grant, or the kernel would enforce something other than what
1324
+ // was checked.
1325
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
929
1326
  // The fence runs first and its fresh target is the one checked and written,
930
1327
  // so a refusal costs no I/O and cannot be raced by a swapped symlink.
931
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1328
+ const fenced = await this.checkedTarget(target, policy);
1329
+ const sub = await this.agentSubstrate();
1330
+ if (sub !== undefined) {
1331
+ // The agent substrate keeps the guard: the distro-side no-replace link
1332
+ // is an atomic primitive, so the check-then-write window the share
1333
+ // backend documents closes here, exactly as on a Linux host. The policy
1334
+ // also selects the confined resident whose mount table matches it.
1335
+ try {
1336
+ return await sub.writeText(fenced, content, expected, signal, policy);
1337
+ } catch (error) {
1338
+ throw this.peerError(error);
1339
+ }
1340
+ }
932
1341
  await this.assertGuard(fenced, expected, signal);
933
1342
  // The guard is deliberately not forwarded: the host backend would publish a
934
1343
  // guarded creation with a hard link, which this filesystem rejects. Once
@@ -948,7 +1357,16 @@ export class WslFileSystem extends LocalFileSystem {
948
1357
  * @returns the edit outcome.
949
1358
  */
950
1359
  async editText(target, edit, expected, signal, sandboxPolicy) {
951
- const fenced = await this.checkedTarget(target, sandboxPolicy);
1360
+ const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve();
1361
+ const fenced = await this.checkedTarget(target, policy);
1362
+ const sub = await this.agentSubstrate();
1363
+ if (sub !== undefined) {
1364
+ try {
1365
+ return await sub.editText(fenced, edit, expected, signal, policy);
1366
+ } catch (error) {
1367
+ throw this.peerError(error);
1368
+ }
1369
+ }
952
1370
  await this.assertGuard(fenced, expected, signal);
953
1371
  return super.editText(fenced, edit, undefined, signal);
954
1372
  }
package/lib/ports.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Listening-port visibility for the distro, the Remote-WSL port-forwarding
3
+ * parity row's DSH-side half.
4
+ *
5
+ * There is no DSH channel that streams live facts to the model; the sanctioned
6
+ * one is the managed `DSH_*` namespace (`ctx.shellEnv`), whose `resolve` is
7
+ * SYNCHRONOUS. So the snapshot is refreshed on a timer and served stale-but-
8
+ * recent: `DSH_WSL_PORTS` names the ports with a listener inside the distro,
9
+ * at most `refreshMs` old. A dev server the model starts shows up on the next
10
+ * refresh, which is the granularity that matters at conversational pace.
11
+ *
12
+ * The scan reads `/proc/net/tcp` and `/proc/net/tcp6` directly — no `ss`, no
13
+ * `netstat`, nothing to install — and keeps sockets in state `0A` (LISTEN).
14
+ * Pure parser, unit-testable without a distro.
15
+ *
16
+ * @module dsh-plugin-wsl/ports
17
+ */
18
+
19
+ /**
20
+ * Parse one `/proc/net/tcp{,6}` body into listening ports.
21
+ * @param {string} text - the file's content.
22
+ * @returns {number[]} deduplicated listening ports, ascending.
23
+ */
24
+ export function parseListeningPorts(text) {
25
+ const ports = new Set();
26
+ for (const line of text.split("\n").slice(1)) {
27
+ const columns = line.trim().split(/\s+/);
28
+ // sl, local_address, rem_address, st, ... — st 0A is LISTEN.
29
+ if (columns.length < 4 || columns[3] !== "0A") continue;
30
+ const portHex = columns[1].split(":")[1];
31
+ if (portHex === undefined) continue;
32
+ const port = Number.parseInt(portHex, 16);
33
+ if (Number.isFinite(port) && port > 0) ports.add(port);
34
+ }
35
+ return [...ports].sort((a, b) => a - b);
36
+ }
37
+
38
+ /**
39
+ * The shell script that prints both proc files' contents.
40
+ * @returns {string} the `sh -c` body.
41
+ */
42
+ export function procNetScript() {
43
+ return "cat /proc/net/tcp /proc/net/tcp6 2>/dev/null";
44
+ }
45
+
46
+ /**
47
+ * Snapshot the distro's listening ports through the agent.
48
+ * @param {object} runner - the {@link WslAgent}-like runner.
49
+ * @param {number} [timeoutMs] - the exec budget.
50
+ * @returns {Promise<number[]>} listening ports, ascending; [] on any failure —
51
+ * a snapshot is a convenience, never an error the session should see.
52
+ */
53
+ export async function listeningPorts(runner, timeoutMs = 10000) {
54
+ try {
55
+ const result = await runner.exec({ cwd: "/", argv: ["sh", "-c", procNetScript(), "ports"], timeoutMs });
56
+ if (result.exitCode !== 0) return [];
57
+ return parseListeningPorts(result.stdout.toString("utf8"));
58
+ } catch {
59
+ return [];
60
+ }
61
+ }