authorgravatar for andrew@johnson5.netAndrew Johnson <andrew@johnson5.net> 2026-09-14 09:10:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 09:10:39-07:00
log8b1ea4dd2465f3cf3cfa02d6878beaa1ec22e71a
treefcf23e7d3312c86c7d6cd848f38e117b3369c8ac
parent9375c779707fb95c06670db6da87441720b2d2e2
signature Signed by PGP key B5690EEEBB952194

fix(desktop): restore Node discovery for WSL providers (#11741)


4 files changed, 90 insertions(+), 16 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+3-3
......@@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => {
399399 observedProbeRoots.push(root);
400400 return { ok: true, resolvedPath };
401401 },
402 // The staged runtime carries its own Node, so the preflight must not
403 // go looking for one in the distro.
402 // The staged runtime carries its own Node and node-pty, so it must
403 // not require the mounted server tree's native dependency check.
404404 ensureNodePty: () => {
405 throw new Error("the staged runtime must not probe for Node");
405 throw new Error("the staged runtime must not probe for node-pty");
406406 },
407407 }),
408408 },
apps/desktop/src/backend/DesktopBackendConfiguration.ts+2-2
......@@ -384,8 +384,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
384384 if (input.runtimeArchive !== null) {
385385 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
386386 if (runtime.ok) {
387 // The staged runtime is self-contained, so the only question is whether
388 // it runs here; there is no Node to find or node-pty to load.
387 // The staged runtime supplies its own Node and node-pty. Provider PATH
388 // discovery must not require either dependency for runtime readiness.
389389 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);
390390 if (stagedProbe.ok) {
391391 yield* wslServerTree.cleanupLegacy;
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+70
......@@ -15,6 +15,7 @@ import {
1515 buildWslRuntimeInstallScript,
1616 buildWslRuntimeInvalidateScript,
1717 buildWslRuntimePruneScript,
18 buildWslRuntimeProbeScript,
1819 DesktopWslDistroListError,
1920 formatMissingToolsReason,
2021 parseNodePath,
......@@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
446447 };
447448 };
448449
450 const probeFixture = (fixture: ReturnType<typeof createFixture>) =>
451 runShell(
452 [
453 `export HOME=${sh(`${fixture.work}/home`)}`,
454 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"',
455 // Isolate login profiles and hide the host's Node/version managers.
456 // The resolver must discover the fixture's installation itself.
457 "bash() { (",
458 " command() {",
459 ' case "$*" in',
460 ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;',
461 ' *) builtin command "$@" ;;',
462 " esac",
463 " }",
464 ' eval "$2"',
465 "); }",
466 buildWslRuntimeProbeScript(fixture.runtimeRoot),
467 ].join("\n"),
468 );
469
470 it("discovers version-managed Node for providers with a standalone runtime", () => {
471 const fixture = createFixture();
472 expect(fixture.install().status).toBe(0);
473 const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`;
474 const setup = runShell(
475 [
476 "set -eu",
477 `mkdir -p ${sh(nodeBin)}`,
478 `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`,
479 `chmod +x ${sh(`${nodeBin}/node`)}`,
480 ].join("\n"),
481 );
482 expect(setup.status, setup.stderr).toBe(0);
483
484 const probe = probeFixture(fixture);
485
486 expect(probe.status, probe.stderr).toBe(0);
487 const resolvedPath = parseResolvedPath(probe.stdout);
488 expect(resolvedPath?.split(":")).toContain(nodeBin);
489 const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`);
490 expect(provider.status, provider.stderr).toBe(0);
491 expect(provider.stdout).toBe("linux-node-provider\n");
492 });
493
494 it("keeps standalone runtime readiness independent of Node availability", () => {
495 const fixture = createFixture();
496 expect(fixture.install().status).toBe(0);
497
498 const probe = probeFixture(fixture);
499
500 expect(probe.status, probe.stderr).toBe(0);
501 expect(parseResolvedPath(probe.stdout)).not.toBeNull();
502 });
503
504 it("keeps the inherited PATH when bash is unavailable", () => {
505 const fixture = createFixture();
506 expect(fixture.install().status).toBe(0);
507 const probe = runShell(
508 [
509 "bash() { return 127; }",
510 'export PATH="/fixture/bin:/usr/bin:/bin"',
511 buildWslRuntimeProbeScript(fixture.runtimeRoot),
512 ].join("\n"),
513 );
514
515 expect(probe.status, probe.stderr).toBe(0);
516 expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin");
517 });
518
449519 it("reuses a warm cache without touching the archive", () => {
450520 const fixture = createFixture();
451521 expect(fixture.install().status).toBe(0);
apps/desktop/src/wsl/DesktopWslEnvironment.ts+15-11
......@@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service<
122122 // Marks a staged runtime as unusable so the next launch reinstalls it.
123123 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
124124 // Proves a staged self-contained runtime can run (`<root>/t3 --version`)
125 // and captures the user's login-shell PATH for the launch. Needs no Node
126 // in the distro; the mounted server tree still goes through ensureNodePty.
125 // and resolves the user's PATH, including version-managed Node for provider
126 // CLIs. Node is optional; the mounted tree still requires ensureNodePty.
127127 readonly probeRuntime: (
128128 distro: string | null,
129129 linuxAppRoot: string,
......@@ -545,13 +545,12 @@ require("node-pty");
545545NODE`;
546546
547547// Readiness proof for a staged self-contained runtime: the executable runs and
548// reports its version, and the login shell's PATH is captured for the launch.
549// This runs under plain `sh` (no Node resolver preamble, since the runtime
550// needs no Node), so the login shell is entered explicitly for the PATH
551// capture; a distro without bash falls back to the PATH sh was started with.
552const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) =>
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) =>
553552 [
554 `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`,
553 `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`,
555554 `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`,
556555 ].join("\n");
557556
......@@ -676,9 +675,14 @@ const probeWslRuntimeImpl = (
676675 linuxAppRoot: string,
677676): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
678677 Effect.gen(function* () {
679 const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, {
680 resolveNode: false,
681 });
678 const probe = yield* runWslShell(
679 distro,
680 buildWslRuntimeProbeScript(linuxAppRoot),
681 PROBE_TIMEOUT,
682 {
683 resolveNode: false,
684 },
685 );
682686 const transportFailureReason = formatWslShellTransportFailureReason(
683687 probe.transportFailure,
684688 "the staged runtime",