diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 1d70ec59a611..f78b26134e41 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -12,11 +12,11 @@ //! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. //! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; @@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { && resolve_hermes_desktop_exe(install_root).is_some() } +fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option { + if let Some(commit) = pin + .commit + .as_ref() + .filter(|commit| !commit.trim().is_empty()) + { + return Some(commit.clone()); + } + + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(install_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + None + } else { + Some(commit) + } +} + +fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result { + use std::io::Write; + + let marker_path = crate::paths::likely_bootstrap_marker(install_root); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "could not create bootstrap marker directory {}", + parent.display() + ) + })?; + } + + let completed_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let marker = serde_json::json!({ + "schemaVersion": 1, + "pinnedCommit": resolve_marker_commit(install_root, pin), + "pinnedBranch": pin.branch.clone(), + "completedAtUnix": completed_at_unix, + }); + let mut body = serde_json::to_vec_pretty(&marker)?; + body.push(b'\n'); + + // Atomic publish (temp sibling + flush + rename), matching Electron's + // writeFileAtomic(). hermes_is_installed() only checks existence, so a + // partial direct write would incorrectly enable the launcher fast path. + let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp"); + { + let mut file = std::fs::File::create(&tmp_path).with_context(|| { + format!( + "could not create temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.write_all(&body).with_context(|| { + format!( + "could not write temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.sync_all().with_context(|| { + format!( + "could not flush temp bootstrap marker {}", + tmp_path.display() + ) + })?; + } + // Windows rename fails if the destination already exists; drop any prior + // marker first so a re-run can still publish a fresh payload. + if marker_path.exists() { + std::fs::remove_file(&marker_path).with_context(|| { + format!( + "could not replace existing bootstrap marker {}", + marker_path.display() + ) + })?; + } + if let Err(err) = std::fs::rename(&tmp_path, &marker_path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "could not publish bootstrap marker {} → {}", + tmp_path.display(), + marker_path.display() + ) + }); + } + + tracing::info!(path = %marker_path.display(), "bootstrap marker written"); + Ok(marker) +} + /// Spawn the already-built desktop app, detached. Returns Err if no built app /// exists or the spawn fails, so the caller can fall back to showing the /// installer UI. @@ -644,6 +745,23 @@ async fn run_bootstrap( .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); + // Marker publish is terminal for this run: a write failure must emit Failed + // so the UI leaves the progress state (it does not poll get_bootstrap_status). + let marker = match write_bootstrap_complete_marker(&install_root, &pin) { + Ok(marker) => marker, + Err(err) => { + let msg = format!("write bootstrap marker failed: {err:#}"); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: msg.clone(), + }, + ); + return Err(anyhow!(msg)); + } + }; + // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can // re-invoke us with `--update` and shortcuts have a stable target. This is // a one-shot install concern; an `--update` re-invocation no-ops because @@ -660,10 +778,7 @@ async fn run_bootstrap( &app, BootstrapEvent::Complete { install_root: install_root.to_string_lossy().into_owned(), - marker: Some(serde_json::json!({ - "pinnedCommit": pin.commit, - "pinnedBranch": pin.branch, - })), + marker: Some(marker), }, ); @@ -903,4 +1018,103 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn bootstrap_complete_marker_uses_desktop_compatible_schema() { + let root = unique_tmp_dir("marker-schema"); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let marker = + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + let marker_path = root.join(".hermes-bootstrap-complete"); + let from_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); + + assert_eq!(marker, from_disk); + assert_eq!(from_disk["schemaVersion"], 1); + assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890"); + assert_eq!(from_disk["pinnedBranch"], "main"); + assert!( + from_disk["completedAtUnix"].as_u64().is_some(), + "marker must carry a completion timestamp" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_complete_marker_is_published_atomically() { + let root = unique_tmp_dir("marker-atomic"); + make_release_tree(&root); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + + let marker_path = root.join(".hermes-bootstrap-complete"); + let tmp_path = root.join(".hermes-bootstrap-complete.tmp"); + assert!( + marker_path.is_file(), + "final marker must exist after atomic publish" + ); + assert!( + !tmp_path.exists(), + "temp sibling must not remain after atomic publish" + ); + assert!( + hermes_is_installed(&root), + "atomically published marker must enable the installer fast path" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn hermes_is_installed_treats_marker_existence_as_sufficient() { + // Documents why write_bootstrap_complete_marker must publish atomically: + // the launcher predicate only checks existence, so a partial/corrupt + // final marker would still enable the fast path. + let root = unique_tmp_dir("marker-existence-only"); + make_release_tree(&root); + std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap(); + + assert!( + hermes_is_installed(&root), + "empty/partial marker content still counts as installed" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn marker_write_failure_leaves_no_final_marker() { + // install_root is a regular file → create_dir_all on its path fails + // before any marker bytes are published under the final name. + let base = unique_tmp_dir("marker-fail"); + let not_a_dir = base.join("not-a-dir"); + std::fs::write(¬_a_dir, b"not a directory").unwrap(); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let err = write_bootstrap_complete_marker(¬_a_dir, &pin) + .expect_err("marker write against a non-directory root must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("bootstrap marker"), + "error should mention the marker path: {msg}" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete").exists(), + "failed write must not leave a final marker that enables the fast path" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(), + "failed write must not leave a temp marker sibling either" + ); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 7c64c91cf6ef..0eec8ccd319d 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) { #[cfg(not(target_os = "macos"))] fn repair_macos_installer_helper(_path: &Path) {} -/// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.ts: +/// Where the bootstrap-complete marker lives (existence-only for the Rust +/// installer fast path; JSON schema-checked by the Electron app). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/desktop/electron/active-runtime-state.test.ts b/apps/desktop/electron/active-runtime-state.test.ts new file mode 100644 index 000000000000..afc3e74b9d4d --- /dev/null +++ b/apps/desktop/electron/active-runtime-state.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state' + +const VALID_MARKER = { + pinnedCommit: '1234567890abcdef1234567890abcdef12345678', + schemaVersion: 1 +} + +test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => { + assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true) +}) + +test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => { + assert.equal(hasValidBootstrapMarker(null, 1), false) + assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false) + assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false) +}) + +test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => { + assert.deepEqual(classifyActiveRuntime(null, 1, true), { + hasValidMarker: false, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + }) +}) + +test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => { + assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), { + hasValidMarker: false, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + }) +}) + +test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => { + assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), { + hasValidMarker: true, + shouldUseActiveRuntime: false, + usabilityReason: 'unusable' + }) +}) + +test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => { + // The reported symptom (#60721): install.sh / install.ps1 produced a healthy + // repo+venv, no desktop-managed marker was ever written, and every launch + // dropped the user back into the first-run installer. + const state = classifyActiveRuntime(null, 1, true) + + assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch') + assert.equal(state.hasValidMarker, false, 'marker provenance stays honest') +}) + +test('a repair that deleted the marker does not strand a healthy install', () => { + // #72166: the repair handler clears the marker unconditionally. Runtime + // usability, not marker presence, must decide the next boot. + assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true) +}) diff --git a/apps/desktop/electron/active-runtime-state.ts b/apps/desktop/electron/active-runtime-state.ts new file mode 100644 index 000000000000..6d922a462778 --- /dev/null +++ b/apps/desktop/electron/active-runtime-state.ts @@ -0,0 +1,58 @@ +export interface BootstrapMarkerLike { + pinnedCommit?: unknown + schemaVersion?: unknown +} + +export interface ActiveRuntimeState { + hasValidMarker: boolean + shouldUseActiveRuntime: boolean + usabilityReason: 'usable' | 'unusable' +} + +export function hasValidBootstrapMarker( + marker: BootstrapMarkerLike | null | undefined, + schemaVersion: number +): boolean { + if (!marker || typeof marker !== 'object') { + return false + } + + if (marker.schemaVersion !== schemaVersion) { + return false + } + + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + return false + } + + return true +} + +// The active install at ~/.hermes/hermes-agent can be real and runnable even if +// Desktop never wrote its first-run bootstrap marker (for example when Hermes +// was installed by the CLI first, or when a past desktop build forgot the +// marker). Runtime usability is authoritative for "can we launch local Hermes +// right now?"; the marker is only provenance about how that install was +// created. A missing/stale marker must never force a healthy local install into +// the first-run bootstrap UI. +export function classifyActiveRuntime( + marker: BootstrapMarkerLike | null | undefined, + schemaVersion: number, + runtimeUsable: boolean +): ActiveRuntimeState { + const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion) + + if (!runtimeUsable) { + return { + hasValidMarker, + shouldUseActiveRuntime: false, + usabilityReason: 'unusable' + } + } + + return { + hasValidMarker, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f967aa24c3b8..13d8a9f78913 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -30,6 +30,7 @@ import { } from 'electron' import nodePty from 'node-pty' +import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' @@ -1032,6 +1033,12 @@ let remoteReauthFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null +// Explicit "the user asked for a repair" flag. Repair used to signal intent by +// deleting the bootstrap marker, which stranded healthy installs whose only +// problem was a transient backend error (#72166). Intent now lives here, so +// repair can force the installer without destroying provenance about how the +// install was created. Cleared once the reinstall is under way. +let bootstrapRepairRequested = false let connectionConfigCache = null let connectionConfigCacheMtime = null const hermesLog = [] @@ -3383,11 +3390,11 @@ function readJson(filePath) { } } -// Bootstrap-complete marker helpers. The marker is written ONCE by the -// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed -// AND the user has finished initial configuration. On every subsequent boot -// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if -// the marker is present and current-schema. +// 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 +// here"), NOT the launch gate -- activeRuntimeState() decides that, because a +// healthy runtime can predate the marker or outlive a repair that cleared it. // // Marker schema (version 1): // { @@ -3420,29 +3427,13 @@ function isActiveRuntimeUsable() { ) } -function isBootstrapComplete() { - const marker = readBootstrapMarker() - - if (!marker || typeof marker !== 'object') { - return false - } - - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) { - return false - } - - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { - return false - } - +function activeRuntimeState() { // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes - // update`, which moves HEAD legitimately. The marker just attests "we - // ran the bootstrap successfully at least once." We DO additionally require - // a runnable venv: an interrupted or split-home install can leave the marker - // + checkout without a venv, and trusting that spawns a dead backend - // ("gateway offline") instead of re-running bootstrap to repair it. - return isActiveRuntimeUsable() + // update`, which moves HEAD legitimately. The marker only attests "a + // desktop-managed bootstrap ran here at least once"; runtime usability is + // what decides whether we can actually launch. + return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) } function writeBootstrapMarker(payload) { @@ -3702,16 +3693,30 @@ function resolveHermesBackend(backendArgs) { } } - // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at - // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. - // The bootstrap marker means install.ps1 stages finished and the user - // completed initial configuration; we trust the install and go straight - // to spawning hermes. Updates flow through the in-app update path - // (applyUpdates -> git pull) or `hermes update` from the CLI. - if (isBootstrapComplete()) { + // 3. ACTIVE_HERMES_ROOT — the canonical install at + // %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent. + // A valid bootstrap marker proves Desktop finished the first-run install + // flow, but marker provenance is NOT the same thing as runtime usability: + // the CLI can create the exact same repo+venv layout, and older desktop + // builds could leave a healthy install behind without the marker. If the + // active runtime is usable, launch it directly; only fall through to + // bootstrap when the runtime itself is unusable. + const activeRuntime = activeRuntimeState() + + if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) { + if (!activeRuntime.hasValidMarker) { + rememberLog( + `[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.` + ) + } + return createActiveBackend(backendArgs) } + if (bootstrapRepairRequested) { + rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer') + } + // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from // a previous tool-only setup, or pip-installed system-wide. Use it but // do NOT write a bootstrap marker; the user did this themselves and we @@ -3885,6 +3890,10 @@ async function ensureRuntime(backend) { bootstrapAbortController = new AbortController() + // The repair request has been honoured by reaching the installer; clear it + // so a later boot isn't forced through bootstrap again. + bootstrapRepairRequested = false + const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, activeRoot: backend.activeRoot, @@ -3979,10 +3988,10 @@ async function ensureRuntime(backend) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files // exist, but venv is missing or broken. This shouldn't happen in - // normal flow because isBootstrapComplete() requires - // isHermesSourceRoot() and the bootstrap writes the marker only after - // install.ps1 succeeds. If we hit this, the user (or a deleted venv) - // broke the invariant; tell them to re-run the install. + // normal flow because activeRuntimeState() requires isHermesSourceRoot() + // plus an importable hermes_cli before it hands back the active runtime. + // If we hit this, the user (or a deleted venv) broke the invariant; tell + // them to re-run the install. throw new Error( `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.' ) @@ -9106,20 +9115,17 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { - // Forceful repair: drop the bootstrap-complete marker so the next - // startHermes() re-runs the full installer (refreshing a broken/partial - // venv), and clear any latched failure + live connection. The renderer - // reloads afterwards to re-drive the boot flow from scratch. - rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') - - try { - if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { - fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) - } - } catch (error) { - rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) - } + // Forceful repair: force the next startHermes() through the full installer + // (refreshing a broken/partial venv) and clear any latched failure + live + // connection. The renderer reloads afterwards to re-drive the boot flow. + // + // We do NOT delete the bootstrap marker here. Repair is also reachable from + // transient backend errors on a perfectly healthy install, and deleting the + // marker in that case stranded the app in first-run setup with no way back + // (#72166). The explicit flag carries the intent instead. + rememberLog('[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure') + bootstrapRepairRequested = true bootstrapFailure = null backendStartFailure = null remoteReauthFailure = null diff --git a/scripts/install.sh b/scripts/install.sh index 43755a59a762..1a15964b7080 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2391,6 +2391,48 @@ maybe_start_gateway() { fi } +write_bootstrap_marker() { + # Writes $INSTALL_DIR/.hermes-bootstrap-complete, which tells the Hermes + # desktop app (apps/desktop/electron/main.ts) and the macOS launcher fast + # path (apps/bootstrap-installer) "a real install finished here -- don't + # re-run first-run bootstrap." + # + # Schema mirrors install.ps1's Write-BootstrapMarker and main.ts's + # writeBootstrapMarker(). Keep the three in lockstep: + # schemaVersion 1 + pinnedCommit (length >= 7) are what the desktop + # validator requires; desktopVersion is omitted because only the desktop + # app knows its own version. + if [ ! -d "$INSTALL_DIR" ]; then + log_warn "Skipping bootstrap marker: $INSTALL_DIR doesn't exist" + return 0 + fi + + # Explicit --commit wins; otherwise read HEAD from the checkout we just + # installed. If neither resolves, skip the marker entirely rather than + # write one the desktop will reject -- an absent marker is a clean + # "bootstrap needed", a malformed one is a confusing half-state. + local pinned_commit="$INSTALL_COMMIT" + if [ -z "$pinned_commit" ]; then + pinned_commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || pinned_commit="" + fi + + if [ -z "$pinned_commit" ]; then + log_warn "Skipping bootstrap marker: could not resolve HEAD in $INSTALL_DIR" + return 0 + fi + + local marker_path="$INSTALL_DIR/.hermes-bootstrap-complete" + local tmp_path="$marker_path.tmp" + + # Atomic publish: the macOS launcher predicate only checks existence, so a + # torn write would arm the fast path against a half-written marker. + printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \ + "$pinned_commit" \ + "$BRANCH" \ + "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$tmp_path" + mv -f "$tmp_path" "$marker_path" +} + print_success() { echo "" echo -e "${GREEN}${BOLD}" @@ -3024,6 +3066,7 @@ run_stage_body() { detect_os resolve_install_layout print_success + write_bootstrap_marker # Code-scoped stamp: write next to the install tree, not into # $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be # bind-mounted into a Docker gateway too), so a stamp there gets @@ -3108,6 +3151,8 @@ main() { print_success + write_bootstrap_marker + # Code-scoped stamp: write next to the install tree, not into $HERMES_HOME. # $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker # gateway too), so a stamp there gets clobbered by the container's 'docker' diff --git a/tests/test_install_sh_bootstrap_marker.py b/tests/test_install_sh_bootstrap_marker.py new file mode 100644 index 000000000000..6eab5d6599de --- /dev/null +++ b/tests/test_install_sh_bootstrap_marker.py @@ -0,0 +1,109 @@ +"""install.sh must stamp the desktop bootstrap-complete marker. + +The marker at ``$INSTALL_DIR/.hermes-bootstrap-complete`` is what the desktop +app (apps/desktop/electron/main.ts) and the macOS launcher fast path +(apps/bootstrap-installer) use to decide "a real install finished here." +install.sh never wrote it, so a CLI-installed Mac/Linux box re-ran first-run +bootstrap on every desktop launch (#60721). + +These exercise the real shell function against a temp checkout rather than +asserting on the text of install.sh. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" + + +def run_write_marker(install_dir, *, commit="", branch="main"): + """Source install.sh and invoke write_bootstrap_marker in isolation. + + install.sh guards its own entrypoint behind MANIFEST_MODE/STAGE_NAME/main, + so sourcing it with --help-less argv defines the functions without running + an install. + """ + script = f""" +set -e +INSTALL_DIR={install_dir!s} +INSTALL_COMMIT={commit!r} +BRANCH={branch!r} +# Pull in the function definitions without triggering an install. +eval "$(sed -n '/^write_bootstrap_marker()/,/^}}/p' {INSTALL_SH!s})" +log_warn() {{ echo "WARN: $*" >&2; }} +write_bootstrap_marker +""" + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=30 + ) + + +def make_checkout(tmp_path): + install_dir = tmp_path / "hermes-agent" + install_dir.mkdir() + subprocess.run(["git", "init", "-q"], cwd=install_dir, check=True) + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", + "--allow-empty", "-m", "init"], + cwd=install_dir, + check=True, + ) + return install_dir + + +def test_marker_matches_the_schema_the_desktop_validates(tmp_path): + """Desktop's isBootstrapComplete() needs schemaVersion 1 + a >=7 char commit.""" + install_dir = make_checkout(tmp_path) + + result = run_write_marker(install_dir) + assert result.returncode == 0, result.stderr + + marker = install_dir / ".hermes-bootstrap-complete" + assert marker.is_file(), "install.sh must stamp the bootstrap marker" + + payload = json.loads(marker.read_text()) + assert payload["schemaVersion"] == 1 + assert len(payload["pinnedCommit"]) >= 7 + assert payload["pinnedBranch"] == "main" + assert payload["completedAt"].endswith("Z") + + +def test_marker_publish_leaves_no_temp_sibling(tmp_path): + """The launcher predicate is existence-only, so the write must be atomic.""" + install_dir = make_checkout(tmp_path) + + run_write_marker(install_dir) + + assert (install_dir / ".hermes-bootstrap-complete").is_file() + assert not (install_dir / ".hermes-bootstrap-complete.tmp").exists() + + +def test_explicit_commit_pin_wins_over_head(tmp_path): + install_dir = make_checkout(tmp_path) + pinned = "abcdef1234567890abcdef1234567890abcdef12" + + run_write_marker(install_dir, commit=pinned) + + payload = json.loads((install_dir / ".hermes-bootstrap-complete").read_text()) + assert payload["pinnedCommit"] == pinned + + +def test_no_marker_written_when_head_cannot_be_resolved(tmp_path): + """A malformed marker is worse than none: absent means a clean re-bootstrap.""" + install_dir = tmp_path / "not-a-checkout" + install_dir.mkdir() + + result = run_write_marker(install_dir) + + assert result.returncode == 0, "an unresolvable HEAD must not fail the install" + assert not (install_dir / ".hermes-bootstrap-complete").exists() + + +def test_missing_install_dir_is_not_fatal(tmp_path): + result = run_write_marker(tmp_path / "does-not-exist") + + assert result.returncode == 0