Files
hermes-agent/hermes_cli/steward.py
ethernet 87d0fadded fix(pm): resolve the install stamp in pm.paths so sealed stages can read it
b207701287 routed PM's stamp reads through hermes_cli.steward.install_stamp_path.
The Docker runtime base runs PM before hermes_cli ships (only __init__ and
runtime_state are copied), so ensure() died with No module named
'hermes_cli.steward'. The resolver is an install-root path built only on
pm.paths.install_root/repo_root; move it there and point every reader at it.
2026-09-24 11:12:27 -04:00

237 lines
8.8 KiB
Python

"""Steward classification and refusal text for sealed install trees.
Adapted from the restack branch's ``installation/tree.py``. Our branch has
no ``installation`` package yet — detection here is rewritten onto the two
facts this branch already ships:
* ``.git`` at the project root says the tree is a checkout the uninstaller
/ updater may mutate.
* The build stamp (``install-stamp.json``, written by
``scripts/write_install_stamp.py``) names the steward in
``distribution`` (``desktop-app``, ``docker``, ``nix``, or a future
package manager) for sealed trees.
A sealed tree (no ``.git``) belongs to a steward: only the steward removes
or replaces its code. The refusal messages below always leave the user a
working next step for their data (``hermes uninstall --data`` / the
desktop app's Settings -> About page).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Optional
UPDATE_MECHANISMS = ("self", "app-installer", "electron-updater", "external", "microsoft-store")
STEWARD_DESKTOP = "desktop-app"
STEWARD_DOCKER = "docker"
STEWARD_NIX = "nix"
STEWARD_APT_TERMUX = "apt-termux"
# What `hermes update` says in a sealed tree, per steward — the refusal
# table shared by the update admission gate. 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"
"Manage updates from within the desktop app.\n"
"Prefer a self-managed source install? See:\n"
" https://hermes-agent.nousresearch.com/docs/user-guide/switching-to-source"
),
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_APT_TERMUX: (
"✗ This Hermes runs from a Termux APT package.\n"
"\n"
"The package manager owns the code tree. Update with:\n"
" pkg upgrade hermes-agent"
),
}
# A git checkout on a Termux host is not ours to update either: the lock
# pins a bionic CPython with no Android wheels, so a source sync would
# build sdists on the phone. The APT package is the only supported shape.
SOURCE_ON_TERMUX_UPDATE_MESSAGE = (
"✗ This Hermes is a source checkout running under Termux.\n"
"\n"
"Source installs are not supported on Termux — `hermes update` would\n"
"build Python packages on the device. Switch to the APT package:\n"
" pkg install hermes-agent"
)
SOURCE_ON_TERMUX_UPDATE_COMMAND = "pkg install hermes-agent"
_STEWARD_UPDATE_FALLBACK = (
"✗ 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."
)
# What the uninstaller says when it refuses to remove code from a sealed
# tree. The steward put the code there; the steward removes it. The
# desktop-app message is per-OS because each OS owns app removal
# differently.
_STEWARD_DELETE_DATA_PREAMBLE = "To delete your Hermes data (chats, configuration, etc),\n"
_STEWARD_DELETE_DATA_CLI = "run:\n$ hermes uninstall --data\n"
_STEWARD_DELETE_DATA_DESKTOP = "Open Hermes Desktop, go to Settings -> About, and delete your data from there.\n"
_STEWARD_UNINSTALL_MESSAGES = {
STEWARD_DOCKER: (
"✗ This Hermes runs from a Docker image.\n"
"\n"
"There is no code to uninstall — remove the container and image:\n"
" docker rm <container> && docker rmi nousresearch/hermes-agent\n"
"\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_CLI
),
STEWARD_APT_TERMUX: (
"✗ This Hermes was installed by a Termux APT package.\n"
"\n"
"The package manager owns the code tree — uninstall it with:\n"
" pkg uninstall hermes-agent\n"
"\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_CLI
),
STEWARD_NIX: (
"✗ This Hermes was installed by Nix.\n"
"\n"
"The store path is immutable — uninstall it the same way you\n"
"installed it: remove hermes-agent from your flake / profile\n"
"(e.g. `nix profile remove`), then rebuild.\n"
"\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_CLI
),
}
_STEWARD_MANAGED_BY_DESKTOP = "✗ Hermes is managed by the desktop app.\n"
_STEWARD_DESKTOP_UNINSTALL_BY_PLATFORM = {
"win32": (
_STEWARD_MANAGED_BY_DESKTOP +
"\n"
"Remove the app from Windows Settings → Apps → Installed apps.\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_DESKTOP
),
"darwin": (
_STEWARD_MANAGED_BY_DESKTOP +
"\n"
"Quit the app and drag Hermes.app from Applications to the Trash.\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_DESKTOP
),
}
_STEWARD_DESKTOP_UNINSTALL_DEFAULT = (
_STEWARD_MANAGED_BY_DESKTOP +
"\n"
"Delete the Hermes AppImage (or app directory) from wherever you\n"
"saved it.\n" +
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_DESKTOP
)
_STEWARD_UNINSTALL_FALLBACK = (
"✗ Hermes is managed by {steward}.\n"
"\n"
"The tree has no git checkout, so the uninstaller will not remove it.\n"
"Remove it with the tool that installed it.\n"
"\n" +
# A generic package-manager steward has no desktop app, and this
# refusal prints in a CLI context — point at the CLI data path.
_STEWARD_DELETE_DATA_PREAMBLE +
_STEWARD_DELETE_DATA_CLI
)
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_UPDATE_FALLBACK.format(steward=steward)
def steward_uninstall_message(steward: str, platform: "str | None" = None) -> str:
"""The uninstall refusal text for a sealed tree."""
if steward == STEWARD_DESKTOP:
key = platform if platform is not None else sys.platform
return _STEWARD_DESKTOP_UNINSTALL_BY_PLATFORM.get(key, _STEWARD_DESKTOP_UNINSTALL_DEFAULT)
message = _STEWARD_UNINSTALL_MESSAGES.get(steward)
if message is not None:
return message
return _STEWARD_UNINSTALL_FALLBACK.format(steward=steward)
def read_install_stamp(project_root: Path) -> dict:
"""The build stamp of ``project_root``, or ``{}``.
Tolerant by design: steward classification must not crash a refusal
path on a malformed stamp — a tree we cannot prove is ours still
refuses (see :func:`sealed_steward`), so garbage degrades safely.
"""
from pm.paths import install_stamp_path
try:
data = json.loads(install_stamp_path(project_root).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def is_bundled_payload(project_root: Path) -> bool:
"""True when ``project_root`` is a bundled desktop payload's ``repo/``.
THE shape predicate for the sealed-bundle branch, and a mirror of the
Electron shell's ``installShape() === 'bundled'``: the build stamp is
the authority, so filesystem coincidences (a sibling ``bin/``, a
surviving ``apps/desktop/package.json``) can never promote a checkout
into it. A missing, foreign, or unreadable stamp means NOT bundled.
"""
try:
return read_install_stamp(Path(project_root)).get("payload") == "bundled"
except Exception: # noqa: BLE001 — a bad stamp must not take a caller down
return False
def sealed_steward(project_root: Path) -> Optional[str]:
"""The steward owning the sealed tree at ``project_root``, or ``None``.
``None`` means the tree is a git checkout (``.git`` present — a
directory, or a worktree/submodule gitfile) and is ours to mutate.
Everything else is sealed: the stamp's ``distribution`` names the
steward, and a missing or unreadable stamp gives ``"unknown"`` —
a tree we cannot prove is ours is not ours to remove.
"""
root = Path(project_root)
if (root / ".git").exists():
return None
distribution = read_install_stamp(root).get("distribution")
return distribution if isinstance(distribution, str) and distribution else "unknown"
def classify_install(project_root: Path) -> "tuple[str, bool]":
"""(steward, code_removal_allowed) for the tree at ``project_root``.
A git checkout reports ``("git", True)``; a sealed tree reports its
steward and ``False``. Used by ``gui_install_summary`` so the desktop
UI can gate its destructive options on the same ladder the CLI uses.
"""
steward = sealed_steward(project_root)
if steward is None:
return ("git", True)
return (steward, False)