mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
The desktop self-update chain (Desktop -> hermes-setup --update -> hermes update -> hermes desktop --build-only -> relaunch) rebuilds Hermes.exe on the user's machine and declared success on bare file EXISTENCE. A truncated PE (corrupt cached Electron zip / interrupted extraction or rcedit rewrite / full disk) or a wrong-architecture unpacked tree therefore shipped as the 'updated' app, which Windows refuses to load with 'This app can't run on your computer' (此应用无法在你的电脑上运行) — and the previous working build had already been wiped by before-pack.mjs, leaving nothing to fall back to. Fix, in three parts: - hermes_cli/main.py: post-build integrity gate on Windows (_ensure_desktop_exe_launchable). Parses the PE header of the freshly built Hermes.exe — MZ/PE magic, section-table completeness vs file size (catches truncation), and COFF machine vs the host arch (catches arm64/x64 mixups). On failure it purges the (likely corrupt) cached Electron zip, invalidates the content-hash build stamp so the updater's retry-once genuinely re-downloads and rebuilds, restores the previous build from the .bak tree when one exists (keeping the corrupt tree as .corrupt for diagnostics), tells the user the update was aborted and their old version kept, and exits nonzero. _desktop_packaged_executable also now prefers a host-loadable PE over pure newest-mtime when multiple win-*-unpacked trees coexist. - apps/desktop/scripts/before-pack.mjs: on win32, the previous unpacked tree is preserved as <appOutDir>.bak (only when it holds the product exe — partial/corrupt trees still get the plain wipe) instead of being destroyed, providing the rollback material for the gate above. Non-Windows behavior is unchanged. - Behavior-contract tests: tests/hermes_cli/test_desktop_exe_integrity.py (23 tests — synthetic PE fixtures for truncation/non-PE/arch-mismatch, rollback semantics, and the build-only exit contract) and 6 new vitest cases in before-pack.test.mjs for the .bak preservation rules. Progresses #69179
155 lines
6.5 KiB
JavaScript
155 lines
6.5 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { test } from 'vitest'
|
|
|
|
import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs'
|
|
|
|
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
// Reproduce the corrupted partial state: license + payload present,
|
|
// electron binary missing — exactly what trips the ENOENT rename.
|
|
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
|
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
|
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
|
|
|
|
const removed = cleanStaleAppOutDir(appOutDir)
|
|
|
|
assert.equal(removed, true)
|
|
assert.equal(fs.existsSync(appOutDir), false)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const missing = path.join(tempRoot, 'does-not-exist')
|
|
assert.equal(cleanStaleAppOutDir(missing), false)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
|
|
assert.equal(cleanStaleAppOutDir(''), false)
|
|
assert.equal(cleanStaleAppOutDir(undefined), false)
|
|
assert.equal(cleanStaleAppOutDir(null), false)
|
|
assert.equal(cleanStaleAppOutDir(42), false)
|
|
})
|
|
|
|
test('beforePack default export resolves even when cleanup throws', async () => {
|
|
// A directory path that rmSync can't remove is simulated by passing a
|
|
// context whose appOutDir is a file the hook will try (and be allowed) to
|
|
// remove; the contract under test is that the hook never rejects.
|
|
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
|
|
})
|
|
|
|
// ─── Windows rollback preservation (#69179) ────────────────────────────────
|
|
|
|
test('preserveRollbackBackup moves a working build to .bak', () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8')
|
|
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
|
|
|
const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe')
|
|
|
|
assert.equal(preserved, true)
|
|
// Original slot vacated so electron-builder stages into a clean tree...
|
|
assert.equal(fs.existsSync(appOutDir), false)
|
|
// ...and the previous working build is intact under .bak for rollback.
|
|
assert.equal(
|
|
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
|
'MZ-old-build'
|
|
)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('preserveRollbackBackup replaces a stale .bak from an older update', () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8')
|
|
fs.mkdirSync(`${appOutDir}.bak`, { recursive: true })
|
|
fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8')
|
|
|
|
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true)
|
|
assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current')
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('preserveRollbackBackup refuses a partial tree missing the product exe', () => {
|
|
// The corrupted partial state (interrupted prior pack) must NOT become
|
|
// rollback material — it is exactly what cleanStaleAppOutDir exists to wipe.
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
|
|
|
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false)
|
|
// Tree untouched; the caller's wipe path handles it.
|
|
assert.equal(fs.existsSync(appOutDir), true)
|
|
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('preserveRollbackBackup ignores missing or invalid input', () => {
|
|
assert.equal(preserveRollbackBackup(''), false)
|
|
assert.equal(preserveRollbackBackup(undefined), false)
|
|
assert.equal(preserveRollbackBackup(null), false)
|
|
assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false)
|
|
})
|
|
|
|
test('beforePack on win32 preserves the previous build instead of wiping it', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8')
|
|
|
|
// No packager info in the context → default 'Hermes.exe' product name.
|
|
// node-pty staging is skipped because arch is not a number here.
|
|
await beforePack({ appOutDir, electronPlatformName: 'win32' })
|
|
|
|
assert.equal(fs.existsSync(appOutDir), false)
|
|
assert.equal(
|
|
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
|
'MZ-working'
|
|
)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('beforePack on linux keeps the plain wipe (no .bak)', async () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
|
try {
|
|
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
|
fs.mkdirSync(appOutDir, { recursive: true })
|
|
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8')
|
|
|
|
await beforePack({ appOutDir, electronPlatformName: 'linux' })
|
|
|
|
assert.equal(fs.existsSync(appOutDir), false)
|
|
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
}
|
|
})
|