change: derive install state from .git and the build stamp

hermes_cli/runtime_tree.py replaces install_manifest.py. A tree with
.git is a git checkout and `hermes update` owns it. A tree without
.git is sealed, and the distribution field of the build stamp names
the steward that replaces it (desktop-app, docker, nix). The refusal
message comes from a per-steward table.

.hermes-install.json dies: staging stops writing it into the payload,
the CLI never reads it, and the update channel lives in config.yaml
(update.channel; main is the default and keeps the current behavior).

Eject gates on Sealed(desktop-app) and is a full handoff: it tells
the user that Setup replaces the desktop app. --channel on a git
checkout writes config instead of a manifest.
This commit is contained in:
ethernet 2026-08-07 17:20:23 -04:00
parent 60372c1630
commit eb676fe66e
10 changed files with 393 additions and 575 deletions

View File

@ -320,19 +320,6 @@ function stageRepo(tag, outDir) {
"--source", "ci",
"--distribution", "desktop-app",
])
// The install manifest is BUILD metadata for a resident bundle: the
// payload repo is always desktop-managed, always the stable channel,
// always pinned to this tag. Shipping it statically means the Python
// side (update refusal, eject, channel vocabulary) reads the same file
// in a resident bundle as in a materialized checkout.
fs.writeFileSync(
path.join(repoDir, ".hermes-install.json"),
JSON.stringify(
{ schemaVersion: 1, installMode: "bundled", channel: "stable", manageStyle: "adopted", pinnedTag: tag },
null,
2
) + "\n"
)
return commit
}

View File

@ -2459,13 +2459,12 @@ DEFAULT_CONFIG = {
# 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).
# git checkouts:
# main — git pull origin main (the default).
# 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.
# auto — same as main (kept for pre-existing configs).
# Sealed installs (the embedded desktop app, docker, nix) ignore
# this setting; their stewards own versioning.
"channel": "auto",
},

View File

