dsh-plugin-wsl-env 0.1.10 → 0.2.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,6 +37,7 @@
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";
@@ -44,6 +45,12 @@ import { FsError } from "@deepseek-ai/dsh-fs";
44
45
  import { canonicalPath, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature, SandboxUnavailableError, writableRoots } from "@deepseek-ai/dsh-sandbox";
45
46
  import { isRelativeWorldPath, isWorldPathUnder, isWslUnc, isUnderDistro, posixToUnc, toDisplayPath, toLinuxPath, toWorldPath, uncToPosix } 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";
47
54
  // `runCapture` and distro discovery live in ./wsl.js so the directory picker
48
55
  // can use them without pulling this file's executor and filesystem peers in.
49
56
  import { canonicalLinuxPath, copyModeInDistro, defaultDistro, defaultShell, linuxHomePath, shellArgs, workdirFailure, wslErrorCode } from "./wsl.js";
@@ -180,6 +187,14 @@ export class WslShellExecutor extends LocalBashExecutor {
180
187
  maxOutputBytes: z.number().default(64e3).volatile(),
181
188
  maxSpillBytes: z.number().default(64 * 1024 * 1024).volatile(),
182
189
  graceMs: z.number().default(3e3).volatile(),
190
+ /**
191
+ * Run commands through the resident agent when it is up (one long-lived
192
+ * in-distro process; in-distro timeout semantics), falling back to the
193
+ * one-shot `wsl.exe` path whenever the agent is out. `false` never uses
194
+ * the agent. The confinement, the output budgets' shapes and the result
195
+ * decoration are identical on both paths.
196
+ */
197
+ agent: z.boolean().default(true),
183
198
  });
184
199
 
