hermes-agent/apps/desktop/scripts/before-pack.test.mjs
teknium1 199f558058 fix(windows): verify rebuilt Hermes.exe integrity before shipping it as an update (#69179)
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
2026-07-24 19:11:35 -07:00

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