From 8596ef3cfd11cbd0fef733d6f6754d5d474dfd6b Mon Sep 17 00:00:00 2001 From: ekko Date: Mon, 6 Jul 2026 19:56:12 +0800 Subject: [PATCH] fix windows desktop runtime trampolines --- packages/desktop/scripts/install-hermes.mjs | 43 +++++++++++-------- packages/desktop/src/main/paths.ts | 4 +- packages/desktop/src/main/runtime-manager.ts | 4 +- .../src/services/hermes/hermes-process.ts | 3 +- .../src/services/runtime-version-manager.ts | 2 +- tests/desktop/runtime-manager.test.ts | 28 +++++++++++- tests/desktop/runtime-paths.test.ts | 27 +++++++++++- tests/server/hermes-process.test.ts | 22 ++++++++++ 8 files changed, 107 insertions(+), 26 deletions(-) diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs index caad5232..21eef140 100644 --- a/packages/desktop/scripts/install-hermes.mjs +++ b/packages/desktop/scripts/install-hermes.mjs @@ -482,16 +482,11 @@ run(pyBin, [ ]) const hermesBin = TARGET_OS === 'win32' - ? resolve(PY_DIR, 'Scripts', 'hermes.exe') + ? resolve(PY_DIR, 'Scripts', 'hermes.cmd') : resolve(PY_DIR, 'bin', 'hermes') const hermesCheckCommand = TARGET_OS === 'win32' ? pyBin : hermesBin const hermesCheckArgs = TARGET_OS === 'win32' ? ['-m', 'hermes_cli.main', '--version'] : ['--version'] -if (!existsSync(hermesBin)) { - console.error(`hermes binary not found at ${hermesBin} after install`) - process.exit(1) -} - // hermes-web-ui's agent-bridge searches for `run_agent.py` at /run_agent.py // (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface // it at the venv root with a *relative* symlink so the venv stays portable when copied @@ -521,17 +516,26 @@ function siteRunAgentRelative() { // shebang to the build-time Python path) with a relative wrapper so the // bundled venv works after being moved into the .app/.exe payload. if (TARGET_OS === 'win32') { - // Windows: pip generates a .exe launcher that embeds a relative shebang - // already. Add a .cmd wrapper that prefers the colocated python.exe. - const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd') - writeFileSync( - cmdPath, - [ - '@echo off', - 'set "PY=%~dp0..\\python.exe"', - '"%PY%" -m hermes_cli.main %*', - ].join('\r\n'), - ) + // Windows: uv/pip .exe launchers can embed build-machine paths. Replace the + // Hermes entry points with relocatable .cmd wrappers and remove the .exe + // trampolines so PATHEXT lookups cannot pick a stale launcher first. + const wrappers = [ + ['hermes', 'hermes_cli.main'], + ['hermes-agent', 'run_agent'], + ['hermes-acp', 'acp_adapter.entry'], + ] + for (const [name, mod] of wrappers) { + const cmdPath = resolve(PY_DIR, 'Scripts', `${name}.cmd`) + writeFileSync( + cmdPath, + [ + '@echo off', + 'set "PY=%~dp0..\\python.exe"', + `"%PY%" -m ${mod} %*`, + ].join('\r\n'), + ) + rmSync(resolve(PY_DIR, 'Scripts', `${name}.exe`), { force: true }) + } } else { const launcher = [ '#!/bin/sh', @@ -556,6 +560,11 @@ if (TARGET_OS === 'win32') { console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`) +if (!existsSync(hermesBin)) { + console.error(`hermes binary not found at ${hermesBin} after install`) + process.exit(1) +} + run(hermesCheckCommand, hermesCheckArgs) if (!SKIP_BROWSER_RUNTIME) { diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index d6459534..4e0a9a15 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -41,7 +41,7 @@ type ActiveRuntimeVersion = { function runtimeRequiredFiles(root: string): string[] { const python = isWin ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3') - const hermes = isWin ? join(root, 'python', 'Scripts', 'hermes.exe') : join(root, 'python', 'bin', 'hermes') + const hermes = isWin ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes') const node = isWin ? join(root, 'node', 'node.exe') : join(root, 'node', 'bin', 'node') const files = [python, hermes, node] if (isWin) files.push(join(root, 'git', 'cmd', 'git.exe')) @@ -348,7 +348,7 @@ export function bundledPython(): string { } export function hermesBin(): string { - return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes') + return isWin ? join(pythonBinDir(), 'hermes.cmd') : join(pythonBinDir(), 'hermes') } export function hermesBinExists(): boolean { diff --git a/packages/desktop/src/main/runtime-manager.ts b/packages/desktop/src/main/runtime-manager.ts index 2390e151..acd39450 100644 --- a/packages/desktop/src/main/runtime-manager.ts +++ b/packages/desktop/src/main/runtime-manager.ts @@ -86,7 +86,7 @@ function requiredRuntimeFiles(root: string): string[] { ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3') const hermesBin = process.platform === 'win32' - ? join(root, 'python', 'Scripts', 'hermes.exe') + ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes') const nodeBin = process.platform === 'win32' ? join(root, 'node', 'node.exe') @@ -107,7 +107,7 @@ function runtimeReady(): boolean { function rootRuntimeReady(root: string): boolean { const gitPath = process.platform === 'win32' ? join(root, 'git', 'cmd', 'git.exe') : null return existsSync(process.platform === 'win32' ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3')) - && existsSync(process.platform === 'win32' ? join(root, 'python', 'Scripts', 'hermes.exe') : join(root, 'python', 'bin', 'hermes')) + && existsSync(process.platform === 'win32' ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes')) && existsSync(process.platform === 'win32' ? join(root, 'node', 'node.exe') : join(root, 'node', 'bin', 'node')) && (!gitPath || existsSync(gitPath)) } diff --git a/packages/server/src/services/hermes/hermes-process.ts b/packages/server/src/services/hermes/hermes-process.ts index cc649680..40a18344 100644 --- a/packages/server/src/services/hermes/hermes-process.ts +++ b/packages/server/src/services/hermes/hermes-process.ts @@ -21,7 +21,8 @@ function bundledCliPythonForWindows(hermesBin: string): string | null { const envPython = process.env.HERMES_AGENT_CLI_PYTHON?.trim() if (envPython) return envPython - if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null + const launcher = basename(hermesBin).toLowerCase() + if (launcher !== 'hermes.exe' && launcher !== 'hermes.cmd') return null const python = resolve(dirname(hermesBin), '..', 'python.exe') return existsSync(python) ? python : null } diff --git a/packages/server/src/services/runtime-version-manager.ts b/packages/server/src/services/runtime-version-manager.ts index a5cc7205..6ae9ccb2 100644 --- a/packages/server/src/services/runtime-version-manager.ts +++ b/packages/server/src/services/runtime-version-manager.ts @@ -164,7 +164,7 @@ function requiredRuntimeFiles(root: string): string[] { ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3') const hermesBin = process.platform === 'win32' - ? join(root, 'python', 'Scripts', 'hermes.exe') + ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes') const nodeBin = process.platform === 'win32' ? join(root, 'node', 'node.exe') diff --git a/tests/desktop/runtime-manager.test.ts b/tests/desktop/runtime-manager.test.ts index 2f73216e..73932f42 100644 --- a/tests/desktop/runtime-manager.test.ts +++ b/tests/desktop/runtime-manager.test.ts @@ -17,6 +17,7 @@ vi.mock('electron', () => ({ })) const originalEnv = { ...process.env } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] const servers: Server[] = [] @@ -32,7 +33,7 @@ function createRuntimeFiles(root: string) { mkdirSync(join(root, 'node'), { recursive: true }) mkdirSync(join(root, 'git', 'cmd'), { recursive: true }) writeFileSync(join(root, 'python', 'python.exe'), '') - writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '') + writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '') writeFileSync(join(root, 'node', 'node.exe'), '') writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '') } else { @@ -50,6 +51,10 @@ function createRuntimeFiles(root: string) { })) } +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + async function createRuntimeArchive(): Promise { const source = tempDir('hermes-runtime-source-') const archive = join(tempDir('hermes-runtime-archive-'), 'hermes-runtime-test.tar.gz') @@ -87,6 +92,7 @@ describe('desktop runtime manager', () => { afterEach(async () => { process.env = { ...originalEnv } + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) vi.resetModules() await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(() => resolve())))) for (const dir of tempDirs.splice(0)) { @@ -122,4 +128,24 @@ describe('desktop runtime manager', () => { expect(existsSync(join(runtimeRoot, 'runtime-manifest.json'))).toBe(true) expect(existsSync(join(process.env.HERMES_WEB_UI_HOME!, 'desktop-runtime', 'active-version.json'))).toBe(true) }) + + it('accepts Windows runtime archives that contain hermes.cmd without hermes.exe', async () => { + setPlatform('win32') + const archive = await createRuntimeArchive() + process.env.HERMES_DESKTOP_RUNTIME_URL = await serveFile(archive) + + const { runtimePlatformKey } = await import('../../packages/desktop/src/main/runtime-paths') + const { ensureDesktopRuntime } = await import('../../packages/desktop/src/main/runtime-manager') + await ensureDesktopRuntime() + + const runtimeRoot = join( + process.env.HERMES_WEB_UI_HOME!, + 'desktop-runtime', + 'hermes', + '0.17.0', + runtimePlatformKey(), + ) + expect(existsSync(join(runtimeRoot, 'python', 'Scripts', 'hermes.cmd'))).toBe(true) + expect(existsSync(join(runtimeRoot, 'python', 'Scripts', 'hermes.exe'))).toBe(false) + }) }) diff --git a/tests/desktop/runtime-paths.test.ts b/tests/desktop/runtime-paths.test.ts index e7488741..db9389dd 100644 --- a/tests/desktop/runtime-paths.test.ts +++ b/tests/desktop/runtime-paths.test.ts @@ -15,6 +15,7 @@ vi.mock('electron', () => ({ })) const originalEnv = { ...process.env } +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] function tempDir(): string { @@ -29,7 +30,7 @@ function createRuntime(root: string, version: string) { mkdirSync(join(root, 'node'), { recursive: true }) mkdirSync(join(root, 'git', 'cmd'), { recursive: true }) writeFileSync(join(root, 'python', 'python.exe'), '') - writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '') + writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '') writeFileSync(join(root, 'node', 'node.exe'), '') writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '') } else { @@ -52,7 +53,7 @@ function createRuntimeWithoutManifest(root: string) { mkdirSync(join(root, 'node'), { recursive: true }) mkdirSync(join(root, 'git', 'cmd'), { recursive: true }) writeFileSync(join(root, 'python', 'python.exe'), '') - writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '') + writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '') writeFileSync(join(root, 'node', 'node.exe'), '') writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '') } else { @@ -64,6 +65,10 @@ function createRuntimeWithoutManifest(root: string) { } } +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + describe('desktop runtime paths', () => { beforeEach(() => { vi.resetModules() @@ -77,6 +82,7 @@ describe('desktop runtime paths', () => { afterEach(() => { process.env = { ...originalEnv } + if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) vi.resetModules() for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -258,4 +264,21 @@ describe('desktop runtime paths', () => { expect(desktopRuntimeDir()).toBe(runtimeDir) expect(targetDesktopRuntimeDir()).toBe(targetRuntimeDir) }) + + it('treats hermes.cmd as the required Windows runtime entry point', async () => { + setPlatform('win32') + const homeDir = tempDir() + process.env.HERMES_WEB_UI_HOME = homeDir + mockElectronApp.isPackaged = true + + const { runtimePlatformKey } = await import('../../packages/desktop/src/main/runtime-paths') + const runtimeDir = join(homeDir, 'desktop-runtime', 'hermes', '0.15.2', runtimePlatformKey()) + createRuntime(runtimeDir, '0.15.2') + + const { desktopRuntimeDir, hermesBin } = await import('../../packages/desktop/src/main/paths') + + expect(desktopRuntimeDir()).toBe(runtimeDir) + expect(hermesBin()).toBe(join(runtimeDir, 'python', 'Scripts', 'hermes.cmd')) + expect(existsSync(join(runtimeDir, 'python', 'Scripts', 'hermes.exe'))).toBe(false) + }) }) diff --git a/tests/server/hermes-process.test.ts b/tests/server/hermes-process.test.ts index c8706ecb..0a23c7e5 100644 --- a/tests/server/hermes-process.test.ts +++ b/tests/server/hermes-process.test.ts @@ -74,6 +74,28 @@ describe('Hermes process invocation', () => { } }) + it('discovers sibling python.exe for a Windows hermes.cmd launcher', async () => { + setPlatform('win32') + const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) + try { + const scripts = join(root, 'Scripts') + mkdirSync(scripts) + writeFileSync(join(root, 'python.exe'), '') + writeFileSync(join(scripts, 'hermes.cmd'), '') + const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') + + await execHermesWithBin(join(scripts, 'hermes.cmd'), ['--version']) + + expect(execFileCalls[0]).toMatchObject({ + command: join(root, 'python.exe'), + args: ['-m', 'hermes_cli.main', '--version'], + options: expect.objectContaining({ windowsHide: true }), + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('keeps normal Hermes command execution unchanged on non-Windows platforms', async () => { setPlatform('darwin') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')