dsh-plugin-wsl-env 0.8.0 → 0.10.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 (54) hide show
  1. package/CHANGELOG.md +488 -12
  2. package/CONTRIBUTING.md +125 -62
  3. package/README.md +99 -68
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +26 -10
  6. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  7. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  8. package/agent/wsl-agent.sh +83 -16
  9. package/cordis.patch.yml +50 -35
  10. package/docs/ARCHITECTURE.md +203 -55
  11. package/docs/CONFIGURATION.md +185 -18
  12. package/docs/LIMITATIONS.md +125 -45
  13. package/docs/PARITY.md +68 -38
  14. package/docs/PEER-PARITY.md +103 -33
  15. package/docs/README.zh.md +105 -64
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +56 -14
  18. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  19. package/lib/agent-protocol.d.ts +10 -4
  20. package/lib/agent-protocol.js +10 -3
  21. package/lib/agent.d.ts +84 -0
  22. package/lib/agent.js +167 -15
  23. package/lib/bwrap.d.ts +8 -0
  24. package/lib/bwrap.js +4 -1
  25. package/lib/fs-routing.d.ts +40 -4
  26. package/lib/fs-routing.js +80 -12
  27. package/lib/fsio-agent.js +1 -1
  28. package/lib/index.d.ts +134 -0
  29. package/lib/index.js +202 -9
  30. package/lib/native-agent.d.ts +124 -0
  31. package/lib/native-agent.js +198 -0
  32. package/lib/ports.d.ts +13 -2
  33. package/lib/ports.js +18 -5
  34. package/lib/preflight.d.ts +91 -0
  35. package/lib/preflight.js +117 -0
  36. package/lib/probe-cache.d.ts +12 -1
  37. package/lib/probe-cache.js +11 -3
  38. package/lib/sandbox-core.d.ts +70 -0
  39. package/lib/sandbox-core.js +187 -18
  40. package/lib/session-runtime.d.ts +161 -0
  41. package/lib/session-runtime.js +220 -0
  42. package/lib/session-scope.d.ts +124 -0
  43. package/lib/session-scope.js +191 -0
  44. package/lib/shell-env.js +10 -3
  45. package/lib/watcher.d.ts +25 -12
  46. package/lib/watcher.js +120 -32
  47. package/lib/workspace-files-route.d.ts +8 -4
  48. package/lib/workspace-files-route.js +42 -14
  49. package/lib/workspace-files-wsl.js +25 -8
  50. package/lib/wsl.js +10 -4
  51. package/locale/preflight/en.json +6 -0
  52. package/locale/preflight/zh.json +6 -0
  53. package/package.json +44 -27
  54. package/scripts/diagnose.sh +92 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Pure builders for the session-scoped sandbox launcher (ADR 0001).
