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>
This commit is contained in:
Austin Pickett
2026-09-28 18:24:07 -04:00
parent a35f600b8a
commit dfcbef358a
4 changed files with 172 additions and 24 deletions

View File

@@ -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.
*

View File

@@ -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<ResolvedHerm
if (bootstrapRepairRequested) {
rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer')
} else {
// 5. A source install outside ACTIVE_HERMES_ROOT (install.sh --dir, a
// setup-hermes.sh clone), found through the launcher it published at a
// fixed user-bin location: a Finder/Dock launch inherits a PATH without
// ~/.local/bin. The reported root is then resolved and probed like any
// installed runtime; HERMES_DESKTOP_IGNORE_EXISTING=1 skips it too.
const userInstall: ReturnType<typeof userLauncherInstallRoot> = 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

View File

@@ -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
}
}

View File

@@ -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<void> => {
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 })
}
})