1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/process";
11
12import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";
13import { satisfiesSemverRange } from "@t3tools/shared/semver";
14
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";
17
18const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
19const LIST_TIMEOUT = Duration.seconds(8);
20const PRE_WARM_TIMEOUT = Duration.seconds(10);
21const WSLPATH_TIMEOUT = Duration.seconds(10);
22const PROBE_TIMEOUT = Duration.seconds(10);
23const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
24const BUILD_TIMEOUT = Duration.minutes(5);
25const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2);
26const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30);
27const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15);
28const USER_HOME_TIMEOUT = Duration.seconds(5);
29const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
30const BUILD_TRANSPORT_RETRY_LIMIT = 2;
31
32export interface EnsureWslNodePtyOptions {
33 readonly allowBuild?: boolean;
34 readonly nodeEngineRange?: string | null;
35}
36
37// The packaged WSL runtime archive plus the SHA-256 identity the build recorded
38// for it. The cache key derives from the same digest, and installation verifies
39// the bytes before promoting the extracted tree.
40export interface WslRuntimeArchive {
41 readonly windowsPath: string;
42 readonly runtimeId: string;
43 readonly sha256: string;
44}
45
46export type PrepareWslRuntimeResult =
47 | {
48 readonly ok: true;
49 readonly linuxAppRoot: string;
50 }
51 | {
52 readonly ok: false;
53 readonly reason: string;
54 };
55
56export type EnsureWslNodePtyResult =
57 | {
58 readonly ok: true;
59 readonly nodePath: string;
60 readonly resolvedPath: string;
61 }
62 | {
63 readonly ok: false;
64 readonly reason: string;
65 readonly fatal: boolean;
66 readonly retryLimit?: number;
67 };
68
69// Outcome of asking the staged self-contained runtime to prove itself. Any
70// failure sends the launch to the mounted server tree; the caller decides what
71// to do with the cache.
72export type ProbeWslRuntimeResult =
73 | {
74 readonly ok: true;
75 readonly resolvedPath: string;
76 }
77 | {
78 readonly ok: false;
79 readonly reason: string;
80 };
81
82export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()(
83 "DesktopWslDistroListError",
84 { reason: Schema.String },
85) {
86 override get message(): string {
87 return this.reason;
88 }
89}
90
91const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError);
92
93export class DesktopWslEnvironment extends Context.Service<
94 DesktopWslEnvironment,
95 {
96 readonly isAvailable: Effect.Effect<boolean>;
97 // Best-effort enumeration for renderer UX. Backend health checks must use
98 // probeDistros so a transient command failure is not mistaken for a
99 // successful empty installation.
100 readonly listDistros: Effect.Effect<readonly WslDistro[]>;
101 readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>;
102 readonly preWarm: (distro: string | null) => Effect.Effect<void>;
103 readonly windowsToWslPath: (
104 distro: string | null,
105 windowsPath: string,
106 ) => Effect.Effect<Option.Option<string>>;
107 // Resolves the user's Linux home dir inside the chosen distro (e.g.
108 // "/home/josh"). Used by the folder picker to expand `~` correctly.
109 readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>;
110 // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter
111 // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's
112 // httpBaseUrl so the renderer can reach it without relying on wslhost's
113 // localhost→WSL automatic forwarding, which is flaky in practice
114 // (the backend can be listening for 30+ seconds before wslhost starts
115 // forwarding 127.0.0.1:port to WSL-side localhost).
116 readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>;
117 readonly prepareRuntime: (
118 distro: string | null,
119 archive: WslRuntimeArchive,
120 ) => Effect.Effect<PrepareWslRuntimeResult>;
121 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
122 // Marks a staged runtime as unusable so the next launch reinstalls it.
123 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
124 // Proves a staged self-contained runtime can run (`<root>/t3 --version`)
125 // and resolves the user's PATH, including version-managed Node for provider
126 // CLIs. Node is optional; the mounted tree still requires ensureNodePty.
127 readonly probeRuntime: (
128 distro: string | null,
129 linuxAppRoot: string,
130 ) => Effect.Effect<ProbeWslRuntimeResult>;
131 readonly ensureNodePty: (
132 distro: string | null,
133 linuxAppRoot: string,
134 options?: EnsureWslNodePtyOptions,
135 ) => Effect.Effect<EnsureWslNodePtyResult>;
136 }
137>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {}
138
139const buildDistroArgs = (distro: string | null): ReadonlyArray<string> =>
140 distro ? ["-d", distro] : [];
141
142const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => {
143 let totalLength = 0;
144 for (const arr of arrays) totalLength += arr.byteLength;
145 const out = new Uint8Array(totalLength);
146 let offset = 0;
147 for (const arr of arrays) {
148 out.set(arr, offset);
149 offset += arr.byteLength;
150 }
151 return out;
152};
153
154const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes);
155
156interface ShellResult {
157 readonly exitCode: number;
158 readonly stdout: string;
159 readonly stderr: string;
160 readonly transportFailure: "timeout" | "spawn" | "process" | null;
161}
162
163const TIMEOUT_RESULT: ShellResult = {
164 exitCode: 124,
165 stdout: "",
166 stderr: "\n[timeout]",
167 transportFailure: "timeout",
168};
169
170const formatWslShellTransportFailureReason = (
171 failure: ShellResult["transportFailure"],
172 subject = "Node.js",
173): string | null => {
174 switch (failure) {
175 case "timeout":
176 return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`;
177 case "spawn":
178 return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`;
179 case "process":
180 return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`;
181 case null:
182 return null;
183 }
184};
185
186// Reuse the SSH remote resolver so WSL and SSH discover version-managed Node
187// the same way. Passing the engine range lets the resolver fall through to
188// version managers like nvm when a system node exists but is too old.
189const buildWslNodeEnvPreamble = (
190 nodeEngineRange?: string | null,
191): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })}
192ensure_remote_node_path || true
193`;
194
195// wsl.exe re-escapes args before forwarding them to the Linux side, which
196// mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to
197// avoid passing it on the command line at all.
198const runWslShell = (
199 distro: string | null,
200 bashScript: string,
201 timeout: Duration.Duration,
202 options: {
203 readonly nodeEngineRange?: string | null;
204 readonly resolveNode?: boolean;
205 } = {},
206): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {
207 const spawner = ChildProcessSpawner.ChildProcessSpawner;
208 // Node probes use a login bash so profile-managed PATH entries and supported
209 // version managers are available. Runtime installation needs only POSIX tools,
210 // so it skips profile loading and runs sh directly.
211 const resolveNode = options.resolveNode !== false;
212 const command = ChildProcess.make(
213 "wsl.exe",
214 resolveNode
215 ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]
216 : [...buildDistroArgs(distro), "--exec", "sh", "-s"],
217 {
218 stdin: Stream.encodeText(
219 Stream.make(
220 resolveNode
221 ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`
222 : bashScript,
223 ),
224 ),
225 stdout: "pipe",
226 stderr: "pipe",
227 killSignal: "SIGTERM",
228 forceKillAfter: PROCESS_TERMINATE_GRACE,
229 },
230 );
231
232 return Effect.scoped(
233 Effect.gen(function* () {
234 const spawnerService = yield* spawner;
235 const spawnResult = yield* spawnerService.spawn(command).pipe(
236 Effect.match({
237 onFailure: (error) => ({ _tag: "Failure", error }) as const,
238 onSuccess: (handle) => ({ _tag: "Success", handle }) as const,
239 }),
240 );
241 if (spawnResult._tag === "Failure") {
242 return {
243 exitCode: 127,
244 stdout: "",
245 stderr: `\n${spawnResult.error.message}`,
246 transportFailure: "spawn",
247 } satisfies ShellResult;
248 }
249 const handle = spawnResult.handle;
250 // Drain stdout and stderr concurrently so neither pipe buffer can fill
251 // and stall the child (node-gyp rebuild emits large output on both).
252 const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all(
253 [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode],
254 { concurrency: "unbounded" },
255 );
256 return {
257 exitCode: exitCode as unknown as number,
258 stdout: decodeUtf8(concatChunks(stdoutBytes)),
259 stderr: decodeUtf8(concatChunks(stderrBytes)),
260 transportFailure: null,
261 } satisfies ShellResult;
262 }),
263 ).pipe(
264 Effect.timeoutOption(timeout),
265 Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)),
266 Effect.catch((error) =>
267 Effect.succeed<ShellResult>({
268 exitCode: 127,
269 stdout: "",
270 stderr: `\n${error.message}`,
271 transportFailure: "process",
272 }),
273 ),
274 );
275};
276
277const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
278
279// Holds the sha256 of the runtime's `t3` executable, written when the install
280// promotes a verified tree. Presence alone only says an install once finished
281// here; the digest is what lets a later launch prove the entry still is what
282// that install wrote.
283const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready";
284const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected";
285const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5;
286
287const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_");
288
289// `archiveSha256` is the digest the build recorded alongside the archive. The
290// install verifies the bytes before extracting, so an archive can never be
291// promoted under an identity that does not describe it.
292export const buildWslRuntimeInstallScript = (
293 linuxArchivePath: string,
294 runtimeId: string,
295 archiveSha256: string,
296): string => {
297 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
298 return [
299 "set -eu",
300 'runtime_parent="$HOME/.t3/wsl-runtime"',
301 `runtime_root="$runtime_parent/${safeRuntimeId}"`,
302 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
303 // The runtime is a self-contained `t3` executable with Node inside, so the
304 // readiness proof is the same one the SSH runner and the CLI installers
305 // use: the file is executable and `t3 --version` exits 0. That covers the
306 // truncated-binary and wrong-arch cases without a separate native probe.
307 "runtime_entry_runs() {",
308 ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1',
309 "}",
310 // Hashing the entry is what tells a working cache from one whose `t3` was
311 // swapped or half-written after install: the file is still there and may
312 // even still run, and launch then picks an executable that is not what
313 // this install verified. Hashing the executable measures in tens of
314 // milliseconds inside the distro, once per launch, against a cold
315 // reinstall of a few hundred megabytes.
316 "runtime_server_entry_digest() {",
317 ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`,
318 "}",
319 "runtime_is_ready() {",
320 ' [ -f "$ready_marker" ] &&',
321 ' runtime_entry_runs "$runtime_root" &&',
322 // An empty or unreadable marker is a miss, not a pass: that is what a
323 // runtime installed before the marker carried a digest looks like, and one
324 // reinstall is the cheapest way to make it verifiable from then on.
325 ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`,
326 ' [ -n "$recorded_entry_digest" ] &&',
327 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
328 "}",
329 'mkdir -p "$runtime_parent"',
330 `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`,
331 "trap 'exit 1' HUP INT TERM",
332 'exec 9> "$runtime_lock"',
333 "flock -x 9",
334 "if runtime_is_ready; then",
335 ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
336 ` printf 'runtimeRoot:%s\\n' "$runtime_root"`,
337 " exit 0",
338 "fi",
339 // Hash only on a cache miss: a warm launch already exited above, and a cold
340 // install is about to read the whole archive through tar anyway. `set -eu`
341 // turns a distro without sha256sum into an install failure, which falls back
342 // to the mounted server tree rather than trusting unverified bytes.
343 `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`,
344 `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`,
345 ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`,
346 " exit 1",
347 "fi",
348 // A backend can still be running out of an unready tree: the probe revokes
349 // the ready marker without stopping the process it just failed for, and
350 // invalidation deliberately leaves the tree in place for exactly that
351 // reason. Deleting it here unlinks node_modules from under a live backend,
352 // which then breaks the moment it lazily loads anything it had not already
353 // read. Move it aside either way, but only delete it now when nothing is
354 // running from it; otherwise hand it to the pruner's scratch sweep, which
355 // is what that delay is for. A process's cmdline keeps the pre-rename path,
356 // so this has to be asked before the move, not after. This script arrives
357 // on stdin, so it cannot match itself.
358 "runtime_in_use() {",
359 // No /proc means no way to tell, and guessing wrong costs a live backend
360 // its runtime. Keeping the tree only costs disk until the sweep runs.
361 " [ -d /proc/1 ] || return 0",
362 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
363 "}",
364 'if [ -e "$runtime_root" ]; then',
365 ' if runtime_in_use "$runtime_root"; then',
366 " runtime_root_in_use=1",
367 " else",
368 " runtime_root_in_use=0",
369 " fi",
370 ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`,
371 ' rmdir "$runtime_stale"',
372 ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then',
373 ' if [ "$runtime_root_in_use" = 1 ]; then',
374 // Renaming keeps the directory's old mtime, so restart the cleanup clock.
375 ' touch "$runtime_stale"',
376 " else",
377 ' rm -rf "$runtime_stale"',
378 " fi",
379 " fi",
380 "fi",
381 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
382 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
383 "trap cleanup_runtime_install EXIT",
384 // The release archive has one top-level `t3-<version>-linux-<arch>/`
385 // directory; strip it so the executable lands at `$runtime_root/t3`.
386 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`,
387 // Never write the ready marker over a tree whose executable does not run.
388 // Failing here drops out to the mounted-tree fallback, which is
389 // recoverable; promoting it would mark the defect ready and cache it.
390 'if ! runtime_entry_runs "$runtime_tmp"; then',
391 " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2",
392 " exit 1",
393 "fi",
394 // The archive's bytes were verified against archiveSha256 above, so the
395 // digest recorded here describes content this install proved. Every later
396 // warm reuse checks the entry against it.
397 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
398 'if [ -z "$installed_entry_digest" ]; then',
399 " printf 'Could not hash the WSL runtime server entry\\n' >&2",
400 " exit 1",
401 "fi",
402 `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`,
403 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then',
404 " :",
405 "elif runtime_is_ready; then",
406 ' rm -rf "$runtime_tmp"',
407 "else",
408 ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`,
409 " exit 1",
410 "fi",
411 `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
412 `printf 'runtimeRoot:%s\\n' "$runtime_root"`,
413 ].join("\n");
414};
415
416// An interrupted install leaves a dot-prefixed scratch directory behind. A cold
417// install extracts a few hundred MB inside the distro, so two hours is far past
418// any live install while still bounding how long an orphan survives.
419const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120;
420
421export const buildWslRuntimePruneScript = (runtimeId: string): string => {
422 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
423 return [
424 "set -eu",
425 'runtime_parent="$HOME/.t3/wsl-runtime"',
426 `current_runtime="$runtime_parent/${safeRuntimeId}"`,
427 '[ -d "$runtime_parent" ] || exit 0',
428 // Serialize the whole retention decision so two backends cannot select
429 // different "previous" caches and delete around one another.
430 'prune_lock="$runtime_parent/.prune.lock"',
431 'exec 8> "$prune_lock"',
432 "flock -x 8",
433 // Without a way to see the distro's processes we cannot tell which caches
434 // are load-bearing, and the retention rules below are not safe on their own.
435 "[ -d /proc/1 ] || exit 0",
436 "runtime_in_use() {",
437 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
438 "}",
439 'previous_runtime=""',
440 'for candidate in "$runtime_parent"/sha256-*; do',
441 ' [ -d "$candidate" ] || continue',
442 ' [ "$candidate" != "$current_runtime" ] || continue',
443 ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`,
444 ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then',
445 ' previous_runtime="$candidate"',
446 " fi",
447 "done",
448 // Only this desktop-owned prefix is eligible. Markerless roots are broken
449 // caches left by invalidation and must not become permanent disk leaks.
450 'for candidate in "$runtime_parent"/sha256-*; do',
451 ' [ -d "$candidate" ] || continue',
452 ' [ "$candidate" != "$current_runtime" ] || continue',
453 ' [ "$candidate" != "$previous_runtime" ] || continue',
454 ' ! runtime_in_use "$candidate" || continue',
455 " candidate_name=${candidate##*/}",
456 ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"',
457 ' exec 9> "$candidate_lock"',
458 // A held lock means another launch is installing or repairing this cache.
459 // Skip instead of waiting or deleting underneath it.
460 " flock -n 9 || continue",
461 ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`,
462 ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`,
463 " flock -u 9",
464 " continue",
465 " fi",
466 ' rm -rf -- "$candidate"',
467 " flock -u 9",
468 "done",
469 // Interrupted installs use dot-prefixed names under this dedicated parent.
470 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
471 ' [ -d "$scratch" ] || continue',
472 ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`,
473 ' rm -rf -- "$scratch"',
474 "done",
475 ].join("\n");
476};
477
478// Drops the ready marker so the next launch reinstalls the runtime from the
479// archive. Readiness is decided inside the install script, so a cached tree
480// that passes there but fails the launch-time probe (a distro whose glibc the
481// executable needs, a tree copied from another machine) would stay ready
482// forever and fail on every launch. Only the probe can see that, so the probe
483// is what revokes the marker. The tree itself is left in place: the install
484// script moves an unready root aside before extracting.
485export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
486 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
487 return [
488 "set -eu",
489 `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`,
490 ].join("\n");
491};
492
493export const parseWslRuntimeRoot = (stdout: string): string | null => {
494 const prefix = "runtimeRoot:";
495 const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
496 if (line === undefined) return null;
497 const runtimeRoot = line.slice(prefix.length).replace(/\r$/, "");
498 return runtimeRoot.startsWith("/") ? runtimeRoot : null;
499};
500
501// The mounted server tree carries no Linux pty.node unless the build put one
502// there. Distinct from a binary that is present but will not load, which is a
503// distro problem rather than a build problem.
504const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4;
505
506const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
507 exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE
508 ? "WSL support is missing from this build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support."
509 : null;
510
511// Captures the login-shell PATH as `resolvedPath:` so the launch can forward the
512// user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name.
513const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`;
514
515const NODE_PTY_PROBE_SCRIPT = (
516 linuxServerDir: string,
517) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
518printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
519${RESOLVED_PATH_LINE}
520cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
521// The WSL Node can't read inside app.asar, so confirm what the server needs is
522// unpacked on the real filesystem before reporting the backend healthy. Exit 3
523// marks this distinct from a node-pty binary problem so the caller can report
524// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at
525// launch (which, in wsl-only mode, would just fail to launch with no fallback).
526//
527// The sentinel must be a package the CLI bundle leaves external. It used to be
528// "effect", back when the bundle externalized its runtime deps and the whole
529// node_modules tree was unpacked. The bundle now inlines its JS dependencies,
530// so "effect" no longer exists on disk and only the native packages do —
531// resolving node-pty is what actually validates the unpacked tree.
532try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); }
533const fs = require("node:fs");
534const path = require("node:path");
535const pkgDir = path.dirname(require.resolve("node-pty/package.json"));
536// node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across
537// Node versions — require() succeeding IS the real compatibility test. Look in
538// the same places node-pty's own loader does.
539const candidates = [
540 path.join(pkgDir, "build", "Release", "pty.node"),
541 path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"),
542];
543if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE});
544require("node-pty");
545NODE`;
546
547// Readiness proof for a staged self-contained runtime: the executable runs and
548// reports its version. Provider CLIs may still need version-managed Node, so
549// resolve it before capturing PATH without requiring it for runtime readiness.
550// A distro without bash falls back to the PATH sh was started with.
551export const buildWslRuntimeProbeScript = (linuxAppRoot: string) =>
552 [
553 `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`,
554 `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`,
555 ].join("\n");
556
557const TOOLCHAIN_CHECK_SCRIPT = [
558 "for tool in node make g++ python3; do",
559 ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"',
560 "done",
561 "if command -v node >/dev/null 2>&1; then",
562 ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`,
563 ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi',
564 "fi",
565].join("\n");
566
567const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) =>
568 [
569 "set -e",
570 `cd ${shellQuote(linuxServerDir)}`,
571 `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`,
572 `cd "$pkg_dir"`,
573 "npx --yes node-gyp rebuild",
574 `node -e 'require("node-pty")'`,
575 ].join("\n");
576
577export interface ToolchainReport {
578 readonly missingTools: ReadonlyArray<string>;
579 readonly nodeVersion: string | null;
580}
581
582export const parseToolchainReport = (stdout: string): ToolchainReport => {
583 const lines = stdout
584 .split("\n")
585 .map((line) => line.trim())
586 .filter((line) => line.length > 0);
587 const missingTools = lines
588 .filter((line) => line.startsWith("missing:"))
589 .map((line) => line.slice("missing:".length));
590 const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:"));
591 const nodeVersion = nodeVersionLine
592 ? nodeVersionLine.slice("nodeVersion:".length).trim() || null
593 : null;
594 return { missingTools, nodeVersion };
595};
596
597// Pulls the absolute node path the WSL distro resolved after the shared remote
598// resolver repaired PATH. Returns null when no node was found, which the caller
599// turns into an actionable "install Node" message instead of a confusing
600// node-pty error.
601export const parseNodePath = (stdout: string): string | null => {
602 const path = stdout
603 .split("\n")
604 .map((line) => line.trim())
605 .filter((line) => line.startsWith("nodePath:"))
606 .map((line) => line.slice("nodePath:".length).trim())
607 .find((value) => value.length > 0);
608 return path ?? null;
609};
610
611export const parseNodeVersion = (stdout: string): string | null => {
612 const version = stdout
613 .split("\n")
614 .map((line) => line.trim())
615 .filter((line) => line.startsWith("nodeVersion:"))
616 .map((line) => line.slice("nodeVersion:".length).trim())
617 .find((value) => value.length > 0);
618 return version ?? null;
619};
620
621// Captures the login-shell PATH after the shared resolver has loaded version
622// managers. Preserve the value byte-for-byte apart from a Windows-style CR so
623// paths containing spaces or apostrophes can be forwarded as one env argv.
624export const parseResolvedPath = (stdout: string): string | null => {
625 const prefix = "resolvedPath:";
626 const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
627 if (line === undefined) return null;
628 const resolvedPath = line.slice(prefix.length).replace(/\r$/, "");
629 return resolvedPath.length > 0 ? resolvedPath : null;
630};
631
632export const formatMissingToolsReason = (
633 report: ToolchainReport,
634 requiredRange: string | null,
635): string | null => {
636 const nodeMissing = report.missingTools.includes("node");
637 const nodeOutOfRange =
638 !nodeMissing &&
639 requiredRange !== null &&
640 report.nodeVersion !== null &&
641 !satisfiesSemverRange(report.nodeVersion, requiredRange);
642 const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node");
643
644 if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) {
645 return null;
646 }
647
648 const issues: string[] = [];
649 const remediations: string[] = [];
650
651 if (nodeMissing) {
652 issues.push("node");
653 remediations.push(
654 `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`,
655 );
656 } else if (nodeOutOfRange) {
657 issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`);
658 remediations.push(
659 `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`,
660 );
661 }
662
663 if (buildToolsMissing.length > 0) {
664 issues.push(...buildToolsMissing);
665 remediations.push(
666 "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)",
667 );
668 }
669
670 return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;
671};
672
673const probeWslRuntimeImpl = (
674 distro: string | null,
675 linuxAppRoot: string,
676): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
677 Effect.gen(function* () {
678 const probe = yield* runWslShell(
679 distro,
680 buildWslRuntimeProbeScript(linuxAppRoot),
681 PROBE_TIMEOUT,
682 {
683 resolveNode: false,
684 },
685 );
686 const transportFailureReason = formatWslShellTransportFailureReason(
687 probe.transportFailure,
688 "the staged runtime",
689 );
690 if (transportFailureReason !== null) {
691 return { ok: false, reason: transportFailureReason } as const;
692 }
693 if (probe.exitCode !== 0) {
694 const trimmedTail = probe.stderr.trim().slice(-500);
695 return {
696 ok: false,
697 reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`,
698 } as const;
699 }
700 const resolvedPath = parseResolvedPath(probe.stdout);
701 if (resolvedPath === null) {
702 return {
703 ok: false,
704 reason: "WSL login-shell PATH could not be resolved during backend preflight.",
705 } as const;
706 }
707 return { ok: true, resolvedPath } as const;
708 });
709
710const ensureNodePtyImpl = (
711 distro: string | null,
712 linuxRepoRoot: string,
713 options: EnsureWslNodePtyOptions = {},
714): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
715 Effect.gen(function* () {
716 // node-pty lives in the apps/server workspace's node_modules; resolve from
717 // there rather than the monorepo root, where Bun's hoist layout omits it.
718 const linuxServerDir = `${linuxRepoRoot}/apps/server`;
719
720 const probe = yield* runWslShell(
721 distro,
722 NODE_PTY_PROBE_SCRIPT(linuxServerDir),
723 PROBE_TIMEOUT,
724 options,
725 );
726 const nodePath = parseNodePath(probe.stdout);
727 const resolvedPath = parseResolvedPath(probe.stdout);
728
729 const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure);
730 if (transportFailureReason !== null) {
731 return {
732 ok: false,
733 reason: transportFailureReason,
734 fatal: false,
735 } as const;
736 }
737
738 // No node at all, even after the shared resolver repaired PATH. Surface
739 // the specific, actionable toolchain message rather than a confusing
740 // node-pty error, and don't try to build.
741 if (nodePath === null) {
742 const toolchainCheck = yield* runWslShell(
743 distro,
744 TOOLCHAIN_CHECK_SCRIPT,
745 TOOLCHAIN_TIMEOUT,
746 options,
747 );
748 const toolchainTransportFailure = formatWslShellTransportFailureReason(
749 toolchainCheck.transportFailure,
750 );
751 if (toolchainTransportFailure !== null) {
752 return {
753 ok: false,
754 reason: toolchainTransportFailure,
755 fatal: false,
756 retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT,
757 } as const;
758 }
759 const report = parseToolchainReport(toolchainCheck.stdout);
760 const reason =
761 formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ??
762 "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app.";
763 return { ok: false, reason, fatal: true } as const;
764 }
765
766 if (resolvedPath === null) {
767 return {
768 ok: false,
769 reason: "WSL login-shell PATH could not be resolved during backend preflight.",
770 fatal: true,
771 } as const;
772 }
773
774 // The packages the server bundle leaves external (node-pty and the other
775 // native addons) couldn't be resolved on the WSL filesystem — a packaging
776 // regression, since those must be unpacked from the asar. Fatal so wsl-only
777 // mode falls back to Windows and dual mode surfaces the reason inline,
778 // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it
779 // actually launches.
780 if (probe.exitCode === 3) {
781 return {
782 ok: false,
783 reason:
784 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.',
785 fatal: true,
786 } as const;
787 }
788
789 if (probe.exitCode === 0) {
790 const rawVersion = parseNodeVersion(probe.stdout);
791 if (
792 rawVersion !== null &&
793 options.nodeEngineRange &&
794 !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim())
795 ) {
796 const range = options.nodeEngineRange.trim();
797 return {
798 ok: false,
799 reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`,
800 fatal: true,
801 } as const;
802 }
803 return { ok: true, nodePath, resolvedPath } as const;
804 }
805
806 if (options.allowBuild !== true) {
807 const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode);
808 if (packagedProbeFailure !== null) {
809 return {
810 ok: false,
811 reason: packagedProbeFailure,
812 fatal: true,
813 } as const;
814 }
815 }
816
817 // node is present but node-pty's native module didn't load.
818 const toolchainCheck = yield* runWslShell(
819 distro,
820 TOOLCHAIN_CHECK_SCRIPT,
821 TOOLCHAIN_TIMEOUT,
822 options,
823 );
824 const toolchainTransportFailure = formatWslShellTransportFailureReason(
825 toolchainCheck.transportFailure,
826 );
827 if (toolchainTransportFailure !== null) {
828 return {
829 ok: false,
830 reason: toolchainTransportFailure,
831 fatal: false,
832 retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT,
833 } as const;
834 }
835 const report = parseToolchainReport(toolchainCheck.stdout);
836
837 if (options.allowBuild !== true) {
838 // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp,
839 // or network is needed — and we must not nag the user to install build
840 // tools they don't need. Still surface a missing/too-old Node (both the
841 // prebuilt and the server require a compatible Node); otherwise reaching
842 // here means the bundled binary itself couldn't load, which is almost
843 // always an unsupported CPU architecture or incompatible system libraries.
844 const nodeOnlyReason = formatMissingToolsReason(
845 {
846 missingTools: report.missingTools.filter((tool) => tool === "node"),
847 nodeVersion: report.nodeVersion,
848 },
849 options.nodeEngineRange?.trim() || null,
850 );
851 return {
852 ok: false,
853 reason:
854 nodeOnlyReason ??
855 "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.",
856 fatal: true,
857 } as const;
858 }
859
860 // Dev only: no prebuilt is bundled in a checkout, so compile node-pty from
861 // source. Run the toolchain check first so a missing compiler or out-of-range
862 // Node surfaces a specific, actionable message instead of an opaque node-gyp
863 // failure. Developers have the toolchain; end users never reach this path.
864 const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null);
865 if (missingReason !== null) {
866 return { ok: false, reason: missingReason, fatal: true } as const;
867 }
868
869 const build = yield* runWslShell(
870 distro,
871 NODE_PTY_BUILD_SCRIPT(linuxServerDir),
872 BUILD_TIMEOUT,
873 options,
874 );
875 const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure);
876 if (buildTransportFailure !== null) {
877 return {
878 ok: false,
879 reason: buildTransportFailure,
880 fatal: false,
881 retryLimit: BUILD_TRANSPORT_RETRY_LIMIT,
882 } as const;
883 }
884 if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const;
885 const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500);
886 return {
887 ok: false,
888 reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`,
889 fatal: true,
890 } as const;
891 });
892
893const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* (
894 distro: string | null,
895 archive: WslRuntimeArchive,
896 windowsToWslPath: (
897 distro: string | null,
898 windowsPath: string,
899 ) => Effect.Effect<Option.Option<string>>,
900): Effect.fn.Return<PrepareWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> {
901 const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath);
902 if (Option.isNone(linuxArchivePath)) {
903 return {
904 ok: false,
905 reason: `wslpath conversion failed for ${archive.windowsPath}`,
906 } as const;
907 }
908
909 const install = yield* runWslShell(
910 distro,
911 buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256),
912 RUNTIME_INSTALL_TIMEOUT,
913 { resolveNode: false },
914 );
915 if (install.transportFailure !== null) {
916 return {
917 ok: false,
918 reason:
919 install.transportFailure === "timeout"
920 ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry."
921 : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.",
922 } as const;
923 }
924 if (install.exitCode !== 0) {
925 const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500);
926 return {
927 ok: false,
928 reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`,
929 } as const;
930 }
931
932 const linuxAppRoot = parseWslRuntimeRoot(install.stdout);
933 return linuxAppRoot === null
934 ? {
935 ok: false,
936 reason: "WSL runtime installation completed without reporting its cache path.",
937 }
938 : { ok: true, linuxAppRoot };
939});
940
941const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* (
942 distro: string | null,
943 runtimeId: string,
944): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
945 const result = yield* runWslShell(
946 distro,
947 buildWslRuntimePruneScript(runtimeId),
948 RUNTIME_PRUNE_TIMEOUT,
949 { resolveNode: false },
950 );
951 if (result.transportFailure === null && result.exitCode === 0) return;
952
953 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
954 yield* Effect.logWarning("Could not prune old WSL runtime caches.", {
955 distro,
956 runtimeId,
957 detail: detail || `exit ${result.exitCode}`,
958 });
959});
960
961const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* (
962 distro: string | null,
963 runtimeId: string,
964): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
965 const result = yield* runWslShell(
966 distro,
967 buildWslRuntimeInvalidateScript(runtimeId),
968 RUNTIME_INVALIDATE_TIMEOUT,
969 { resolveNode: false },
970 );
971 if (result.transportFailure === null && result.exitCode === 0) return;
972
973 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
974 // Best effort: the caller has already fallen back to the mounted tree, so a
975 // failure here only costs the reinstall that would have repaired the cache.
976 yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", {
977 distro,
978 runtimeId,
979 detail: detail || `exit ${result.exitCode}`,
980 });
981});
982
983export const probeWslDistros: Effect.Effect<
984 readonly WslDistro[],
985 DesktopWslDistroListError,
986 ChildProcessSpawner.ChildProcessSpawner
987> = Effect.scoped(
988 Effect.gen(function* () {
989 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
990 const command = ChildProcess.make("wsl.exe", ["--list", "--verbose"], {
991 stdin: "ignore",
992 stdout: "pipe",
993 stderr: "ignore",
994 killSignal: "SIGTERM",
995 forceKillAfter: PROCESS_TERMINATE_GRACE,
996 });
997 const handle = yield* spawner.spawn(command);
998 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
999 const exitCode = yield* handle.exitCode;
1000 if ((exitCode as unknown as number) !== 0) {
1001 return yield* new DesktopWslDistroListError({
1002 reason: `wsl.exe --list --verbose exited with code ${String(exitCode)}`,
1003 });
1004 }
1005 return parseWslDistroList(Buffer.from(concatChunks(stdoutBytes)));
1006 }),
1007).pipe(
1008 Effect.mapError((error) =>
1009 isDesktopWslDistroListError(error)
1010 ? error
1011 : new DesktopWslDistroListError({
1012 reason: `Failed to run wsl.exe --list --verbose: ${error.message}`,
1013 }),
1014 ),
1015 Effect.timeoutOption(LIST_TIMEOUT),
1016 Effect.flatMap(
1017 Option.match({
1018 onNone: () =>
1019 new DesktopWslDistroListError({
1020 reason: "wsl.exe --list --verbose timed out",
1021 }),
1022 onSome: Effect.succeed,
1023 }),
1024 ),
1025);
1026
1027const preWarmImpl = (
1028 distro: string | null,
1029): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> =>
1030 Effect.scoped(
1031 Effect.gen(function* () {
1032 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1033 const command = ChildProcess.make("wsl.exe", [...buildDistroArgs(distro), "--", "true"], {
1034 stdin: "ignore",
1035 stdout: "ignore",
1036 stderr: "ignore",
1037 killSignal: "SIGTERM",
1038 forceKillAfter: PROCESS_TERMINATE_GRACE,
1039 });
1040 const handle = yield* spawner.spawn(command);
1041 yield* handle.exitCode;
1042 }),
1043 ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore);
1044
1045const windowsToWslPathImpl = (
1046 distro: string | null,
1047 windowsPath: string,
1048): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => {
1049 // wsl.exe interprets backslashes as escape chars; normalize to forward slashes.
1050 const normalized = windowsPath.replaceAll("\\", "/");
1051 return Effect.scoped(
1052 Effect.gen(function* () {
1053 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1054 const command = ChildProcess.make(
1055 "wsl.exe",
1056 [...buildDistroArgs(distro), "--", "wslpath", "-u", normalized],
1057 {
1058 stdin: "ignore",
1059 stdout: "pipe",
1060 stderr: "ignore",
1061 killSignal: "SIGTERM",
1062 forceKillAfter: PROCESS_TERMINATE_GRACE,
1063 },
1064 );
1065 const handle = yield* spawner.spawn(command);
1066 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1067 const exitCode = yield* handle.exitCode;
1068 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1069 const converted = decodeUtf8(concatChunks(stdoutBytes)).trim();
1070 return converted.length > 0 ? Option.some(converted) : Option.none<string>();
1071 }),
1072 ).pipe(
1073 Effect.timeoutOption(WSLPATH_TIMEOUT),
1074 Effect.map(Option.flatten),
1075 Effect.orElseSucceed(() => Option.none<string>()),
1076 );
1077};
1078
1079const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
1080
1081const getDistroIpImpl = (
1082 distro: string | null,
1083): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> =>
1084 Effect.scoped(
1085 Effect.gen(function* () {
1086 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1087 // `hostname -I` prints a space-separated list of all non-loopback
1088 // IPs the distro has bound. The first entry on the WSL2 default
1089 // network is always the eth0 vEthernet address Windows can reach
1090 // directly (no wslhost forwarding required).
1091 const command = ChildProcess.make(
1092 "wsl.exe",
1093 [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"],
1094 {
1095 stdin: "ignore",
1096 stdout: "pipe",
1097 stderr: "ignore",
1098 killSignal: "SIGTERM",
1099 forceKillAfter: PROCESS_TERMINATE_GRACE,
1100 },
1101 );
1102 const handle = yield* spawner.spawn(command);
1103 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1104 const exitCode = yield* handle.exitCode;
1105 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1106 const raw = decodeUtf8(concatChunks(stdoutBytes)).trim();
1107 const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part));
1108 return candidate ? Option.some(candidate) : Option.none<string>();
1109 }),
1110 ).pipe(
1111 Effect.timeoutOption(USER_HOME_TIMEOUT),
1112 Effect.map(Option.flatten),
1113 Effect.orElseSucceed(() => Option.none<string>()),
1114 );
1115
1116const getUserHomeImpl = (
1117 distro: string | null,
1118): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> =>
1119 Effect.scoped(
1120 Effect.gen(function* () {
1121 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1122 const command = ChildProcess.make(
1123 "wsl.exe",
1124 // printf so there's no trailing newline noise; getent so we get the
1125 // real home from /etc/passwd even if $HOME is unset for some reason.
1126 [
1127 ...buildDistroArgs(distro),
1128 "--",
1129 "sh",
1130 "-c",
1131 'printf "%s" "$(getent passwd "$(id -un)" | cut -d: -f6)"',
1132 ],
1133 {
1134 stdin: "ignore",
1135 stdout: "pipe",
1136 stderr: "ignore",
1137 killSignal: "SIGTERM",
1138 forceKillAfter: PROCESS_TERMINATE_GRACE,
1139 },
1140 );
1141 const handle = yield* spawner.spawn(command);
1142 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1143 const exitCode = yield* handle.exitCode;
1144 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1145 const home = decodeUtf8(concatChunks(stdoutBytes)).trim();
1146 return home.startsWith("/") ? Option.some(home) : Option.none<string>();
1147 }),
1148 ).pipe(
1149 Effect.timeoutOption(USER_HOME_TIMEOUT),
1150 Effect.map(Option.flatten),
1151 Effect.orElseSucceed(() => Option.none<string>()),
1152 );
1153
1154const makeIsAvailable = (
1155 platform: NodeJS.Platform,
1156 windir: string,
1157): Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path> =>
1158 Effect.gen(function* () {
1159 if (platform !== "win32") return false;
1160 const path = yield* Path.Path;
1161 const fileSystem = yield* FileSystem.FileSystem;
1162 const wslExePath = path.join(windir, "System32", "wsl.exe");
1163 return yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false));
1164 });
1165
1166export interface DesktopWslEnvironmentTestStub {
1167 readonly isAvailable?: boolean;
1168 readonly distros?: ReadonlyArray<WslDistro>;
1169 readonly distroListError?: DesktopWslDistroListError;
1170 readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>;
1171 readonly getUserHome?: (distro: string | null) => Option.Option<string>;
1172 readonly getDistroIp?: (distro: string | null) => Option.Option<string>;
1173 readonly prepareRuntime?: (
1174 distro: string | null,
1175 archive: WslRuntimeArchive,
1176 ) => PrepareWslRuntimeResult;
1177 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1178 readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1179 // Defaults to success with a plain PATH: a staged runtime that was prepared
1180 // is assumed to run unless the test says otherwise.
1181 readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult;
1182 readonly ensureNodePty?: (
1183 distro: string | null,
1184 linuxAppRoot: string,
1185 options?: EnsureWslNodePtyOptions,
1186 ) => EnsureWslNodePtyResult;
1187}
1188
1189export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
1190 const probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError> =
1191 stub.distroListError ? Effect.fail(stub.distroListError) : Effect.succeed(stub.distros ?? []);
1192 return Layer.succeed(
1193 DesktopWslEnvironment,
1194 DesktopWslEnvironment.of({
1195 isAvailable: Effect.succeed(stub.isAvailable ?? false),
1196 listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])),
1197 probeDistros,
1198 preWarm: () => Effect.void,
1199 windowsToWslPath: (distro, windowsPath) =>
1200 Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()),
1201 getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()),
1202 getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>()),
1203 prepareRuntime: (distro, archive) =>
1204 Effect.succeed(
1205 stub.prepareRuntime?.(distro, archive) ?? {
1206 ok: false,
1207 reason: "prepareRuntime stub not configured",
1208 },
1209 ),
1210 pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,
1211 invalidateRuntime: (distro, runtimeId) =>
1212 stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void,
1213 probeRuntime: (distro, linuxAppRoot) =>
1214 Effect.succeed(
1215 stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" },
1216 ),
1217 ensureNodePty: (distro, linuxAppRoot, options) =>
1218 Effect.succeed(
1219 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
1220 ok: false,
1221 reason: "ensureNodePty stub not configured",
1222 fatal: true,
1223 },
1224 ),
1225 }),
1226 );
1227};
1228
1229export const layer = Layer.effect(
1230 DesktopWslEnvironment,
1231 Effect.gen(function* () {
1232 const environment = yield* DesktopEnvironment.DesktopEnvironment;
1233 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1234 const fileSystem = yield* FileSystem.FileSystem;
1235 const windir = process.env.WINDIR ?? "C:\\Windows";
1236
1237 const provideSpawner = <A, E>(
1238 effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
1239 ): Effect.Effect<A, E> =>
1240 effect.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
1241
1242 // Probe wsl.exe once at layer init and cache the result, exposing
1243 // `isAvailable` as a resolved value rather than a re-running effect.
1244 // WSL availability is effectively static for the process lifetime — the
1245 // Windows feature isn't added/removed mid-session, and backend mode
1246 // changes already require an app restart — so the cached boolean stays
1247 // accurate. Crucially this keeps `isAvailable` synchronously resolvable:
1248 // it's read inside the sync IPC handler getLocalEnvironmentBootstraps
1249 // (via the primary instance's lazy label -> resolvePrimaryLabel ->
1250 // describePrimary). The underlying probe does a filesystem `exists`
1251 // check, so leaving it as a live effect would make Effect.runSync throw
1252 // there and break the renderer's synchronous bootstrap path.
1253 const wslAvailable = yield* makeIsAvailable(environment.platform, windir).pipe(
1254 Effect.provideService(FileSystem.FileSystem, fileSystem),
1255 Effect.provideService(Path.Path, environment.path),
1256 Effect.withSpan("desktop.wsl.isAvailable"),
1257 );
1258 const isAvailable = Effect.succeed(wslAvailable);
1259
1260 const windowsToWslPath = (distro: string | null, windowsPath: string) =>
1261 provideSpawner(windowsToWslPathImpl(distro, windowsPath)).pipe(
1262 Effect.withSpan("desktop.wsl.windowsToWslPath"),
1263 );
1264
1265 // Cache user-home results per distro key — folder picker can be opened
1266 // many times in a session and the value is stable for the life of the
1267 // distro. Negative results aren't cached so a transient wsl.exe failure
1268 // doesn't permanently disable tilde expansion.
1269 const userHomeCache = new Map<string, string>();
1270 const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) {
1271 const key = distro ?? "__default__";
1272 const cached = userHomeCache.get(key);
1273 if (cached !== undefined) return Option.some(cached);
1274 const resolved = yield* provideSpawner(getUserHomeImpl(distro));
1275 if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value);
1276 return resolved;
1277 });
1278
1279 const getDistroIp = (distro: string | null) =>
1280 provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp"));
1281
1282 const probeDistros = provideSpawner(probeWslDistros).pipe(
1283 Effect.withSpan("desktop.wsl.probeDistros"),
1284 );
1285
1286 return DesktopWslEnvironment.of({
1287 isAvailable,
1288 listDistros: probeDistros.pipe(
1289 Effect.orElseSucceed(() => []),
1290 Effect.withSpan("desktop.wsl.listDistros"),
1291 ),
1292 probeDistros,
1293 preWarm: (distro) =>
1294 provideSpawner(preWarmImpl(distro)).pipe(Effect.withSpan("desktop.wsl.preWarm")),
1295 windowsToWslPath,
1296 getUserHome,
1297 getDistroIp,
1298 prepareRuntime: (distro, archive) =>
1299 provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe(
1300 Effect.withSpan("desktop.wsl.prepareRuntime"),
1301 ),
1302 pruneRuntimes: (distro, runtimeId) =>
1303 provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe(
1304 Effect.withSpan("desktop.wsl.pruneRuntimes"),
1305 ),
1306 invalidateRuntime: (distro, runtimeId) =>
1307 provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(
1308 Effect.withSpan("desktop.wsl.invalidateRuntime"),
1309 ),
1310 probeRuntime: (distro, linuxAppRoot) =>
1311 provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe(
1312 Effect.withSpan("desktop.wsl.probeRuntime"),
1313 ),
1314 ensureNodePty: (distro, linuxAppRoot, options) =>
1315 provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(
1316 Effect.withSpan("desktop.wsl.ensureNodePty"),
1317 ),
1318 });
1319 }),
1320);