@ -1,219 +0,0 @@
"""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

@ -9192,16 +9192,14 @@ def cmd_update(args):
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
# A tree without .git is sealed: a steward (the desktop app, docker,
# nix, a package manager) replaces it wholesale, and `hermes update`
# has nothing to update. Refuse and name the steward's mechanism.
from hermes_cli.runtime_tree import Sealed, runtime_tree, steward_update_message
if is_bundled_install(PROJECT_ROOT):
print(format_bundled_update_message())
tree = runtime_tree(PROJECT_ROOT)
if isinstance(tree, Sealed):
print(steward_update_message(tree.steward))
sys.exit(1)
if getattr(args, "check", False):

171
hermes_cli/runtime_tree.py Normal file
View File

@ -0,0 +1,171 @@
"""Derive who owns the running tree — no stored mode flags.
The install model has two axes (design:
.hermes/plans/2026-08-07_183000-two-axis-install-model.md):
* A tree with ``.git`` is a **git checkout**: ``hermes update`` owns it.
The checkout's existence IS the fact; no manifest records it.
* A tree without ``.git`` is **sealed**: something external replaces it
wholesale. The build stamp (``.hermes_build_info.json``) names that
steward in its ``distribution`` field: ``desktop-app`` (the embedded
desktop bundle), ``docker``, ``nix``, or a future package manager.
The update channel (``stable`` or ``main``) lives in config.yaml under
``update.channel``. It applies to git checkouts only sealed trees
version-track through their stewards.
If a future feature writes to user checkouts (nothing does today), it
must add an explicit opt-out fact FIRST. The old ``manageStyle: ejected``
stickiness guarded against desktop-side adoption and rematerialization;
both are deleted, so the guard went with them.
This is a pure-stdlib leaf module. It does not import hermes_cli.config.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
BUILD_INFO_NAME = ".hermes_build_info.json"
STEWARD_DESKTOP = "desktop-app"
STEWARD_DOCKER = "docker"
STEWARD_NIX = "nix"
CHANNEL_MAIN = "main"
CHANNEL_STABLE = "stable"
_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE)
# What `hermes update` says in a sealed tree, per steward. The fallback
# covers stewards this build does not know (a newer package-manager value
# read by older code).
STEWARD_UPDATE_MESSAGES = {
STEWARD_DESKTOP: (
"✗ This Hermes runs from inside the desktop app bundle.\n"
"\n"
"The app updates itself, and every app update carries the agent\n"
"with it. There is nothing for `hermes update` to do here.\n"
"\n"
"To manage the agent from the command line with git, run\n"
"`hermes update --eject`. It installs a source checkout and a\n"
"desktop app built from it."
),
STEWARD_DOCKER: (
"✗ This Hermes runs from a Docker image.\n"
"\n"
"The image is immutable. Pull the new image to update:\n"
" docker pull nousresearch/hermes-agent:latest"
),
STEWARD_NIX: (
"✗ This Hermes runs from the Nix store.\n"
"\n"
"The store path is immutable. Update through your flake:\n"
" nix flake update && rebuild your profile or system"
),
}
_STEWARD_FALLBACK_MESSAGE = (
"✗ This Hermes install is managed by {steward}.\n"
"\n"
"The tree has no git checkout, so `hermes update` cannot update it.\n"
"Update it with the tool that installed it."
)
@dataclass(frozen=True)
class GitCheckout:
"""A tree with .git — `hermes update` owns it."""
root: Path
@dataclass(frozen=True)
class Sealed:
"""A gitless tree — the steward replaces it wholesale."""
root: Path
steward: str
def read_build_info(project_root: Path) -> dict:
"""The baked build stamp of ``project_root``, or ``{}``."""
try:
data = json.loads((Path(project_root) / BUILD_INFO_NAME).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def runtime_tree(project_root: Path) -> GitCheckout | Sealed:
"""Classify the tree at ``project_root``.
``.git`` present (a directory, or a worktree/submodule gitfile) means a
git checkout. Everything else is sealed, with the steward read from the
build stamp; a missing or unknown stamp gives steward ``"unknown"``.
"""
root = Path(project_root)
if (root / ".git").exists():
return GitCheckout(root=root)
distribution = read_build_info(root).get("distribution")
steward = distribution if isinstance(distribution, str) and distribution else "unknown"
return Sealed(root=root, steward=steward)
def steward_update_message(steward: str) -> str:
"""The `hermes update` refusal text for a sealed tree."""
message = STEWARD_UPDATE_MESSAGES.get(steward)
if message is not None:
return message
return _STEWARD_FALLBACK_MESSAGE.format(steward=steward)
def managed_install_roots() -> tuple[Path, ...]:
"""The canonical roots where installers create the agent checkout.
* per-user: ``$HERMES_HOME/hermes-agent`` (usually ``~/.hermes``)
* FHS root installs (install.sh as root on Linux):
``/usr/local/lib/hermes-agent``
"""
from hermes_constants import get_hermes_home
return (get_hermes_home() / "hermes-agent", Path("/usr/local/lib/hermes-agent"))
def is_managed_install_root(path: Path) -> bool:
"""True when ``path`` is a canonical installer-created checkout root.
`hermes update` updates these without a question. A checkout anywhere
else is somebody's working tree, and update asks first.
"""
try:
resolved = Path(path).resolve()
except OSError:
return False
for root in managed_install_roots():
try:
if resolved == root.resolve():
return True
except OSError:
continue
return False
def resolve_update_channel(config: Optional[dict] = None) -> str:
"""The effective update channel for a git checkout.
``update.channel`` from config.yaml when it is ``stable`` or ``main``;
anything else (missing, ``auto``, unknown) means ``main``. Sealed trees
never ask: their stewards own versioning.
"""
configured = 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 CHANNEL_MAIN

View File

@ -239,22 +239,21 @@ def _stable_channel_active(args) -> bool:
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).
cases the effective channel comes from ``update.channel`` in config.yaml
(see hermes_cli.runtime_tree.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
from hermes_cli.runtime_tree 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
return resolve_update_channel(config) == CHANNEL_STABLE
except Exception as exc:
logger.warning("Channel resolution failed; defaulting to main: %s", exc)
return False
@ -4024,19 +4023,18 @@ def _normalize_managed_eol(git_cmd, repo_root):
pass
def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
"""Eject a resident bundle: hand the install to Hermes Setup.
def _eject_embedded_bundle(bundle_repo_root: Path, tag_label: str) -> int:
"""Eject an embedded bundle: a full handoff to Hermes Setup.
The resident bundle is sealed (codesigned resources); no git graft is
The embedded 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.
checks). 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 runs. Setup then performs a normal source install at
~/.hermes/hermes-agent AND builds a source-managed desktop app from it
that app replaces the embedded one. Network is required.
Returns a process exit code.
"""
@ -4044,20 +4042,17 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
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"):
if (target / ".git").exists():
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.
# The exact commit of this bundle. The tag is the display label; 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):
@ -4081,7 +4076,7 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
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]})...")
print(f"\u2192 Hermes starts the installer, pinned to {tag_label} ({commit[:12]})...")
ok = _launch_hermes_setup(setup_path, scratch, commit)
if not ok:
shutil.rmtree(scratch, ignore_errors=True)
@ -4089,9 +4084,12 @@ def _eject_resident_bundle(bundle_repo_root: Path, pinned_tag: str) -> int:
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("\u2713 Hermes Setup is running. This is a full handoff:")
print(f" \u2022 It installs a source checkout at {target}")
print(" \u2022 It builds a new desktop app from that checkout and")
print(" replaces this one.")
print(" \u2022 Close the Hermes desktop app now, so that Setup can")
print(" replace it.")
print(" \u2022 After the install, update with: hermes update")
return 0
@ -4174,65 +4172,58 @@ def _launch_hermes_setup(setup_path: Path, scratch: Path, commit: str) -> bool:
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.
An embedded desktop install runs the agent out of the sealed app
bundle. The eject is a full handoff to Hermes Setup: it installs a
source checkout and a desktop app built from it, and that app
replaces the embedded one. On a git checkout, the command only
switches the update channel in config.yaml.
Returns a process exit code.
"""
from hermes_cli.install_manifest import (
from hermes_cli.runtime_tree import (
CHANNEL_MAIN,
CHANNEL_STABLE,
MODE_SOURCE,
STYLE_EJECTED,
install_manifest_path,
read_install_manifest,
write_install_manifest,
STEWARD_DESKTOP,
GitCheckout,
runtime_tree,
)
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'.")
print(f"\u2717 Unknown channel '{channel}'. Use 'stable' or 'main'.")
return 1
if manifest.get("installMode") != "bundled":
# The install is already source-managed. Obey an explicit --channel
tree = runtime_tree(project_root)
if isinstance(tree, GitCheckout):
# The install is already git-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}'.")
from hermes_cli.config import set_config_value
set_config_value("update.channel", channel)
print(f"\u2713 The install is already git-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.)")
print("\u2713 Nothing to eject. This install is already git-managed.")
print(" (Only embedded desktop 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")
if tree.steward != STEWARD_DESKTOP:
from hermes_cli.runtime_tree import steward_update_message
print(steward_update_message(tree.steward))
return 1
# Hermes Setup writes the ejected checkout's own manifest, so an explicit
# Hermes Setup writes the ejected checkout's channel, 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(f"\u26a0 --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)
build_info = _read_json_or_none(Path(project_root) / ".hermes_build_info.json") or {}
tag_label = str(build_info.get("tag") or build_info.get("displayVersion") or "this release")
return _eject_embedded_bundle(Path(project_root), tag_label)
def _cmd_update_impl(args, gateway_mode: bool):

View File

@ -1,207 +0,0 @@
"""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,104 @@
"""Tests for hermes_cli/runtime_tree.py — tree classification and channel."""
import json
from pathlib import Path
from hermes_cli.runtime_tree import (
CHANNEL_MAIN,
CHANNEL_STABLE,
STEWARD_UPDATE_MESSAGES,
GitCheckout,
Sealed,
is_managed_install_root,
resolve_update_channel,
runtime_tree,
steward_update_message,
)
class TestRuntimeTree:
def test_a_tree_with_git_is_a_checkout(self, tmp_path):
(tmp_path / ".git").mkdir()
tree = runtime_tree(tmp_path)
assert isinstance(tree, GitCheckout)
assert tree.root == tmp_path
def test_a_worktree_gitfile_also_counts(self, tmp_path):
# Linked worktrees and submodules have a .git FILE, not a directory.
(tmp_path / ".git").write_text("gitdir: /somewhere/else\n")
assert isinstance(runtime_tree(tmp_path), GitCheckout)
def test_a_gitless_tree_is_sealed_with_the_stamped_steward(self, tmp_path):
(tmp_path / ".hermes_build_info.json").write_text(
json.dumps({"commit": "a" * 40, "distribution": "desktop-app"})
)
tree = runtime_tree(tmp_path)
assert isinstance(tree, Sealed)
assert tree.steward == "desktop-app"
def test_a_gitless_tree_without_a_stamp_is_sealed_unknown(self, tmp_path):
tree = runtime_tree(tmp_path)
assert isinstance(tree, Sealed)
assert tree.steward == "unknown"
def test_a_corrupt_stamp_degrades_to_unknown(self, tmp_path):
(tmp_path / ".hermes_build_info.json").write_text("{not json")
tree = runtime_tree(tmp_path)
assert isinstance(tree, Sealed)
assert tree.steward == "unknown"
class TestStewardMessages:
def test_every_known_steward_names_its_mechanism(self):
assert "--eject" in steward_update_message("desktop-app")
assert "docker pull" in steward_update_message("docker")
assert "flake" in steward_update_message("nix")
def test_an_unknown_steward_gets_the_fallback_with_its_name(self):
message = steward_update_message("pacman")
assert "pacman" in message
assert "cannot update" in message
def test_every_table_entry_is_a_refusal(self):
for steward, message in STEWARD_UPDATE_MESSAGES.items():
assert message.startswith("\u2717"), steward
class TestManagedInstallRoot:
def test_hermes_home_checkout_is_managed(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
root = tmp_path / ".hermes" / "hermes-agent"
root.mkdir(parents=True)
assert is_managed_install_root(root) is True
def test_a_dev_tree_is_not_managed(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
dev = tmp_path / "src" / "hermes-agent"
dev.mkdir(parents=True)
assert is_managed_install_root(dev) is False
def test_the_fhs_root_layout_is_managed(self):
# /usr/local/lib/hermes-agent need not exist for the answer; the
# comparison is by path. If it does not resolve, False is safe.
result = is_managed_install_root(Path("/usr/local/lib/hermes-agent"))
assert result in (True, False) # never raises
# On machines without the dir, resolve() still succeeds (no symlinks
# involved), so the comparison holds.
assert result is True
class TestResolveUpdateChannel:
def test_stable_from_config(self):
assert resolve_update_channel({"update": {"channel": "stable"}}) == CHANNEL_STABLE
def test_main_is_the_default(self):
assert resolve_update_channel(None) == CHANNEL_MAIN
assert resolve_update_channel({}) == CHANNEL_MAIN
assert resolve_update_channel({"update": {}}) == CHANNEL_MAIN
def test_auto_and_unknown_mean_main(self):
assert resolve_update_channel({"update": {"channel": "auto"}}) == CHANNEL_MAIN
assert resolve_update_channel({"update": {"channel": "nightly"}}) == CHANNEL_MAIN
def test_case_and_whitespace_are_forgiven(self):
assert resolve_update_channel({"update": {"channel": " Stable "}}) == CHANNEL_STABLE

View File

@ -78,20 +78,14 @@ class TestStableChannelActive:
"""--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"):
def test_config_stable_activates(self):
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "stable"}}):
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"):
def test_default_config_stays_main(self):
with patch("hermes_cli.config.load_config", return_value={"update": {"channel": "auto"}}):
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"):
def test_config_failure_defaults_to_main(self):
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
assert _stable_channel_active(_Args()) is False