3
+ *
4
+ * The runtime is one transient systemd **user scope** per session, running the
5
+ * agent as its main process; commands are the agent's children, so a
6
+ * background dev server stays in the scope's cgroup, `/tmp` is the distro's
7
+ * real `/tmp`, and `ps` sees the session. Session end stops the scope and the
8
+ * cgroup reaps everything.
9
+ *
10
+ * Nothing here spawns: the host inserts {@link sessionScopePrefix} between
11
+ * `wsl.exe --exec` and the agent's own argv, runs
12
+ * {@link sessionScopeProbeArgv} before enabling the mode, and runs
13
+ * {@link sessionScopeStopArgv} when the session ends. Keeping the shapes pure
14
+ * is what lets the launcher be tested without systemd.
15
+ *
16
+ * This is a TypeScript source built to `lib/session-scope.js`; edit THIS file
17
+ * and run `pnpm run build` — the artifact under `lib/` is generated, and
18
+ * `pnpm test` fails when it drifts.
19
+ *
20
+ * @module dsh-plugin-wsl/session-scope
21
+ */
22
+ /** The longest piece of a unit name this builder emits, per component. */
23
+ const MAX_COMPONENT = 64;
24
+ /**
25
+ * Characters systemd accepts in a unit name; everything else is folded to `_`.
26
+ * The allowed set is `[A-Za-z0-9:_.\-]`; `:` is legal in unit names but not
27
+ * useful here, so it is folded too, keeping the name easy to copy into a
28
+ * shell.
29
+ */
30
+ const UNIT_CHARS = /[^A-Za-z0-9_.-]/gu;
31
+ /**
32
+ * Sanitize one component of a unit name.
33
+ *
34
+ * @param value - the raw component.
35
+ * @param fallback - the component to use when nothing survives.
36
+ * @returns the sanitized, length-capped component.
37
+ */
38
+ function component(value, fallback) {
39
+ const clean = value.replace(UNIT_CHARS, "_").slice(0, MAX_COMPONENT);
40
+ return clean.length > 0 ? clean : fallback;
41
+ }
42
+ /**
43
+ * Build the systemd user unit name for one session runtime.
44
+ *
45
+ * The caller supplies a token that is unique for the session (a random hex
46
+ * string, a session id); the distro name is part of the name so two distros'
47
+ * runtimes are never confused in `systemctl --user list-units`. The result is
48
+ * deterministic for the pair, which is what lets the host stop the unit later
49
+ * without keeping a map.
50
+ *
51
+ * @param distro - the distro name.
52
+ * @param token - the per-session unique token.
53
+ * @returns a unit name safe for `systemd-run --unit`.
54
+ */
55
+ export function sessionScopeUnit(distro, token) {
56
+ return `dsh-wsl-${component(distro, "distro")}-${component(token, "session")}`;
57
+ }
58
+ /**
59
+ * The arguments inserted after `wsl.exe --exec` to run the agent in a
60
+ * transient user scope. The agent's own argv (its confinement prefix, the
61
+ * shell and the script) follows the `--` terminator.
62
+ *
63
+ * `--scope` is deliberate: a scope's stdio is inherited, so the protocol's
64
+ * pipes stay the transport's; a transient *service* would need `--pipe` and a
65
+ * fresh verification of its EOF/exit-code semantics (ADR open question).
66
+ *
67
+ * @param unit - the unit name from {@link sessionScopeUnit}.
68
+ * @returns the argv fragment, ending with `--`.
69
+ */
70
+ export function sessionScopePrefix(unit) {
71
+ return ["systemd-run", "--user", "--scope", "--quiet", "--unit", unit, "--"];
72
+ }
73
+ /**
74
+ * The host argv that stops one session runtime. Stopping the scope is the
75
+ * cleanup step that replaces the per-command PID namespace: the cgroup kills
76
+ * every descendant, including a background dev server a command left behind.
77
+ *
78
+ * @param options - the transport path, distro and unit name.
79
+ * @returns the `wsl.exe --exec systemctl --user stop` argv.
80
+ */
81
+ export function sessionScopeStopArgv({ wslPath, distro, unit }) {
82
+ return [
83
+ wslPath,
84
+ ...(distro.length > 0 ? ["-d", distro] : []),
85
+ "--exec",
86
+ "systemctl",
87
+ "--user",
88
+ "stop",
89
+ unit,
90
+ ];
91
+ }
92
+ /**
93
+ * The script the preflight runs inside the distro before the session runtime
94
+ * is enabled. It answers one word so the host can map the answer to a remedy:
95
+ *
96
+ * - `ok` — systemd is PID 1, the user manager answers, `systemd-run` exists
97
+ * and the user lingers (a headless `wsl.exe --exec` has no login session).
98
+ * - `no-systemd` — `/run/systemd/system` is absent (systemd is not PID 1).
99
+ * - `no-user-manager` — `systemctl --user` does not report running/degraded.
100
+ * - `no-systemd-run` — the `systemd-run` binary is missing.
101
+ * - `no-linger` — the user manager would stop with the last session; the
102
+ * remedy is `loginctl enable-linger <user>`.
103
+ *
104
+ * `degraded` is accepted: a partially failed unit elsewhere does not make the
105
+ * user manager unable to create a scope.
106
+ *
107
+ * @returns the shell script.
108
+ */
109
+ export function sessionScopeProbeScript() {
110
+ return [
111
+ '[ -d /run/systemd/system ] || { echo no-systemd; exit 0; }',
112
+ 'state=$(systemctl --user is-system-running 2>/dev/null || true)',
113
+ 'case "$state" in running|degraded) ;; *) echo no-user-manager; exit 0;; esac',
114
+ 'command -v systemd-run >/dev/null 2>&1 || { echo no-systemd-run; exit 0; }',
115
+ 'loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q "^Linger=yes$" || { echo no-linger; exit 0; }',
116
+ "echo ok",
117
+ ].join("\n");
118
+ }
119
+ /**
120
+ * The script the session preflight runs to stop scopes a crashed app left
121
+ * behind. A scope is stale when its cgroup has no agent process: the shell
122
+ * `wsl-agent.sh` or the native `dsh-wsl-agent` is the liveness marker. A live
123
+ * agent means another session still owns it, so it must not be stopped.
124
+ * The script only lists `dsh-wsl-*.scope` units and only stops the ones it
125
+ * can prove stale; if there is no user manager it exits 0 (the session
126
+ * preflight reports that condition separately).
127
+ *
128
+ * @returns the shell script.
129
+ */
130
+ export function sessionScopeSweepScript() {
131
+ return [
132
+ // Without a user manager there is nothing to list.
133
+ 'systemctl --user is-system-running >/dev/null 2>&1 || exit 0',
134
+ "systemctl --user list-units 'dsh-wsl-*.scope' --all --plain --no-legend --no-pager 2>/dev/null | while read -r unit _; do",
135
+ ' [ -n "$unit" ] || continue',
136
+ ' cg=$(systemctl --user show -p ControlGroup --value "$unit" 2>/dev/null)',
137
+ ' [ -n "$cg" ] || continue',
138
+ ' alive=0',
139
+ ' if [ -r "/sys/fs/cgroup$cg/cgroup.procs" ]; then',
140
+ ' while read -r pid; do',
141
+ ' [ -n "$pid" ] || continue',
142
+ " if tr '\\0' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null | grep -q -e 'wsl-agent.sh' -e 'dsh-wsl-agent'; then alive=1; break; fi",
143
+ ' done < "/sys/fs/cgroup$cg/cgroup.procs"',
144
+ ' fi',
145
+ ' [ "$alive" -eq 1 ] || systemctl --user stop "$unit" >/dev/null 2>&1 || true',
146
+ 'done',
147
+ 'exit 0',
148
+ ].join("\n");
149
+ }
150
+ /**
151
+ * Map the preflight's answer to a user-facing refusal, or `null` when the
152
+ * platform can host a session runtime. The probe prints one word (it always
153
+ * exits 0 so the host reads a verdict, not a spawn error); an empty or unknown
154
+ * answer is reported with whatever diagnostic the host captured.
155
+ *
156
+ * @param options - the probe output and the distro it ran in.
157
+ * @returns the refusal with a remedy, or null.
158
+ */
159
+ export function sessionScopeProblem({ output, distro }) {
160
+ const word = output.stdout.trim().split(/\r?\n/u)[0]?.trim() ?? "";
161
+ switch (word) {
162
+ case "ok":
163
+ return null;
164
+ case "no-systemd":
165
+ return `sessionSandbox needs systemd as PID 1 in "${distro}": set [boot] systemd=true in /etc/wsl.conf, run "wsl --shutdown", and reopen the session.`;
166
+ case "no-user-manager":
167
+ return `sessionSandbox needs a reachable systemd user manager in "${distro}": log in to the distro once, or enable lingering (see the next refusal).`;
168
+ case "no-systemd-run":
169
+ return `sessionSandbox needs "systemd-run" in "${distro}"; the distro's systemd package is incomplete or missing.`;
170
+ case "no-linger":
171
+ return `sessionSandbox needs the user manager to outlive a login session: run "loginctl enable-linger $USER" in "${distro}" and retry.`;
172
+ default:
173
+ return `the sessionSandbox preflight in "${distro}" answered ${word === "" ? "(nothing)" : `"${word}"`}${output.error === undefined ? "" : `: ${output.error}`}; see docs/adr/0001-session-scoped-sandbox.md.`;
174
+ }
175
+ }
176
+ /**
177
+ * The host argv for the preflight probe.
178
+ *
179
+ * @param options - the transport path and distro.
180
+ * @returns the `wsl.exe --exec sh -c <script>` argv.
181
+ */
182
+ export function sessionScopeProbeArgv({ wslPath, distro }) {
183
+ return [
184
+ wslPath,
185
+ ...(distro.length > 0 ? ["-d", distro] : []),
186
+ "--exec",
187
+ "sh",
188
+ "-c",
189
+ sessionScopeProbeScript(),
190
+ ];
191
+ }
package/lib/shell-env.js CHANGED
@@ -30,7 +30,7 @@ import z from "@deepseek-ai/schemastery";
30
30
  import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
