dsh-plugin-wsl-env 0.9.0 → 0.11.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 (46) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +85 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +59 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +98 -74
  15. package/docs/ROADMAP.md +79 -0
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  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-shared.js +33 -5
  22. package/lib/agent.d.ts +187 -0
  23. package/lib/agent.js +311 -23
  24. package/lib/bwrap.d.ts +8 -0
  25. package/lib/bwrap.js +4 -1
  26. package/lib/fs-routing.d.ts +40 -4
  27. package/lib/fs-routing.js +80 -12
  28. package/lib/fsio-agent.js +1 -1
  29. package/lib/index.d.ts +134 -0
  30. package/lib/index.js +191 -3
  31. package/lib/native-agent.d.ts +124 -0
  32. package/lib/native-agent.js +198 -0
  33. package/lib/ports.d.ts +13 -2
  34. package/lib/ports.js +18 -5
  35. package/lib/preflight.js +24 -1
  36. package/lib/session-runtime.d.ts +161 -0
  37. package/lib/session-runtime.js +220 -0
  38. package/lib/session-scope.d.ts +124 -0
  39. package/lib/session-scope.js +191 -0
  40. package/lib/shell-env.js +10 -3
  41. package/lib/watcher.d.ts +25 -12
  42. package/lib/watcher.js +120 -32
  43. package/lib/workspace-files-route.d.ts +8 -4
  44. package/lib/workspace-files-route.js +42 -14
  45. package/lib/workspace-files-wsl.js +25 -8
  46. package/package.json +35 -25
