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 <noreply@anthropic.com>

* test: keep onTestFinished callbacks returning void

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
niceSprite
2026-10-27 06:08:14 -07:00
committed by GitHub
co-authored by niceSprite Claude Neil
parent 62d9eb635b
commit 27a8963806
6 changed files with 74 additions and 5 deletions
+4 -1
View File
@@ -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
}
+28 -1
View File
@@ -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', () => {
+8 -1
View File
@@ -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<string, string>([['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'))
})
})
+8 -1
View File
@@ -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] ?? ''
+24
View File
@@ -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)
)
}
+2 -1
View File
@@ -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')
]