From 60372c16305788ce9e73a949b0dceebb50c7fe3d Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 7 Aug 2026 17:12:47 -0400 Subject: [PATCH] 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. --- apps/desktop/electron/app-updater.test.ts | 33 +-- apps/desktop/electron/app-updater.ts | 16 +- apps/desktop/electron/bundled-runtime.test.ts | 213 +++++++----------- apps/desktop/electron/bundled-runtime.ts | 170 +++++++------- apps/desktop/electron/main.ts | 109 +++++---- apps/desktop/scripts/stage-agent-payloads.mjs | 111 +++------ .../scripts/stage-agent-payloads.test.mjs | 36 +-- 7 files changed, 259 insertions(+), 429 deletions(-) diff --git a/apps/desktop/electron/app-updater.test.ts b/apps/desktop/electron/app-updater.test.ts index 76b186b4a2ec9..9c3f7d472ecf6 100644 --- a/apps/desktop/electron/app-updater.test.ts +++ b/apps/desktop/electron/app-updater.test.ts @@ -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 ─────────────────────────────────────────────── diff --git a/apps/desktop/electron/app-updater.ts b/apps/desktop/electron/app-updater.ts index 65e6a82ec2c7d..61cfab7eeabcc 100644 --- a/apps/desktop/electron/app-updater.ts +++ b/apps/desktop/electron/app-updater.ts @@ -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 } /** diff --git a/apps/desktop/electron/bundled-runtime.test.ts b/apps/desktop/electron/bundled-runtime.test.ts index 4979546d395ed..6df809bb91cbe 100644 --- a/apps/desktop/electron/bundled-runtime.test.ts +++ b/apps/desktop/electron/bundled-runtime.test.ts @@ -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 => ({ - 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 ─────────────────────────────────────── diff --git a/apps/desktop/electron/bundled-runtime.ts b/apps/desktop/electron/bundled-runtime.ts index 76ae8173ed28b..8eb78e918b262 100644 --- a/apps/desktop/electron/bundled-runtime.ts +++ b/apps/desktop/electron/bundled-runtime.ts @@ -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 } /** * 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-/...), 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 = 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' } /** diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index be121ff651c9e..d09e67dfce6f0 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 diff --git a/apps/desktop/scripts/stage-agent-payloads.mjs b/apps/desktop/scripts/stage-agent-payloads.mjs index ce40028eeb87d..3a2642bd704ed 100644 --- a/apps/desktop/scripts/stage-agent-payloads.mjs +++ b/apps/desktop/scripts/stage-agent-payloads.mjs @@ -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= 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")}`) } diff --git a/apps/desktop/scripts/stage-agent-payloads.test.mjs b/apps/desktop/scripts/stage-agent-payloads.test.mjs index 9409b7b589def..1959a8322dcbd 100644 --- a/apps/desktop/scripts/stage-agent-payloads.test.mjs +++ b/apps/desktop/scripts/stage-agent-payloads.test.mjs @@ -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 ────────────────────────────────────────────────────