feat(update): install manifest, release channels, and eject

.hermes-install.json marks a checkout as source-managed or
desktop-bundled and records its update channel. A missing file means
source mode on the main channel, so no existing install changes.

`hermes update` reads the manifest:
- On a bundled install it refuses and points at the in-app updater.
- On the stable channel it fast-forwards the checkout to the newest
  final release tag (vX.Y.Z, three-digit major cap so legacy CalVer
  tags never match) instead of origin/main. The ZIP fallback resolves
  the tag through the GitHub API because that path runs when git file
  I/O is broken.
- `update.channel` in config.yaml overrides the channel for source
  installs. "auto" defers to the manifest.

`hermes update --eject` is the exit from desktop management. On a
bundled install it downloads Hermes Setup and launches it pinned to
the exact commit the bundle was built from; the installer creates a
normal source checkout at ~/.hermes/hermes-agent. Hermes Setup accepts
the new `--pin-commit <sha>` argument for this flow. On a
source-managed install, --eject with --channel only switches the
channel. The "ejected" manageStyle is the permanent opt-out that stops
future auto-adoption.
This commit is contained in:
ethernet 2026-08-07 16:39:19 -04:00
parent 01aba5cf16
commit 694bd9ab2e
12 changed files with 1276 additions and 16 deletions

7
.gitignore vendored
View File

