diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 64187c92c13..7c45dea4e40 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1285,8 +1285,14 @@ function findOnPath(command) { const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) + // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. + // A real command must resolve via its .exe/.cmd (Windows command-resolution + // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash + // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`. + // The empty entry is kept LAST so callers that already include the extension + // (py.exe, pwsh.exe, powershell.exe) still resolve. const extensions = IS_WINDOWS - ? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)] + ? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] : [''] for (const entry of pathEntries) { @@ -2243,7 +2249,18 @@ async function handOffWindowsBootstrapRecovery(reason) { : configuredBranch || DEFAULT_UPDATE_BRANCH const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') - const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') + // Choose the gentle in-place --update when ANY real-install signal is present, + // not just the `hermes.exe` console-script shim. That shim is generated at the + // END of venv setup and is absent in exactly the interrupted/quarantined states + // this recovery exists to heal — gating on it alone forced the destructive + // --repair (full venv recreate) and drove reinstall loops. The venv interpreter + // and the bootstrap-complete marker are present earlier and are better signals. + const haveRealInstall = + fileExists(venvPython) || + fileExists(venvHermes) || + fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) + const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] await releaseBackendLockForUpdate(updateRoot) diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.cjs new file mode 100644 index 00000000000..ada41ce2905 --- /dev/null +++ b/apps/desktop/electron/windows-hermes-resolution.test.cjs @@ -0,0 +1,67 @@ +'use strict' + +// Regression guards for Windows `hermes` resolution in main.cjs. +// +// main.cjs has no module.exports, so these follow the repo's source-assertion +// test pattern (see windows-child-process.test.cjs). They pin the two Windows +// resolution bugs that caused desktop reinstall loops: +// 1. findOnPath() tried the empty extension FIRST, so an extensionless +// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the +// shim then failed the --version probe and the desktop fell through to a +// spurious bootstrap/repair. +// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive +// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script +// shim, written at the END of venv setup and absent in interrupted +// states), so it escalated to a full venv recreate even on healthy +// installs. + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +function readMain() { + return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n') +} + +test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => { + const source = readMain() + // Fixed order: PATHEXT first, empty string LAST. + assert.match( + source, + /\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/, + 'extensions array must end with the empty string, not start with it' + ) + // The buggy empty-first order must not return. + assert.doesNotMatch( + source, + /\['', \.\.\.\(process\.env\.PATHEXT/, + 'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe' + ) +}) + +test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => { + const source = readMain() + assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall') + assert.match( + source, + /fileExists\(venvPython\)/, + 'recovery must accept the venv interpreter as a real-install signal' + ) + assert.match( + source, + /\.hermes-bootstrap-complete/, + 'recovery must accept the bootstrap-complete marker as a real-install signal' + ) + assert.match( + source, + /updaterArgs = haveRealInstall \? \['--update'/, + 'updaterArgs must gate on haveRealInstall' + ) + // The old too-narrow check (only venv\Scripts\hermes.exe) must not return. + assert.doesNotMatch( + source, + /updaterArgs = fileExists\(venvHermes\) \?/, + 'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair' + ) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6ec73647b7b..b4e3328402c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",