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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
apps/desktop/electron/user-launcher-install.test.ts
Normal file
89
apps/desktop/electron/user-launcher-install.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user