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.
- package/CHANGELOG.md +456 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +85 -68
- package/SUPPORT.md +3 -3
- package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
- package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
- package/agent/wsl-agent.sh +83 -16
- package/cordis.patch.yml +41 -35
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONFIGURATION.md +7 -4
- package/docs/LIMITATIONS.md +59 -18
- package/docs/PARITY.md +3 -1
- package/docs/PEER-PARITY.md +18 -11
- package/docs/README.zh.md +98 -74
- package/docs/ROADMAP.md +79 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
- package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
- package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
- package/lib/agent-protocol.d.ts +10 -4
- package/lib/agent-protocol.js +10 -3
- package/lib/agent-shared.js +33 -5
- package/lib/agent.d.ts +187 -0
- package/lib/agent.js +311 -23
- package/lib/bwrap.d.ts +8 -0
- package/lib/bwrap.js +4 -1
- package/lib/fs-routing.d.ts +40 -4
- package/lib/fs-routing.js +80 -12
- package/lib/fsio-agent.js +1 -1
- package/lib/index.d.ts +134 -0
- package/lib/index.js +191 -3
- package/lib/native-agent.d.ts +124 -0
- package/lib/native-agent.js +198 -0
- package/lib/ports.d.ts +13 -2
- package/lib/ports.js +18 -5
- package/lib/preflight.js +24 -1
- package/lib/session-runtime.d.ts +161 -0
- package/lib/session-runtime.js +220 -0
- package/lib/session-scope.d.ts +124 -0
- package/lib/session-scope.js +191 -0
- package/lib/shell-env.js +10 -3
- package/lib/watcher.d.ts +25 -12
- package/lib/watcher.js +120 -32
- package/lib/workspace-files-route.d.ts +8 -4
- package/lib/workspace-files-route.js +42 -14
- package/lib/workspace-files-wsl.js +25 -8
- 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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
15
|
-
*
|
|
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:
|
|
22
|
-
* - `E` — ERROR: the watched
|
|
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
|
|
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
|
|
45
|
-
* itself and its direct children; 0 (the default) scans the whole
|
|
46
|
-
* which is the contract's semantics
|
|
47
|
-
*
|
|
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
|