@ -165,6 +165,13 @@ docs/superpowers/*
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
/.install_method
# Install-mode manifest (bundled vs source, channel, pins) and the build stamp
# staged into bundled payload trees. Both are Hermes-managed runtime state in
# the checkout root, never a code change. After `hermes update --eject` grafts
# git onto a bundled tree, these must not read as local edits.
/.hermes-install.json
/.hermes_build_info.json
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/

View File

@ -98,7 +98,13 @@ pub async fn start_bootstrap(
let app_for_task = app.clone();
let state_for_task = state.inner().clone();
let args_for_task = args;
let mut args_for_task = args;
// A process-level `--pin-commit` (the resident-eject flow) beats both
// the frontend's null and the build-time pin: this Setup binary is the
// latest release, but the eject wants the ejecting app's own commit.
if let Some(pin) = state.pin_commit.as_ref() {
args_for_task.commit = Some(pin.clone());
}
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
tokio::spawn(async move {

View File

@ -64,6 +64,37 @@ where
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
}
/// Extract a runtime commit pin from `--pin-commit <sha>`. The one caller
/// today is `hermes update --eject` on a resident desktop bundle: it
/// launches Hermes Setup pinned to the exact commit its own release was
/// built from, so the ejected source checkout matches the code the user
/// was running. A runtime pin beats the build-time `BUILD_PIN_COMMIT`
/// because the downloaded Hermes-Setup binary is the LATEST build — its
/// baked pin names a newer release than the app doing the eject. Only a
/// full 40-char hex sha is accepted; anything else is ignored and the
/// build-time pin applies.
pub fn pin_commit_from_args<I, S>(args: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut take_next = false;
for a in args {
let a = a.as_ref();
if take_next {
let sha = a.trim().to_ascii_lowercase();
if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return Some(sha);
}
return None;
}
if a == "--pin-commit" {
take_next = true;
}
}
None
}
/// Process-wide install state, shared across Tauri commands.
///
/// The bootstrap is a one-shot, single-tenant process — we only need one
@ -74,13 +105,17 @@ pub struct AppState {
/// How this process was launched (install vs update). Immutable for the
/// lifetime of the process; read by the `get_mode` command.
pub mode: AppMode,
/// Runtime commit pin from `--pin-commit` (the resident-eject flow).
/// Overrides `BUILD_PIN_COMMIT` for every bootstrap this process runs.
pub pin_commit: Option<String>,
}
impl AppState {
fn new(mode: AppMode) -> Self {
fn new(mode: AppMode, pin_commit: Option<String>) -> Self {
Self {
bootstrap: Mutex::new(None),
mode,
pin_commit,
}
}
}
@ -103,14 +138,15 @@ pub fn run() {
// Hermes is already installed, so users can re-run setup to repair a broken
// install instead of the launcher fast path silently relaunching the app.
let force_setup = force_setup_from_args(std::env::args().skip(1));
tracing::info!(?mode, force_setup, "Hermes installer starting");
let pin_commit = pin_commit_from_args(std::env::args().skip(1));
tracing::info!(?mode, force_setup, ?pin_commit, "Hermes installer starting");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.manage(Arc::new(AppState::new(mode)))
.manage(Arc::new(AppState::new(mode, pin_commit.clone())))
.setup(move |app| {
use tauri::Manager;
// Launcher fast path (macOS only): a bare ("Install") launch when
@ -129,7 +165,9 @@ pub fn run() {
//
// `--reinstall`/`--repair` opts out so a broken install can be
// repaired by re-running setup instead of launching the bad app.
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
// `--pin-commit` opts out too: that launch IS an install request
// (the resident-eject flow), never a launcher double-click.
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup && pin_commit.is_none() {
let install_root = paths::hermes_home().join("hermes-agent");
if bootstrap::hermes_is_installed(&install_root) {
match bootstrap::spawn_installed_desktop(&install_root) {
@ -187,7 +225,7 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::{force_setup_from_args, AppMode};
use super::{force_setup_from_args, pin_commit_from_args, AppMode};
#[test]
fn bare_args_are_install() {
@ -229,4 +267,30 @@ mod tests {
AppMode::Update
);
}
#[test]
fn pin_commit_takes_only_a_full_hex_sha() {
let sha = "a".repeat(40);
assert_eq!(
pin_commit_from_args(["--pin-commit", &sha]),
Some(sha.clone())
);
// Uppercase input normalizes to lowercase.
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
assert_eq!(
pin_commit_from_args(["--pin-commit", upper]),
Some(upper.to_ascii_lowercase())
);
// Tags, branches, short shas, and missing values are all rejected —
// the build-time pin applies instead.
assert_eq!(pin_commit_from_args(["--pin-commit", "v1.2.3"]), None);
assert_eq!(pin_commit_from_args(["--pin-commit", "deadbeef"]), None);
assert_eq!(pin_commit_from_args(["--pin-commit"]), None);
assert_eq!(pin_commit_from_args(Vec::<String>::new()), None);
// Position-independent among other flags.
assert_eq!(
pin_commit_from_args(["--update", "--pin-commit", &sha, "--foo"]),
Some(sha)
);
}
}

View File

@ -2456,6 +2456,19 @@ DEFAULT_CONFIG = {
"backup_count": 3, # Number of rotated backup files to keep
},
# Settings for the update pipeline.
"update": {
# This setting selects the releases that `hermes update` tracks on
# source (git) installs:
# auto — use the install manifest (.hermes-install.json).
# This is the same as "main" for each pre-existing install.
# main — git pull origin main (the current behavior).
# stable — check out the latest tagged release, not main.
# Bundled desktop installs ignore this setting and always track
# stable. Their updates come from the updater of the desktop app.
"channel": "auto",
},
# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
# curated model lists for OpenRouter and Nous Portal from this URL,
# falling back to the in-repo snapshot on network failure. Lets us

View File

@ -0,0 +1,219 @@
"""Install-manifest functions for bundled desktop installs.
``.hermes-install.json`` is a small marker file next to the managed checkout.
It sits in the parent directory of ``hermes_cli/``. This is the same anchor
rule as ``.install_method`` in ``hermes_cli/config.py``. The marker describes
the running code, not ``$HERMES_HOME``. Two installs that share one data
directory cannot overwrite each other's marker.
The file records where a checkout came from and where its updates come from:
{
"schemaVersion": 1,
"installMode": "bundled" | "source",
"channel": "stable" | "main",
"manageStyle": "adopted" | "ejected", # optional
"pinnedCommit": "<sha>", # optional
"pinnedTag": "v0.17.0" # optional, bundled installs only
}
Semantics
---------
* ``installMode: "source"`` the user manages the checkout. ``hermes update``
owns updates (git pull, tag checkout, or ZIP fallback). **A missing file
means source mode.** Every install that exists today is a source install.
No migration is necessary.
* ``installMode: "bundled"`` the checkout came from payloads inside the
desktop installer. The desktop app owns updates. It rebuilds the checkout
offline after the app updates itself. ``hermes update`` refuses and points
at the in-app updater.
* ``channel`` ``"main"`` follows the git main branch (source mode only).
``"stable"`` follows tagged releases. :func:`resolve_update_channel` gives
the effective channel. ``update.channel`` in config.yaml can override the
channel for source installs. Bundled installs are always stable.
* ``manageStyle`` how the install got into its current mode.
``installMode`` says where it is now. Values:
- ``"adopted"`` the install is desktop-managed (a resident bundle's
static manifest, or an installer run that selected bundled mode).
- ``"ejected"`` the user ran ``hermes update --eject``. This opt-out is
permanent, although the resulting ``installMode`` is ``"source"``.
- missing a legacy checkout from before manifests, or a plain source
install.
This is a pure-stdlib leaf module. It does not import hermes_cli.config.
A config import would pull the full config machinery into every consumer.
The desktop bootstrap and the install scripts also write this file without
Python.
"""
import json
import logging
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
INSTALL_MANIFEST_NAME = ".hermes-install.json"
INSTALL_MANIFEST_SCHEMA_VERSION = 1
MODE_SOURCE = "source"
MODE_BUNDLED = "bundled"
_VALID_MODES = (MODE_SOURCE, MODE_BUNDLED)
CHANNEL_MAIN = "main"
CHANNEL_STABLE = "stable"
_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE)
STYLE_ADOPTED = "adopted"
STYLE_EJECTED = "ejected"
_VALID_STYLES = (STYLE_ADOPTED, STYLE_EJECTED)
def _default_manifest() -> dict:
"""The implicit manifest for a checkout with no ``.hermes-install.json``.
Source mode on the main channel. This is the current behavior, so
installs from before manifests do not change.
"""
return {
"schemaVersion": INSTALL_MANIFEST_SCHEMA_VERSION,
"installMode": MODE_SOURCE,
"channel": CHANNEL_MAIN,
}
def install_manifest_path(project_root: Optional[Path] = None) -> Path:
"""Path of the manifest for the running code's install tree."""
root = project_root if project_root is not None else Path(__file__).parent.parent
return Path(root).resolve() / INSTALL_MANIFEST_NAME
def read_install_manifest(project_root: Optional[Path] = None) -> dict:
"""Read the install manifest and correct bad values.
This function does not raise errors. A missing, unreadable, or malformed
file falls back to the source/main default. Unknown ``installMode`` or
``channel`` values (for example, from a future Hermes with more values)
also fall back. This keeps the update logic safe. The function keeps
unknown extra keys, so a round trip of a future manifest does not remove
fields.
"""
path = install_manifest_path(project_root)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return _default_manifest()
except (OSError, ValueError) as exc:
logger.warning("Unreadable install manifest at %s (%s); assuming source install", path, exc)
return _default_manifest()
if not isinstance(raw, dict):
logger.warning("Install manifest at %s is not a JSON object; assuming source install", path)
return _default_manifest()
manifest = dict(raw)
if manifest.get("installMode") not in _VALID_MODES:
manifest["installMode"] = MODE_SOURCE
if manifest.get("channel") not in _VALID_CHANNELS:
manifest["channel"] = CHANNEL_MAIN if manifest["installMode"] == MODE_SOURCE else CHANNEL_STABLE
# Drop unknown manageStyle values. Do not replace them with a default.
# A missing style means "adoption can examine this checkout". An invented
# style can block adoption in error, or worse, remove an eject opt-out
# that a future vocabulary wrote. Exception: a value that contains
# "eject" stays ejected. The opt-out must survive vocabulary changes in
# both directions.
style = manifest.get("manageStyle")
if style is not None and style not in _VALID_STYLES:
if isinstance(style, str) and "eject" in style.lower():
manifest["manageStyle"] = STYLE_EJECTED
else:
del manifest["manageStyle"]
manifest.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION)
return manifest
def write_install_manifest(
manifest: dict,
project_root: Optional[Path] = None,
) -> Path:
"""Write the manifest atomically (tmp file + rename). Return the path.
The function validates mode, channel, and style before the write. A bad
write is worse than an error: every reader silently converts bad values
to source/main, and the caller's intent (for example, to mark an install
bundled) is lost without a signal.
"""
if manifest.get("installMode") not in _VALID_MODES:
raise ValueError(f"invalid installMode: {manifest.get('installMode')!r}")
if manifest.get("channel") not in _VALID_CHANNELS:
raise ValueError(f"invalid channel: {manifest.get('channel')!r}")
if manifest.get("manageStyle") is not None and manifest["manageStyle"] not in _VALID_STYLES:
raise ValueError(f"invalid manageStyle: {manifest.get('manageStyle')!r}")
payload = dict(manifest)
payload.setdefault("schemaVersion", INSTALL_MANIFEST_SCHEMA_VERSION)
path = install_manifest_path(project_root)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(path)
return path
def is_bundled_install(project_root: Optional[Path] = None) -> bool:
"""True when the running checkout came from desktop payloads."""
return read_install_manifest(project_root).get("installMode") == MODE_BUNDLED
def is_ejected(project_root: Optional[Path] = None) -> bool:
"""True when the user ejected this checkout from desktop management.
This is the permanent opt-out signal for auto-adoption. Auto-adoption
must not move an ejected checkout back into the bundled path, although
its ``installMode`` is ``source``.
"""
return read_install_manifest(project_root).get("manageStyle") == STYLE_EJECTED
def resolve_update_channel(
config: Optional[dict] = None,
project_root: Optional[Path] = None,
) -> str:
"""Give the effective update channel for this install.
Resolution order:
1. Bundled installs are always ``stable``. The desktop app rebuilds the
checkout from tagged release payloads. A config override cannot change
what the installer ships. Eject first to change this.
2. ``update.channel`` from config.yaml, when it is ``stable`` or ``main``.
The values ``auto``, empty, and unknown fall through.
3. The channel from the manifest. The source default is ``main``.
"""
manifest = read_install_manifest(project_root)
if manifest.get("installMode") == MODE_BUNDLED:
return CHANNEL_STABLE
configured: Any = None
if isinstance(config, dict):
update_cfg = config.get("update")
if isinstance(update_cfg, dict):
configured = update_cfg.get("channel")
if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS:
return configured.strip().lower()
return manifest.get("channel", CHANNEL_MAIN)
def format_bundled_update_message() -> str:
"""Refusal text for ``hermes update`` on a bundled install."""
return (
"✗ The Hermes desktop app manages this Hermes install.\n"
"\n"
"The desktop app updates the agent together with itself. Use the\n"
"in-app updater (Settings → Check for updates), not `hermes update`.\n"
"\n"
"If you want to manage this checkout yourself with `hermes update`,\n"
"eject it from desktop management first. After an eject, the desktop\n"
"app continues to update itself, but the agent checkout is yours."
)

View File

@ -9183,6 +9183,27 @@ def cmd_update(args):
print(recommended_update_command_for_method(install_method))
sys.exit(1)
# --eject runs BEFORE the bundled-install refusal below. The eject
# operation is the one update operation that must work on a bundled
# install. It is the exit from desktop management. On source installs
# it only sets the channel or does nothing.
if getattr(args, "eject", False):
from hermes_cli.update_cmd import cmd_update_eject
sys.exit(cmd_update_eject(args))
# Bundled desktop installs are materialized from payloads shipped inside
# the desktop app. The updater of the app re-materializes the checkout
# after the app updates itself. If `hermes update` changes that checkout,
# the checkout no longer agrees with the stamped tag of the shell. Thus
# refuse, and point at the in-app updater or at eject. Eject changes the
# install to source mode.
from hermes_cli.install_manifest import format_bundled_update_message, is_bundled_install
if is_bundled_install(PROJECT_ROOT):
print(format_bundled_update_message())
sys.exit(1)
if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
# what a subsequent `hermes update --branch=<x>` would actually pull.

View File

@ -73,4 +73,29 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
default=False,
help="Windows: mutate the venv even while other processes are running from its interpreter (desktop backend, gateway, terminals). Those processes keep native .pyd files locked, so the dependency sync will likely fail partway and strand the install half-updated. Use only if you know the detected holders are false positives.",
)
update_parser.add_argument(
"--eject",
action="store_true",
default=False,
help=(
"Take control of updates for a desktop-bundled install. This "
"option marks the checkout as source-managed (git updates with "
"`hermes update`) and fetches the full git history. The desktop "
"app continues to update itself, but it no longer touches the "
"agent checkout. This option has no effect on installs that are "
"already source-managed."
),
)
update_parser.add_argument(
"--channel",
default=None,
choices=("stable", "main"),
metavar="CHANNEL",
help=(
"With --eject: the releases that the ejected install tracks. Use "
"'stable' for tagged releases or 'main' for the git main branch. "
"The desktop cadence before the eject is 'stable'. The default "
"is 'main'."
),
)
update_parser.set_defaults(func=cmd_update)

View File

@ -27,6 +27,7 @@ import hashlib
import json
import logging
import os
import re
import shlex
import shutil
import subprocess
@ -154,6 +155,154 @@ _UPDATE_CRITICAL_FILES = (
"hermes_constants.py",
)
# Release tags have the form v1.2.3. A tag can have a pre-release suffix.
# The stable channel ignores tags with a suffix. Stable means final releases only.
# The major component is capped at three digits. The historical CalVer tags
# (for example v2026.7.20) use a four-digit year, and a numeric sort would
# rank them above every SemVer release. This matches _SEMVER_TAG_RE in
# scripts/write_install_stamp.py.
_RELEASE_TAG_RE = re.compile(r"^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)$")
def _parse_release_tag(tag: str):
"""Parse ``vX.Y.Z`` into a sortable (X, Y, Z) tuple, or return None.
Tags with a pre-release or build suffix (``v1.2.3-rc1``) return None.
Tags that do not have the shape of a final release also return None.
The stable channel only moves between final releases.
"""
m = _RELEASE_TAG_RE.match(tag.strip())
if not m:
return None
return tuple(int(g) for g in m.groups())
def _latest_release_tag_from_ls_remote(output: str):
"""Select the newest final-release tag from ``git ls-remote --tags`` output.
Returns ``(tag, sha)`` or ``(None, None)``. Peeled entries (``^{}``) have
priority over the tag-object SHA. Thus annotated tags and lightweight tags
both give the commit SHA.
"""
best = None # (version_tuple, tag)
shas = {} # tag -> commit sha (peeled wins)
for line in output.splitlines():
parts = line.split("\t")
if len(parts) != 2:
continue
sha, ref = parts
if not ref.startswith("refs/tags/"):
continue
name = ref[len("refs/tags/"):]
peeled = name.endswith("^{}")
if peeled:
name = name[:-3]
version = _parse_release_tag(name)
if version is None:
continue
if peeled or name not in shas:
shas[name] = sha.strip()
if best is None or version > best[0]:
best = (version, name)
if best is None:
return None, None
tag = best[1]
return tag, shas.get(tag)
def _resolve_latest_release_tag(git_cmd, cwd):
"""Ask origin for the newest final release tag. Returns (tag, sha) or (None, None)."""
try:
result = subprocess.run(
git_cmd + ["ls-remote", "--tags", "origin", "v*"],
cwd=cwd,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=60,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.warning("Could not list release tags from origin: %s", exc)
return None, None
if result.returncode != 0:
logger.warning(
"git ls-remote --tags failed: %s",
(result.stderr or "").strip().splitlines()[:1],
)
return None, None
return _latest_release_tag_from_ls_remote(result.stdout)
def _stable_channel_active(args) -> bool:
"""Return True when this update must track tagged releases, not a branch.
``args`` is the update argparse namespace, or None when the caller has no
branch flag to honor. An explicit ``--branch`` always wins. With this flag
the user tells us the exact update target. If a tag silently overrides the
flag, the class of bug that --branch prevents comes back. In all other
cases the effective channel comes from the install manifest and
``update.channel`` in config.yaml
(see hermes_cli.install_manifest.resolve_update_channel).
"""
if getattr(args, "branch", None):
return False
try:
from hermes_cli.config import load_config
from hermes_cli.install_manifest import CHANNEL_STABLE, resolve_update_channel
config = None
try:
config = load_config()
except Exception as exc:
logger.debug("Could not load config for channel resolution: %s", exc)
return resolve_update_channel(config, _m().PROJECT_ROOT) == CHANNEL_STABLE
except Exception as exc:
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
return False
def _github_latest_release_tag():
"""Resolve the newest final-release tag with the GitHub API (no git necessary).
The ZIP-fallback path uses this function. That path exists because git
file I/O is broken. The function tries /releases/latest first, because that
endpoint obeys the draft and prerelease curation. If that fails, it lists
the tags and selects the maximum final release.
Returns the tag name or None.
"""
import urllib.error
import urllib.request
def _get_json(url):
req = urllib.request.Request(
url, headers={"Accept": "application/vnd.github+json",
"User-Agent": "hermes-update"}
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
base = "https://api.github.com/repos/NousResearch/hermes-agent"
try:
data = _get_json(f"{base}/releases/latest")
tag = data.get("tag_name")
if isinstance(tag, str) and _parse_release_tag(tag) is not None:
return tag
except (urllib.error.URLError, OSError, ValueError) as exc:
logger.debug("GitHub /releases/latest failed: %s", exc)
try:
data = _get_json(f"{base}/tags?per_page=100")
candidates = [
(v, t["name"])
for t in data
if isinstance(t, dict) and isinstance(t.get("name"), str)
and (v := _parse_release_tag(t["name"])) is not None
]
if candidates:
return max(candidates)[1]
except (urllib.error.URLError, OSError, ValueError) as exc:
logger.warning("Could not resolve latest release from GitHub API: %s", exc)
return None
def _capture_head_sha(git_cmd, cwd) -> str | None:
"""Return the current HEAD SHA, or None if it can't be resolved."""
try:
@ -802,8 +951,21 @@ def _update_via_zip(args):
f"--branch {branch}`, or update against main with `hermes update`."
)
_m().sys.exit(1)
# Stable channel: pull the archive of the release tag, not main. The ZIP
# path runs when git file I/O is broken. Thus resolve the tag with the
# GitHub API, not with git. No git invocation is necessary.
zip_ref = f"refs/heads/{branch}"
if _stable_channel_active(args):
tag = _github_latest_release_tag()
if tag is None:
print("✗ Hermes cannot resolve the latest release from the GitHub API.")
print(" Switch channels with: hermes config set update.channel main")
_m().sys.exit(1)
print(f"→ Update channel: stable. Hermes downloads release {tag}.")
zip_ref = f"refs/tags/{tag}"
zip_url = (
f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
f"https://github.com/NousResearch/hermes-agent/archive/{zip_ref}.zip"
)
print("→ Downloading latest version...")
@ -2284,6 +2446,40 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
# Stable channel: if the caller did not ask for a branch, the question is
# "is there a newer tagged release?". The question is not "are there new
# commits on main?". Compare against the newest release tag and return.
if not branch_explicit:
if _stable_channel_active(None):
print("→ Update channel: stable (tagged releases)")
tag, tag_sha = _resolve_latest_release_tag(git_cmd, _m().PROJECT_ROOT)
if tag is None:
print("✗ No release tags found on origin. A check of the stable channel is not possible.")
print(" Switch channels with: hermes config set update.channel main")
sys.exit(1)
head_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT)
# Newer releases possibly do not exist locally yet. "At the tag"
# is a SHA comparison. The merge-base check tells us whether HEAD
# contains the tag (HEAD is ahead of the release or at the release).
at_or_past_tag = False
if head_sha and tag_sha:
if head_sha == tag_sha:
at_or_past_tag = True
else:
contained = subprocess.run(
git_cmd + ["merge-base", "--is-ancestor", tag_sha, "HEAD"],
cwd=_m().PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
at_or_past_tag = contained.returncode == 0
if at_or_past_tag:
print(f"✓ Up to date with the latest release ({tag}).")
else:
print(f"→ New release available: {tag}")
print(" Run `hermes update` to install it.")
return
# Fetch only the branch we compare against; prefer upstream as the canonical
# reference. A bare `git fetch <remote>` pulls every ref, and this repo has
# thousands of auto-generated branches, so scope the fetch to <branch>.
@ -3827,6 +4023,218 @@ def _normalize_managed_eol(git_cmd, repo_root):
# Never let line-ending cleanup block an update.
pass
def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
"""Eject a resident bundle: hand the install to Hermes Setup.
The resident bundle is sealed (codesigned resources); no git graft is
possible or wanted, and hand-rolling clone+venv here would duplicate
the installer badly (no PATH setup, no config templates, no system
checks). Instead this downloads the official Hermes Setup app from the
website and launches it pinned to the EXACT commit this bundle was
built from (.hermes_build_info.json), so the ejected source checkout
matches the code the user is running. The installer then performs a
normal source install at ~/.hermes/hermes-agent; the desktop prefers
that checkout on its next launch. Network is required the same
contract as every other eject.
Returns a process exit code.
"""
if sys.platform not in ("darwin", "win32"):
print("\u2717 Hermes Setup is only published for macOS and Windows.")
print(" Install from source instead:")
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
print(" The desktop app will prefer that checkout on its next launch.")
return 1
target = get_hermes_home() / "hermes-agent"
existing = _read_json_or_none(target / ".hermes-install.json")
if (target / ".git").exists() and (existing is None or existing.get("installMode") == "source"):
print(f"\u2713 A source checkout already exists at {target}.")
print(" The desktop app will use it on its next launch.")
print(" Update it with: hermes update")
return 0
# The exact commit of this bundle. The pinned tag is the fallback label
# for the message; the pin itself must be a commit sha because tags can
# be re-pointed but the sha names what this bundle actually runs.
build_info = _read_json_or_none(bundle_repo_root / ".hermes_build_info.json") or {}
commit = str(build_info.get("commit") or "")
if not re.fullmatch(r"[0-9a-f]{40}", commit):
print("\u2717 An eject is not possible. The bundle's build info has no valid commit.")
print(" Reinstall from source instead:")
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
return 1
setup_name = "Hermes-Setup.dmg" if sys.platform == "darwin" else "Hermes-Setup.exe"
setup_url = f"https://hermes-assets.nousresearch.com/{setup_name}"
print("\u2695 Hermes ejects this install from the sealed app bundle...")
print(f"\u2192 Hermes downloads Hermes Setup from {setup_url} ...")
import tempfile
scratch = Path(tempfile.mkdtemp(prefix="hermes-eject-"))
setup_path = scratch / setup_name
if not _download_hermes_setup(setup_url, setup_path):
shutil.rmtree(scratch, ignore_errors=True)
print("\u2717 The download failed. Hermes aborted the eject. The install is unchanged.")
return 1
print(f"\u2192 Hermes starts the installer, pinned to {pinned_tag} ({commit[:12]})...")
ok = _launch_hermes_setup(setup_path, scratch, commit)
if not ok:
shutil.rmtree(scratch, ignore_errors=True)
print("\u2717 Hermes could not start the installer. The install is unchanged.")
print(f" The downloaded file is gone; get it manually: {setup_url}")
return 1
print("\u2713 Hermes Setup is running. Follow its window to finish the eject.")
print(f" \u2022 It installs a source-managed checkout at {target}")
print(" \u2022 The desktop app will use that checkout on its next launch.")
print(" \u2022 After the install, update with: hermes update")
return 0
def _read_json_or_none(path: Path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
def _download_hermes_setup(url: str, dest: Path) -> bool:
"""Download the installer to ``dest``. Returns False on any failure."""
import urllib.request
# The asset CDN rejects urllib's default Python-urllib/3.x agent
# with 403; identify as Hermes instead.
request = urllib.request.Request(url, headers={"User-Agent": "hermes-agent-eject"})
try:
with urllib.request.urlopen(request, timeout=120) as resp, open(dest, "wb") as out:
shutil.copyfileobj(resp, out)
return True
except OSError as exc:
print(f" {exc}")
return False
def _launch_hermes_setup(setup_path: Path, scratch: Path, commit: str) -> bool:
"""Start the downloaded Hermes Setup detached, pinned to ``commit``.
macOS: mount the dmg, copy the .app out to the scratch dir (so the
mount can go away), detach, and open the copy. ``open`` passes args
after ``--args`` to the app process. Windows: run the exe directly.
Returns False when any step fails; never raises.
"""
try:
if sys.platform == "darwin":
mount = scratch / "mnt"
mount.mkdir()
attach = subprocess.run(
["hdiutil", "attach", str(setup_path), "-mountpoint", str(mount),
"-nobrowse", "-quiet"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if attach.returncode != 0:
print(f" hdiutil attach failed: {(attach.stderr or '').strip()}")
return False
try:
apps = sorted(mount.glob("*.app"))
if not apps:
print(" The mounted image has no .app.")
return False
app_copy = scratch / apps[0].name
shutil.copytree(apps[0], app_copy, symlinks=True)
finally:
subprocess.run(
["hdiutil", "detach", str(mount), "-quiet"],
capture_output=True, check=False,
)
launch = subprocess.run(
["open", "-n", str(app_copy), "--args", "--pin-commit", commit],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if launch.returncode != 0:
print(f" open failed: {(launch.stderr or '').strip()}")
return launch.returncode == 0
# Windows: the exe is the app. Detach so the eject command returns.
subprocess.Popen(
[str(setup_path), "--pin-commit", commit],
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
close_fds=True,
)
return True
except OSError as exc:
print(f" {exc}")
return False
def cmd_update_eject(args) -> int:
"""Implement ``hermes update --eject``.
A desktop-bundled install runs the agent out of the sealed app bundle
(resident mode). The eject creates a normal source install beside it:
it downloads Hermes Setup from the website and launches it pinned to
the exact commit this bundle was built from. The desktop app prefers
the resulting source checkout on its next launch. On an install that
is already source-managed, the command only switches the channel.
Returns a process exit code.
"""
from hermes_cli.install_manifest import (
CHANNEL_MAIN,
CHANNEL_STABLE,
MODE_SOURCE,
STYLE_EJECTED,
install_manifest_path,
read_install_manifest,
write_install_manifest,
)
project_root = _m().PROJECT_ROOT
manifest = read_install_manifest(project_root)
channel = getattr(args, "channel", None) or CHANNEL_MAIN
if channel not in (CHANNEL_MAIN, CHANNEL_STABLE):
print(f"✗ Unknown channel '{channel}'. Use 'stable' or 'main'.")
return 1
if manifest.get("installMode") != "bundled":
# The install is already source-managed. Obey an explicit --channel
# request, so that `hermes update --eject --channel stable` is a
# one-shot way to switch. But do not touch the git history.
if getattr(args, "channel", None):
manifest["installMode"] = MODE_SOURCE
manifest["channel"] = channel
# We do not set the ejected mark here. This shorthand runs on
# checkouts that the desktop never managed, or on checkouts that
# the user already ejected. In the second case, the code below
# keeps the existing style. A plain channel switch is not an
# adoption opt-out.
write_install_manifest(manifest, project_root)
print(f"✓ The install is already source-managed. The channel is now '{channel}'.")
else:
print("✓ Nothing to eject. This install is already source-managed.")
print(" (Only desktop-bundled installs need an eject.)")
return 0
pinned_tag = manifest.get("pinnedTag") or ""
if not re.fullmatch(r"v(0|[1-9]\d{0,2})\.\d+\.\d+", pinned_tag):
print("✗ An eject is not possible. The install manifest has no valid pinned tag.")
print(" Reinstall from source instead:")
print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash")
return 1
# Hermes Setup writes the ejected checkout's own manifest, so an explicit
# --channel cannot apply here. Say so instead of dropping it silently.
if getattr(args, "channel", None):
print(f"⚠ --channel {channel} does not apply to this eject.")
print(" After the install, set it with: hermes update --eject --channel " + channel)
return _eject_resident_bundle(project_root, pinned_tag)
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
@ -4030,9 +4438,30 @@ def _cmd_update_impl(args, gateway_mode: bool):
# against.
branch = _m()._resolve_update_branch(args)
# target_ref is the reference that we count against, fast-forward to,
# and reset to. On the main channel it is origin/<branch>. That is the
# historical behavior. On the stable channel it is the commit of the
# newest release tag. The current branch pointer fast-forwards to the
# release. Thus the checkout keeps its branch shape (no detached HEAD).
# The next stable update then fast-forward merges to the next tag.
target_ref = f"origin/{branch}"
stable_tag = None
if _stable_channel_active(args):
print("→ Update channel: stable (tagged releases)")
stable_tag, _stable_tag_sha = _resolve_latest_release_tag(
git_cmd, _m().PROJECT_ROOT
)
if stable_tag is None:
print("✗ No release tags found on origin. An update on the stable channel is not possible.")
print(" Switch channels with: hermes config set update.channel main")
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
sys.exit(1)
print(f"→ Latest release: {stable_tag}")
print("→ Fetching updates...")
fetch_target = ["tag", stable_tag] if stable_tag else [branch]
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin", branch],
git_cmd + ["fetch", "origin", *fetch_target],
cwd=_m().PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
@ -4068,8 +4497,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
# to the target. When the target is "main" this is the historical
# "always update against main" behavior; for any other target it's
# the same thing — get HEAD onto the requested branch first, then
# fast-forward.
if current_branch != branch:
# fast-forward. On the stable channel we do NOT switch branches. The
# branch of the checkout fast-forwards (or resets) to the commit of
# the release tag.
if stable_tag is not None:
target_ref = stable_tag
auto_stash_ref = _m()._stash_local_changes_if_needed(git_cmd, _m().PROJECT_ROOT)
elif current_branch != branch:
label = (
"detached HEAD"
if current_branch == "HEAD"
@ -4121,7 +4555,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Check if there are updates
result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
git_cmd + ["rev-list", f"HEAD..{target_ref}", "--count"],
cwd=_m().PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
@ -4133,7 +4567,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
_invalidate_update_cache()
# Even if origin is up to date, the fork may be behind upstream
if is_fork and branch == "main":
# (main channel only, because a stable checkout tracks tags, not main).
if is_fork and branch == "main" and stable_tag is None:
_m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT)
# Restore stash and switch back to original branch if we moved
@ -4249,7 +4684,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# `pull --ff-only origin <branch>` given the fresh tracking ref;
# the divergence fallback below is unchanged.
pull_result = subprocess.run(
git_cmd + ["merge", "--ff-only", f"origin/{branch}"],
git_cmd + ["merge", "--ff-only", target_ref],
cwd=_m().PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
@ -4262,17 +4697,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
" ⚠ Fast-forward not possible (history diverged), resetting to match remote..."
)
reset_result = subprocess.run(
git_cmd + ["reset", "--hard", f"origin/{branch}"],
git_cmd + ["reset", "--hard", target_ref],
cwd=_m().PROJECT_ROOT,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
)
if reset_result.returncode != 0:
print(f"✗ Failed to reset to origin/{branch}.")
print(f"✗ Failed to reset to {target_ref}.")
if reset_result.stderr.strip():
print(f" {reset_result.stderr.strip()}")
print(
f" Try manually: git fetch origin && git reset --hard origin/{branch}"
f" Try manually: git fetch origin && git reset --hard {target_ref}"
)
sys.exit(1)

View File

@ -1029,6 +1029,11 @@ _CATEGORY_MERGE: Dict[str, str] = {
"prompt_caching": "agent",
"goals": "agent",
"updates": "general",
# `update.channel` is the only schema-surfaced field under `update` (the
# bundled-install release-channel selector) — fold it into general next
# to the sibling `updates` section rather than spawning a one-field
# orphan category.
"update": "general",
# `onboarding.profile_build` is the only schema-surfaced onboarding field
# (`onboarding.seen` is an internal latch dict, not a user setting), so fold
# it into the agent tab rather than spawning a one-field orphan category.

View File

@ -0,0 +1,207 @@
"""Tests for hermes_cli/install_manifest.py — bundled-install manifest plumbing."""
import json
import pytest
from hermes_cli.install_manifest import (
CHANNEL_MAIN,
CHANNEL_STABLE,
INSTALL_MANIFEST_NAME,
MODE_BUNDLED,
MODE_SOURCE,
STYLE_ADOPTED,
STYLE_EJECTED,
format_bundled_update_message,
install_manifest_path,
is_bundled_install,
is_ejected,
read_install_manifest,
resolve_update_channel,
write_install_manifest,
)
def _write_raw(tmp_path, payload):
(tmp_path / INSTALL_MANIFEST_NAME).write_text(
payload if isinstance(payload, str) else json.dumps(payload),
encoding="utf-8",
)
class TestReadInstallManifest:
def test_absent_file_means_source_main(self, tmp_path):
"""The back-compat contract: no manifest ⇒ source install on main."""
manifest = read_install_manifest(tmp_path)
assert manifest["installMode"] == MODE_SOURCE
assert manifest["channel"] == CHANNEL_MAIN
def test_malformed_json_degrades_to_source(self, tmp_path):
_write_raw(tmp_path, "{not json")
assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE
def test_non_object_json_degrades_to_source(self, tmp_path):
_write_raw(tmp_path, '["bundled"]')
assert read_install_manifest(tmp_path)["installMode"] == MODE_SOURCE
def test_unknown_mode_degrades_to_source(self, tmp_path):
"""A future vocabulary must not brick an older reader."""
_write_raw(tmp_path, {"installMode": "quantum", "channel": "stable"})
manifest = read_install_manifest(tmp_path)
assert manifest["installMode"] == MODE_SOURCE
assert manifest["channel"] == CHANNEL_STABLE
def test_unknown_channel_defaults_by_mode(self, tmp_path):
_write_raw(tmp_path, {"installMode": "bundled", "channel": "nightly"})
assert read_install_manifest(tmp_path)["channel"] == CHANNEL_STABLE
_write_raw(tmp_path, {"installMode": "source", "channel": "nightly"})
assert read_install_manifest(tmp_path)["channel"] == CHANNEL_MAIN
def test_extra_keys_preserved(self, tmp_path):
_write_raw(
tmp_path,
{"installMode": "bundled", "channel": "stable", "pinnedTag": "v0.17.0", "futureKey": 7},
)
manifest = read_install_manifest(tmp_path)
assert manifest["pinnedTag"] == "v0.17.0"
assert manifest["futureKey"] == 7
class TestWriteInstallManifest:
def test_roundtrip(self, tmp_path):
write_install_manifest(
{"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE, "pinnedTag": "v1.2.3"},
tmp_path,
)
manifest = read_install_manifest(tmp_path)
assert manifest["installMode"] == MODE_BUNDLED
assert manifest["pinnedTag"] == "v1.2.3"
assert manifest["schemaVersion"] == 1
def test_rejects_invalid_mode(self, tmp_path):
with pytest.raises(ValueError):
write_install_manifest({"installMode": "quantum", "channel": "stable"}, tmp_path)
assert not install_manifest_path(tmp_path).exists()
def test_rejects_invalid_channel(self, tmp_path):
with pytest.raises(ValueError):
write_install_manifest({"installMode": "source", "channel": "nightly"}, tmp_path)
def test_atomic_no_tmp_leftover(self, tmp_path):
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path)
leftovers = [p.name for p in tmp_path.iterdir() if p.name.endswith(".tmp")]
assert leftovers == []
class TestIsBundledInstall:
def test_default_is_not_bundled(self, tmp_path):
assert not is_bundled_install(tmp_path)
def test_bundled_manifest_detected(self, tmp_path):
write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path)
assert is_bundled_install(tmp_path)
class TestManageStyle:
def test_absent_by_default(self, tmp_path):
assert "manageStyle" not in read_install_manifest(tmp_path)
assert not is_ejected(tmp_path)
def test_valid_styles_roundtrip(self, tmp_path):
for style in (STYLE_ADOPTED, STYLE_EJECTED):
write_install_manifest(
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": style},
tmp_path,
)
assert read_install_manifest(tmp_path)["manageStyle"] == style
def test_ejected_is_sticky_signal(self, tmp_path):
write_install_manifest(
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": STYLE_EJECTED},
tmp_path,
)
assert is_ejected(tmp_path)
def test_unknown_style_dropped_not_defaulted(self, tmp_path):
"""A future vocabulary must not wrongly block (or force) adoption."""
_write_raw(
tmp_path,
{"installMode": "source", "channel": "main", "manageStyle": "quantum"},
)
assert "manageStyle" not in read_install_manifest(tmp_path)
def test_eject_smelling_future_style_stays_ejected(self, tmp_path):
"""The opt-out survives vocabulary drift: 'force-ejected' etc. reads as ejected."""
_write_raw(
tmp_path,
{"installMode": "source", "channel": "main", "manageStyle": "force-ejected"},
)
assert read_install_manifest(tmp_path)["manageStyle"] == STYLE_EJECTED
assert is_ejected(tmp_path)
def test_write_rejects_invalid_style(self, tmp_path):
with pytest.raises(ValueError):
write_install_manifest(
{"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN, "manageStyle": "quantum"},
tmp_path,
)
class TestResolveUpdateChannel:
def test_default_install_is_main(self, tmp_path):
assert resolve_update_channel(None, tmp_path) == CHANNEL_MAIN
def test_bundled_is_always_stable_even_with_config_main(self, tmp_path):
write_install_manifest({"installMode": MODE_BUNDLED, "channel": CHANNEL_STABLE}, tmp_path)
config = {"update": {"channel": "main"}}
assert resolve_update_channel(config, tmp_path) == CHANNEL_STABLE
def test_config_overrides_manifest_on_source(self, tmp_path):
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_MAIN}, tmp_path)
assert resolve_update_channel({"update": {"channel": "stable"}}, tmp_path) == CHANNEL_STABLE
def test_config_auto_defers_to_manifest(self, tmp_path):
write_install_manifest({"installMode": MODE_SOURCE, "channel": CHANNEL_STABLE}, tmp_path)
assert resolve_update_channel({"update": {"channel": "auto"}}, tmp_path) == CHANNEL_STABLE
def test_config_garbage_defers_to_manifest(self, tmp_path):
assert resolve_update_channel({"update": {"channel": 42}}, tmp_path) == CHANNEL_MAIN
assert resolve_update_channel({"update": "stable"}, tmp_path) == CHANNEL_MAIN
class TestDefaultConfigContract:
def test_update_channel_key_exists_and_is_valid(self):
"""update.channel must exist in DEFAULT_CONFIG with an accepted value."""
from hermes_cli.config_defaults import DEFAULT_CONFIG
channel = DEFAULT_CONFIG["update"]["channel"]
assert channel in ("auto", CHANNEL_MAIN, CHANNEL_STABLE)
class TestCmdUpdateRefusal:
def test_cmd_update_refuses_on_bundled(self, monkeypatch, capsys):
"""cmd_update exits 1 with the bundled message before touching git."""
import hermes_cli.install_manifest as im
from hermes_cli import main as hermes_main
monkeypatch.setattr(im, "is_bundled_install", lambda root=None: True)
# Neutralize the earlier refusal branches so we reach the bundled check.
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda root=None: "git")
class Args:
check = False
gateway = False
branch = None
with pytest.raises(SystemExit) as excinfo:
hermes_main.cmd_update(Args())
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "desktop app" in out
assert "eject" in out
def test_message_mentions_in_app_updater(self):
msg = format_bundled_update_message()
assert "hermes update" in msg
assert "eject" in msg

View File

@ -0,0 +1,97 @@
"""Tests for the stable update channel (tag-tracking) in hermes_cli/update_cmd.py."""
from unittest.mock import patch
from hermes_cli.update_cmd import (
_latest_release_tag_from_ls_remote,
_parse_release_tag,
_stable_channel_active,
)
class TestParseReleaseTag:
def test_final_releases_parse(self):
assert _parse_release_tag("v0.17.0") == (0, 17, 0)
assert _parse_release_tag("v10.2.33") == (10, 2, 33)
assert _parse_release_tag(" v1.2.3 ") == (1, 2, 3)
def test_prereleases_and_garbage_rejected(self):
for tag in ("v1.2.3-rc1", "v1.2.3-beta.1", "v1.2", "1.2.3", "release-1", "vv1.2.3", ""):
assert _parse_release_tag(tag) is None, tag
def test_calver_tags_rejected(self):
"""Historical CalVer tags (v2026.7.20) must not win a numeric sort.
The major component is capped at three digits, the same rule as
_SEMVER_TAG_RE in scripts/write_install_stamp.py and
latestReleaseFromLsRemote in apps/desktop. A four-digit year would
rank above every SemVer release forever.
"""
assert _parse_release_tag("v2026.7.20") is None
assert _parse_release_tag("v1000.0.0") is None
assert _parse_release_tag("v999.0.0") == (999, 0, 0)
def test_numeric_ordering_not_lexicographic(self):
"""v0.10.0 must sort above v0.9.0 — the whole point of tuple parsing."""
newer, older = _parse_release_tag("v0.10.0"), _parse_release_tag("v0.9.0")
assert newer is not None and older is not None
assert newer > older
class TestLatestReleaseTagFromLsRemote:
def test_picks_newest_final_release(self):
output = (
"aaa1\trefs/tags/v0.9.0\n"
"bbb2\trefs/tags/v0.10.0\n"
"ccc3\trefs/tags/v0.10.1-rc1\n"
"ddd4\trefs/tags/some-other-tag\n"
)
tag, sha = _latest_release_tag_from_ls_remote(output)
assert tag == "v0.10.0"
assert sha == "bbb2"
def test_peeled_sha_wins_for_annotated_tags(self):
output = (
"tagobj\trefs/tags/v1.0.0\n"
"commitsha\trefs/tags/v1.0.0^{}\n"
)
tag, sha = _latest_release_tag_from_ls_remote(output)
assert tag == "v1.0.0"
assert sha == "commitsha"
def test_no_release_tags(self):
assert _latest_release_tag_from_ls_remote("aaa\trefs/tags/nightly\n") == (None, None)
assert _latest_release_tag_from_ls_remote("") == (None, None)
def test_malformed_lines_ignored(self):
output = "garbage line no tab\naaa\trefs/heads/main\nbbb\trefs/tags/v2.0.0\n"
assert _latest_release_tag_from_ls_remote(output) == ("v2.0.0", "bbb")
class _Args:
def __init__(self, branch=None):
self.branch = branch
class TestStableChannelActive:
def test_explicit_branch_always_wins(self):
"""--branch means main-style behavior regardless of channel config."""
assert _stable_channel_active(_Args(branch="bb/gui")) is False
def test_config_stable_activates(self, tmp_path):
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}), \
patch("hermes_cli.install_manifest.install_manifest_path",
return_value=tmp_path / ".hermes-install.json"):
assert _stable_channel_active(_Args()) is True
def test_default_config_stays_main(self, tmp_path):
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}), \
patch("hermes_cli.install_manifest.install_manifest_path",
return_value=tmp_path / ".hermes-install.json"):
assert _stable_channel_active(_Args()) is False
def test_config_failure_defaults_to_main(self, tmp_path):
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")), \
patch("hermes_cli.install_manifest.install_manifest_path",
return_value=tmp_path / ".hermes-install.json"):
assert _stable_channel_active(_Args()) is False

View File

@ -0,0 +1,161 @@
"""Tests for ``hermes update --eject`` (hermes_cli/update_cmd.py::cmd_update_eject).
A bundled install is a resident desktop bundle: the agent runs out of the
sealed app resources, and its manifest says ``installMode: bundled``. The
eject downloads Hermes Setup from the website and launches it pinned to
the bundle's exact build commit. The tests fake only the two hard process
boundaries the download and the installer launch and run everything
else for real.
"""
import json
import pytest
import hermes_cli.update_cmd as update_cmd
from hermes_cli.install_manifest import (
CHANNEL_STABLE,
MODE_BUNDLED,
MODE_SOURCE,
read_install_manifest,
write_install_manifest,
)
from hermes_cli.update_cmd import cmd_update_eject
COMMIT = "ab" * 20
@pytest.fixture
def bundle_repo(tmp_path, monkeypatch):
"""The payload repo of a resident bundle: manifest + build info."""
repo = tmp_path / "bundle" / "repo"
repo.mkdir(parents=True)
write_install_manifest(
{
"installMode": MODE_BUNDLED,
"channel": CHANNEL_STABLE,
"manageStyle": "adopted",
"pinnedTag": "v0.1.0",
},
repo,
)
(repo / ".hermes_build_info.json").write_text(
json.dumps({"commit": COMMIT, "tag": "v0.1.0"})
)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
return repo
class _Args:
def __init__(self, channel=None):
self.eject = True
self.channel = channel
@pytest.fixture
def fake_setup(monkeypatch):
"""Fake the download + launch boundary; record what eject asked for."""
calls = {}
def fake_download(url, dest):
calls["url"] = url
dest.write_bytes(b"fake-installer")
return True
def fake_launch(setup_path, scratch, commit):
calls["setup_path"] = setup_path
calls["commit"] = commit
return True
monkeypatch.setattr(update_cmd, "_download_hermes_setup", fake_download)
monkeypatch.setattr(update_cmd, "_launch_hermes_setup", fake_launch)
monkeypatch.setattr(update_cmd.sys, "platform", "darwin")
return calls
class TestEjectResident:
def test_eject_downloads_setup_and_pins_the_bundle_commit(
self, bundle_repo, fake_setup, capsys
):
rc = cmd_update_eject(_Args())
out = capsys.readouterr().out
assert rc == 0
# The pin is the bundle's own commit — never the tag, never HEAD.
assert fake_setup["commit"] == COMMIT
assert "Hermes-Setup.dmg" in fake_setup["url"]
assert "hermes-assets.nousresearch.com" in fake_setup["url"]
assert "Hermes Setup is running" in out
def test_eject_windows_uses_the_exe(self, bundle_repo, fake_setup, monkeypatch):
monkeypatch.setattr(update_cmd.sys, "platform", "win32")
assert cmd_update_eject(_Args()) == 0
assert fake_setup["url"].endswith("Hermes-Setup.exe")
def test_eject_refuses_unsupported_platforms(self, bundle_repo, monkeypatch, capsys):
monkeypatch.setattr(update_cmd.sys, "platform", "linux")
assert cmd_update_eject(_Args()) == 1
assert "install.sh" in capsys.readouterr().out
def test_eject_refuses_without_a_valid_commit(self, bundle_repo, fake_setup, capsys):
(bundle_repo / ".hermes_build_info.json").write_text(
json.dumps({"commit": "not-a-sha"})
)
assert cmd_update_eject(_Args()) == 1
assert "commit" in capsys.readouterr().out
assert "commit" not in fake_setup # never launched
def test_eject_skips_when_a_source_checkout_already_exists(
self, bundle_repo, fake_setup, tmp_path, monkeypatch, capsys
):
import hermes_cli.config as config_mod
home = tmp_path / "hermes-home"
target = home / "hermes-agent"
(target / ".git").mkdir(parents=True)
monkeypatch.setattr(update_cmd, "get_hermes_home", lambda: home)
assert cmd_update_eject(_Args()) == 0
out = capsys.readouterr().out
assert "already exists" in out
assert "url" not in fake_setup # no download
def test_failed_download_aborts_cleanly(self, bundle_repo, fake_setup, monkeypatch, capsys):
monkeypatch.setattr(
update_cmd, "_download_hermes_setup", lambda url, dest: False
)
assert cmd_update_eject(_Args()) == 1
out = capsys.readouterr().out
assert "unchanged" in out
# The bundle manifest is untouched: still bundled.
assert read_install_manifest(bundle_repo).get("installMode") == MODE_BUNDLED
class TestEjectSourceManaged:
def test_source_install_with_channel_switches_channel_only(
self, tmp_path, monkeypatch, capsys
):
repo = tmp_path / "src-checkout"
repo.mkdir()
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
assert cmd_update_eject(_Args(channel="stable")) == 0
manifest = read_install_manifest(repo)
assert manifest["channel"] == "stable"
assert manifest["installMode"] == MODE_SOURCE
def test_source_install_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
repo = tmp_path / "src-checkout"
repo.mkdir()
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
assert cmd_update_eject(_Args()) == 0
assert "Nothing to eject" in capsys.readouterr().out