diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index a41a8ed97..8cc5e36c0 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -701,6 +701,30 @@ test('resolveAgentExecutable accepts Windows CODEX_BIN overrides with executable } }); +test('resolveAgentExecutable searches Windows fnm multishell shims under a minimal GUI PATH (#3062)', () => { + const home = mkdtempSync(join(tmpdir(), 'od-agent-fnm-multishell-')); + const localAppData = join(home, 'AppData', 'Local'); + const fnmMultishell = join(localAppData, 'fnm_multishells', '12345-abcd'); + try { + return withEnvSnapshot(['PATH', 'PATHEXT', 'OD_AGENT_HOME', 'LOCALAPPDATA'], () => { + mkdirSync(fnmMultishell, { recursive: true }); + writeFileSync(join(fnmMultishell, 'codex.CMD'), '@echo off\r\nexit /b 0\r\n'); + process.env.OD_AGENT_HOME = home; + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = ''; + process.env.PATHEXT = '.EXE;.CMD;.BAT'; + + const resolved = withPlatform('win32', () => + resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' })), + ); + + assert.equal(resolved, join(fnmMultishell, 'codex.CMD')); + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test('detectAgents applies configured env while probing the CLI', async () => { const dir = mkdtempSync(join(tmpdir(), 'od-agent-env-')); try { diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 50a9a2119..233869a66 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -987,6 +987,28 @@ export function wellKnownUserToolchainBins( dirs.push(dir); } } + if (process.platform === "win32") { + const localAppData = + resolveUserScopedHome(env.LOCALAPPDATA, home) ?? join(home, "AppData", "Local"); + const appData = + resolveUserScopedHome(env.APPDATA, home) ?? join(home, "AppData", "Roaming"); + dirs.push(join(appData, "npm")); + const fnmDir = typeof env.FNM_DIR === "string" ? env.FNM_DIR.trim() : ""; + for (const root of [ + join(localAppData, "fnm", "node-versions"), + ...(fnmDir.length > 0 ? [join(fnmDir, "node-versions")] : []), + ]) { + for (const dir of existingChildBinDirs(root, ["installation", "bin"])) { + dirs.push(dir); + } + } + // fnm exposes npm-global CLIs via per-shell shims under fnm_multishells + // after `fnm env`. GUI-launched Electron inherits a stripped PATH without + // those ephemeral dirs (issue #3062). + for (const dir of existingChildBinDirs(join(localAppData, "fnm_multishells"), [])) { + dirs.push(dir); + } + } return dirs; } diff --git a/packages/platform/tests/index.test.ts b/packages/platform/tests/index.test.ts index f5e6b3563..b981607e5 100644 --- a/packages/platform/tests/index.test.ts +++ b/packages/platform/tests/index.test.ts @@ -1042,4 +1042,35 @@ describe("wellKnownUserToolchainBins", () => { rmSync(home, { recursive: true, force: true }); } }); + + it("includes Windows fnm node installs, multishell shims, and npm globals (#3062)", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + const home = mkdtempSync(join(tmpdir(), "wkutb-win-fnm-")); + const localAppData = join(home, "AppData", "Local"); + const appData = join(home, "AppData", "Roaming"); + const fnmNodeBin = join(localAppData, "fnm", "node-versions", "v20.20.2", "installation", "bin"); + const fnmMultishell = join(localAppData, "fnm_multishells", "12345-abcd"); + const npmGlobal = join(appData, "npm"); + try { + mkdirSync(fnmNodeBin, { recursive: true }); + mkdirSync(fnmMultishell, { recursive: true }); + mkdirSync(npmGlobal, { recursive: true }); + writeFileSync(join(fnmNodeBin, "marker"), ""); + writeFileSync(join(fnmMultishell, "marker"), ""); + writeFileSync(join(npmGlobal, "marker"), ""); + + const dirs = wellKnownUserToolchainBins({ + home, + env: { LOCALAPPDATA: localAppData, APPDATA: appData }, + includeSystemBins: false, + }); + expect(dirs).toContain(fnmNodeBin); + expect(dirs).toContain(fnmMultishell); + expect(dirs).toContain(npmGlobal); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + rmSync(home, { recursive: true, force: true }); + } + }); });