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", () => {...@@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => {
399 observedProbeRoots.push(root);399 observedProbeRoots.push(root);
400 return { ok: true, resolvedPath };400 return { ok: true, resolvedPath };
401 },401 },
402 // The staged runtime carries its own Node, so the preflight must not402 // The staged runtime carries its own Node and node-pty, so it must
403 // go looking for one in the distro.403 // not require the mounted server tree's native dependency check.
404 ensureNodePty: () => {404 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");
406 },406 },
407 }),407 }),
408 },408 },
apps/desktop/src/backend/DesktopBackendConfiguration.ts+2-2
...@@ -384,8 +384,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -384,8 +384,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
384 if (input.runtimeArchive !== null) {384 if (input.runtimeArchive !== null) {
385 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);385 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
386 if (runtime.ok) {386 if (runtime.ok) {
387 // The staged runtime is self-contained, so the only question is whether387 // The staged runtime supplies its own Node and node-pty. Provider PATH
388 // it runs here; there is no Node to find or node-pty to load.388 // discovery must not require either dependency for runtime readiness.
389 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);389 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);
390 if (stagedProbe.ok) {390 if (stagedProbe.ok) {
391 yield* wslServerTree.cleanupLegacy;391 yield* wslServerTree.cleanupLegacy;
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+70
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 buildWslRuntimeInstallScript,15 buildWslRuntimeInstallScript,
16 buildWslRuntimeInvalidateScript,16 buildWslRuntimeInvalidateScript,
17 buildWslRuntimePruneScript,17 buildWslRuntimePruneScript,
18 buildWslRuntimeProbeScript,
18 DesktopWslDistroListError,19 DesktopWslDistroListError,
19 formatMissingToolsReason,20 formatMissingToolsReason,
20 parseNodePath,21 parseNodePath,
...@@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
446 };447 };
447 };448 };
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
449 it("reuses a warm cache without touching the archive", () => {519 it("reuses a warm cache without touching the archive", () => {
450 const fixture = createFixture();520 const fixture = createFixture();
451 expect(fixture.install().status).toBe(0);521 expect(fixture.install().status).toBe(0);
apps/desktop/src/wsl/DesktopWslEnvironment.ts+15-11
...@@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service<...@@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service<
122 // Marks a staged runtime as unusable so the next launch reinstalls it.122 // Marks a staged runtime as unusable so the next launch reinstalls it.
123 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;123 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
124 // Proves a staged self-contained runtime can run (`<root>/t3 --version`)124 // 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 Node125 // and resolves the user's PATH, including version-managed Node for provider
126 // in the distro; the mounted server tree still goes through ensureNodePty.126 // CLIs. Node is optional; the mounted tree still requires ensureNodePty.
127 readonly probeRuntime: (127 readonly probeRuntime: (
128 distro: string | null,128 distro: string | null,
129 linuxAppRoot: string,129 linuxAppRoot: string,
...@@ -545,13 +545,12 @@ require("node-pty");...@@ -545,13 +545,12 @@ require("node-pty");
545NODE`;545NODE`;
546546
547// Readiness proof for a staged self-contained runtime: the executable runs and547// 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.548// reports its version. Provider CLIs may still need version-managed Node, so
549// This runs under plain `sh` (no Node resolver preamble, since the runtime549// resolve it before capturing PATH without requiring it for runtime readiness.
550// needs no Node), so the login shell is entered explicitly for the PATH550// A distro without bash falls back to the PATH sh was started with.
551// capture; a distro without bash falls back to the PATH sh was started with.551export const buildWslRuntimeProbeScript = (linuxAppRoot: string) =>
552const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) =>
553 [552 [
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}`,
555 `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`,554 `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`,
556 ].join("\n");555 ].join("\n");
557556
...@@ -676,9 +675,14 @@ const probeWslRuntimeImpl = (...@@ -676,9 +675,14 @@ const probeWslRuntimeImpl = (
676 linuxAppRoot: string,675 linuxAppRoot: string,
677): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> =>676): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
678 Effect.gen(function* () {677 Effect.gen(function* () {
679 const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, {678 const probe = yield* runWslShell(
680 resolveNode: false,679 distro,
681 });680 buildWslRuntimeProbeScript(linuxAppRoot),
681 PROBE_TIMEOUT,
682 {
683 resolveNode: false,
684 },
685 );
682 const transportFailureReason = formatWslShellTransportFailureReason(686 const transportFailureReason = formatWslShellTransportFailureReason(
683 probe.transportFailure,687 probe.transportFailure,
684 "the staged runtime",688 "the staged runtime",