185
200
  constructor(ctx, config) {
@@ -249,6 +264,15 @@ export class WslShellExecutor extends LocalBashExecutor {
249
264
  return this.config.hostCwd.get() ?? process.env.SystemRoot ?? process.cwd();
250
265
  }
251
266
 
267
+ /**
268
+ * The shared resident agent serving this executor's distro.
269
+ * @param distro - the resolved distro name.
270
+ * @returns {WslAgent} the shared agent.
271
+ */
272
+ executionAgent(distro) {
273
+ return sharedAgent(distro);
274
+ }
275
+
252
276
  /**
253
277
  * The argv that runs INSIDE the distro for one spec: the resolved shell, its
254
278
  * login/command flags, and the command string. This is the argv a
@@ -492,6 +516,88 @@ export class WslShellExecutor extends LocalBashExecutor {
492
516
  const distro = await this.distro();
493
517
  const shell = await this.shell();
494
518
  const resolved = await this.withDefaultWorkdir(spec);
519
+ if (this.config.agent) {
520
+ try {
521
+ return await this.executeViaAgent(resolved, distro, shell);
522
+ } catch (error) {
523
+ // Only an out-of-service agent falls back; anything else (a cwd
524
+ // refusal, a sandbox refusal) is the command's own verdict.
525
+ if (!(error instanceof AgentUnavailableError)) throw error;
526
+ }
527
+ }
528
+ return this.executeOneShot(resolved, distro, shell);
529
+ }
530
+
531
+ /**
532
+ * The agent-backed execution path: one long-lived in-distro process, the
533
+ * confinement argv unchanged, the handle shape unchanged. Any
534
+ * {@link AgentUnavailableError} propagates for the caller's fallback.
535
+ * @param resolved - the resolved spec from {@link WslShellExecutor.execute}.
536
+ * @param distro - the resolved distro name.
537
+ * @param shell - the resolved distro shell.
538
+ * @returns the decorated execution result.
539
+ */
540
+ async executeViaAgent(resolved, distro, shell) {
541
+ const policy = resolved.sandboxPolicy;
542
+ const confined = this.sandbox !== undefined && policy !== undefined && policy.mode !== "danger-full-access"
543
+ ? await this.sandbox.confine(this.distroArgv(resolved, shell), { ...policy, mode: policy.mode }, { distro })
544
+ : undefined;
545
+ const mode = policy?.mode;
546
+ const execution = agentExecutionHandle({
547
+ agent: await this.executionAgent(distro),
548
+ // The one-shot path converts the spec's workdir into the distro's
549
+ // coordinate system inside argv(); the agent carries the cwd as a frame
550
+ // field, so the SAME conversion happens here — a raw UNC must never
551
+ // reach `chdir` (that is the bug this line fixes).
552
+ cwd: toLinuxPath(resolved.workdir, { distro }),
553
+ argv: confined !== undefined ? confined.argv : this.distroArgv(resolved, shell),
554
+ timeoutMs: resolved.timeoutMs,
555
+ signal: resolved.signal,
556
+ onStarted: confined !== undefined
557
+ ? (proc) => {
558
+ this.processFacts.set(proc, {
559
+ mode,
560
+ enforcement: confined.enforcement,
561
+ denialSignatures: confined.denialSignatures,
562
+ runnerFailureRules: confined.runnerFailureRules,
563
+ runnerProgram: confined.argv[0],
564
+ workdir: resolved.workdir,
565
+ });
566
+ }
567
+ : undefined,
568
+ });
569
+ return WslShellExecutor.decorateResult(execution, (result) => {
570
+ this.throwIfDistroUnavailable(result, distro);
571
+ this.throwIfWorkdirMissing(result, resolved.workdir);
572
+ if (this.sandbox === undefined) return result;
573
+ if (confined === undefined) return { ...result, sandbox: { mode, denied: false } };
574
+ const { enforcement, denialSignatures, runnerFailureRules } = confined;
575
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules);
576
+ if (runnerFailure !== undefined) throw new SandboxUnavailableError(mode, runnerFailure.detail);
577
+ return {
578
+ ...result,
579
+ sandbox: {
580
+ mode,
581
+ denied: matchesSignature(result.exitCode, result.stderr.text, denialSignatures),
582
+ enforcement,
583
+ },
584
+ };
585
+ }, (error) => {
586
+ if (resolved.signal?.aborted === true) resolved.signal.throwIfAborted();
587
+ throw error;
588
+ });
589
+ }
590
+
591
+ /**
592
+ * The pre-agent execution path, verbatim: one `wsl.exe` per command through
593
+ * the inherited `executeArgv` machinery. Kept as the permanent fallback for
594
+ * an out-of-service agent and as the `agent: false` behaviour.
595
+ * @param resolved - the resolved spec.
596
+ * @param distro - the resolved distro name.
597
+ * @param shell - the resolved distro shell.
598
+ * @returns the decorated execution result.
599
+ */
600
+ async executeOneShot(resolved, distro, shell) {
495
601
  const policy = resolved.sandboxPolicy;
496
602
  if (this.sandbox === undefined || policy === undefined || policy.mode === "danger-full-access") {
497
603
  // `executeArgv` resolves to the execution handle, so it has to be awaited
@@ -626,6 +732,15 @@ export class WslFileSystem extends LocalFileSystem {
626
732
  * distinct failing path, cached.
627
733
  */
628
734
  resolveSymlinks: z.boolean().default(true),
735
+ /**
736
+ * Run the in-distro side of publication (mode copy + atomic rename) through
737
+ * the resident agent (`agent/wsl-agent.sh`), one round trip instead of a
738
+ * `wsl.exe` chmod plus a 9p rename. Any agent failure falls back to the
739
+ * legacy two-step path for that call and, after the agent's own rebuild
740
+ * budget is spent, permanently — this flag never changes what is published,
741
+ * only how many round trips it costs.
742
+ */
743
+ agent: z.boolean().default(true),
629
744
  });
630
745
 
631
746
  constructor(ctx, config) {
@@ -692,11 +807,45 @@ export class WslFileSystem extends LocalFileSystem {
692
807
  const source = uncToPosix(replaced);
693
808
  const staged = uncToPosix(replacement);
694
809
  if (source !== undefined && staged !== undefined) {
810
+ if (this.config.agent) {
811
+ try {
812
+ // One in-distro round trip: mode copy + atomic rename. The staged
813
+ // temp is on the share, but the agent reaches it by its Linux path,
814
+ // where the kernel does the rename — no 9p publication involved.
815
+ const agent = await this.publicationAgent();
816
+ const result = await agent.exec({
817
+ cwd: "/",
818
+ argv: publicationArgv(source.linuxPath, staged.linuxPath),
819
+ timeoutMs: 30000,
820
+ });
821
+ if (result.exitCode === 0) return;
822
+ // A nonzero from the script is a real publication failure (chmod or
823
+ // rename); report it rather than retrying through the legacy path,
824
+ // which would fail the same way but slower.
825
+ throw new FsError(`publishing "${replaced}" failed inside the distro (exit ${result.exitCode})`, "FS_IO_ERROR");
826
+ } catch (error) {
827
+ // The agent being out is the ONLY thing the legacy path exists for.
828
+ if (!(error instanceof AgentUnavailableError)) throw error;
829
+ }
830
+ }
695
831
  await copyModeInDistro(source.distro, source.linuxPath, staged.linuxPath, { wslPath: this.config.wslPath });
696
832
  }
697
833
  await rename(replacement, replaced);
698
834
  }
699
835
 
836
+ /**
837
+ * The resident agent serving this filesystem's in-distro side work, created
838
+ * on first use and kept for the provider's lifetime (its own idle timer
839
+ * retires the process; a later call starts a fresh one).
840
+ * @returns {Promise<WslAgent>} the agent for the pinned distro.
841
+ */
842
+ async publicationAgent() {
843
+ if (this.agentInstance === undefined) {
844
+ this.agentInstance = sharedAgent(await this.distro());
845
+ }
846
+ return this.agentInstance;
847
+ }
848
+
700
849
  /**
701
850
  * The pinned distro, resolving WSL's default on first use.
702
851
  * @returns the distro name.
@@ -796,7 +945,23 @@ export class WslFileSystem extends LocalFileSystem {
796
945
  if (cached !== undefined) return cached;
797
946
  const parsed = uncToPosix(target.targetKey);
798
947
  if (parsed === undefined) return undefined;
799
- const canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
948
+ // The agent serves the canonical path when it is up (no extra `wsl.exe`
949
+ // per distinct failing path); the one-shot helper is the fallback and the
950
+ // `agent: false` path.
951
+ let canonical;
952
+ if (this.config.agent) {
953
+ try {
954
+ const agent = await this.publicationAgent();
955
+ const result = await agent.exec({ cwd: "/", argv: ["readlink", "-f", parsed.linuxPath], timeoutMs: 10000, signal });
956
+ const resolved = result.stdout.toString("utf8").trim();
957
+ canonical = resolved.startsWith("/") ? resolved : undefined;
958
+ } catch (error) {
959
+ if (!(error instanceof AgentUnavailableError)) throw error;
960
+ }
961
+ }
962
+ if (canonical === undefined) {
963
+ canonical = await canonicalLinuxPath(parsed.distro, parsed.linuxPath, { wslPath: this.config.wslPath, signal });
964
+ }
800
965
  if (canonical === undefined || canonical === parsed.linuxPath) return undefined;
801
966
  const replacement = { targetKey: posixToUnc(parsed.distro, canonical), displayPath: target.displayPath };
802
967
  this.canonicalTargets.set(target.targetKey, replacement);
@@ -885,13 +1050,33 @@ export class WslFileSystem extends LocalFileSystem {
885
1050
  }
886
1051
 
887
1052
  /**
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`.
1053
+ * Watch from inside the distro, where the kernel can actually report change:
1054
+ * a long-lived `wsl.exe` poll loop (`lib/watcher.js`) fires the seam's
1055
+ * invalidation callback. The seam asks for coarse invalidation, not events,
1056
+ * so a poll is honest — and creations and deletions are both seen, because
1057
+ * they bump the parent directory's mtime. Replaces the previous flat
1058
+ * refusal (`FS_IO_ERROR`), which was the correct answer over 9p only.
1059
+ *
1060
+ * @param target - the resolved file or directory to observe.
1061
+ * @param changed - the invalidation callback.
1062
+ * @param signal - cancels initialization; the caller closes an armed watcher.
1063
+ * @returns resolves once active, with the async close function.
892
1064
  */
893
- watch() {
894
- return Promise.reject(new FsError("Filesystem watching is not supported over the WSL UNC share.", "FS_IO_ERROR"));
1065
+ async watch(target, changed, signal) {
1066
+ signal.throwIfAborted();
1067
+ const parsed = uncToPosix(target.targetKey);
1068
+ if (parsed === undefined) {
1069
+ throw new FsError(`cannot watch "${target.displayPath}": not a path inside the distro`, "FS_IO_ERROR");
1070
+ }
1071
+ const distro = await this.distro();
1072
+ return armDistroWatcher({
1073
+ wslPath: this.config.wslPath,
1074
+ distro,
1075
+ linuxPath: parsed.linuxPath,
1076
+ onChange: changed,
1077
+ onError: (error) => changed(error),
1078
+ signal,
1079
+ });
895
1080
  }
896
1081
 
897
1082
  /**
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
+ }
package/lib/sandbox.js CHANGED
@@ -146,7 +146,9 @@ export class WslSandbox {
146
146
  if (!(await this.usable(distro, signal))) {
147
147
  throw new SandboxUnavailableError(
148
148
  policy.mode,
149
- `bwrap is not usable inside distro "${distro}" (install it: sudo apt install bubblewrap)`,
149
+ `bwrap is not usable inside distro "${distro}". Install it with ` +
150
+ `"scripts/bootstrap.sh ${distro} --install" (from the package), or directly: ` +
151
+ `wsl.exe -d ${distro} -u root -- apt-get install -y bubblewrap`,
150
152
  );
151
153
  }
152
154
  const workspaceRoot = toLinuxPath(policy.workspaceRoot, { distro });
package/lib/shell-env.js CHANGED
@@ -26,6 +26,8 @@
26
26
  import z from "@deepseek-ai/schemastery";
27
27
  import { isWslUnc } from "./paths.js";
28
28
  import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
29
+ import { sharedAgent } from "./agent-shared.js";
30
+ import { listeningPorts } from "./ports.js";
29
31
 
30
32
  export const name = "wsl-shell-env";
31
33
 
@@ -34,6 +36,8 @@ export const inject = ["shellEnv"];
34
36
  export const Config = z.object({
35
37
  /** Distro these facts describe; empty means this machine's default distro. */
36
38
  distro: z.string().default(""),
39
+ /** How often the listening-port snapshot refreshes, in milliseconds. */
40
+ portsRefreshMs: z.number().default(10 * 1000),
37
41
  });
38
42
 
39
43
  export async function apply(ctx, config = {}) {
@@ -56,7 +60,21 @@ export async function apply(ctx, config = {}) {
56
60
  DSH_WSL_HOME: {
57
61
  description: `Home directory of the user model shell calls run as inside ${distro}.`,
58
62
  },
63
+ DSH_WSL_PORTS: {
64
+ description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last scan.`,
65
+ },
66
+ };
67
+
68
+ // The port snapshot refreshes on a timer and is served synchronously: the
69
+ // registry's resolve is sync by contract, so live reads are impossible —
70
+ // the timer bounds staleness instead (see lib/ports.js).
71
+ let ports = [];
72
+ const refreshPorts = async () => {
73
+ ports = await listeningPorts(sharedAgent(distro), 8000);
59
74
  };
75
+ void refreshPorts();
76
+ const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
77
+ portsTimer.unref?.();
60
78
 
61
79
  const dispose = ctx.shellEnv.register({
62
80
  name: "dsh-plugin-wsl-env",
@@ -64,11 +82,19 @@ export async function apply(ctx, config = {}) {
64
82
  resolve: (execution) => {
65
83
  const cwd = execution?.agent?.session?.header?.cwd;
66
84
  if (!isWslUnc(cwd)) return {};
67
- return { DSH_WSL_DISTRO: distro, DSH_WSL_SHELL: shell, DSH_WSL_HOME: home };
85
+ return {
86
+ DSH_WSL_DISTRO: distro,
87
+ DSH_WSL_SHELL: shell,
88
+ DSH_WSL_HOME: home,
89
+ DSH_WSL_PORTS: ports.join(","),
90
+ };
68
91
  },
69
92
  });
70
93
 
71
94
  // Disposing matters: a config hot-reload re-runs apply(), and the registry
72
95
  // rejects a second contributor claiming the same keys.
73
- ctx.effect(() => dispose);
96
+ ctx.effect(() => {
97
+ clearInterval(portsTimer);
98
+ dispose();
99
+ });
74
100
  }
package/lib/subprocess.js CHANGED
@@ -74,6 +74,7 @@ import z from "@deepseek-ai/schemastery";
74
74
  import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
75
75
  import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
76
76
  import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
77
+ import { terminalRoute } from "./terminal-route.js";
77
78
 
78
79
  /**
79
80
  * Env names a terminal launch keeps for the distro. `TERM` rides along because
@@ -120,6 +121,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
120
121
  hostCwd: z.string().volatile(),
121
122
  /** Extra env names to forward into the distro through WSLENV. */
122
123
  forwardEnv: z.array(z.string()).default([]),
124
+ /**
125
+ * Give a session opened on a WINDOWS folder a host shell instead of the
126
+ * distro's shell started at `/mnt/<drive>/…`. WSL-folder sessions always
127
+ * get the distro shell — that is this provider's purpose. `false` restores
128
+ * the composition-owned terminal for every session.
129
+ */
130
+ hostSessions: z.boolean().default(true),
123
131
  });
124
132
 
125
133
  constructor(ctx, config) {
@@ -185,6 +193,13 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
185
193
  */
186
194
  async spawnTerminal(spec) {
187
195
  if (!this.launchesDistro(spec.argv)) return super.spawnTerminal(spec);
196
+ // A session on a Windows folder is a HOST session: it gets the host shell
197
+ // in its own directory, not the distro's shell at `/mnt/<drive>/…`. The
198
+ // wsl.exe argv still names this provider's launch, so the substitution
199
+ // stays inside the override — every other consumer is untouched.
200
+ if (this.config.hostSessions && terminalRoute(spec.cwd) === "host") {
201
+ return super.spawnTerminal({ ...spec, argv: ["powershell.exe", "-NoLogo"] });
202
+ }
188
203
  const target = await this.terminalTarget(spec.cwd);
189
204
  const argv = wslTerminalArgv({
190
205
  wslPath: this.config.wslPath,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which execution world a terminal launch belongs to.
3
+ *
4
+ * The terminal controller hands this provider the request's directory, and the
5
+ * directory's coordinate system is the evidence of intent: a UNC path under a
6
+ * distro's share names a WSL session outright, a POSIX absolute path is a
7
+ * distro path by construction, and a Windows drive path is a session opened on
8
+ * a Windows folder — which deserves a HOST shell, not the distro's shell
9
+ * started at `/mnt/<drive>/…`. An empty directory carries no evidence either
10
+ * way and defaults to the distro, which is what a profile pins.
11
+ *
12
+ * Pure by construction.
13
+ *
14
+ * @module dsh-plugin-wsl/terminal-route
15
+ */
16
+
17
+ import { isPosixAbsolute, uncToPosix, windowsToLinuxMount } from "./paths.js";
18
+
19
+ /**
20
+ * @param cwd - the terminal request's working directory, any coordinate system.
21
+ * @returns `"distro"` when the launch belongs inside the pinned distro,
22
+ * `"host"` when it belongs to a Windows-folder session.
23
+ */
24
+ export function terminalRoute(cwd) {
25
+ const text = String(cwd ?? "").trim();
26
+ if (text.length === 0) return "distro";
27
+ if (uncToPosix(text) !== undefined) return "distro";
28
+ if (isPosixAbsolute(text)) return "distro";
29
+ if (windowsToLinuxMount(text) !== undefined) return "host";
30
+ return "distro";
31
+ }
package/lib/watcher.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * In-distro file watching for `WslFileSystem.watch`.
3
+ *
4
+ * A 9p share gives Node no usable change notification — chokidar over
5
+ * `\\wsl.localhost` is the unreliability that made this provider refuse to
6
+ * watch at all. The seam's contract, however, asks for far less than events:
7
+ * `watch(target, changed, signal)` wants ONE invalidation callback, invoked
8
+ * whenever something under `target` may have changed. That is cheap to honor
9
+ * from inside the distro, where the kernel does know.
10
+ *
11
+ * The watcher is a long-lived `wsl.exe` shell loop: periodically
12
+ * `find <dir> -newer <stamp>` — the directory itself is in the scan, so
13
+ * creations AND deletions (which bump the parent's mtime) are both seen — and
14
+ * one `C` line per hit. No `inotifywait` dependency; the mtime walk is exact
15
+ * for interactive-paced edits, which is the only pace a session watch serves.
16
+ *
17
+ * Known blind spot, accepted and documented: a file created with an mtime
18
+ * older than the stamp (`cp -p`, `tar -x`) is not seen until something else
19
+ * touches the tree.
20
+ *
21
+ * @module dsh-plugin-wsl/watcher
22
+ */
23
+
24
+ import { spawn } from "node:child_process";
25
+ import { createInterface } from "node:readline";
26
+
27
+ /**
28
+ * The shell loop the watcher runs, parameterised by argv so no path can break
29
+ * quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
30
+ * Pure text, so it is unit-testable.
31
+ * @param {number} [intervalSeconds] - poll cadence; must be >= 1.
32
+ * @returns {string} the `sh -c` script body.
33
+ */
34
+ export function watchLoopScript(intervalSeconds = 2) {
35
+ const seconds = Math.max(1, Math.floor(intervalSeconds));
36
+ // `find` lists the directory itself first: a child's creation or deletion
37
+ // bumps the parent's mtime, so both edges are seen, not just modifications.
38
+ return [
39
+ 'D="$1"',
40
+ 'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
41
+ 'touch "$S"',
42
+ // The READY line is the activation barrier: everything changed BEFORE it
43
+ // predates the stamp, so the host must not resolve the watch promise until
44
+ // it arrives — otherwise a write racing the first tick is invisible forever.
45
+ 'printf \'R\\n\'',
46
+ // Check first, sleep second: a change made right after activation is seen
47
+ // on the first cadence, not one cadence later.
48
+ 'while :; do',
49
+ ' if [ -n "$(find "$D" -newer "$S" -print -quit 2>/dev/null)" ]; then',
50
+ ' touch "$S"',
51
+ ' printf \'C\\n\'',
52
+ ' fi',
53
+ ` sleep ${seconds}`,
54
+ 'done',
55
+ ].join("\n");
56
+ }
57
+
58
+ /**
59
+ * Arm one in-distro watcher over a Linux directory.
60
+ *
61
+ * @param {object} options - the watch request.
62
+ * @param {string} options.wslPath - the `wsl.exe` path.
63
+ * @param {string} options.distro - the distro to watch inside.
64
+ * @param {string} options.linuxPath - the absolute Linux path to observe.
65
+ * @param {(error: Error) => void} [options.onError] - watcher-level failures
66
+ * (an exited loop); the seam reports these through its own channel.
67
+ * @param {() => void} options.onChange - the invalidation callback.
68
+ * @param {AbortSignal} options.signal - cancels initialization; the CALLER
69
+ * invokes the returned close to stop an armed watcher.
70
+ * @param {number} [options.intervalSeconds] - poll cadence, default 2.
71
+ * @param {(wslPath: string, args: string[]) => import("node:child_process").ChildProcess} [options.spawn]
72
+ * injectable process factory for tests.
73
+ * @returns {Promise<() => Promise<void>>} resolves once observation is active,
74
+ * with the async close function the seam's contract asks for.
75
+ */
76
+ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError, signal, intervalSeconds = 2, spawn: spawnProcess }) {
77
+ const doSpawn = spawnProcess ?? ((path, args) => spawn(path, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }));
78
+ return new Promise((resolve, reject) => {
79
+ if (signal?.aborted) {
80
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
81
+ return;
82
+ }
83
+ const child = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", watchLoopScript(intervalSeconds), "wsl-watch", linuxPath]);
84
+ let settled = false;
85
+ let closed = false;
86
+ const lines = createInterface({ input: child.stdout });
87
+ lines.on("line", (line) => {
88
+ if (line === "R") {
89
+ if (!settled && !signal?.aborted) {
90
+ settled = true;
91
+ resolve(close);
92
+ }
93
+ return;
94
+ }
95
+ if (line === "C") {
96
+ onChange();
97
+ }
98
+ });
99
+ const close = async () => {
100
+ if (closed) return;
101
+ closed = true;
102
+ child.removeAllListeners("exit");
103
+ lines.close();
104
+ child.stdout.destroy();
105
+ child.kill();
106
+ await new Promise((resolveClose) => {
107
+ if (child.exitCode !== null || child.signalCode !== null) return resolveClose();
108
+ child.once("exit", resolveClose);
109
+ });
110
+ };
111
+ const fail = (error) => {
112
+ if (settled) {
113
+ onError?.(error);
114
+ return;
115
+ }
116
+ settled = true;
117
+ void close();
118
+ reject(error);
119
+ };
120
+ signal?.addEventListener("abort", () => fail(signal.reason instanceof Error ? signal.reason : new Error("aborted")), { once: true });
121
+ child.on("error", fail);
122
+ child.on("exit", (code, signalName) => {
123
+ if (!closed) fail(new Error(`distro watcher exited unexpectedly (code=${code} signal=${signalName ?? "none"})`));
124
+ });
125
+
126
+ });
127
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.1.10",
4
- "description": "Run a DeepSeek Harness session against a WSL distro: commands and the model's file tools run inside it, the folder picker and the GUI terminal open there, and commands are confined by bubblewrap inside the distro",
3
+ "version": "0.2.0",
4
+ "description": "让 DeepSeek Harness 以 WSL 子系统作为会话的执行环境:shell、文件读写与命令执行均在子系统内完成,目录选择器与 GUI 终端同样定位到子系统路径,并通过子系统内的 bubblewrap 对命令进行沙箱隔离",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -22,6 +22,7 @@
22
22
  "./shell-env": "./lib/shell-env.js",
23
23
  "./wsl": "./lib/wsl.js",
24
24
  "./paths": "./lib/paths.js",
25
+ "./agent": "./lib/agent.js",
25
26
  "./package.json": "./package.json"
26
27
  },
27
28
  "dsh": {
@@ -31,12 +32,14 @@
31
32
  },
32
33
  "files": [
33
34
  "lib/**/*.js",
35
+ "agent/*.sh",
34
36
  "cordis.patch.yml",
35
37
  "examples/**/*.yml",
36
38
  "README.md",
37
39
  "docs/README.zh.md",
38
40
  "docs/CONFIGURATION.md",
39
41
  "docs/LIMITATIONS.md",
42
+ "docs/PARITY.md",
40
43
  "docs/ARCHITECTURE.md",
41
44
  "CHANGELOG.md",
42
45
  "CONTRIBUTING.md",
@@ -48,7 +51,7 @@
48
51
  "test": "npm run lint:style && npm run test:syntax && npm run test:unit",
49
52
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=70 test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs",
50
53
  "test:syntax": "node test/syntax.mjs",
51
- "test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs",
54
+ "test:unit": "node --test test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-publish.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs",
52
55
  "lint:style": "node test/style.mjs",
53
56
  "prepublishOnly": "npm test",
54
57
  "probe": "bash test/probe/run.sh",
@@ -59,7 +62,10 @@
59
62
  "probe:terminal": "bash test/probe/terminal.sh",
60
63
  "probe:sandbox": "bash test/probe/sandbox.sh",
61
64
  "probe:sandbox-shell": "bash test/probe/sandbox-shell.sh",
62
- "sync:windows": "bash test/probe/sync-to-windows.sh"
65
+ "sync:windows": "bash test/probe/sync-to-windows.sh",
66
+ "probe:watch": "bash test/probe/watch.sh",
67
+ "probe:exec": "bash test/probe/exec.sh",
68
+ "bootstrap": "bash scripts/bootstrap.sh"
63
69
  },
64
70
  "engines": {
65
71
  "node": ">=20"