From 2b0b5e4c5391ffba191d92f672ac8aa8f38c0e8b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 16:06:50 -0500 Subject: [PATCH] fix(install): stamp the bootstrap-complete marker from install.sh too install.ps1 wrote the marker on Windows and the Rust installer now writes it, but install.sh -- the path every Mac and Linux CLI install takes -- never did. A machine set up with install.sh therefore looked uninstalled to the desktop app, which re-ran first-run bootstrap on every launch. Stamp the same schema-v1 payload install.ps1 writes, from both the staged `complete` stage and monolithic main(). An unresolvable HEAD skips the marker rather than writing one the desktop validator rejects: absent reads as a clean "bootstrap needed", malformed reads as a confusing half-state. --- scripts/install.sh | 45 +++++++++ tests/test_install_sh_bootstrap_marker.py | 109 ++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/test_install_sh_bootstrap_marker.py 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