From dfcbef358aec494f6adefebec86beafd9fcbb623 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 28 Sep 2026 18:24:07 -0400 Subject: [PATCH] fix(desktop): find a source install through its published user-bin launcher The PM rework dropped the resolver's "existing hermes on PATH" rung, so Desktop only accepts HERMES_DESKTOP_HERMES or $HERMES_HOME/hermes-agent. A healthy install elsewhere (install.sh --dir, a setup-hermes.sh clone) now falls to bootstrap-needed and the first-run choice, offering to install a second copy. The old rung also read raw process.env.PATH, which a Finder/Dock launch inherits without ~/.local/bin. Add a rung before bootstrap-needed: probe the launcher published at its fixed user-bin location (~/.local/bin or $HERMES_HOME/bin; Windows: %LOCALAPPDATA%\hermes\bin), read the install directory its --version reports, and resolve that root through resolveSourceInstallationBackend, the same validated path ACTIVE_HERMES_ROOT uses. HERMES_DESKTOP_IGNORE_EXISTING and repair requests still skip it, and a miss is now logged instead of silent. The launcher lookup is shared with resolveInstallationLauncher. Fixes #73014 Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com> --- apps/desktop/electron/backend-resolution.ts | 3 +- apps/desktop/electron/main.ts | 28 +++++- apps/desktop/electron/updater-process.ts | 76 +++++++++++----- .../electron/user-launcher-install.test.ts | 89 +++++++++++++++++++ 4 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/electron/user-launcher-install.test.ts diff --git a/apps/desktop/electron/backend-resolution.ts b/apps/desktop/electron/backend-resolution.ts index 0e198774ce..cf38ed8607 100644 --- a/apps/desktop/electron/backend-resolution.ts +++ b/apps/desktop/electron/backend-resolution.ts @@ -1,6 +1,7 @@ /** * HERMES_DESKTOP_IGNORE_EXISTING=1 (`hermes desktop --ignore-existing`) keeps - * Desktop off the installed runtime at ACTIVE_HERMES_ROOT. Backend resolution + * Desktop off installed runtimes (ACTIVE_HERMES_ROOT, or the install a + * published user-bin launcher reports). Backend resolution * then falls through to bootstrap-needed, which shows the first-run choice * (connect a remote, or install) instead of starting a local serve. * diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c40109bb78..f9a065bf32 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -574,7 +574,8 @@ import { resolveStagedUpdaterBinary, resolveVenvDir, spawnUpdaterProcess, - stagedUpdaterSupportsPrewrittenMarker + stagedUpdaterSupportsPrewrittenMarker, + userLauncherInstallRoot } from './updater-process' import { AppInstallerStrategy } from './updater/app-installer' import { createChannelAppInstallerStrategy } from './updater/app-installer' @@ -5254,9 +5255,32 @@ async function resolveHermesBackend(backendArgs: string[]): Promise = userLauncherInstallRoot(IS_WINDOWS, HERMES_HOME) + + if (userInstall) { + const userBackend: SourceBackend | null = await installedRuntimeGate.resolve(userInstall.root, () => + resolveSourceInstallationBackend(userInstall.root, backendArgs, { hermesHome: HERMES_HOME }) + ) + + if (userBackend) { + rememberLog(`[boot] Using Hermes install at ${userInstall.root} (published launcher ${userInstall.launcher})`) + + return userBackend + } + + rememberLog(`[bootstrap] Hermes install at ${userInstall.root} (from ${userInstall.launcher}) is not usable`) + } else { + rememberLog(`[bootstrap] no usable Hermes install at ${ACTIVE_HERMES_ROOT} and no published user-bin launcher`) + } } - // 5. Nothing usable yet -- signal the bootstrap runner that we need to + // 6. Nothing usable yet -- signal the bootstrap runner that we need to // clone+install. Phase 1D's bootstrap-runner consumes this sentinel // and drives install.ps1 stages with a progress UI. Until 1D lands, // callers see the sentinel and surface it as a user-facing error diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index ce85120e8f..94cd54700b 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -42,23 +42,11 @@ export function resolveInstallationLauncher( // Earlier PM installers published only to user-bin. Trust that historical // launcher only after its existing version surface proves exact source identity. if (stagedFileExists(path.join(updateRoot, 'hermes_cli', '_launchers.py'))) { - const defaultHome: string = platformDefaultHermesHome(os.homedir(), process.env, isWindows ? 'win32' : 'linux') + const extraDirs: string[] = isWindows ? [path.join(path.dirname(updateRoot), 'bin')] : [] - const dirs: string[] = isWindows - ? [ - path.join(hermesHome || defaultHome, 'bin'), - path.join(defaultHome, 'bin'), - path.join(path.dirname(updateRoot), 'bin') - ] - : [path.join(os.homedir(), '.local', 'bin'), path.join(hermesHome || defaultHome, 'bin')] - - for (const dir of new Set(dirs)) { - for (const name of names) { - const candidate: string = path.join(dir, name) - - if (stagedFileExists(candidate) && launcherTargetsInstallation(candidate, updateRoot)) { - return candidate - } + for (const candidate of userBinLaunchers(isWindows, hermesHome, extraDirs)) { + if (launcherTargetsInstallation(candidate, updateRoot)) { + return candidate } } } @@ -79,7 +67,53 @@ export function resolveInstallationLauncher( // these would change the command instead of naming a file. const CMD_UNSAFE_PATH: RegExp = /["%&|<>^\r\n]/ +/** Published user-bin launchers, at fixed locations: a GUI launch's PATH may omit them. */ +function userBinLaunchers(isWindows: boolean, hermesHome: string, extraDirs: string[] = []): string[] { + const names: string[] = isWindows ? ['hermes.exe', 'hermes.cmd'] : ['hermes'] + const defaultHome: string = platformDefaultHermesHome(os.homedir(), process.env, isWindows ? 'win32' : 'linux') + + const dirs: string[] = isWindows + ? [path.join(hermesHome || defaultHome, 'bin'), path.join(defaultHome, 'bin'), ...extraDirs] + : [path.join(os.homedir(), '.local', 'bin'), path.join(hermesHome || defaultHome, 'bin'), ...extraDirs] + + return [...new Set(dirs)] + .flatMap((dir: string): string[] => names.map((name: string): string => path.join(dir, name))) + .filter(stagedFileExists) +} + +/** + * A source install outside the canonical root (install.sh --dir, a + * setup-hermes.sh clone) is reachable only through the user-bin launcher it + * published. Return the install directory that launcher reports, when it is a + * Hermes source tree; the caller still resolves and probes it by root. + */ +export function userLauncherInstallRoot( + isWindows: boolean = process.platform === 'win32', + hermesHome: string = process.env.HERMES_HOME ?? '' +): { launcher: string; root: string } | null { + for (const launcher of userBinLaunchers(isWindows, hermesHome)) { + const root: string | null = launcherInstallDirectory(launcher) + + if (root && existsSync(path.join(root, 'hermes_cli', 'main.py'))) { + return { launcher, root } + } + } + + return null +} + export function launcherTargetsInstallation(launcher: string, root: string): boolean { + try { + const reported: string | null = launcherInstallDirectory(launcher, root) + + return reported !== null && reported === realpathSync(root) + } catch { + return false + } +} + +/** The real install directory a launcher's `--version` reports, or null. */ +function launcherInstallDirectory(launcher: string, root?: string): string | null { try { // Node refuses to exec a .cmd directly (CVE-2024-27980), and `shell:true` // would hand an interpolated path to cmd.exe wholesale. Invoke cmd.exe @@ -88,7 +122,7 @@ export function launcherTargetsInstallation(launcher: string, root: string): boo const viaCmd: boolean = process.platform === 'win32' && /\.cmd$/i.test(launcher) if (viaCmd && CMD_UNSAFE_PATH.test(launcher)) { - return false + return null } const command: string = viaCmd ? (process.env.ComSpec ?? 'cmd.exe') : launcher @@ -100,18 +134,18 @@ export function launcherTargetsInstallation(launcher: string, root: string): boo timeout: 15000, windowsHide: true, windowsVerbatimArguments: viaCmd, - env: { ...process.env, HERMES_INSTALL_ROOT: root } + env: root ? { ...process.env, HERMES_INSTALL_ROOT: root } : process.env }) if (probe.error || probe.status !== 0) { - return false + return null } const reported: string | undefined = /^Install directory: (.+)$/m.exec(probe.stdout)?.[1]?.trim() - return reported !== undefined && realpathSync(reported) === realpathSync(root) + return reported ? realpathSync(reported) : null } catch { - return false + return null } } diff --git a/apps/desktop/electron/user-launcher-install.test.ts b/apps/desktop/electron/user-launcher-install.test.ts new file mode 100644 index 0000000000..47787b7900 --- /dev/null +++ b/apps/desktop/electron/user-launcher-install.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, test, vi } from 'vitest' + +import { resolveSourceInstallationBackend } from './source-backend' +import { userLauncherInstallRoot } from './updater-process' + +afterEach((): void => { + vi.unstubAllEnvs() +}) + +function sourceTree(root: string): void { + fs.mkdirSync(path.join(root, 'hermes_cli'), { recursive: true }) + fs.mkdirSync(path.join(root, 'pm')) + fs.writeFileSync(path.join(root, 'hermes_cli', 'main.py'), '') + fs.writeFileSync(path.join(root, 'hermes_cli', '_launchers.py'), '') +} + +function publishLauncher(dir: string, reported: string): string { + fs.mkdirSync(dir, { recursive: true }) + const launcher: string = path.join(dir, process.platform === 'win32' ? 'hermes.cmd' : 'hermes') + + const body: string = + process.platform === 'win32' + ? `@echo off\r\necho Install directory: ${reported}\r\n` + : `#!/bin/sh\nprintf '%s\\n' 'Install directory: ${reported}'\n` + + fs.writeFileSync(launcher, body, { mode: 0o755 }) + + return launcher +} + +// The reported machine: setup-hermes.sh published ~/.local/bin/hermes for a +// clone outside ~/.hermes/hermes-agent, and a Finder/Dock launch inherited +// launchd's PATH, which has no ~/.local/bin. Desktop must still find it. +test.skipIf(process.platform === 'win32')( + 'a ~/.local/bin launcher resolves its non-canonical install on a GUI PATH without ~/.local/bin', + async (): Promise => { + const base: string = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'user-launcher-'))) + + try { + const home: string = path.join(base, 'home') + const root: string = path.join(home, 'src', 'hermes-agent') + sourceTree(root) + const launcher: string = publishLauncher(path.join(home, '.local', 'bin'), root) + vi.stubEnv('HOME', home) + vi.stubEnv('PATH', '/usr/bin:/bin:/usr/sbin:/sbin') + + const found = userLauncherInstallRoot(false, path.join(home, '.hermes')) + assert.deepEqual(found, { launcher, root }) + + const backend = await resolveSourceInstallationBackend(root, ['serve'], { + isWindows: false, + hermesHome: path.join(home, '.hermes') + }) + + assert.equal(backend?.command, launcher) + assert.equal(backend?.root, root) + assert.deepEqual(backend?.args, ['serve']) + } finally { + fs.rmSync(base, { recursive: true, force: true }) + } + } +) + +test('a user-bin launcher is ignored when it reports no Hermes source tree', (): void => { + const base: string = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'user-launcher-'))) + + try { + const home: string = path.join(base, 'home') + const hermesHome: string = path.join(home, '.hermes') + const notSource: string = path.join(base, 'elsewhere') + fs.mkdirSync(notSource) + publishLauncher(path.join(hermesHome, 'bin'), notSource) + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('LOCALAPPDATA', path.join(home, 'AppData', 'Local')) + + assert.equal(userLauncherInstallRoot(process.platform === 'win32', hermesHome), null) + + sourceTree(notSource) + assert.equal(userLauncherInstallRoot(process.platform === 'win32', hermesHome)?.root, notSource) + } finally { + fs.rmSync(base, { recursive: true, force: true }) + } +})