authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-07-18 17:24:13+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-18 11:54:13+00:00
logbf76535fe4da71d8de7b8bd5ffa0d2086b7af8d0
tree2a7d8411ef87d1b20567e989928ead9b2c31bbd7
parent5c66110e269cc5a852763e5f06a81f2a763d3181
signature Signed by PGP key B5690EEEBB952194

fix(desktop): Validate WSL node version against engine range after probe success (#3621)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

2 files changed, 66 insertions(+), 4 deletions(-)

apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+24
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 formatNodePtyProbeFailureReason,16 formatNodePtyProbeFailureReason,
17 formatWslShellTransportFailureReason,17 formatWslShellTransportFailureReason,
18 parseNodePath,18 parseNodePath,
19 parseNodeVersion,
19 parseResolvedPath,20 parseResolvedPath,
20 parseToolchainReport,21 parseToolchainReport,
21 probeWslDistros,22 probeWslDistros,
...@@ -173,6 +174,29 @@ describe("parseNodePath", () => {...@@ -173,6 +174,29 @@ describe("parseNodePath", () => {
173 });174 });
174});175});
175176
177describe("parseNodeVersion", () => {
178 it("extracts the node version from a nodeVersion: line", () => {
179 expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0");
180 });
181
182 it("returns null when the version value is empty", () => {
183 expect(parseNodeVersion("nodeVersion:")).toBeNull();
184 });
185
186 it("returns null when there is no nodeVersion line at all", () => {
187 expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull();
188 });
189
190 it("ignores surrounding noise and trims whitespace", () => {
191 const stdout = [
192 "some preamble noise",
193 " nodeVersion:22.16.0 ",
194 "nodePath:/usr/bin/node",
195 ].join("\n");
196 expect(parseNodeVersion(stdout)).toBe("22.16.0");
197 });
198});
199
176describe("parseResolvedPath", () => {200describe("parseResolvedPath", () => {
177 it("preserves spaces and apostrophes in the resolved login-shell PATH", () => {201 it("preserves spaces and apostrophes in the resolved login-shell PATH", () => {
178 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";202 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
apps/desktop/src/wsl/DesktopWslEnvironment.ts+42-4
...@@ -32,7 +32,11 @@ export interface EnsureWslNodePtyOptions {...@@ -32,7 +32,11 @@ export interface EnsureWslNodePtyOptions {
32}32}
3333
34export type EnsureWslNodePtyResult =34export type EnsureWslNodePtyResult =
35 | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string }35 | {
36 readonly ok: true;
37 readonly nodePath: string;
38 readonly resolvedPath: string;
39 }
36 | {40 | {
37 readonly ok: false;41 readonly ok: false;
38 readonly reason: string;42 readonly reason: string;
...@@ -222,6 +226,7 @@ export const formatNodePtyProbeFailureReason = (exitCode: number): string | null...@@ -222,6 +226,7 @@ export const formatNodePtyProbeFailureReason = (exitCode: number): string | null
222const NODE_PTY_PROBE_SCRIPT = (226const NODE_PTY_PROBE_SCRIPT = (
223 linuxServerDir: string,227 linuxServerDir: string,
224) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"228) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
229printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
225printf 'resolvedPath:%s\\n' "$PATH"230printf 'resolvedPath:%s\\n' "$PATH"
226cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1231cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
227// The server bundle externalizes its deps to node_modules, and the WSL Node232// The server bundle externalizes its deps to node_modules, and the WSL Node
...@@ -318,6 +323,16 @@ export const parseNodePath = (stdout: string): string | null => {...@@ -318,6 +323,16 @@ export const parseNodePath = (stdout: string): string | null => {
318 return path ?? null;323 return path ?? null;
319};324};
320325
326export const parseNodeVersion = (stdout: string): string | null => {
327 const version = stdout
328 .split("\n")
329 .map((line) => line.trim())
330 .filter((line) => line.startsWith("nodeVersion:"))
331 .map((line) => line.slice("nodeVersion:".length).trim())
332 .find((value) => value.length > 0);
333 return version ?? null;
334};
335
321// Captures the login-shell PATH after the shared resolver has loaded version336// Captures the login-shell PATH after the shared resolver has loaded version
322// managers. Preserve the value byte-for-byte apart from a Windows-style CR so337// managers. Preserve the value byte-for-byte apart from a Windows-style CR so
323// paths containing spaces or apostrophes can be forwarded as one env argv.338// paths containing spaces or apostrophes can be forwarded as one env argv.
...@@ -404,7 +419,11 @@ const ensureNodePtyImpl = (...@@ -404,7 +419,11 @@ const ensureNodePtyImpl = (
404419
405 const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure);420 const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure);
406 if (transportFailureReason !== null) {421 if (transportFailureReason !== null) {
407 return { ok: false, reason: transportFailureReason, fatal: false } as const;422 return {
423 ok: false,
424 reason: transportFailureReason,
425 fatal: false,
426 } as const;
408 }427 }
409428
410 // No node at all, even after the shared resolver repaired PATH. Surface429 // No node at all, even after the shared resolver repaired PATH. Surface
...@@ -457,12 +476,31 @@ const ensureNodePtyImpl = (...@@ -457,12 +476,31 @@ const ensureNodePtyImpl = (
457 } as const;476 } as const;
458 }477 }
459478
460 if (probe.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const;479 if (probe.exitCode === 0) {
480 const rawVersion = parseNodeVersion(probe.stdout);
481 if (
482 rawVersion !== null &&
483 options.nodeEngineRange &&
484 !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim())
485 ) {
486 const range = options.nodeEngineRange.trim();
487 return {
488 ok: false,
489 reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`,
490 fatal: true,
491 } as const;
492 }
493 return { ok: true, nodePath, resolvedPath } as const;
494 }
461495
462 if (options.allowBuild !== true) {496 if (options.allowBuild !== true) {
463 const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode);497 const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode);
464 if (packagedProbeFailure !== null) {498 if (packagedProbeFailure !== null) {
465 return { ok: false, reason: packagedProbeFailure, fatal: true } as const;499 return {
500 ok: false,
501 reason: packagedProbeFailure,
502 fatal: true,
503 } as const;
466 }504 }
467 }505 }
468506