change: an embedded app always runs its embedded payload

Two decisions land together because the code cannot compile between
them:

- Staging has no per-item skip. A stage failure throws and the build
  fails. The payload manifest shrinks to a complete-payload sentinel
  (schemaVersion 3, tag, commit). External builds write an
  external:true stub.
- Backend selection is a constant of the artifact. resolvePayload
  requires every runtime item directory; when it resolves, the app
  spawns the embedded backend without a look at any checkout. A
  payload with no runnable interpreter is a damaged artifact and
  raises an error instead of a silent checkout fallback.

decideResidentRuntime, the adoption-era checkout examination, and the
installMode parameter of shouldUseAppUpdater are deleted. The app
self-update gate is now: embedded stamp AND packaged. The update
channel moves to config.yaml (update.channel); Electron mirrors it
with a narrow parser for the version pill. The resident vocabulary is
renamed to embedded; thin builds are now called external.
This commit is contained in:
ethernet 2026-08-07 17:12:47 -04:00
parent 18478723f1
commit 60372c1630
7 changed files with 259 additions and 429 deletions

View File

@ -6,39 +6,16 @@ import { describeFeedCheck, shouldUseAppUpdater } from './app-updater'
// ── shouldUseAppUpdater ─────────────────────────────────────────────
test('app updater runs only for packaged bundled installs with payloads', () => {
assert.equal(
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'bundled', isPackaged: true }),
true
)
test('app updater runs for packaged embedded builds', () => {
assert.equal(shouldUseAppUpdater({ stampHasPayload: true, isPackaged: true }), true)
})
test('a thin build never uses the app updater', () => {
assert.equal(
shouldUseAppUpdater({ stampHasPayload: false, installMode: 'bundled', isPackaged: true }),
false
)
})
test('a source or ejected checkout keeps the git update path', () => {
// Eject writes installMode: source. The gate must fall through to git.
assert.equal(
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'source', isPackaged: true }),
false
)
// No manifest at all: a legacy checkout. Adoption may run later, but the
// updater gate stays closed until the manifest says bundled.
assert.equal(
shouldUseAppUpdater({ stampHasPayload: true, installMode: null, isPackaged: true }),
false
)
test('an external build never uses the app updater', () => {
assert.equal(shouldUseAppUpdater({ stampHasPayload: false, isPackaged: true }), false)
})
test('dev runs never use the app updater', () => {
assert.equal(
shouldUseAppUpdater({ stampHasPayload: true, installMode: 'bundled', isPackaged: false }),
false
)
assert.equal(shouldUseAppUpdater({ stampHasPayload: true, isPackaged: false }), false)
})
// ── describeFeedCheck ───────────────────────────────────────────────

View File

