From 27a8963806f04dfe1966dfbdf080e12ab7d76273 Mon Sep 17 00:00:00 2001 From: niceSprite Date: Tue, 27 Oct 2026 21:08:14 +0800 Subject: [PATCH] fix(windows): probe fnm's real default alias dir for Node fallback (#11359) * fix(windows): probe fnm's real default alias dir for Node fallback On Windows, fnm stores the default alias under FNM_DIR, else %APPDATA%\fnm (legacy ~\.fnm), and node.exe sits directly in aliases\default with no bin subdir. Probe those dirs and append them to PATH so fnm's default only backfills a missing Node instead of shadowing an inherited one. Fixes #11356 Co-authored-by: niceSprite <16523864+iFwu@users.noreply.github.com> Co-authored-by: Claude * test: keep onTestFinished callbacks returning void --------- Co-authored-by: Neil Co-authored-by: Claude --- src/cli/handlers/account.ts | 5 +++- src/main/codex-cli/command.test.ts | 29 +++++++++++++++++++++- src/main/startup/configure-process.test.ts | 9 ++++++- src/main/startup/configure-process.ts | 9 ++++++- src/shared/fnm-default-alias-dirs.ts | 24 ++++++++++++++++++ src/shared/node-cli-command-resolution.ts | 3 ++- 6 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 src/shared/fnm-default-alias-dirs.ts diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index c93edb63d5a..9ed4bfd164b 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -9,6 +9,7 @@ import { stripElectronRunAsNode } from '../runtime/orca-app-executable' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { quotePosixShell } from '../../shared/shell-argument-quoting' +import { isWindowsFnmDefaultAliasDir } from '../../shared/fnm-default-alias-dirs' import { getVersionManagerBinPaths, resolveCliCommand, @@ -55,7 +56,9 @@ function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const existing = new Set(currentEntries) const missing = getVersionManagerBinPaths().filter((entry) => !existing.has(entry)) if (missing.length > 0) { - env[pathKey] = [...missing, ...currentEntries].join(delimiter) + const append = missing.filter(isWindowsFnmDefaultAliasDir) + const prepend = missing.filter((entry) => !append.includes(entry)) + env[pathKey] = [...prepend, ...currentEntries, ...append].join(delimiter) } return env } diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index cbf2744233f..2089dd15235 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -1,7 +1,7 @@ import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' -import { afterEach, describe, expect, it, onTestFinished } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { getVersionManagerBinPaths, withCliRuntimeOnPath, @@ -319,6 +319,33 @@ describe('getVersionManagerBinPaths', () => { expect(paths).toContain(join(root, '.local', 'bin')) expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm')) }) + + it('uses the Windows fnm default alias layout, honoring FNM_DIR (stablyai/orca#11356)', () => { + const root = makeCommandFixture('orca-vm-paths-') + const appDataAlias = join(root, 'AppData', 'Roaming', 'fnm', 'aliases', 'default') + vi.stubEnv('FNM_DIR', '') + vi.stubEnv('APPDATA', '') + onTestFinished(() => { + vi.unstubAllEnvs() + }) + + const paths = getVersionManagerBinPaths({ platform: 'win32', homePath: root }) + expect(paths).toContain(appDataAlias) + expect(paths).not.toContain(join(root, '.fnm', 'aliases', 'default', 'bin')) + + const redirectedAppData = join(root, 'redirected') + const redirectedAlias = join(redirectedAppData, 'fnm', 'aliases', 'default') + vi.stubEnv('APPDATA', redirectedAppData) + expect(getVersionManagerBinPaths({ platform: 'win32', homePath: root })).toContain( + redirectedAlias + ) + + const fnmDir = join(root, 'custom-fnm') + vi.stubEnv('FNM_DIR', fnmDir) + const overridden = getVersionManagerBinPaths({ platform: 'win32', homePath: root }) + expect(overridden).toContain(join(fnmDir, 'aliases', 'default')) + expect(overridden).not.toContain(redirectedAlias) + }) }) describe('withCliRuntimeOnPath', () => { diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 88557fbbc8c..feb4c7fe9e5 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' vi.mock('electron', () => { const paths = new Map([['appData', '/tmp/app-data']]) @@ -224,6 +224,10 @@ describe('patchPackagedProcessPath', () => { setPlatform('win32') Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + vi.stubEnv('FNM_DIR', 'C:\\fnm') + onTestFinished(() => { + vi.unstubAllEnvs() + }) const pathDelimiter = process.platform === 'win32' ? ';' : ':' process.env.PATH = `C:\\Windows\\System32${pathDelimiter}C:\\Windows` @@ -233,6 +237,9 @@ describe('patchPackagedProcessPath', () => { const userLocalBin = join(homedir(), '.local', 'bin') expect(segments).toContain(userLocalBin) expect(segments.indexOf(userLocalBin)).toBeLessThan(segments.indexOf('C:\\Windows\\System32')) + // #11356: fnm's default Node only backfills; it must not shadow an inherited system Node. + const fnmAlias = join('C:\\fnm', 'aliases', 'default') + expect(segments.indexOf(fnmAlias)).toBeGreaterThan(segments.indexOf('C:\\Windows')) }) }) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 6c7611dfb7e..6f19ccec24d 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -4,6 +4,7 @@ import { mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' +import { isWindowsFnmDefaultAliasDir } from '../../shared/fnm-default-alias-dirs' import { getMainE2EConfig } from '../e2e-config' import { DISABLED_CHROMIUM_FEATURES } from './disabled-chromium-features' import { readHttp1CompatibilityMarker } from './http1-compatibility-marker' @@ -158,7 +159,13 @@ export function patchPackagedProcessPath(): void { } // env-node shebangs require runtime shims; exclude generic user bins so arbitrary wrappers cannot shadow system tools. - prependPaths.push(...getVersionManagerBinPaths().filter((path) => !isGenericUserBinDir(path))) + for (const path of getVersionManagerBinPaths()) { + if (isWindowsFnmDefaultAliasDir(path)) { + appendPaths.push(path) + } else if (!isGenericUserBinDir(path)) { + prependPaths.push(path) + } + } const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' const currentPath = process.env[pathKey] ?? '' diff --git a/src/shared/fnm-default-alias-dirs.ts b/src/shared/fnm-default-alias-dirs.ts new file mode 100644 index 00000000000..63faf70b95c --- /dev/null +++ b/src/shared/fnm-default-alias-dirs.ts @@ -0,0 +1,24 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** fnm's `default` alias dir. Windows: FNM_DIR, else %APPDATA%\fnm, else legacy ~\.fnm; node.exe has no bin subdir (#11356). */ +export function getFnmDefaultAliasDirectories( + platform: NodeJS.Platform, + homePath: string +): string[] { + if (platform !== 'win32') { + return [join(homePath, '.fnm', 'aliases', 'default', 'bin')] + } + const fnmDir = process.env.FNM_DIR + const appData = process.env.APPDATA || join(homePath, 'AppData', 'Roaming') + const baseDirs = fnmDir ? [fnmDir] : [join(appData, 'fnm'), join(homePath, '.fnm')] + return baseDirs.map((baseDir) => join(baseDir, 'aliases', 'default')) +} + +/** Windows fnm default only backfills a missing node; leading PATH would shadow an inherited system/nvm-windows/`fnm use` Node. */ +export function isWindowsFnmDefaultAliasDir(path: string): boolean { + return ( + process.platform === 'win32' && + getFnmDefaultAliasDirectories(process.platform, homedir()).includes(path) + ) +} diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts index eac3972fd18..595d0941368 100644 --- a/src/shared/node-cli-command-resolution.ts +++ b/src/shared/node-cli-command-resolution.ts @@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync import { homedir } from 'node:os' import { delimiter, dirname, isAbsolute, join } from 'node:path' import { firstWindowsPathEnvKey, isNativeExecutable } from './cli-runtime-pairing-inputs' +import { getFnmDefaultAliasDirectories } from './fnm-default-alias-dirs' import { getSystemCliInstallDirectories } from './system-cli-install-dirs' type ResolveCommandOptions = { @@ -93,7 +94,7 @@ function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: s const directories = [ join(homePath, '.volta', 'bin'), join(homePath, '.asdf', 'shims'), - join(homePath, '.fnm', 'aliases', 'default', 'bin'), + ...getFnmDefaultAliasDirectories(platform, homePath), join(homePath, '.local', 'share', 'mise', 'shims') ]