@@ -0,0 +1,220 @@
1
+ /**
2
+ * The session-scoped runtime registry (ADR 0001).
3
+ *
4
+ * One long-lived agent per `(distro, workspaceRoot, policy)`, shared by the
5
+ * providers that need it and refcounted: the first `acquire` spawns the agent
6
+ * inside a systemd user scope, the last `release` closes the agent and stops
7
+ * the scope (the cgroup then reaps any background dev server a command left
8
+ * behind). The agent runs with no idle close — its lifetime is the session's —
9
+ * but an explicit client lease still catches a wedged host.
10
+ *
11
+ * The confinement here is the agent's own bwrap mount profile, WITHOUT a PID
12
+ * namespace: commands are the agent's children, so a background process
13
+ * survives the command, `/tmp` is the distro's real `/tmp` under
14
+ * `workspace-write`, and `ps` sees the session. `danger-full-access` gets the
15
+ * scope but no mounts.
16
+ *
17
+ * Nothing in this module touches `wsl.exe` by default except through the
18
+ * injectable factories; `test/session-runtime.test.mjs` drives the refcount,
19
+ * the prefix composition and the stop ordering without systemd.
20
+ *
21
+ * This is a TypeScript source built to `lib/session-runtime.js`; edit THIS
22
+ * file and run `pnpm run build` — the artifact under `lib/` is generated, and
23
+ * `pnpm test` fails when it drifts.
24
+ *
25
+ * @module dsh-plugin-wsl/session-runtime
26
+ */
27
+ import { spawn } from "node:child_process";
28
+ import { randomUUID } from "node:crypto";
29
+ import { WslAgent, pinnedWindowsEnv } from "./agent.js";
30
+ import { agentScriptDigest, agentScriptPath } from "./agent-shared.js";
31
+ import { bwrapArgvPrefix } from "./bwrap.js";
32
+ import { toLinuxPath } from "./paths.js";
33
+ import { sessionScopePrefix, sessionScopeProblem, sessionScopeProbeArgv, sessionScopeStopArgv, sessionScopeUnit } from "./session-scope.js";
34
+ /**
35
+ * Run the session runtime's preflight through an injected host runner and map
36
+ * the answer to a refusal. Shared by the shell executor and the filesystem
37
+ * provider, so both gate the runtime on the same four requirements.
38
+ *
39
+ * @param options - the transport, distro and runner.
40
+ * @returns the remedy-bearing refusal, or null when the platform is usable.
41
+ */
42
+ export async function probeSessionRuntime({ wslPath, distro, run }) {
43
+ /** @type {{ stdout: string, error?: string }} */
44
+ let output;
45
+ try {
46
+ output = { stdout: await run(sessionScopeProbeArgv({ wslPath, distro })) };
47
+ }
48
+ catch (error) {
49
+ output = { stdout: "", error: String(error) };
50
+ }
51
+ return sessionScopeProblem({ output, distro });
52
+ }
53
+ /** The native binary path and HELLO digest per distro, when opted in. */
54
+ const NATIVE_AGENTS = new Map();
55
+ /**
56
+ * Set (or clear) the native binary the session runtime should spawn for one
57
+ * distro. The host calls this after the copy/verify step; an empty path
58
+ * clears it and restores the shell agent.
59
+ *
60
+ * @param distro - the distro.
61
+ * @param path - the Linux binary path, or empty to clear.
62
+ * @param digest - the binary's sha256, which the HELLO must present.
63
+ */
64
+ export function setSessionNativeAgent(distro, path, digest = "") {
65
+ if (path.length === 0)
66
+ NATIVE_AGENTS.delete(distro);
67
+ else
68
+ NATIVE_AGENTS.set(distro, { path, digest });
69
+ }
70
+ /** The default agent factory: the same script/digest the shared resident pins. */
71
+ const spawnSessionAgent = (options) => {
72
+ const native = NATIVE_AGENTS.get(options.distro);
73
+ if (native !== undefined) {
74
+ // The native agent reads its own KILL frame while a command runs, so USR1
75
+ // is both unnecessary and fatal (it has no handler); cancellation is the
76
+ // frame path. The HELLO digest binds the process to the copied binary.
77
+ return new WslAgent({
78
+ distro: options.distro,
79
+ wslPath: options.wslPath,
80
+ scriptPath: native.path,
81
+ expectedDigest: native.digest,
82
+ argvPrefix: options.argvPrefix,
83
+ idleMs: options.idleMs,
84
+ leaseMs: options.leaseMs,
85
+ signalable: false,
86
+ spawnTransport: ({ wslPath, distro, argvPrefix }) => spawn(wslPath, ["-d", distro, "--exec", ...argvPrefix, native.path], {
87
+ stdio: ["pipe", "pipe", "pipe"],
88
+ windowsHide: true,
89
+ env: pinnedWindowsEnv(process.env),
90
+ }),
91
+ });
92
+ }
93
+ return new WslAgent({
94
+ distro: options.distro,
95
+ wslPath: options.wslPath,
96
+ scriptPath: toLinuxPath(agentScriptPath),
97
+ expectedDigest: agentScriptDigest,
98
+ argvPrefix: options.argvPrefix,
99
+ idleMs: options.idleMs,
100
+ leaseMs: options.leaseMs,
101
+ signalable: options.signalable,
102
+ });
103
+ };
104
+ /** The default stop: a second `wsl.exe` asks the user manager to stop the unit. */
105
+ const stopSessionScope = ({ wslPath, distro, unit }) => {
106
+ const argv = sessionScopeStopArgv({ wslPath, distro, unit });
107
+ const child = spawn(argv[0] ?? wslPath, argv.slice(1), {
108
+ stdio: "ignore",
109
+ windowsHide: true,
110
+ env: pinnedWindowsEnv(process.env),
111
+ });
112
+ // The scope may already be gone (the agent's exit, a restart). The stop is
113
+ // best effort; a stale unit is visible to `systemctl --user list-units`.
114
+ child.on("error", () => { });
115
+ };
116
+ /** The default token: short, unique, and sanitized by {@link sessionScopeUnit}. */
117
+ const defaultToken = () => randomUUID().replace(/-/gu, "").slice(0, 12);
118
+ /**
119
+ * The refcounted registry. One instance lives for the process; providers share
120
+ * it so the shell and filesystem sides address the same runtime for a session.
121
+ */
122
+ export class SessionRuntimes {
123
+ /** The live runtimes, by key. */
124
+ runtimes = new Map();
125
+ /** The agent factory. */
126
+ spawnAgent;
127
+ /** The scope stopper. */
128
+ stopScope;
129
+ /** The unit-token generator. */
130
+ token;
131
+ /**
132
+ * @param options - the injectable edges; production passes none.
133
+ */
134
+ constructor(options = {}) {
135
+ this.spawnAgent = options.spawnAgent ?? spawnSessionAgent;
136
+ this.stopScope = options.stopScope ?? stopSessionScope;
137
+ this.token = options.token ?? defaultToken;
138
+ }
139
+ /**
140
+ * The key a request addresses: distro, workspace, mode and mask. A policy
141
+ * change is a different runtime, because the mount profile is fixed for the
142
+ * agent's lifetime.
143
+ *
144
+ * @param request - the runtime request.
145
+ * @returns the registry key.
146
+ */
147
+ static key(request) {
148
+ return [request.distro, request.workspaceRoot, request.mode, request.maskWindowsDrive === true ? "masked" : "visible"].join("\u0000");
149
+ }
150
+ /**
151
+ * Acquire the runtime for one key, creating it on first use.
152
+ *
153
+ * @param request - the runtime request.
154
+ * @returns the live entry; the caller holds one reference.
155
+ */
156
+ acquire(request) {
157
+ const key = SessionRuntimes.key(request);
158
+ const existing = this.runtimes.get(key);
159
+ if (existing !== undefined) {
160
+ existing.refs += 1;
161
+ return existing;
162
+ }
163
+ const wslPath = request.wslPath ?? "wsl.exe";
164
+ const unit = sessionScopeUnit(request.distro, this.token());
165
+ const prefix = sessionScopePrefix(unit);
166
+ if (request.mode !== "danger-full-access") {
167
+ prefix.push(...bwrapArgvPrefix({ mode: request.mode, workspaceRoot: toLinuxPath(request.workspaceRoot, { distro: request.distro }) }, { maskWindowsDrive: request.maskWindowsDrive, realTmp: true, pidNamespace: false }));
168
+ }
169
+ const agent = this.spawnAgent({
170
+ distro: request.distro,
171
+ wslPath,
172
+ argvPrefix: prefix,
173
+ idleMs: 0,
174
+ leaseMs: request.leaseMs ?? 0,
175
+ signalable: true,
176
+ });
177
+ const entry = { key, unit, agent, distro: request.distro, wslPath, refs: 1 };
178
+ this.runtimes.set(key, entry);
179
+ return entry;
180
+ }
181
+ /**
182
+ * Release one reference. The last release closes the agent and stops the
183
+ * scope, in that order: the agent's exit ends the protocol, the stop reaps
184
+ * whatever the session left running.
185
+ *
186
+ * @param key - the entry's key.
187
+ * @returns resolved once the last holder's teardown finished.
188
+ */
189
+ async release(key) {
190
+ const entry = this.runtimes.get(key);
191
+ if (entry === undefined)
192
+ return;
193
+ entry.refs -= 1;
194
+ if (entry.refs > 0)
195
+ return;
196
+ this.runtimes.delete(key);
197
+ try {
198
+ await entry.agent.close();
199
+ }
200
+ finally {
201
+ this.stopScope({ wslPath: entry.wslPath, distro: entry.distro, unit: entry.unit });
202
+ }
203
+ }
204
+ /**
205
+ * Release every runtime, for a host shutdown or a test. Sequential on
206
+ * purpose: concurrent closes would race the shared agent's state.
207
+ *
208
+ * @returns resolved once every runtime is torn down.
209
+ */
210
+ async disposeAll() {
211
+ for (const key of [...this.runtimes.keys()]) {
212
+ const entry = this.runtimes.get(key);
213
+ if (entry !== undefined)
214
+ entry.refs = 1;
215
+ await this.release(key);
216
+ }
217
+ }
218
+ }
219
+ /** The process-wide registry the providers will share. */
220
+ export const sessionRuntimes = new SessionRuntimes();
@@ -0,0 +1,124 @@
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
+ /**
23
+ * Build the systemd user unit name for one session runtime.
24
+ *
25
+ * The caller supplies a token that is unique for the session (a random hex
26
+ * string, a session id); the distro name is part of the name so two distros'
27
+ * runtimes are never confused in `systemctl --user list-units`. The result is
28
+ * deterministic for the pair, which is what lets the host stop the unit later
29
+ * without keeping a map.
30
+ *
31
+ * @param distro - the distro name.
32
+ * @param token - the per-session unique token.
33
+ * @returns a unit name safe for `systemd-run --unit`.
34
+ */
35
+ export declare function sessionScopeUnit(distro: string, token: string): string;
36
+ /**
37
+ * The arguments inserted after `wsl.exe --exec` to run the agent in a
38
+ * transient user scope. The agent's own argv (its confinement prefix, the
39
+ * shell and the script) follows the `--` terminator.
40
+ *
41
+ * `--scope` is deliberate: a scope's stdio is inherited, so the protocol's
42
+ * pipes stay the transport's; a transient *service* would need `--pipe` and a
43
+ * fresh verification of its EOF/exit-code semantics (ADR open question).
44
+ *
45
+ * @param unit - the unit name from {@link sessionScopeUnit}.
46
+ * @returns the argv fragment, ending with `--`.
47
+ */
48
+ export declare function sessionScopePrefix(unit: string): string[];
49
+ /**
50
+ * The host argv that stops one session runtime. Stopping the scope is the
51
+ * cleanup step that replaces the per-command PID namespace: the cgroup kills
52
+ * every descendant, including a background dev server a command left behind.
53
+ *
54
+ * @param options - the transport path, distro and unit name.
55
+ * @returns the `wsl.exe --exec systemctl --user stop` argv.
56
+ */
57
+ export declare function sessionScopeStopArgv({ wslPath, distro, unit }: {
58
+ /** The `wsl.exe` path. */
59
+ wslPath: string;
60
+ /** The distro name; empty means the default distro. */
61
+ distro: string;
62
+ /** The unit name from {@link sessionScopeUnit}. */
63
+ unit: string;
64
+ }): string[];
65
+ /**
66
+ * The script the preflight runs inside the distro before the session runtime
67
+ * is enabled. It answers one word so the host can map the answer to a remedy:
68
+ *
69
+ * - `ok` — systemd is PID 1, the user manager answers, `systemd-run` exists
70
+ * and the user lingers (a headless `wsl.exe --exec` has no login session).
71
+ * - `no-systemd` — `/run/systemd/system` is absent (systemd is not PID 1).
72
+ * - `no-user-manager` — `systemctl --user` does not report running/degraded.
73
+ * - `no-systemd-run` — the `systemd-run` binary is missing.
74
+ * - `no-linger` — the user manager would stop with the last session; the
75
+ * remedy is `loginctl enable-linger <user>`.
76
+ *
77
+ * `degraded` is accepted: a partially failed unit elsewhere does not make the
78
+ * user manager unable to create a scope.
79
+ *
80
+ * @returns the shell script.
81
+ */
82
+ export declare function sessionScopeProbeScript(): string;
83
+ /**
84
+ * The script the session preflight runs to stop scopes a crashed app left
85
+ * behind. A scope is stale when its cgroup has no agent process: the shell
86
+ * `wsl-agent.sh` or the native `dsh-wsl-agent` is the liveness marker. A live
87
+ * agent means another session still owns it, so it must not be stopped.
88
+ * The script only lists `dsh-wsl-*.scope` units and only stops the ones it
89
+ * can prove stale; if there is no user manager it exits 0 (the session
90
+ * preflight reports that condition separately).
91
+ *
92
+ * @returns the shell script.
93
+ */
94
+ export declare function sessionScopeSweepScript(): string;
95
+ /**
96
+ * Map the preflight's answer to a user-facing refusal, or `null` when the
97
+ * platform can host a session runtime. The probe prints one word (it always
98
+ * exits 0 so the host reads a verdict, not a spawn error); an empty or unknown
99
+ * answer is reported with whatever diagnostic the host captured.
100
+ *
101
+ * @param options - the probe output and the distro it ran in.
102
+ * @returns the refusal with a remedy, or null.
103
+ */
104
+ export declare function sessionScopeProblem({ output, distro }: {
105
+ /** The probe's stdout, or the host error when it could not run. */
106
+ output: {
107
+ stdout: string;
108
+ error?: string;
109
+ };
110
+ /** The distro the probe ran in. */
111
+ distro: string;
112
+ }): string | null;
113
+ /**
114
+ * The host argv for the preflight probe.
115
+ *
116
+ * @param options - the transport path and distro.
117
+ * @returns the `wsl.exe --exec sh -c <script>` argv.
118
+ */
119
+ export declare function sessionScopeProbeArgv({ wslPath, distro }: {
120
+ /** The `wsl.exe` path. */
121
+ wslPath: string;
122
+ /** The distro name; empty means the default distro. */
123
+ distro: string;
124
+ }): string[];
@@ -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