@ -3,7 +3,7 @@
// Bundled installs update through GitHub Releases: electron-updater reads
// latest*.yml from the release that the desktop-bundled-release workflow
// attached, downloads the new installer, and applies it. The swapped-in app
// carries the new runtime in its own resources (resident mode), so there is
// carries the new runtime in its own resources (embedded mode), so there is
// no post-update install step at all.
//
// Source installs never reach this module. The callers gate on the install
@ -17,21 +17,23 @@ import type { AppUpdater } from 'electron-updater'
export interface UpdaterGateFacts {
stampHasPayload: boolean
installMode: string | null // from .hermes-install.json; null = no manifest
isPackaged: boolean
}
/**
* True when this launch must use electron-updater for app updates.
*
* All three conditions are necessary:
* - the build carries payloads (a thin build has no matching feed artifacts),
* - the checkout opted into desktop management (installMode bundled) an
* ejected or source checkout keeps the git update path,
* Both conditions are necessary:
* - the build carries an embedded payload (an external build has no
* matching feed artifacts),
* - the app is packaged (dev runs have no app-update.yml).
*
* This is a constant of the artifact, not of machine state. An eject
* replaces the whole app with a source-built external one (no embedded
* stamp), so no "ejected embedded install" state exists to gate on.
*/
export function shouldUseAppUpdater(facts: UpdaterGateFacts): boolean {
return facts.stampHasPayload === true && facts.installMode === 'bundled' && facts.isPackaged === true
return facts.stampHasPayload === true && facts.isPackaged === true
}
/**

View File

@ -3,163 +3,89 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
decideResidentRuntime,
findResidentPython,
EMBEDDED_RUNTIME_ITEMS,
findEmbeddedPython,
latestReleaseFromLsRemote,
type PayloadInfo,
resolveChannel,
resolvePayload
PAYLOAD_SCHEMA_VERSION,
resolvePayload,
updateChannelFromConfig
} from '../electron/bundled-runtime'
// ─── resolvePayload ────────────────────────────────────────────────
const readerFor = (manifest: unknown) => (p: string) => {
if (!p.endsWith('manifest.json')) {throw new Error('ENOENT')}
if (!p.endsWith('manifest.json')) {
throw new Error('ENOENT')
}
return JSON.stringify(manifest)
}
test('resolvePayload returns null for dev runs, thin stubs, and garbage', () => {
const allDirsExist = () => true
const noDirsExist = () => false
const completeManifest = { schemaVersion: PAYLOAD_SCHEMA_VERSION, tag: 'v1.2.3', commit: 'a'.repeat(40) }
test('resolvePayload returns null for dev runs, external stubs, and garbage', () => {
assert.equal(resolvePayload(null), null)
assert.equal(resolvePayload(undefined), null)
assert.equal(resolvePayload('/res', readerFor({ schemaVersion: 1, thin: true, items: {} })), null)
assert.equal(
resolvePayload('/res', () => {
throw new Error('ENOENT')
}),
resolvePayload('/res', readerFor({ schemaVersion: PAYLOAD_SCHEMA_VERSION, external: true }), allDirsExist),
null
)
assert.equal(resolvePayload('/res', readerFor('not-an-object')), null)
// A manifest with items but no staged item returns null (an all-skipped payload).
assert.equal(
resolvePayload('/res', readerFor({ tag: 'v1.0.0', items: { repo: { status: 'skipped' } } })),
resolvePayload(
'/res',
() => {
throw new Error('ENOENT')
},
allDirsExist
),
null
)
assert.equal(resolvePayload('/res', readerFor('not-an-object'), allDirsExist), null)
})
test('resolvePayload rejects old-schema manifests', () => {
// A schema-2 manifest comes from a pre-embedded artifact. The app and
// its payload travel together, so a mismatch means a foreign artifact.
assert.equal(
resolvePayload('/res', readerFor({ schemaVersion: 2, tag: 'v1.0.0', items: { repo: { status: 'staged' } } }), allDirsExist),
null
)
})
test('resolvePayload returns dir + tag for a real payload', () => {
const p = resolvePayload('/res', readerFor({ tag: 'v1.2.3', items: { repo: { status: 'staged' } } }))
test('resolvePayload rejects a payload with a missing item directory', () => {
// Completeness is a build invariant; a missing directory here means a
// damaged or truncated artifact.
assert.equal(resolvePayload('/res', readerFor(completeManifest), noDirsExist), null)
// One missing item out of five is still a rejection.
const allButUv = (p: string) => !p.endsWith('/uv')
assert.equal(resolvePayload('/res', readerFor(completeManifest), allButUv), null)
})
test('resolvePayload returns dir + tag for a complete payload', () => {
const p = resolvePayload('/res', readerFor(completeManifest), allDirsExist)
assert.ok(p)
assert.match(p.dir, /agent-payload$/)
assert.equal(p.tag, 'v1.2.3')
})
// ─── decideResidentRuntime ─────────────────────────────────────────
const residentPayload = (overrides: Partial<PayloadInfo> = {}): PayloadInfo => ({
dir: '/res/agent-payload',
tag: 'v2.0.0',
schemaVersion: 2,
items: {
repo: { status: 'staged' },
uv: { status: 'staged' },
python: { status: 'staged' },
'site-packages': { status: 'staged' },
node: { status: 'staged' }
},
...overrides
test('the required items include uv — plugin lazy installs are mandatory', () => {
assert.deepEqual([...EMBEDDED_RUNTIME_ITEMS].sort(), ['node', 'python', 'repo', 'site-packages', 'uv'])
})
test('a complete schema-2 payload runs resident on a fresh machine', () => {
const d = decideResidentRuntime({
payload: residentPayload(),
checkoutExists: false,
checkoutManifest: null,
markerSaysDesktop: false
})
// ─── findEmbeddedPython ────────────────────────────────────────────
assert.equal(d.resident, true)
})
test('resident even over an old desktop-managed checkout (marker or bundled manifest)', () => {
// Phase-1 materialized checkout: manifest says bundled.
assert.equal(
decideResidentRuntime({
payload: residentPayload(),
checkoutExists: true,
checkoutManifest: { installMode: 'bundled' },
markerSaysDesktop: true
}).resident,
true
)
// Pre-manifest desktop install: no manifest, but the desktop marker
// proves provenance.
assert.equal(
decideResidentRuntime({
payload: residentPayload(),
checkoutExists: true,
checkoutManifest: null,
markerSaysDesktop: true
}).resident,
true
)
})
test('never resident over a checkout the user owns', () => {
// Ejected / deliberate source install.
const ejected = decideResidentRuntime({
payload: residentPayload(),
checkoutExists: true,
checkoutManifest: { installMode: 'source' },
markerSaysDesktop: true
})
assert.equal(ejected.resident, false)
assert.match(ejected.reason, /source-managed/)
// CLI-first user: checkout exists, no manifest, no desktop marker.
const cliFirst = decideResidentRuntime({
payload: residentPayload(),
checkoutExists: true,
checkoutManifest: null,
markerSaysDesktop: false
})
assert.equal(cliFirst.resident, false)
assert.match(cliFirst.reason, /CLI-first/)
})
test('thin, pre-resident, and incomplete payloads never run resident', () => {
assert.match(
decideResidentRuntime({
payload: null,
checkoutExists: false,
checkoutManifest: null,
markerSaysDesktop: false
}).reason,
/thin/
)
// Phase-1 artifact: schemaVersion 1.
assert.match(
decideResidentRuntime({
payload: residentPayload({ schemaVersion: 1 }),
checkoutExists: false,
checkoutManifest: null,
markerSaysDesktop: false
}).reason,
/predates/
)
// uv is mandatory: runtime lazy installs for plugins depend on it.
const noUv = residentPayload()
noUv.items = { ...noUv.items, uv: { status: 'skipped' } }
const d = decideResidentRuntime({
payload: noUv,
checkoutExists: false,
checkoutManifest: null,
markerSaysDesktop: false
})
assert.equal(d.resident, false)
assert.match(d.reason, /missing: uv/)
})
// ─── findResidentPython ────────────────────────────────────────────
test('findResidentPython picks the patch-versioned dir and needs a real binary', () => {
test('findEmbeddedPython picks the patch-versioned dir and needs a real binary', () => {
const fsStub = (dirs: string[], files: string[]) => ({
readdirSync: (p: string) => {
if (!p.endsWith('python')) {throw new Error('ENOENT')}
if (!p.endsWith('python')) {
throw new Error('ENOENT')
}
return dirs
},
@ -167,7 +93,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
})
// Patch-versioned real dir wins over the minor alias (reverse sort).
const python = findResidentPython(
const python = findEmbeddedPython(
'/res/agent-payload',
'darwin',
fsStub(
@ -180,7 +106,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
// No python dir at all → null, not a throw.
assert.equal(
findResidentPython('/res/agent-payload', 'darwin', {
findEmbeddedPython('/res/agent-payload', 'darwin', {
readdirSync: () => {
throw new Error('ENOENT')
},
@ -195,7 +121,7 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
const winRoot = 'win-res/agent-payload'
const winExpected = ['win-res/agent-payload', 'python', 'cpython-3.11.15-windows-x86_64-none', 'python.exe'].join('/')
const winPython = findResidentPython(
const winPython = findEmbeddedPython(
winRoot,
'win32',
fsStub(['cpython-3.11.15-windows-x86_64-none'], [winExpected]) as never
@ -204,12 +130,27 @@ test('findResidentPython picks the patch-versioned dir and needs a real binary',
assert.match(String(winPython), /python\.exe$/)
})
test('channel: bundled is always stable, source carries its own, absent means main', () => {
assert.equal(resolveChannel({ installMode: 'bundled', channel: 'main' }), 'stable')
assert.equal(resolveChannel({ installMode: 'source', channel: 'stable' }), 'stable')
assert.equal(resolveChannel({ installMode: 'source', channel: 'main' }), 'main')
assert.equal(resolveChannel(null), 'main')
assert.equal(resolveChannel({}), 'main')
// ─── updateChannelFromConfig ───────────────────────────────────────
test('channel comes from update.channel in config.yaml; absent means main', () => {
assert.equal(updateChannelFromConfig('update:\n channel: stable\n'), 'stable')
assert.equal(updateChannelFromConfig('update:\n channel: "stable"\n'), 'stable')
assert.equal(updateChannelFromConfig('update:\n channel: main\n'), 'main')
assert.equal(updateChannelFromConfig('model:\n provider: nous\n'), 'main')
assert.equal(updateChannelFromConfig(null), 'main')
assert.equal(updateChannelFromConfig(''), 'main')
})
test('channel parsing stays inside the update block', () => {
// A channel key in ANOTHER block must not leak into the answer.
const text = 'gateway:\n channel: stable\nupdate:\n interval: 1\nmodel:\n channel: stable\n'
assert.equal(updateChannelFromConfig(text), 'main')
// The update block ends at the next top-level key.
const ended = 'update:\n interval: 1\nother:\n channel: stable\n'
assert.equal(updateChannelFromConfig(ended), 'main')
})
// ── latestReleaseFromLsRemote ───────────────────────────────────────

View File

@ -1,10 +1,11 @@
// bundled-runtime.ts: decision logic for the bundled desktop runtime.
// This module finds payloads, decides marker-tag invalidation, and decides
// silent adoption for pristine legacy checkouts. Marker-tag invalidation
// tells us when an app update forces offline re-materialization.
// bundled-runtime.ts: pure helpers for the embedded desktop runtime.
// An Embedded artifact carries the whole agent runtime in its resources
// and ALWAYS spawns the backend from there — there is no decision contest
// against checkouts. This module only answers: does a complete payload
// exist (resolvePayload), where is its interpreter (findEmbeddedPython),
// and what update channel applies (resolveChannel).
//
// Design: .hermes/plans/2026-08-05_desktop-bundled-payloads-channels-eject.md
// (§1.4 adoption, §4.3 bundled update flow).
// Design: .hermes/plans/2026-08-07_183000-two-axis-install-model.md.
//
// All functions in this file are pure, and the callers inject the
// dependencies. Thus vitest covers the whole decision surface. The impure
@ -18,20 +19,27 @@ import path from 'node:path'
export interface PayloadInfo {
dir: string
tag: string | null
schemaVersion: number | null
items: Record<string, { status: string }>
}
/**
* Resolve the agent-payload directory that ships in the resources of the
* packaged app. Returns null for thin builds (a stub manifest with
* thin:true), for dev runs (no resourcesPath), and for unreadable manifests.
* Every caller treats null as "behave exactly like the current network
* bootstrap".
* packaged app. Returns null for external builds (a stub manifest with
* external:true), for dev runs (no resourcesPath), for unreadable or
* old-schema manifests, and for payloads with a missing item directory.
* Item presence is a build-time invariant (staging fails the build on an
* incomplete payload), so a missing directory here means a damaged or
* truncated artifact the caller reports it, it does not fall back.
*/
export function resolvePayload(
resourcesPath: string | null | undefined,
readFile: (p: string) => string = p => fs.readFileSync(p, 'utf8')
readFile: (p: string) => string = p => fs.readFileSync(p, 'utf8'),
dirExists: (p: string) => boolean = p => {
try {
return fs.statSync(p).isDirectory()
} catch {
return false
}
}
): PayloadInfo | null {
if (!resourcesPath) {
return null
@ -47,95 +55,42 @@ export function resolvePayload(
return null
}
if (!parsed || typeof parsed !== 'object' || parsed.thin === true) {
if (!parsed || typeof parsed !== 'object' || parsed.external === true) {
return null
}
const items = parsed.items && typeof parsed.items === 'object' ? parsed.items : {}
const hasAny = Object.values(items).some((v: any) => v && v.status === 'staged')
if (parsed.schemaVersion !== PAYLOAD_SCHEMA_VERSION) {
return null
}
if (!hasAny) {
if (!EMBEDDED_RUNTIME_ITEMS.every(item => dirExists(path.join(dir, item)))) {
return null
}
return {
dir,
tag: typeof parsed.tag === 'string' ? parsed.tag : null,
schemaVersion: typeof parsed.schemaVersion === 'number' ? parsed.schemaVersion : null,
items
tag: typeof parsed.tag === 'string' ? parsed.tag : null
}
}
// ─── resident runtime (plan: 2026-08-07_resources-resident-bundled-runtime) ──
// The manifest schema this build understands. Staging writes the same
// number (stage-agent-payloads.mjs); the app and its payload travel in the
// same artifact, so a mismatch means a damaged or foreign artifact.
export const PAYLOAD_SCHEMA_VERSION = 3
// The payload items a resident launch requires — all of them. uv never
// installs the runtime (site-packages ships prebuilt), but runtime lazy
// installs for plugins are a mandatory feature, and uv is what installs
// them into the writable overlay. A payload without uv is an incomplete
// artifact, not a degraded one.
export const RESIDENT_RUNTIME_ITEMS = ['repo', 'uv', 'python', 'site-packages', 'node'] as const
export interface ResidentDecision {
resident: boolean
reason: string
}
/**
* Decide whether this launch runs the backend directly out of the payload
* in resources (resident) instead of a checkout at ~/.hermes/hermes-agent.
*
* Resident is the default for a complete schema-2 payload. The checkout
* wins only when the user demonstrably owns it:
* - its manifest says installMode:source (an eject, or a deliberate
* install.sh run both write that manifest), or
* - it exists with NO manifest and NO desktop-written bootstrap marker,
* which is the pre-manifest curl|bash cohort. CLI-first users keep
* their install; the desktop never silently shadows it.
*
* A pre-manifest checkout WITH a desktop marker (old desktop installs)
* and a phase-1 bundled checkout (manifest installMode:bundled) both go
* resident: their materialized trees were desktop-managed anyway, and
* nothing is deleted the preference is reversible by eject.
*/
export function decideResidentRuntime(facts: {
payload: PayloadInfo | null
checkoutExists: boolean
checkoutManifest: { installMode?: string } | null
markerSaysDesktop: boolean
}): ResidentDecision {
const { payload, checkoutExists, checkoutManifest, markerSaysDesktop } = facts
if (!payload) {
return { resident: false, reason: 'thin build (no payload)' }
}
if ((payload.schemaVersion ?? 0) < 2) {
return { resident: false, reason: 'payload predates the resident layout' }
}
const missing = RESIDENT_RUNTIME_ITEMS.filter((item) => payload.items[item]?.status !== 'staged')
if (missing.length > 0) {
return { resident: false, reason: `payload incomplete (missing: ${missing.join(', ')})` }
}
if (checkoutManifest && checkoutManifest.installMode === 'source') {
return { resident: false, reason: 'checkout at the active root is source-managed' }
}
if (checkoutExists && !checkoutManifest && !markerSaysDesktop) {
return { resident: false, reason: 'legacy checkout without desktop provenance (CLI-first user)' }
}
return { resident: true, reason: 'complete resident payload' }
}
// The runtime items inside a complete embedded payload — all of them. uv
// never installs the runtime (site-packages ships prebuilt), but runtime
// lazy installs for plugins are a mandatory feature, and uv is what
// installs them into the writable overlay. A payload without uv is an
// incomplete artifact, not a degraded one.
export const EMBEDDED_RUNTIME_ITEMS = ['repo', 'uv', 'python', 'site-packages', 'node'] as const
/**
* Locate the payload CPython binary. The install directory is
* patch-versioned (python/cpython-3.11.15-<triple>/...), so this scans
* rather than hardcoding, and it verifies the binary exists.
*/
export function findResidentPython(
export function findEmbeddedPython(
payloadDir: string,
platform: NodeJS.Platform = process.platform,
fsImpl: Pick<typeof fs, 'readdirSync' | 'existsSync'> = fs
@ -169,20 +124,47 @@ export function findResidentPython(
// ─── update channel ─────────────────────────────────────────────────────────
/**
* The update channel of a checkout. Mirrors the resolution in
* hermes_cli/install_manifest.py: a bundled install is always stable, a
* source manifest carries its own channel, and a missing or unreadable
* manifest means main. The channel decides what the version pill compares
* against. The install mode decides only the apply mechanism.
* The update channel of a source checkout, read from config.yaml text
* (`update.channel`). The CLI owns this key; Electron only mirrors it for
* the version pill. Anything but an explicit `stable` means `main` the
* default channel. Embedded artifacts never call this: their updates are
* release-fed by construction.
*
* The parser is deliberately narrow: find the top-level `update:` block,
* then the first `channel:` inside it. config.yaml is machine-written
* (`hermes config set update.channel ...`), so this shape is stable.
*/
export function resolveChannel(
manifest: { installMode?: string; channel?: string } | null | undefined
): 'stable' | 'main' {
if (manifest?.installMode === 'bundled') {
return 'stable'
export function updateChannelFromConfig(configText: string | null | undefined): 'stable' | 'main' {
if (!configText) {
return 'main'
}
return manifest?.channel === 'stable' ? 'stable' : 'main'
let inUpdateBlock = false
for (const raw of configText.split('\n')) {
const line = raw.replace(/\s+$/, '')
if (/^update:\s*$/.test(line)) {
inUpdateBlock = true
continue
}
if (inUpdateBlock) {
// The block ends at the next top-level key (no leading whitespace).
if (/^\S/.test(line)) {
break
}
const match = line.match(/^\s+channel:\s*["']?(stable|main)["']?\s*(#.*)?$/)
if (match) {
return match[1] as 'stable' | 'main'
}
}
}
return 'main'
}
/**

View File

@ -50,7 +50,7 @@ import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from '
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
import { decideBootstrapRepair } from './bootstrap-repair-guard'
import { runBootstrap } from './bootstrap-runner'
import { decideResidentRuntime, findResidentPython, latestReleaseFromLsRemote, resolveChannel, resolvePayload } from './bundled-runtime'
import { findEmbeddedPython, latestReleaseFromLsRemote, resolvePayload, updateChannelFromConfig } from './bundled-runtime'
import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'
import {
authModeFromStatus,
@ -2595,11 +2595,11 @@ async function checkUpdates() {
}
}
// Source install on the stable channel (an ejected bundled install, or a
// manual channel switch): compare against the newest release tag, not
// against the tip of main. A commits-behind-main count is meaningless
// vocabulary on this channel and reads as an alarming +N.
if (resolveChannel(readJson(INSTALL_MANIFEST_PATH) as any) === 'stable') {
// Source install on the stable channel (an ejected install, or a manual
// channel switch): compare against the newest release tag, not against
// the tip of main. A commits-behind-main count is meaningless vocabulary
// on this channel and reads as an alarming +N.
if (updateChannelFromConfig(readTextOrNull(path.join(HERMES_HOME, 'config.yaml'))) === 'stable') {
return checkStableChannelUpdates()
}
@ -3798,6 +3798,14 @@ function readJson(filePath) {
}
}
function readTextOrNull(filePath) {
try {
return fs.readFileSync(filePath, 'utf8')
} catch {
return null
}
}
// Bootstrap-complete marker helpers. The marker is written by whichever
// installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the
// first-launch bootstrap runner. It is provenance ("a bootstrap finished
@ -3816,48 +3824,29 @@ function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
// ─── Bundled-runtime decisions (resident mode) ──────────────────────────────
const INSTALL_MANIFEST_PATH = path.join(ACTIVE_HERMES_ROOT, '.hermes-install.json')
// ─── Embedded-runtime facts ─────────────────────────────────────────────────
/**
* The resident-runtime decision for this launch: run the backend directly
* out of the payload in resources, with no materialized checkout. Facts
* are re-read on every call (cheap file reads) so an eject flips the
* answer without an app restart.
* The embedded payload of this artifact, or null on external builds.
* Resolution re-reads cheap file facts on every call; the answer is a
* constant of the artifact in practice (the payload ships inside the
* sealed resources and never changes at runtime).
*/
function residentRuntimeDecision() {
const marker = readBootstrapMarker() as any
return decideResidentRuntime({
payload: resolvePayload(process.resourcesPath),
checkoutExists: directoryExists(ACTIVE_HERMES_ROOT),
checkoutManifest: readJson(INSTALL_MANIFEST_PATH) as any,
// desktopVersion has been written by every desktop bootstrap since the
// marker existed; its presence is desktop provenance for the checkout.
markerSaysDesktop: Boolean(marker && typeof marker.desktopVersion === 'string')
})
function embeddedPayload() {
return resolvePayload(process.resourcesPath)
}
/**
* True when app updates go through electron-updater instead of git.
* Reads the manifest on every call. An eject flips the manifest to source
* mode, and the next check must honor that without an app restart.
*
* A resident launch is bundled BY CONSTRUCTION: the code came from the
* app's own resources, so the checkout manifest (which may not even
* exist) has no say.
* A constant of the artifact: embedded builds self-update, external
* builds never do. No machine state has a say an eject replaces the
* whole app with a source-built external one.
*/
function bundledUpdaterActive(): boolean {
const stamp = INSTALL_STAMP as any
const manifest = residentRuntimeDecision().resident
? { installMode: 'bundled' }
: (readJson(INSTALL_MANIFEST_PATH) as any)
return shouldUseAppUpdater({
stampHasPayload: Boolean(stamp && stamp.payload),
installMode: manifest && typeof manifest.installMode === 'string' ? manifest.installMode : null,
isPackaged: app.isPackaged
})
}
@ -4127,7 +4116,7 @@ function createActiveBackend(backendArgs) {
}
}
// createResidentBackend — run the backend directly out of the payload in
// createEmbeddedBackend — run the backend directly out of the payload in
// the app's resources. Nothing is materialized: the payload CPython's
// hermes-bundle.pth resolves repo/ and site-packages/ relative to itself,
// so the spawn needs NO PYTHONPATH and survives renames, Gatekeeper
@ -4137,17 +4126,17 @@ function createActiveBackend(backendArgs) {
// writes (codesign would break; the mount may be read-only anyway).
// - HERMES_LAZY_INSTALL_TARGET: plugin/lazy deps install into a writable
// overlay via the existing uv-pip --target machinery in lazy_deps.
function createResidentBackend(backendArgs) {
function createEmbeddedBackend(backendArgs) {
const payload = resolvePayload(process.resourcesPath)
if (!payload) {
return null
}
const command = findResidentPython(payload.dir)
const command = findEmbeddedPython(payload.dir)
if (!command) {
rememberLog(`[resident] payload at ${payload.dir} has no runnable CPython; falling back to checkout resolution`)
rememberLog(`[embedded] payload at ${payload.dir} has no runnable CPython — the artifact is damaged`)
return null
}
@ -4181,41 +4170,47 @@ function createResidentBackend(backendArgs) {
return {
kind: 'python',
label: `Hermes resident bundle (${payload.tag || 'untagged'})`,
label: `Hermes embedded runtime (${payload.tag || 'untagged'})`,
command,
args: ['-m', 'hermes_cli.main', ...backendArgs],
env,
root: repoRoot,
resident: true,
embedded: true,
bootstrap: false,
shell: false
}
}
function resolveHermesBackend(backendArgs) {
// 0. Resident bundle — the payload in resources IS the runtime. When the
// decision says resident, nothing is materialized, no bootstrap runs,
// and the checkout (if any) is simply not preferred: reversible, and
// an eject flips the decision on the next resolution. This must come
// before adoption: adoption exists to migrate legacy checkouts into
// the MATERIALIZED bundled path, which a resident launch has no use
// for. The HERMES_DESKTOP_HERMES_ROOT escape hatch still wins — it
// exists precisely to point a packaged app at a developer checkout.
// 0. Embedded runtime — an Embedded artifact ALWAYS runs the backend out
// of its own resources. No checkout examination, no contest: backend
// selection is a constant of the artifact. Checkouts on the machine
// belong to the CLI and are never consulted here. The
// HERMES_DESKTOP_HERMES_ROOT escape hatch still wins — it exists
// precisely to point a packaged app at a developer checkout.
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
const resident = overrideRoot ? { resident: false, reason: 'HERMES_DESKTOP_HERMES_ROOT override' } : residentRuntimeDecision()
const payload = overrideRoot ? null : embeddedPayload()
if (resident.resident) {
const backend = createResidentBackend(backendArgs)
if (payload) {
const backend = createEmbeddedBackend(backendArgs)
if (backend) {
rememberLog(`[resident] running from the app bundle: ${resident.reason}`)
rememberLog(`[embedded] running from the app bundle (${payload.tag || 'untagged'})`)
return backend
}
// A complete manifest but an unusable payload (no CPython found) is a
// broken artifact; fall through to the checkout/bootstrap chain.
} else {
rememberLog(`[resident] not resident: ${resident.reason}`)
// A payload that resolves but yields no runnable interpreter is a
// damaged artifact. Do NOT fall through to a checkout: a silent
// fallback hides the build defect. Surface the failure instead.
throw new Error(
`The embedded runtime at ${payload.dir} is damaged (no runnable CPython). ` +
'Reinstall Hermes from the website.'
)
}
if (overrideRoot) {
rememberLog('[embedded] skipped: HERMES_DESKTOP_HERMES_ROOT override')
}
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer

View File

@ -4,7 +4,7 @@
* .hermes/plans/2026-08-07_resources-resident-bundled-runtime.md.
*
* Output: apps/desktop/build/agent-payload/
* manifest.json schemaVersion, tag, commit, platform, arch, per-item status
* manifest.json schemaVersion, tag, commit, platform, arch
* repo/ plain source tree at the release tag (no .git),
* plus the PREBUILT JS surfaces (ui-tui dist +
* node_modules, web_dist) and the build stamp
@ -22,11 +22,9 @@
*
* Gating: the script does nothing unless HERMES_DESKTOP_BUNDLED=1. That
* variable is an internal build-time env for CI wiring, not user config.
* Thus dev builds and current CI keep producing thin builds. You can skip
* individual items with --skip=<item,item> for incremental CI caching.
* The manifest.json records every skip. The desktop only runs resident when
* every runtime item is staged; a partial payload falls back to the network
* bootstrap path.
* Thus dev builds and current CI keep producing external builds. There is
* no per-item skip: an embedded payload is complete, or this script throws
* and the build fails.
*
* The heavy work shells out to git, uv, and tar. The decision logic
* (target resolution, pip arg construction, manifest shape) is exported as
@ -39,14 +37,12 @@ import path from "node:path"
import { isMain } from "./utils.mjs"
export const PAYLOAD_SCHEMA_VERSION = 2
export const PAYLOAD_SCHEMA_VERSION = 3
const DESKTOP_ROOT = path.resolve(import.meta.dirname, "..")
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = path.join(DESKTOP_ROOT, "build", "agent-payload")
export const PAYLOAD_ITEMS = ["repo", "uv", "python", "site-packages", "node"]
/**
* Map (process.platform, process.arch) to the uv, python-build-standalone,
* and node target descriptors. There is one artifact per (os, arch) pair.
@ -197,11 +193,12 @@ export function bannerExpectations(target) {
}
}
/**
* Resolve the release tag to stage. CI passes --tag=vX.Y.Z. Local runs can
* fall back to `git describe` for smoke tests. When bundling was requested
* and no tag exists, payload staging is a hard error. A bundled artifact
* without a pinned tag produces un-adoptable, un-updatable installs.
* without a pinned tag produces un-updatable installs.
*/
export function resolveTag(argv, describeFn) {
const explicit = argv.find((a) => a.startsWith("--tag="))
@ -221,37 +218,13 @@ export function resolveTag(argv, describeFn) {
)
}
export function parseSkips(argv) {
const flag = argv.find((a) => a.startsWith("--skip="))
if (!flag) return new Set()
const skips = new Set(
flag
.slice("--skip=".length)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
)
for (const s of skips) {
if (!PAYLOAD_ITEMS.includes(s)) {
throw new Error(`unknown --skip item: ${s} (valid: ${PAYLOAD_ITEMS.join(", ")})`)
}
}
return skips
}
/**
* Build the manifest that describes the contents of the payload tree.
* `items` records per-item presence. Thus the resident-runtime gate in
* the Electron main process can require exactly the items it needs and
* refuse to run resident from an incomplete artifact.
* Build the manifest that marks a complete embedded payload. The Electron
* main process treats its presence (schemaVersion match, external: absent)
* as the payload-present sentinel. Completeness is a build-time invariant:
* main() throws before this manifest is written when any stage fails.
*/
export function buildManifest({ tag, commit, target, staged, skipped }) {
const items = {}
for (const item of PAYLOAD_ITEMS) {
items[item] = staged.includes(item)
? { status: "staged" }
: { status: "skipped", reason: skipped.has(item) ? "explicit-skip" : "failed" }
}
export function buildManifest({ tag, commit, target }) {
return {
schemaVersion: PAYLOAD_SCHEMA_VERSION,
tag,
@ -259,7 +232,6 @@ export function buildManifest({ tag, commit, target, staged, skipped }) {
platform: target.platform,
arch: target.arch,
builtAt: new Date().toISOString(),
items,
}
}
@ -595,13 +567,12 @@ function main() {
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.writeFileSync(
path.join(OUT_DIR, "manifest.json"),
JSON.stringify({ schemaVersion: PAYLOAD_SCHEMA_VERSION, thin: true, items: {} }, null, 2) + "\n"
JSON.stringify({ schemaVersion: PAYLOAD_SCHEMA_VERSION, external: true }, null, 2) + "\n"
)
console.log("[stage-agent-payloads] HERMES_DESKTOP_BUNDLED != 1 — wrote thin stub manifest")
console.log("[stage-agent-payloads] HERMES_DESKTOP_BUNDLED != 1 — wrote external stub manifest")
return
}
const target = resolveTargets()
const skips = parseSkips(process.argv.slice(2))
const tag = resolveTag(process.argv.slice(2), () => {
try {
return execSync("git describe --tags --exact-match", { cwd: REPO_ROOT, encoding: "utf8" }).trim()
@ -611,46 +582,24 @@ function main() {
})
fs.mkdirSync(OUT_DIR, { recursive: true })
const staged = []
let commit = null
let payloadPython = null
const steps = {
repo: () => {
commit = stageRepo(tag, OUT_DIR)
},
uv: () => {
payloadPython = stageUvAndPython(target, OUT_DIR)
},
python: () => {
// The uv step stages python too (one uv invocation). Guard the
// manifest: a --skip=uv run must not record python as staged.
if (!payloadPython) {
throw new Error("python: the uv step was skipped, so no interpreter was staged — skip python too")
}
},
"site-packages": () => {
stageSitePackages(target, OUT_DIR, payloadPython)
// The glue that makes the payload interpreter resolve repo/ and
// site-packages/ wherever the bundle sits. Written after both
// stages exist so a failed staging run never leaves a .pth that
// points at nothing.
writeBundlePth(OUT_DIR, payloadPython)
},
node: () => stageNode(target, OUT_DIR),
}
// Every stage runs, in order. A failure throws and the build fails:
// an embedded payload is complete, or it does not exist.
console.log(`[stage-agent-payloads] staging: repo (${target.key}, ${tag})`)
const commit = stageRepo(tag, OUT_DIR)
console.log(`[stage-agent-payloads] staging: uv + python (${target.key}, ${tag})`)
const payloadPython = stageUvAndPython(target, OUT_DIR)
console.log(`[stage-agent-payloads] staging: site-packages (${target.key}, ${tag})`)
stageSitePackages(target, OUT_DIR, payloadPython)
// The glue that makes the payload interpreter resolve repo/ and
// site-packages/ wherever the bundle sits. Written after both stages
// exist so a failed staging run never leaves a .pth that points at
// nothing.
writeBundlePth(OUT_DIR, payloadPython)
console.log(`[stage-agent-payloads] staging: node (${target.key}, ${tag})`)
stageNode(target, OUT_DIR)
for (const item of PAYLOAD_ITEMS) {
if (skips.has(item)) {
console.log(`[stage-agent-payloads] skip: ${item}`)
continue
}
console.log(`[stage-agent-payloads] staging: ${item} (${target.key}, ${tag})`)
steps[item]()
staged.push(item)
}
const manifest = buildManifest({ tag, commit, target, staged, skipped: skips })
const manifest = buildManifest({ tag, commit, target })
fs.writeFileSync(path.join(OUT_DIR, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n")
console.log(`[stage-agent-payloads] wrote ${path.join(OUT_DIR, "manifest.json")}`)
}

View File

@ -7,8 +7,7 @@ import {
bannerExpectations,
buildManifest,
bundlePthLines,
parseSkips,
PAYLOAD_ITEMS,
PAYLOAD_SCHEMA_VERSION,
pipTargetArgs,
pythonDirPattern,
pythonRequest,
@ -98,38 +97,23 @@ test('falls back to git describe only for exact release tags', () => {
assert.throws(() => resolveTag([], () => null), /no release tag/)
})
// ─── parseSkips ────────────────────────────────────────────────────
test('parseSkips accepts known items and rejects unknown ones', () => {
assert.deepEqual([...parseSkips(['--skip=site-packages,node'])].sort(), ['node', 'site-packages'])
assert.equal(parseSkips([]).size, 0)
assert.throws(() => parseSkips(['--skip=venv']), /unknown --skip/)
// Retired payload items must not silently no-op in CI caching configs.
assert.throws(() => parseSkips(['--skip=wheels']), /unknown --skip/)
})
// ─── buildManifest ─────────────────────────────────────────────────
test('manifest records staged vs explicitly-skipped vs failed per item', () => {
test('the manifest is a complete-payload sentinel: schema, tag, commit', () => {
const target = resolveTargets('linux', 'x64')
const manifest = buildManifest({
tag: 'v1.0.0',
commit: 'a'.repeat(40),
target,
staged: ['repo', 'uv', 'python'],
skipped: new Set(['site-packages'])
target
})
assert.equal(manifest.schemaVersion, PAYLOAD_SCHEMA_VERSION)
assert.equal(manifest.tag, 'v1.0.0')
// Invariant: every payload item has an entry. The resident-runtime gate
// reads presence. An absent entry is ambiguous.
for (const item of PAYLOAD_ITEMS) {
assert.ok(manifest.items[item], item)
}
assert.equal(manifest.items.repo.status, 'staged')
assert.equal(manifest.items['site-packages'].status, 'skipped')
assert.equal(manifest.items['site-packages'].reason, 'explicit-skip')
// node was not staged and not explicitly skipped, so its status is failed.
assert.equal(manifest.items.node.reason, 'failed')
assert.equal(manifest.commit, 'a'.repeat(40))
assert.equal(manifest.platform, 'linux')
// No per-item status exists: completeness is a build invariant, not a
// runtime question. The external stub is the only other manifest shape.
assert.equal('items' in manifest, false)
})
// ─── arch guards ────────────────────────────────────────────────────