31
31
  import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
32
32
  import { sharedAgent } from "./agent-shared.js";
33
- import { listeningPorts } from "./ports.js";
33
+ import { scanListeningPorts } from "./ports.js";
34
34
  export const name = "wsl-shell-env";
35
35
  export const inject = ["shellEnv"];
36
36
  export const Config = z.object({
@@ -68,7 +68,7 @@ export async function apply(ctx, config = {}) {
68
68
  description: `Home directory of the user model shell calls run as inside ${distro}.`,
69
69
  },
70
70
  DSH_WSL_PORTS: {
71
- 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.`,
71
+ 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 successful scan (or no scan has succeeded yet); a failed scan keeps the last successful list.`,
72
72
  },
73
73
  };
74
74
  // The port snapshot refreshes on a timer and is served synchronously: the
@@ -76,7 +76,14 @@ export async function apply(ctx, config = {}) {
76
76
  // the timer bounds staleness instead (see lib/ports.js).
77
77
  let ports = [];
78
78
  const refreshPorts = async () => {
79
- ports = await listeningPorts(sharedAgent(distro), 8000);
79
+ const next = await scanListeningPorts(sharedAgent(distro), 8000);
80
+ if (next === undefined) {
81
+ // A transient agent outage must not read as "no ports": keep the last
82
+ // successful snapshot and leave a debug breadcrumb.
83
+ ctx.logger?.debug?.("wsl-shell-env: port snapshot failed; keeping the last known list");
84
+ return;
85
+ }
86
+ ports = next;
80
87
  };
81
88
  void refreshPorts();
82
89
  const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
package/lib/watcher.d.ts CHANGED
@@ -8,21 +8,33 @@
8
8
  * whenever something under `target` may have changed. That is cheap to honor
9
9
  * from inside the distro, where the kernel does know.
10
10
  *
11
- * The watcher is a long-lived `wsl.exe` shell loop: periodically
11
+ * When `inotifywait` is installed and the target is an unbounded directory,
12
+ * the watcher uses inotify: recursive kernel watches, one `C` per 0.5s of
13
+ * events (a build's event storm is coalesced, the first event is immediate).
14
+ * Otherwise it falls back to the long-lived polling loop: every cadence,
12
15
  * `find <dir> -newer <stamp>` — the directory itself is in the scan, so
13
16
  * 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.
17
+ * one `C` line per hit. A FILE target uses the `mtime:size` fingerprint (so a
18
+ * rename-over or an in-place write is seen), and a positive `maxDepth` keeps
19
+ * the polling scan because inotify cannot bound its own walk.
16
20
  *
17
21
  * The loop speaks three one-letter lines, the watcher protocol:
18
22
  *
19
23
  * - `R` — READY: the activation barrier; the host resolves the watch promise
20
24
  * on it, never before.
21
- * - `C` — CHANGED: something under the target may have changed.
22
- * - `E` — ERROR: the watched directory no longer exists, and the loop has
25
+ * - `C` — CHANGED: the target may have changed.
26
+ * - `E` — ERROR: the watched path no longer exists, and the loop has
23
27
  * exited. Without it a vanished target would sit armed forever — `find`
24
28
  * over a missing directory prints nothing, indistinguishable from "no
25
29
  * changes" — so the loop says so and the host reports it.
30
+ * - `P <pid>` — the loop shell's PID, sent before READY. The host uses it to
31
+ * stop the loop with a second `wsl.exe` on close: killing the transport
32
+ * alone does not reliably close the distro-side pipe.
33
+ * - `H` — HEARTBEAT: liveness only, ignored by the host. It exists so the
34
+ * loop notices a closed stdout pipe: the host's close kills `wsl.exe` but
35
+ * cannot signal the distro-side shell, and without a write that shell
36
+ * would sleep and poll forever. The `PIPE` trap turns that write's
37
+ * SIGPIPE into a clean exit through the stamp-removing EXIT trap.
26
38
  *
27
39
  * Known blind spot, accepted and documented: a file created with an mtime
28
40
  * older than the stamp (`cp -p`, `tar -x`) is not seen until something else
@@ -37,19 +49,20 @@
37
49
  import type { ChildProcess } from "node:child_process";
38
50
  /**
39
51
  * The shell loop the watcher runs, parameterised by argv so no path can break
40
- * quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
41
- * Pure text, so it is unit-testable.
52
+ * quoting: `$1` is the Linux path (a directory or a file), `$2` the poll
53
+ * interval in seconds. Pure text, so it is unit-testable.
42
54
  *
43
55
  * @param intervalSeconds - poll cadence; must be >= 1.
44
- * @param maxDepth - bound the scan's depth, 1 = the directory
45
- * itself and its direct children; 0 (the default) scans the whole tree,
46
- * which is the contract's semantics but a real cost over a tree with a
47
- * `node_modules` in it.
56
+ * @param maxDepth - bound the scan's depth for a DIRECTORY target, 1 = the
57
+ * directory itself and its direct children; 0 (the default) scans the whole
58
+ * tree, which is the contract's semantics. Positive values select the
59
+ * polling scan (inotify is used only for the unbounded walk). A file target
60
+ * ignores it.
48
61
  * @returns the `sh -c` script body.
49
62
  */
50
63
  export declare function watchLoopScript(intervalSeconds?: number, maxDepth?: number): string;
51
64
  /**
52
- * Arm one in-distro watcher over a Linux directory.
65
+ * Arm one in-distro watcher over a Linux directory or file.
53
66
  *
54
67
  * @param options - the watch request.
55
68
  * @returns resolves once observation is active, with the async close function
package/lib/watcher.js CHANGED
@@ -8,21 +8,33 @@
8
8
  * whenever something under `target` may have changed. That is cheap to honor
9
9
  * from inside the distro, where the kernel does know.
10
10
  *
11
- * The watcher is a long-lived `wsl.exe` shell loop: periodically
11
+ * When `inotifywait` is installed and the target is an unbounded directory,
12
+ * the watcher uses inotify: recursive kernel watches, one `C` per 0.5s of
13
+ * events (a build's event storm is coalesced, the first event is immediate).
14
+ * Otherwise it falls back to the long-lived polling loop: every cadence,
12
15
  * `find <dir> -newer <stamp>` — the directory itself is in the scan, so
13
16
  * 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.
17
+ * one `C` line per hit. A FILE target uses the `mtime:size` fingerprint (so a
18
+ * rename-over or an in-place write is seen), and a positive `maxDepth` keeps
19
+ * the polling scan because inotify cannot bound its own walk.
16
20
  *
17
21
  * The loop speaks three one-letter lines, the watcher protocol:
18
22
  *
19
23
  * - `R` — READY: the activation barrier; the host resolves the watch promise
20
24
  * on it, never before.
21
- * - `C` — CHANGED: something under the target may have changed.
22
- * - `E` — ERROR: the watched directory no longer exists, and the loop has
25
+ * - `C` — CHANGED: the target may have changed.
26
+ * - `E` — ERROR: the watched path no longer exists, and the loop has
23
27
  * exited. Without it a vanished target would sit armed forever — `find`
24
28
  * over a missing directory prints nothing, indistinguishable from "no
25
29
  * changes" — so the loop says so and the host reports it.
30
+ * - `P <pid>` — the loop shell's PID, sent before READY. The host uses it to
31
+ * stop the loop with a second `wsl.exe` on close: killing the transport
32
+ * alone does not reliably close the distro-side pipe.
33
+ * - `H` — HEARTBEAT: liveness only, ignored by the host. It exists so the
34
+ * loop notices a closed stdout pipe: the host's close kills `wsl.exe` but
35
+ * cannot signal the distro-side shell, and without a write that shell
36
+ * would sleep and poll forever. The `PIPE` trap turns that write's
37
+ * SIGPIPE into a clean exit through the stamp-removing EXIT trap.
26
38
  *
27
39
  * Known blind spot, accepted and documented: a file created with an mtime
28
40
  * older than the stamp (`cp -p`, `tar -x`) is not seen until something else
@@ -38,14 +50,15 @@ import { spawn } from "node:child_process";
38
50
  import { createInterface } from "node:readline";
39
51
  /**
40
52
  * The shell loop the watcher runs, parameterised by argv so no path can break
41
- * quoting: `$1` is the Linux directory, `$2` the poll interval in seconds.
42
- * Pure text, so it is unit-testable.
53
+ * quoting: `$1` is the Linux path (a directory or a file), `$2` the poll
54
+ * interval in seconds. Pure text, so it is unit-testable.
43
55
  *
44
56
  * @param intervalSeconds - poll cadence; must be >= 1.
45
- * @param maxDepth - bound the scan's depth, 1 = the directory
46
- * itself and its direct children; 0 (the default) scans the whole tree,
47
- * which is the contract's semantics but a real cost over a tree with a
48
- * `node_modules` in it.
57
+ * @param maxDepth - bound the scan's depth for a DIRECTORY target, 1 = the
58
+ * directory itself and its direct children; 0 (the default) scans the whole
59
+ * tree, which is the contract's semantics. Positive values select the
60
+ * polling scan (inotify is used only for the unbounded walk). A file target
61
+ * ignores it.
49
62
  * @returns the `sh -c` script body.
50
63
  */
51
64
  export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
@@ -56,37 +69,90 @@ export function watchLoopScript(intervalSeconds = 2, maxDepth = 0) {
56
69
  const depthArgs = depth > 0 ? ` -maxdepth ${depth}` : "";
57
70
  // `find` lists the directory itself first: a child's creation or deletion
58
71
  // bumps the parent's mtime, so both edges are seen, not just modifications.
72
+ // A FILE target has no tree to walk; its `mtime:size` fingerprint answers the
73
+ // same one-way question ("may have changed") and covers a rename-over too,
74
+ // since the path is re-stat'ed each tick.
59
75
  return [
60
76
  'D="$1"',
61
77
  'S="${TMPDIR:-/tmp}/wsl-watch.$$.stamp"',
78
+ 'FIFO="${TMPDIR:-/tmp}/wsl-watch.$$.fifo"',
79
+ 'IW=""',
62
80
  'touch "$S"',
63
- // The stamp is the loop's private state, so every way out removes it. The
64
- // EXIT trap covers plain exits; a signal alone would end the shell WITHOUT
65
- // running it, so TERM/INT/HUP are trapped to a plain exit first.
66
- "trap 'rm -f \"$S\"' EXIT",
67
- "trap 'exit 0' TERM INT HUP",
68
- // The READY line is the activation barrier: everything changed BEFORE it
69
- // predates the stamp, so the host must not resolve the watch promise until
70
- // it arrives — otherwise a write racing the first tick is invisible forever.
81
+ // The stamp/FIFO are the loop's private state, so every way out removes
82
+ // them. The EXIT trap covers plain exits; a signal alone would end the
83
+ // shell WITHOUT running it, so TERM/INT/HUP/PIPE are trapped to a plain
84
+ // exit first. `pkill -P $$` also reaps inotifywait/sleep/find.
85
+ "trap 'kill \"$IW\" 2>/dev/null; pkill -P $$ 2>/dev/null; exec 3>&- 2>/dev/null; rm -f \"$FIFO\" \"$S\" 2>/dev/null' EXIT",
86
+ "trap 'exit 0' TERM INT HUP PIPE",
87
+ 'F=""',
88
+ '[ -f "$D" ] && F=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
89
+ // Prefer inotify for an unbounded DIRECTORY watch: events are immediate and
90
+ // the kernel does the walking. A file target uses the fingerprint loop
91
+ // (it also reports a removal as E), and a bounded scan (maxDepth > 0)
92
+ // keeps the polling loop, which honors the bound.
93
+ `if [ -d "$D" ] && [ ${depth} -eq 0 ] && command -v inotifywait >/dev/null 2>&1; then`,
94
+ ' if mkfifo "$FIFO" 2>/dev/null; then',
95
+ ' exec 3<>"$FIFO"',
96
+ ' inotifywait -m -q -r -e create,delete,modify,move,attrib --format "%e" "$D" >"$FIFO" 2>/dev/null &',
97
+ ' IW=$!',
98
+ ' fi',
99
+ 'fi',
100
+ // The PID precedes READY so a close can stop the loop even if the pipe
101
+ // stays open; killing wsl.exe alone does not close it reliably.
102
+ "printf 'P %s\\n' \"$$\"",
71
103
  "printf 'R\\n'",
72
- // Check first, sleep second: a change made right after activation is seen
73
- // on the first cadence, not one cadence later.
104
+ // One C per half second: a build's event storm becomes a coarse
105
+ // invalidation, while the first event is immediate. A quiet tree blocks
106
+ // in read; the close stops the shell through the P line, not the pipe.
107
+ 'if [ -n "$IW" ]; then',
108
+ // A one-second marker on the same FIFO wakes the blocking read so the
109
+ // TERM trap and the vanished-target check run even on a quiet tree. The
110
+ // marker is not an event: it is skipped, and only checked against the
111
+ // target's existence.
112
+ ' ( while :; do sleep 1; printf \'H\\n\' >"$FIFO" 2>/dev/null; done ) &',
113
+ ' while read -r ev <&3; do',
114
+ ' if [ "$ev" = "H" ]; then',
115
+ ' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
116
+ ' continue',
117
+ ' fi',
118
+ ' [ -d "$D" ] || { printf \'E\\n\'; exit 1; }',
119
+ " printf 'C\\n'",
120
+ ' sleep 0.5',
121
+ ' done',
122
+ ' exec 3>&-',
123
+ 'fi',
124
+ // Polling fallback: no inotifywait, a bounded scan, or inotifywait exited
125
+ // (a vanished target or an inotify limit). The stamp is the baseline.
74
126
  "while :; do",
75
- ' if [ ! -d "$D" ]; then',
76
- // Say so and die: silence here is the armed-forever, fires-never trap.
127
+ ' if [ -d "$D" ]; then',
128
+ ` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
129
+ ' touch "$S"',
130
+ " printf 'C\\n'",
131
+ ' fi',
132
+ ' elif [ -f "$D" ]; then',
133
+ ' cur=$(stat -c "%Y:%s" -- "$D" 2>/dev/null || :)',
134
+ ' if [ -z "$cur" ]; then',
135
+ // The file was removed (or became unreadable): the target is gone.
136
+ " printf 'E\\n'",
137
+ ' exit 1',
138
+ ' fi',
139
+ ' if [ "$cur" != "$F" ]; then',
140
+ ' F="$cur"',
141
+ " printf 'C\\n'",
142
+ ' fi',
143
+ ' else',
144
+ // Neither a directory nor a file: say so and die, rather than spin.
77
145
  " printf 'E\\n'",
78
- " exit 1",
79
- " fi",
80
- ` if [ -n "$(find "$D"${depthArgs} -newer "$S" -print -quit 2>/dev/null)" ]; then`,
81
- ' touch "$S"',
82
- " printf 'C\\n'",
83
- " fi",
146
+ ' exit 1',
147
+ ' fi',
148
+ // Liveness: the host ignores H; the write dies with the pipe.
149
+ " printf 'H\\n'",
84
150
  ` sleep ${seconds}`,
85
- "done",
151
+ 'done',
86
152
  ].join("\n");
87
153
  }
88
154
  /**
89
- * Arm one in-distro watcher over a Linux directory.
155
+ * Arm one in-distro watcher over a Linux directory or file.
90
156
  *
91
157
  * @param options - the watch request.
92
158
  * @returns resolves once observation is active, with the async close function
@@ -118,8 +184,15 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
118
184
  const detail = stderrTail.trim();
119
185
  return detail.length > 0 ? `${message}: ${detail}` : message;
120
186
  };
187
+ let loopPid;
121
188
  const lines = createInterface({ input: child.stdout });
122
189
  lines.on("line", (line) => {
190
+ if (line.startsWith("P ")) {
191
+ const pid = Number.parseInt(line.slice(2), 10);
192
+ if (Number.isInteger(pid) && pid > 0)
193
+ loopPid = pid;
194
+ return;
195
+ }
123
196
  if (line === "R") {
124
197
  if (!settled && !signal?.aborted) {
125
198
  settled = true;
@@ -128,7 +201,7 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
128
201
  return;
129
202
  }
130
203
  if (line === "E") {
131
- fail(new Error(describe(`the watched directory "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
204
+ fail(new Error(describe(`the watched path "${linuxPath}" no longer exists inside "${distro}", so the watch has ended`)));
132
205
  return;
133
206
  }
134
207
  if (line === "C") {
@@ -139,10 +212,25 @@ export function armDistroWatcher({ wslPath, distro, linuxPath, onChange, onError
139
212
  if (closed)
140
213
  return;
141
214
  closed = true;
215
+ const pid = loopPid;
142
216
  child.removeAllListeners("exit");
143
217
  lines.close();
144
218
  child.stdout?.destroy();
145
219
  child.kill();
220
+ if (pid !== undefined) {
221
+ // A second wsl.exe reaches the same distro namespace; the loop's TERM
222
+ // trap exits through the EXIT cleanup, which removes the stamp.
223
+ try {
224
+ const stopper = doSpawn(wslPath, ["-d", distro, "--exec", "sh", "-c", 'kill -TERM "$1" 2>/dev/null', "sh", String(pid)]);
225
+ stopper.stdout?.destroy();
226
+ stopper.stderr?.destroy();
227
+ stopper.on("error", () => { });
228
+ stopper.unref?.();
229
+ }
230
+ catch {
231
+ // Best effort: the transport kill already happened.
232
+ }
233
+ }
146
234
  await new Promise((resolveClose) => {
147
235
  if (child.exitCode !== null || child.signalCode !== null)
148
236
  return resolveClose();
@@ -42,12 +42,14 @@ export declare function linuxJoin(linuxRoot: string, path: string): string;
42
42
  /**
43
43
  * The record invocation for one path: line 1 is the no-follow kind (the
44
44
  * upstream lstat gate), line 2 the follow-stat fields (the upstream final
45
- * stat), tab-joined.
45
+ * stat), tab-joined. The path is resolved and contained first — the GUI's
46
+ * workspace is a boundary for every read, not just for listings.
46
47
  *
47
48
  * @param file - absolute Linux path.
49
+ * @param root - the workspace root in Linux form (containment boundary).
48
50
  * @returns the agent argv.
49
51
  */
50
- export declare function statRecordArgv(file: string): string[];
52
+ export declare function statRecordArgv(file: string, root: string): string[];
51
53
  /**
52
54
  * Parse one stat record (kind + follow fields, tab-joined).
53
55
  *
@@ -77,9 +79,10 @@ export declare function wireKind(kind: string): string;
77
79
  * @param file - absolute Linux path.
78
80
  * @param offset - first line to return (1-based).
79
81
  * @param limit - maximum lines to return.
82
+ * @param root - the workspace root in Linux form (containment boundary).
80
83
  * @returns the agent argv.
81
84
  */
82
- export declare function pageArgv(file: string, offset: number, limit: number): string[];
85
+ export declare function pageArgv(file: string, offset: number, limit: number, root: string): string[];
83
86
  /**
84
87
  * The byte-window invocation: the stat record rides stderr, the window rides
85
88
  * stdout. `$1` is the file, `$2` the byte offset, `$3` the byte count.
@@ -87,9 +90,10 @@ export declare function pageArgv(file: string, offset: number, limit: number): s
87
90
  * @param file - absolute Linux path.
88
91
  * @param offset - byte offset (0-based).
89
92
  * @param length - byte count.
93
+ * @param root - the workspace root in Linux form (containment boundary).
90
94
  * @returns the agent argv.
91
95
  */
92
- export declare function bytesArgv(file: string, offset: number, length: number): string[];
96
+ export declare function bytesArgv(file: string, offset: number, length: number, root: string): string[];
93
97
  /**
94
98
  * Parse the stat record a read or byte-window relayed on stderr.
95
99
  *
@@ -64,21 +64,43 @@ export function linuxJoin(linuxRoot, path) {
64
64
  const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
65
65
  return joined.length === 0 ? "/" : joined;
66
66
  }
67
+ /**
68
+ * The containment guard every distro-side workspace read shares. `$1` is the
69
+ * requested file, `$2` the workspace root; both are canonicalized with
70
+ * `realpath -L`, and a target that is neither the root itself nor below it is
71
+ * refused with exit 3 (the `workspace-file/outside-workspace` refusal). The
72
+ * resolved file is left in `real` for the rest of the script.
73
+ *
74
+ * A root of `/` admits every absolute path: the workspace IS the distro. The
75
+ * `-L` spelling matches the listing guard's, so symlinked workspace roots and
76
+ * `..` segments resolve the same way on both sides.
77
+ */
78
+ const CONTAIN_FN = [
79
+ 'contain() {',
80
+ ' real=$(realpath -L -- "$1") || exit 2',
81
+ ' root=$(realpath -L -- "$2") || exit 2',
82
+ ' case "$root" in /) ;; *) case "$real" in "$root"|"$root"/*) ;; *) echo "OUTSIDE:$real" >&2; exit 3;; esac;; esac',
83
+ '}',
84
+ ].join("\n");
67
85
  /**
68
86
  * The record invocation for one path: line 1 is the no-follow kind (the
69
87
  * upstream lstat gate), line 2 the follow-stat fields (the upstream final
70
- * stat), tab-joined.
88
+ * stat), tab-joined. The path is resolved and contained first — the GUI's
89
+ * workspace is a boundary for every read, not just for listings.
71
90
  *
72
91
  * @param file - absolute Linux path.
92
+ * @param root - the workspace root in Linux form (containment boundary).
73
93
  * @returns the agent argv.
74
94
  */
75
- export function statRecordArgv(file) {
95
+ export function statRecordArgv(file, root) {
76
96
  const script = [
77
- 'k=$(stat -c %F -- "$1") || exit 2',
78
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
97
+ CONTAIN_FN,
98
+ 'contain "$1" "$2"',
99
+ 'k=$(stat -c %F -- "$real") || exit 2',
100
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
79
101
  "printf '%s\\t%s\\n' \"$k\" \"$s\"",
80
102
  ].join("\n");
81
- return ["sh", "-c", script, "sh", file];
103
+ return ["sh", "-c", script, "sh", file, root];
82
104
  }
83
105
  /**
84
106
  * Parse one stat record (kind + follow fields, tab-joined).
@@ -115,16 +137,19 @@ export function wireKind(kind) {
115
137
  * @param file - absolute Linux path.
116
138
  * @param offset - first line to return (1-based).
117
139
  * @param limit - maximum lines to return.
140
+ * @param root - the workspace root in Linux form (containment boundary).
118
141
  * @returns the agent argv.
119
142
  */
120
- export function pageArgv(file, offset, limit) {
143
+ export function pageArgv(file, offset, limit, root) {
121
144
  const script = [
122
- 'k=$(stat -c %F -- "$1") || exit 2',
123
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
145
+ CONTAIN_FN,
146
+ 'contain "$1" "$4"',
147
+ 'k=$(stat -c %F -- "$real") || exit 2',
148
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
124
149
  "printf '%s\\t%s\\n' \"$k\" \"$s\" >&2",
125
- 'sed -n "${2},$(( $2 + $3 ))p" -- "$1"',
150
+ 'sed -n "${2},$(( $2 + $3 ))p" -- "$real"',
126
151
  ].join("\n");
127
- return ["sh", "-c", script, "sh", file, String(offset), String(limit)];
152
+ return ["sh", "-c", script, "sh", file, String(offset), String(limit), root];
128
153
  }
129
154
  /**
130
155
  * The byte-window invocation: the stat record rides stderr, the window rides
@@ -133,15 +158,18 @@ export function pageArgv(file, offset, limit) {
133
158
  * @param file - absolute Linux path.
134
159
  * @param offset - byte offset (0-based).
135
160
  * @param length - byte count.
161
+ * @param root - the workspace root in Linux form (containment boundary).
136
162
  * @returns the agent argv.
137
163
  */
138
- export function bytesArgv(file, offset, length) {
164
+ export function bytesArgv(file, offset, length, root) {
139
165
  const script = [
140
- 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$1") || exit 2',
166
+ CONTAIN_FN,
167
+ 'contain "$1" "$4"',
168
+ 's=$(stat -L -c %s\\t%D\\t%i\\t%Y\\t%Z -- "$real") || exit 2',
141
169
  "printf '%s\\n' \"$s\" >&2",
142
- 'tail -c "+$(( $2 + 1 ))" -- "$1" | head -c "$3"',
170
+ 'tail -c "+$(( $2 + 1 ))" -- "$real" | head -c "$3"',
143
171
  ].join("\n");
144
- return ["sh", "-c", script, "sh", file, String(offset), String(length)];
172
+ return ["sh", "-c", script, "sh", file, String(offset), String(length), root];
145
173
  }
146
174
  /**
147
175
  * Parse the stat record a read or byte-window relayed on stderr.