View File

@ -1,11 +1,10 @@
"""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.
An embedded desktop install runs the agent out of the sealed app bundle:
a gitless tree whose build stamp says ``distribution: desktop-app``. The
eject is a full handoff to Hermes Setup. The tests fake only the two hard
process boundaries the download and the installer launch and run
everything else for real.
"""
import json
@ -13,35 +12,23 @@ 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
def _write_build_info(root, **overrides):
info = {"commit": COMMIT, "tag": "v0.1.0", "distribution": "desktop-app"}
info.update(overrides)
(root / ".hermes_build_info.json").write_text(json.dumps(info))
@pytest.fixture
def bundle_repo(tmp_path, monkeypatch):
"""The payload repo of a resident bundle: manifest + build info."""
"""The payload repo of an embedded bundle: build info, no .git."""
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"})
)
_write_build_info(repo)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
@ -75,7 +62,7 @@ def fake_setup(monkeypatch):
return calls
class TestEjectResident:
class TestEjectEmbedded:
def test_eject_downloads_setup_and_pins_the_bundle_commit(
self, bundle_repo, fake_setup, capsys
):
@ -87,7 +74,9 @@ class TestEjectResident:
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
# The handoff instructs the user to close the app: Setup replaces it.
assert "full handoff" in out
assert "Close the Hermes desktop app" in out
def test_eject_windows_uses_the_exe(self, bundle_repo, fake_setup, monkeypatch):
monkeypatch.setattr(update_cmd.sys, "platform", "win32")
@ -100,9 +89,7 @@ class TestEjectResident:
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"})
)
_write_build_info(bundle_repo, commit="not-a-sha")
assert cmd_update_eject(_Args()) == 1
assert "commit" in capsys.readouterr().out
assert "commit" not in fake_setup # never launched
@ -110,8 +97,6 @@ class TestEjectResident:
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)
@ -127,32 +112,47 @@ class TestEjectResident:
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
assert "unchanged" in capsys.readouterr().out
class TestEjectSourceManaged:
def test_source_install_with_channel_switches_channel_only(
class TestEjectOtherSealedTrees:
def test_docker_tree_gets_the_docker_message_not_an_eject(
self, tmp_path, monkeypatch, capsys
):
repo = tmp_path / "src-checkout"
repo = tmp_path / "docker-tree"
repo.mkdir()
write_install_manifest({"installMode": MODE_SOURCE, "channel": "main"}, repo)
_write_build_info(repo, distribution="docker")
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
assert cmd_update_eject(_Args()) == 1
assert "docker pull" in capsys.readouterr().out
def test_source_install_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
class TestEjectGitCheckout:
def test_git_checkout_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)
(repo / ".git").mkdir(parents=True)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
written = {}
import hermes_cli.config as config_mod
monkeypatch.setattr(
config_mod, "set_config_value", lambda key, value, **kw: written.update({key: value})
)
assert cmd_update_eject(_Args(channel="stable")) == 0
assert written == {"update.channel": "stable"}
assert "git-managed" in capsys.readouterr().out
def test_git_checkout_without_channel_is_a_noop(self, tmp_path, monkeypatch, capsys):
repo = tmp_path / "src-checkout"
(repo / ".git").mkdir(parents=True)
import hermes_cli.main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)