fix windows desktop runtime trampolines

This commit is contained in:
ekko
2026-07-06 19:56:12 +08:00
parent 79eab56a63
commit 8596ef3cfd
8 changed files with 107 additions and 26 deletions
+26 -17
View File
@@ -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 <python_root>/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) {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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))
}
@@ -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
}
@@ -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')
+27 -1
View File
@@ -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<string> {
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<void>(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)
})
})
+25 -2
View File
@@ -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)
})
})
+22
View File
@@ -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')