dsh-plugin-wsl-env 0.11.0 → 0.13.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 +181 -0
- package/CONTRIBUTING.md +29 -7
- package/README.md +124 -138
- package/SECURITY.md +11 -1
- package/SUPPORT.md +29 -2
- package/cordis.patch.yml +17 -19
- package/docs/ARCHITECTURE.md +30 -29
- package/docs/CONFIGURATION.md +81 -21
- package/docs/LIMITATIONS.md +71 -6
- package/docs/MIGRATION.md +98 -0
- package/docs/PARITY.md +1 -1
- package/docs/PERFORMANCE.md +60 -0
- package/docs/QUICKSTART.md +125 -0
- package/docs/README.zh.md +117 -137
- package/docs/ROADMAP-1.0.md +780 -0
- package/docs/ROADMAP.md +4 -0
- package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
- package/docs/UPSTREAM-LSP-SEAM.md +90 -0
- package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
- package/lib/agent-errors.d.ts +19 -0
- package/lib/agent-errors.js +22 -0
- package/lib/agent-exec.js +2 -2
- package/lib/agent.d.ts +68 -5
- package/lib/agent.js +129 -22
- package/lib/distro-route.d.ts +75 -0
- package/lib/distro-route.js +86 -0
- package/lib/fs-routing.d.ts +4 -1
- package/lib/index.d.ts +166 -37
- package/lib/index.js +353 -75
- package/lib/paths.d.ts +15 -0
- package/lib/paths.js +28 -0
- package/lib/preflight.d.ts +46 -3
- package/lib/preflight.js +83 -14
- package/lib/shell-env.d.ts +9 -3
- package/lib/shell-env.js +121 -43
- package/lib/subprocess.d.ts +12 -3
- package/lib/subprocess.js +21 -3
- package/package.json +23 -15
- package/scripts/diagnose.sh +9 -88
- package/scripts/doctor.mjs +548 -0
- package/scripts/profile-preset.mjs +254 -0
- package/lib/workspace-files-route.d.ts +0 -136
- package/lib/workspace-files-route.js +0 -225
- package/lib/workspace-files-wsl.d.ts +0 -257
- package/lib/workspace-files-wsl.js +0 -418
package/lib/paths.d.ts
CHANGED
|
@@ -138,6 +138,21 @@ export declare function windowsToLinuxMount(winPath: string): string | undefined
|
|
|
138
138
|
* @returns an absolute Linux path, or the input unchanged when it cannot be mapped.
|
|
139
139
|
*/
|
|
140
140
|
export declare function toLinuxPath(value: string, options?: WorldPathOptions): string;
|
|
141
|
+
/**
|
|
142
|
+
* Join a requested path onto a workspace root in the distro's Linux
|
|
143
|
+
* coordinate system: UNC absolutes map to their Linux paths, POSIX absolutes
|
|
144
|
+
* win over the root, and relative paths join onto it.
|
|
145
|
+
*
|
|
146
|
+
* This is the path half of the shipped `workspace-files` service's `ctx.fs`
|
|
147
|
+
* calls: the routed backend receives the same path/`cwd` pairs a host
|
|
148
|
+
* workspace would, so the stock service can serve a distro workspace without a
|
|
149
|
+
* second implementation.
|
|
150
|
+
*
|
|
151
|
+
* @param linuxRoot - the workspace root in Linux form.
|
|
152
|
+
* @param path - the requested path, in any coordinate system.
|
|
153
|
+
* @returns the absolute Linux path.
|
|
154
|
+
*/
|
|
155
|
+
export declare function linuxJoin(linuxRoot: string, path: string): string;
|
|
141
156
|
/**
|
|
142
157
|
* Resolve a caller-supplied path into the UNC coordinate system the host's fs
|
|
143
158
|
* calls use. Linux paths from the model, UNC paths from the harness, and
|
package/lib/paths.js
CHANGED
|
@@ -238,6 +238,34 @@ export function toLinuxPath(value, options) {
|
|
|
238
238
|
const baseLinux = toLinuxPath(base, { distro: options?.distro });
|
|
239
239
|
return `${baseLinux.replace(/\/+$/, "")}/${text.replace(/^\.\//, "")}`;
|
|
240
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Join a requested path onto a workspace root in the distro's Linux
|
|
243
|
+
* coordinate system: UNC absolutes map to their Linux paths, POSIX absolutes
|
|
244
|
+
* win over the root, and relative paths join onto it.
|
|
245
|
+
*
|
|
246
|
+
* This is the path half of the shipped `workspace-files` service's `ctx.fs`
|
|
247
|
+
* calls: the routed backend receives the same path/`cwd` pairs a host
|
|
248
|
+
* workspace would, so the stock service can serve a distro workspace without a
|
|
249
|
+
* second implementation.
|
|
250
|
+
*
|
|
251
|
+
* @param linuxRoot - the workspace root in Linux form.
|
|
252
|
+
* @param path - the requested path, in any coordinate system.
|
|
253
|
+
* @returns the absolute Linux path.
|
|
254
|
+
*/
|
|
255
|
+
export function linuxJoin(linuxRoot, path) {
|
|
256
|
+
// The UNC check runs on the RAW spelling: the backslash-to-slash rewrite
|
|
257
|
+
// below would leave `//wsl.localhost/...`, which the identity regex cannot
|
|
258
|
+
// see.
|
|
259
|
+
const parsed = uncToPosix(path);
|
|
260
|
+
if (parsed !== undefined)
|
|
261
|
+
return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
|
|
262
|
+
const text = String(path).replaceAll("\\", "/");
|
|
263
|
+
if (text.startsWith("/"))
|
|
264
|
+
return text.length > 1 ? text.replace(/\/+$/, "") : "/";
|
|
265
|
+
const base = linuxRoot === "/" ? "" : linuxRoot;
|
|
266
|
+
const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
|
|
267
|
+
return joined.length === 0 ? "/" : joined;
|
|
268
|
+
}
|
|
241
269
|
/**
|
|
242
270
|
* Resolve a caller-supplied path into the UNC coordinate system the host's fs
|
|
243
271
|
* calls use. Linux paths from the model, UNC paths from the harness, and
|
package/lib/preflight.d.ts
CHANGED
|
@@ -27,28 +27,52 @@ import z from "@deepseek-ai/schemastery";
|
|
|
27
27
|
import type { Context } from "@deepseek-ai/cordis";
|
|
28
28
|
export declare const name = "wsl-preflight";
|
|
29
29
|
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Distro to preflight. Empty/`"auto"` follows the session workspace; an
|
|
32
|
+
* explicit name pins; `"default"` forces WSL's default.
|
|
33
|
+
*/
|
|
31
34
|
distro: z<string, string, "defined">;
|
|
32
35
|
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
33
36
|
wslPath: z<string, string, "defined">;
|
|
34
37
|
/** Whether the session-start sandbox preflight runs at all. */
|
|
35
38
|
sandbox: z<boolean, boolean, "defined">;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the preflight also checks the session-runtime prerequisites
|
|
41
|
+
* (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
|
|
42
|
+
* matching the shipped `sessionSandbox: true`; turn it off when the profile
|
|
43
|
+
* opts out of the session runtime, or the warning would name a prerequisite
|
|
44
|
+
* that profile does not use.
|
|
45
|
+
*/
|
|
46
|
+
sessionSandbox: z<boolean, boolean, "defined">;
|
|
36
47
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
37
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Distro to preflight. Empty/`"auto"` follows the session workspace; an
|
|
50
|
+
* explicit name pins; `"default"` forces WSL's default.
|
|
51
|
+
*/
|
|
38
52
|
distro: z<string, string, "defined">;
|
|
39
53
|
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
40
54
|
wslPath: z<string, string, "defined">;
|
|
41
55
|
/** Whether the session-start sandbox preflight runs at all. */
|
|
42
56
|
sandbox: z<boolean, boolean, "defined">;
|
|
57
|
+
/**
|
|
58
|
+
* Whether the preflight also checks the session-runtime prerequisites
|
|
59
|
+
* (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
|
|
60
|
+
* matching the shipped `sessionSandbox: true`; turn it off when the profile
|
|
61
|
+
* opts out of the session runtime, or the warning would name a prerequisite
|
|
62
|
+
* that profile does not use.
|
|
63
|
+
*/
|
|
64
|
+
sessionSandbox: z<boolean, boolean, "defined">;
|
|
43
65
|
}>>, "plain">;
|
|
44
66
|
/** The config schema's validated output. */
|
|
45
67
|
export interface PreflightConfig {
|
|
46
|
-
/**
|
|
68
|
+
/** The distro mode: empty/`auto`, an explicit name, or `default`. */
|
|
47
69
|
distro?: string;
|
|
48
70
|
/** The `wsl.exe` path the probe spawns. */
|
|
49
71
|
wslPath?: string;
|
|
50
72
|
/** Whether the session-start sandbox preflight runs at all. */
|
|
51
73
|
sandbox?: boolean;
|
|
74
|
+
/** Whether the session-runtime prerequisites are preflighted too. */
|
|
75
|
+
sessionSandbox?: boolean;
|
|
52
76
|
}
|
|
53
77
|
/**
|
|
54
78
|
* The slice of a created agent this service inspects; the harness's agent
|
|
@@ -63,6 +87,25 @@ export interface PreflightAgent {
|
|
|
63
87
|
cwd?: string | undefined;
|
|
64
88
|
};
|
|
65
89
|
} | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The agent's own context, when the harness exposes it. Used to publish the
|
|
92
|
+
* advisory into the session's runtime context; absent in the unit tests and
|
|
93
|
+
* on a harness without the `systemPrompt` seam, where the log line stands
|
|
94
|
+
* alone.
|
|
95
|
+
*/
|
|
96
|
+
ctx?: {
|
|
97
|
+
inject?: (names: string[], install: (scope: PreflightPromptScope) => void) => unknown;
|
|
98
|
+
} | undefined;
|
|
99
|
+
}
|
|
100
|
+
/** The slice of a session context that can carry the advisory. */
|
|
101
|
+
export interface PreflightPromptScope {
|
|
102
|
+
systemPrompt: {
|
|
103
|
+
context: (entry: {
|
|
104
|
+
name: string;
|
|
105
|
+
order: number;
|
|
106
|
+
text: () => string;
|
|
107
|
+
}) => unknown;
|
|
108
|
+
};
|
|
66
109
|
}
|
|
67
110
|
/**
|
|
68
111
|
* The cordis context extended with the slices this service consumes. `logger`
|
package/lib/preflight.js
CHANGED
|
@@ -26,16 +26,28 @@
|
|
|
26
26
|
import z from "@deepseek-ai/schemastery";
|
|
27
27
|
import { isWslUnc } from "./paths.js";
|
|
28
28
|
import { defaultDistro, runCapture } from "./wsl.js";
|
|
29
|
+
import { distroOfUnc, isDefaultDistro, pinnedDistro } from "./distro-route.js";
|
|
29
30
|
import { bwrapFailure, bwrapUsable, missingBwrapRemedy } from "./sandbox-core.js";
|
|
30
|
-
import { sessionScopeSweepScript } from "./session-scope.js";
|
|
31
|
+
import { sessionScopeProblem, sessionScopeProbeScript, sessionScopeSweepScript } from "./session-scope.js";
|
|
31
32
|
export const name = "wsl-preflight";
|
|
32
33
|
export const Config = z.object({
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Distro to preflight. Empty/`"auto"` follows the session workspace; an
|
|
36
|
+
* explicit name pins; `"default"` forces WSL's default.
|
|
37
|
+
*/
|
|
34
38
|
distro: z.string().default(""),
|
|
35
39
|
/** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
|
|
36
40
|
wslPath: z.string().default("wsl.exe"),
|
|
37
41
|
/** Whether the session-start sandbox preflight runs at all. */
|
|
38
42
|
sandbox: z.boolean().default(true),
|
|
43
|
+
/**
|
|
44
|
+
* Whether the preflight also checks the session-runtime prerequisites
|
|
45
|
+
* (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
|
|
46
|
+
* matching the shipped `sessionSandbox: true`; turn it off when the profile
|
|
47
|
+
* opts out of the session runtime, or the warning would name a prerequisite
|
|
48
|
+
* that profile does not use.
|
|
49
|
+
*/
|
|
50
|
+
sessionSandbox: z.boolean().default(true),
|
|
39
51
|
});
|
|
40
52
|
/** Distros whose stale-scope sweep has already run in this process. */
|
|
41
53
|
const sweptDistros = new Set();
|
|
@@ -67,6 +79,52 @@ export async function apply(ctx, config = {}) {
|
|
|
67
79
|
return;
|
|
68
80
|
const wslPath = config.wslPath ?? "wsl.exe";
|
|
69
81
|
const warn = (message) => ctx.logger?.warn?.(message);
|
|
82
|
+
/**
|
|
83
|
+
* Publish one advisory: the log line always, plus — when the harness exposes
|
|
84
|
+
* the agent's `systemPrompt` — a session-scoped runtime-context entry, so the
|
|
85
|
+
* warning is visible to the session instead of only in the app log. DSH
|
|
86
|
+
* 0.2.1 has no toast/notice seam; docs/UPSTREAM-SESSION-NOTICE.md records the
|
|
87
|
+
* ask, and the runtime context is the available visible channel today.
|
|
88
|
+
*
|
|
89
|
+
* @param agent - the session that just opened.
|
|
90
|
+
* @param message - the advisory, remedy included.
|
|
91
|
+
*/
|
|
92
|
+
const announce = (agent, message) => {
|
|
93
|
+
warn(message);
|
|
94
|
+
const agentCtx = agent.ctx;
|
|
95
|
+
if (agentCtx === undefined || typeof agentCtx.inject !== "function")
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
agentCtx.inject(["systemPrompt"], (scope) => {
|
|
99
|
+
scope.systemPrompt.context({ name: "wsl-preflight", order: 200, text: () => message });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
ctx.logger?.debug?.(`wsl-preflight: could not publish the notice to the session (${error?.message ?? error})`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Ask the distro's session-runtime probe. Only a recognized refusal is a
|
|
108
|
+
* verdict: an empty or unfamiliar answer means "cannot tell" (a fixture, an
|
|
109
|
+
* old image, a wedged shell) and stays at debug level — the preflight is
|
|
110
|
+
* advisory and must not invent a prerequisite from silence.
|
|
111
|
+
*
|
|
112
|
+
* @param distro - the resolved distro.
|
|
113
|
+
* @returns the remedy, or undefined when the session runtime is usable or unknown.
|
|
114
|
+
*/
|
|
115
|
+
const probeSession = async (distro) => {
|
|
116
|
+
try {
|
|
117
|
+
const output = await runCapture([wslPath, "-d", distro, "--exec", "sh", "-c", sessionScopeProbeScript()]);
|
|
118
|
+
const word = output.trim().split(/\r?\n/u)[0]?.trim() ?? "";
|
|
119
|
+
if (!["no-systemd", "no-user-manager", "no-systemd-run", "no-linger"].includes(word))
|
|
120
|
+
return undefined;
|
|
121
|
+
return sessionScopeProblem({ output: { stdout: word }, distro }) ?? undefined;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
ctx.logger?.debug?.(`wsl-preflight: the session probe could not run (${error?.message ?? error})`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
70
128
|
/**
|
|
71
129
|
* Probe the distro once and surface an unusable `bwrap` as a warning.
|
|
72
130
|
*
|
|
@@ -76,18 +134,24 @@ export async function apply(ctx, config = {}) {
|
|
|
76
134
|
// Only a distro workspace has commands that confine: a host session would
|
|
77
135
|
// warn about a sandbox it never uses.
|
|
78
136
|
const cwd = agent?.session?.header?.cwd;
|
|
79
|
-
if (cwd === undefined || !isWslUnc(cwd))
|
|
137
|
+
if (agent === undefined || cwd === undefined || !isWslUnc(cwd))
|
|
80
138
|
return;
|
|
81
|
-
|
|
139
|
+
const pinned = pinnedDistro(config.distro);
|
|
140
|
+
let distro = pinned ?? "";
|
|
82
141
|
if (distro.length === 0) {
|
|
83
|
-
|
|
84
|
-
distro =
|
|
142
|
+
if (!isDefaultDistro(config.distro)) {
|
|
143
|
+
distro = distroOfUnc(cwd) ?? "";
|
|
85
144
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
if (distro.length === 0) {
|
|
146
|
+
try {
|
|
147
|
+
distro = await defaultDistro({ wslPath });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
// No WSL at all is the missing-wsl probe's territory; the first
|
|
151
|
+
// command's own error is the precise one. Stay quiet at warn level.
|
|
152
|
+
ctx.logger?.debug?.(`wsl-preflight: no default distro to preflight (${error?.message ?? error})`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
91
155
|
}
|
|
92
156
|
}
|
|
93
157
|
// bwrapUsable answers false on a failed probe — it never rejects — so
|
|
@@ -97,11 +161,16 @@ export async function apply(ctx, config = {}) {
|
|
|
97
161
|
// runtime; a live agent in another session's cgroup makes its scope safe.
|
|
98
162
|
sweepStaleScopes(wslPath, distro);
|
|
99
163
|
const usable = await bwrapUsable({ wslPath, distro });
|
|
100
|
-
if (usable) {
|
|
101
|
-
|
|
164
|
+
if (!usable) {
|
|
165
|
+
announce(agent, `wsl-preflight: ${bwrapFailure(wslPath, distro) ?? missingBwrapRemedy(distro)}`);
|
|
102
166
|
return;
|
|
103
167
|
}
|
|
104
|
-
|
|
168
|
+
ctx.logger?.debug?.(`wsl-preflight: bwrap is usable inside distro "${distro}"`);
|
|
169
|
+
if (!(config.sessionSandbox ?? true))
|
|
170
|
+
return;
|
|
171
|
+
const sessionProblem = await probeSession(distro);
|
|
172
|
+
if (sessionProblem !== undefined)
|
|
173
|
+
announce(agent, `wsl-preflight: ${sessionProblem}`);
|
|
105
174
|
};
|
|
106
175
|
ctx.on("agent/created", (payload) => void check(payload?.agent));
|
|
107
176
|
// Mount order is not a guarantee: a session created during startup can finish
|
package/lib/shell-env.d.ts
CHANGED
|
@@ -31,19 +31,25 @@ import type { Context } from "@deepseek-ai/cordis";
|
|
|
31
31
|
export declare const name = "wsl-shell-env";
|
|
32
32
|
export declare const inject: string[];
|
|
33
33
|
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Distro these facts describe. Empty/`"auto"` follows each session's
|
|
36
|
+
* workspace; an explicit name pins; `"default"` forces WSL's default.
|
|
37
|
+
*/
|
|
35
38
|
distro: z<string, string, "defined">;
|
|
36
39
|
/** How often the listening-port snapshot refreshes, in milliseconds. */
|
|
37
40
|
portsRefreshMs: z<number, number, "defined">;
|
|
38
41
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
39
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Distro these facts describe. Empty/`"auto"` follows each session's
|
|
44
|
+
* workspace; an explicit name pins; `"default"` forces WSL's default.
|
|
45
|
+
*/
|
|
40
46
|
distro: z<string, string, "defined">;
|
|
41
47
|
/** How often the listening-port snapshot refreshes, in milliseconds. */
|
|
42
48
|
portsRefreshMs: z<number, number, "defined">;
|
|
43
49
|
}>>, "plain">;
|
|
44
50
|
/** The config schema's validated output. */
|
|
45
51
|
export interface ShellEnvConfig {
|
|
46
|
-
/**
|
|
52
|
+
/** The distro mode: empty/`auto`, an explicit name, or `default`. */
|
|
47
53
|
distro?: string;
|
|
48
54
|
/** How often the listening-port snapshot refreshes, in milliseconds. */
|
|
49
55
|
portsRefreshMs?: number;
|
package/lib/shell-env.js
CHANGED
|
@@ -27,14 +27,18 @@
|
|
|
27
27
|
* @module dsh-plugin-wsl/shell-env
|
|
28
28
|
*/
|
|
29
29
|
import z from "@deepseek-ai/schemastery";
|
|
30
|
-
import {
|
|
31
|
-
import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
|
|
30
|
+
import { uncToPosix } from "./paths.js";
|
|
31
|
+
import { defaultDistro, defaultShell, linuxHomePath, listDistros } from "./wsl.js";
|
|
32
|
+
import { isDefaultDistro, pinnedDistro } from "./distro-route.js";
|
|
32
33
|
import { sharedAgent } from "./agent-shared.js";
|
|
33
34
|
import { scanListeningPorts } from "./ports.js";
|
|
34
35
|
export const name = "wsl-shell-env";
|
|
35
36
|
export const inject = ["shellEnv"];
|
|
36
37
|
export const Config = z.object({
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Distro these facts describe. Empty/`"auto"` follows each session's
|
|
40
|
+
* workspace; an explicit name pins; `"default"` forces WSL's default.
|
|
41
|
+
*/
|
|
38
42
|
distro: z.string().default(""),
|
|
39
43
|
/** How often the listening-port snapshot refreshes, in milliseconds. */
|
|
40
44
|
portsRefreshMs: z.number().default(10 * 1000),
|
|
@@ -47,74 +51,148 @@ export const Config = z.object({
|
|
|
47
51
|
*/
|
|
48
52
|
export async function apply(ctx, config = {}) {
|
|
49
53
|
// `resolve` is called synchronously by the registry (`Object.entries` of its
|
|
50
|
-
// result), so
|
|
54
|
+
// result), so a fact that needs `wsl.exe` cannot be fetched there. The
|
|
55
|
+
// default/pinned distro is probed once at mount; every other distro is
|
|
56
|
+
// probed when a session created in it appears, so a machine with several
|
|
57
|
+
// stopped distros does not boot them all at application start.
|
|
51
58
|
const configured = config.distro ?? "";
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const portsRefreshMs = config.portsRefreshMs ?? 10_000;
|
|
60
|
+
const pinned = pinnedDistro(configured);
|
|
61
|
+
let distros;
|
|
62
|
+
if (pinned !== undefined) {
|
|
63
|
+
distros = [pinned];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
try {
|
|
67
|
+
distros = await listDistros();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
distros = [];
|
|
71
|
+
}
|
|
72
|
+
if (distros.length === 0) {
|
|
73
|
+
try {
|
|
74
|
+
distros = [await defaultDistro()];
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
distros = [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const defaultName = distros[0];
|
|
82
|
+
/** The facts one distro can answer; a failed probe leaves its key absent. */
|
|
83
|
+
const facts = new Map();
|
|
84
|
+
const inflight = new Set();
|
|
85
|
+
const ensureFacts = async (distro) => {
|
|
86
|
+
if (facts.has(distro) || inflight.has(distro))
|
|
87
|
+
return;
|
|
88
|
+
inflight.add(distro);
|
|
89
|
+
try {
|
|
90
|
+
const [shell, home] = await Promise.all([
|
|
91
|
+
defaultShell(distro),
|
|
92
|
+
linuxHomePath(distro).catch(() => undefined),
|
|
93
|
+
]);
|
|
94
|
+
facts.set(distro, { shell, home });
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
inflight.delete(distro);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const initial = pinned ?? defaultName;
|
|
101
|
+
if (initial !== undefined)
|
|
102
|
+
await ensureFacts(initial);
|
|
60
103
|
const variables = {
|
|
61
104
|
DSH_WSL_DISTRO: {
|
|
62
|
-
description:
|
|
105
|
+
description: "Name of the WSL distro this session's workspace lives in.",
|
|
63
106
|
},
|
|
64
107
|
DSH_WSL_SHELL: {
|
|
65
|
-
description:
|
|
108
|
+
description: "Absolute path of the login shell that model shell calls run in inside that distro. Commands are executed by this shell, not by bash.",
|
|
66
109
|
},
|
|
67
110
|
DSH_WSL_HOME: {
|
|
68
|
-
description:
|
|
111
|
+
description: "Home directory of the user model shell calls run as inside that distro.",
|
|
69
112
|
},
|
|
70
113
|
DSH_WSL_PORTS: {
|
|
71
|
-
description: `Ports with a listener inside
|
|
114
|
+
description: `Ports with a listener inside that distro (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(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
115
|
},
|
|
73
116
|
};
|
|
74
117
|
// The port snapshot refreshes on a timer and is served synchronously: the
|
|
75
118
|
// registry's resolve is sync by contract, so live reads are impossible —
|
|
76
|
-
// the timer bounds staleness instead (see lib/ports.js).
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
119
|
+
// the timer bounds staleness instead (see lib/ports.js). A timer starts only
|
|
120
|
+
// for a distro a session actually uses, so multi-distro users do not keep
|
|
121
|
+
// every distro's resident agent warm.
|
|
122
|
+
const ports = new Map();
|
|
123
|
+
const timers = new Map();
|
|
124
|
+
const refreshPorts = async (distro) => {
|
|
125
|
+
try {
|
|
126
|
+
const next = await scanListeningPorts(sharedAgent(distro), 8000);
|
|
127
|
+
if (next === undefined) {
|
|
128
|
+
// A transient agent outage must not read as "no ports": keep the last
|
|
129
|
+
// successful snapshot and leave a debug breadcrumb.
|
|
130
|
+
ctx.logger?.debug?.("wsl-shell-env: port snapshot failed; keeping the last known list");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
ports.set(distro, next);
|
|
85
134
|
}
|
|
86
|
-
|
|
135
|
+
catch {
|
|
136
|
+
// Same posture as a failed scan: keep the last successful list.
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const ensurePorts = (distro) => {
|
|
140
|
+
if (timers.has(distro))
|
|
141
|
+
return;
|
|
142
|
+
void refreshPorts(distro);
|
|
143
|
+
const timer = setInterval(() => void refreshPorts(distro), portsRefreshMs);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
timers.set(distro, timer);
|
|
146
|
+
};
|
|
147
|
+
const lifecycle = ctx;
|
|
148
|
+
const consider = (agent) => {
|
|
149
|
+
const parsed = uncToPosix(String(agent?.session?.header?.cwd ?? ""));
|
|
150
|
+
if (parsed === undefined)
|
|
151
|
+
return;
|
|
152
|
+
if (pinned !== undefined && parsed.distro.toLowerCase() !== pinned.toLowerCase())
|
|
153
|
+
return;
|
|
154
|
+
if (isDefaultDistro(configured) && defaultName !== undefined && parsed.distro.toLowerCase() !== defaultName.toLowerCase())
|
|
155
|
+
return;
|
|
156
|
+
void ensureFacts(parsed.distro);
|
|
157
|
+
ensurePorts(parsed.distro);
|
|
87
158
|
};
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
159
|
+
const off = lifecycle.on?.("agent/created", (payload) => consider(payload?.agent));
|
|
160
|
+
for (const agent of lifecycle.agents?.list?.() ?? [])
|
|
161
|
+
consider(agent);
|
|
91
162
|
const dispose = ctx.shellEnv.register({
|
|
92
163
|
name: "dsh-plugin-wsl-env",
|
|
93
164
|
variables,
|
|
94
165
|
resolve: (execution) => {
|
|
95
166
|
const cwd = execution?.agent?.session?.header?.cwd;
|
|
96
|
-
|
|
167
|
+
const parsed = uncToPosix(String(cwd ?? ""));
|
|
168
|
+
if (parsed === undefined)
|
|
169
|
+
return {};
|
|
170
|
+
const named = parsed.distro;
|
|
171
|
+
if (pinned !== undefined && named.toLowerCase() !== pinned.toLowerCase())
|
|
97
172
|
return {};
|
|
98
|
-
|
|
99
|
-
// is not that distro — the shell refuses its commands outright (the
|
|
100
|
-
// cross-distro guard) — so the honest resolution is no facts at all,
|
|
101
|
-
// not another distro's home and shell presented as this session's.
|
|
102
|
-
if (isAnotherDistrosUnc(cwd, distro))
|
|
173
|
+
if (isDefaultDistro(configured) && defaultName !== undefined && named.toLowerCase() !== defaultName.toLowerCase())
|
|
103
174
|
return {};
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
175
|
+
const canonical = [...facts.keys()].find((distro) => distro.toLowerCase() === named.toLowerCase()) ?? named;
|
|
176
|
+
const known = facts.get(canonical);
|
|
177
|
+
ensurePorts(canonical);
|
|
178
|
+
const resolved = {
|
|
179
|
+
DSH_WSL_DISTRO: canonical,
|
|
180
|
+
DSH_WSL_PORTS: (ports.get(canonical) ?? []).join(","),
|
|
108
181
|
};
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
182
|
+
if (known?.home !== undefined)
|
|
183
|
+
resolved.DSH_WSL_HOME = known.home;
|
|
184
|
+
if (known?.shell !== undefined)
|
|
185
|
+
resolved.DSH_WSL_SHELL = known.shell;
|
|
186
|
+
return resolved;
|
|
112
187
|
},
|
|
113
188
|
});
|
|
114
189
|
// Disposing matters: a config hot-reload re-runs apply(), and the registry
|
|
115
190
|
// rejects a second contributor claiming the same keys.
|
|
116
191
|
ctx.effect(() => () => {
|
|
117
|
-
|
|
192
|
+
off?.();
|
|
193
|
+
for (const timer of timers.values())
|
|
194
|
+
clearInterval(timer);
|
|
195
|
+
timers.clear();
|
|
118
196
|
dispose();
|
|
119
197
|
});
|
|
120
198
|
}
|
package/lib/subprocess.d.ts
CHANGED
|
@@ -89,7 +89,10 @@ import type { SubprocessTerminalActivity } from "./terminal-activity.js";
|
|
|
89
89
|
* builds.
|
|
90
90
|
*/
|
|
91
91
|
export interface WslSubprocessConfig {
|
|
92
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Distro mode: empty/`"auto"` follows the launch directory, `"default"`
|
|
94
|
+
* forces WSL's default, and an explicit name pins.
|
|
95
|
+
*/
|
|
93
96
|
distro: string;
|
|
94
97
|
/** Path to `wsl.exe`. */
|
|
95
98
|
wslPath: string;
|
|
@@ -121,7 +124,10 @@ export interface WslSubprocessConfig {
|
|
|
121
124
|
*/
|
|
122
125
|
export declare class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
123
126
|
static Config: z<Schemastery.ObjectS<NoInfer<{
|
|
124
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Distro mode: empty/`"auto"` follows the launch directory, `"default"`
|
|
129
|
+
* forces WSL's default, and an explicit name pins.
|
|
130
|
+
*/
|
|
125
131
|
distro: z<string, string, "defined">;
|
|
126
132
|
/** Path to `wsl.exe`; overridable for a non-standard install. */
|
|
127
133
|
wslPath: z<string, string, "defined">;
|
|
@@ -163,7 +169,10 @@ export declare class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
163
169
|
*/
|
|
164
170
|
terminalIdleReclaim: z<boolean, boolean, "defined">;
|
|
165
171
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
166
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Distro mode: empty/`"auto"` follows the launch directory, `"default"`
|
|
174
|
+
* forces WSL's default, and an explicit name pins.
|
|
175
|
+
*/
|
|
167
176
|
distro: z<string, string, "defined">;
|
|
168
177
|
/** Path to `wsl.exe`; overridable for a non-standard install. */
|
|
169
178
|
wslPath: z<string, string, "defined">;
|
package/lib/subprocess.js
CHANGED
|
@@ -90,6 +90,7 @@ import { sharedAgent } from "./agent-shared.js";
|
|
|
90
90
|
import { parseTerminalActivity, TERMINAL_ID_ENV, terminalActivityProbe, wrapTerminalHandle } from "./terminal-activity.js";
|
|
91
91
|
import { AgentUnavailableError } from "./agent-errors.js";
|
|
92
92
|
import { terminalRoute } from "./terminal-route.js";
|
|
93
|
+
import { pinnedDistro, isDefaultDistro } from "./distro-route.js";
|
|
93
94
|
/**
|
|
94
95
|
* Env names a terminal launch keeps for the distro. `TERM` rides along because
|
|
95
96
|
* the PTY consumer sets it for the host process, and WSL imports no name that
|
|
@@ -111,7 +112,10 @@ const WSL_PROGRAM = "wsl";
|
|
|
111
112
|
*/
|
|
112
113
|
export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
113
114
|
static Config = z.object({
|
|
114
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Distro mode: empty/`"auto"` follows the launch directory, `"default"`
|
|
117
|
+
* forces WSL's default, and an explicit name pins.
|
|
118
|
+
*/
|
|
115
119
|
distro: z.string().default(""),
|
|
116
120
|
/** Path to `wsl.exe`; overridable for a non-standard install. */
|
|
117
121
|
wslPath: z.string().default(DEFAULT_WSL_PATH),
|
|
@@ -160,7 +164,7 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
160
164
|
constructor(ctx, config) {
|
|
161
165
|
super(ctx);
|
|
162
166
|
this.config = config;
|
|
163
|
-
this.resolvedDistro = config.distro;
|
|
167
|
+
this.resolvedDistro = pinnedDistro(config.distro) ?? "";
|
|
164
168
|
}
|
|
165
169
|
/**
|
|
166
170
|
* The distro a launch uses when the request's directory does not name one,
|
|
@@ -192,8 +196,22 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
|
|
|
192
196
|
async terminalTarget(cwd) {
|
|
193
197
|
const text = String(cwd ?? "").trim();
|
|
194
198
|
const named = uncToPosix(text);
|
|
195
|
-
if (named !== undefined)
|
|
199
|
+
if (named !== undefined) {
|
|
200
|
+
const pinned = pinnedDistro(this.config.distro);
|
|
201
|
+
if (pinned !== undefined && named.distro.toLowerCase() !== pinned.toLowerCase()) {
|
|
202
|
+
throw new Error(`"${text}" names a workspace in another WSL distro, and this profile pins its terminal to "${pinned}". ` +
|
|
203
|
+
`Open a session in that distro, or set this row's distro to ''/auto.`);
|
|
204
|
+
}
|
|
205
|
+
if (isDefaultDistro(this.config.distro)) {
|
|
206
|
+
const fallback = await this.distro();
|
|
207
|
+
if (named.distro.toLowerCase() !== fallback.toLowerCase()) {
|
|
208
|
+
throw new Error(`"${text}" names a workspace in another WSL distro, and this profile forces WSL's default ("${fallback}"). ` +
|
|
209
|
+
`Open a session in that distro, or set this row's distro to ''/auto.`);
|
|
210
|
+
}
|
|
211
|
+
return { distro: fallback, linuxCwd: named.linuxPath };
|
|
212
|
+
}
|
|
196
213
|
return { distro: named.distro, linuxCwd: named.linuxPath };
|
|
214
|
+
}
|
|
197
215
|
const distro = await this.distro();
|
|
198
216
|
if (isPosixAbsolute(text))
|
|
199
217
|
return { distro, linuxCwd: text };
|