diff --git a/.gitignore b/.gitignore index ee1cb15f4495d..4f1721f7a2398 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,9 @@ docs/superpowers/* # 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/ + +# Snapcraft local build artifacts +/parts/ +/prime/ +/stage/ +/*.snap diff --git a/gateway/run.py b/gateway/run.py index 64eb8eb560e1c..0c1999fa0c0aa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4330,10 +4330,12 @@ async def start(self) -> bool: for v in _builtin_allow_all_vars + _plugin_allow_all_vars ) if not _any_allowlist and not _allow_all: + from hermes_cli.config import get_env_path logger.warning( "No user allowlists configured. All unauthorized users will be denied. " - "Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, " - "or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)." + "Set GATEWAY_ALLOW_ALL_USERS=true in %s to allow open access, " + "or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).", + get_env_path(), ) # Discover Python plugins before shell hooks so plugin block diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 223d4239f6944..269169ddb4feb 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -239,6 +239,8 @@ def _reject_denylisted_env_var(key: str) -> None: "homebrew": "Homebrew", "nix": "NixOS", "nixos": "NixOS", + "snap": "Snap", + "snapcraft": "Snap", } @@ -267,6 +269,17 @@ def is_managed() -> bool: return get_managed_system() is not None +def is_config_managed() -> bool: + """Return True when user configuration is managed declaratively. + + NixOS owns both the package and generated config, so interactive config + writers must refuse mutations. Package managers like Homebrew and Snap own + the install tree only; users still configure Hermes in their writable + HERMES_HOME. + """ + return get_managed_system() == "NixOS" + + _NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)" @@ -275,6 +288,8 @@ def get_managed_update_command() -> Optional[str]: managed_system = get_managed_system() if managed_system == "Homebrew": return "brew upgrade hermes-agent" + if managed_system == "Snap": + return "snap refresh hermes-agent" if managed_system == "NixOS": return _NIX_UPDATE_MSG return None @@ -364,6 +379,8 @@ def recommended_update_command_for_method(method: str) -> str: return _NIX_UPDATE_MSG if method == "homebrew": return "brew upgrade hermes-agent" + if method == "snap": + return "snap refresh hermes-agent" if method == "docker": return "docker pull nousresearch/hermes-agent:latest" if method == "pip": @@ -460,6 +477,15 @@ def format_managed_message(action: str = "modify this Hermes installation") -> s " brew upgrade hermes-agent" ) + if managed_system == "Snap": + env_hint = raw or "snap" + return ( + f"Cannot {action}: this Hermes installation is managed by Snap " + f"(HERMES_MANAGED={env_hint}).\n" + "Use:\n" + " snap refresh hermes-agent" + ) + return ( f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" "Use your package manager to upgrade or reinstall Hermes." @@ -616,7 +642,7 @@ def _secure_dir(path): created at runtime by kanban workers don't land as root:root and block subsequent uid-mapped workers). """ - if is_managed(): + if is_config_managed(): return try: mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() @@ -664,7 +690,7 @@ def _secure_file(path): Skipped in containers — Docker/Podman volume mounts often need broader permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems. """ - if is_managed() or _is_container(): + if is_config_managed() or _is_container(): return try: if os.path.exists(str(path)): @@ -685,12 +711,12 @@ def _ensure_default_soul_md(home: Path) -> None: def ensure_hermes_home(): """Ensure ~/.hermes directory structure exists with secure permissions. - In managed mode (NixOS), dirs are created by the activation script with + In config-managed mode (NixOS), dirs are created by the activation script with setgid + group-writable (2770). We skip mkdir and set umask(0o007) so any files created (e.g. SOUL.md) are group-writable (0660). """ home = get_hermes_home() - if is_managed(): + if is_config_managed(): old_umask = os.umask(0o007) try: _ensure_hermes_home_managed(home) @@ -5014,7 +5040,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: def save_config(config: Dict[str, Any]): """Save configuration to ~/.hermes/config.yaml.""" with _CONFIG_LOCK: - if is_managed(): + if is_config_managed(): managed_error("save configuration") return from utils import atomic_yaml_write @@ -5280,7 +5306,7 @@ def _check_non_ascii_credential(key: str, value: str) -> str: def save_env_value(key: str, value: str): """Save or update a value in ~/.hermes/.env.""" - if is_managed(): + if is_config_managed(): managed_error(f"set {key}") return if not _ENV_VAR_NAME_RE.match(key): @@ -5355,7 +5381,7 @@ def remove_env_value(key: str) -> bool: Returns True if the key was found and removed, False otherwise. """ - if is_managed(): + if is_config_managed(): managed_error(f"remove {key}") return False if not _ENV_VAR_NAME_RE.match(key): @@ -5654,7 +5680,7 @@ def show_config(): def edit_config(): """Open config file in user's editor.""" - if is_managed(): + if is_config_managed(): managed_error("edit configuration") return config_path = get_config_path() @@ -5694,7 +5720,7 @@ def edit_config(): def set_config_value(key: str, value: str): """Set a configuration value.""" - if is_managed(): + if is_config_managed(): managed_error("set configuration values") return # Check if it's an API key (goes to .env) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 4971f1faeceed..98cf128433e11 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1118,7 +1118,16 @@ def run_doctor(args): _check_gateway_service_linger(issues) _check_s6_supervision(issues) - if sys.platform != "win32": + from hermes_cli.config import get_managed_system as _get_managed_system + _managed_system = _get_managed_system() + if sys.platform != "win32" and _managed_system: + # Package-managed installs (Snap, Homebrew, NixOS) own the `hermes` + # command and the install tree. There is no pip venv entry point or + # ~/.local/bin symlink to verify, and `pip install -e` cannot run + # against a read-only install — so the checks below don't apply. + _section("Command Installation") + check_ok(f"Managed by {_managed_system} (command provided by the {_managed_system} package)") + elif sys.platform != "win32": _section("Command Installation") # Determine the venv entry point location _venv_bin = None diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 0b1f97046b84f..571d7e23e2ee2 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -26,6 +26,7 @@ from hermes_cli.config import ( get_env_value, get_hermes_home, + is_config_managed, is_managed, managed_error, read_raw_config, @@ -5433,7 +5434,7 @@ def _configure_platform(platform: dict) -> None: def gateway_setup(): """Interactive setup for messaging platforms + gateway service.""" - if is_managed(): + if is_config_managed(): managed_error("run gateway setup") return @@ -5891,6 +5892,24 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool: def _gateway_command_inner(args): subcmd = getattr(args, "gateway_command", None) + from hermes_cli.config import get_managed_system + + if get_managed_system() == "Snap" and subcmd in {"install", "uninstall", "start", "stop", "restart"}: + if subcmd == "install": + print("The gateway service is declared by the Hermes Snap package.") + print("Use: snap start hermes-agent.gateway") + print("Use: snap services hermes-agent.gateway") + return + if subcmd == "uninstall": + print("The gateway service is declared by the Hermes Snap package.") + print("Use: snap stop hermes-agent.gateway") + print("To remove Hermes entirely, use: snap remove hermes-agent") + return + action = {"start": "start", "stop": "stop", "restart": "restart"}[subcmd] + print(f"This Hermes installation is managed by Snap.") + print(f"Use: snap {action} hermes-agent.gateway") + return + # Default to run if no subcommand if subcmd is None or subcmd == "run": if _maybe_redirect_run_to_s6_supervision(args): @@ -5908,7 +5927,7 @@ def _gateway_command_inner(args): # Service management commands if subcmd == "install": if is_managed(): - managed_error("install gateway service (managed by NixOS)") + managed_error("install gateway service") return force = getattr(args, "force", False) system = getattr(args, "system", False) @@ -6002,7 +6021,7 @@ def _gateway_command_inner(args): elif subcmd == "uninstall": if is_managed(): - managed_error("uninstall gateway service (managed by NixOS)") + managed_error("uninstall gateway service") return system = getattr(args, "system", False) if is_termux(): @@ -6360,6 +6379,11 @@ def _gateway_command_inner(args): run_gateway(verbose=0) elif subcmd == "status": + if get_managed_system() == "Snap": + print("This Hermes installation is managed by Snap.") + print("Use: snap services hermes-agent.gateway") + return + deep = getattr(args, "deep", False) full = getattr(args, "full", False) system = getattr(args, "system", False) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 13d8277528978..ef7f88e01921a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2166,9 +2166,16 @@ def cmd_setup(args): def cmd_postinstall(args): """One-shot bootstrap for pip users: install non-Python deps + run setup.""" - from hermes_cli.config import stamp_install_method + from hermes_cli.config import get_managed_system, stamp_install_method from hermes_cli.dep_ensure import ensure_dependency + managed_system = get_managed_system() + if managed_system == "Snap": + print("Hermes is installed as a Snap; postinstall is not needed.") + print("Use `hermes setup` to configure providers and tools.") + print("Use `snap refresh hermes-agent` to update the package.") + return + stamp_install_method("pip") print("⚕ Hermes post-install bootstrap") @@ -6418,6 +6425,14 @@ def cmd_version(args): def cmd_uninstall(args): """Uninstall Hermes Agent.""" _require_tty("uninstall") + from hermes_cli.config import get_managed_system + + if get_managed_system() == "Snap": + print("Hermes is installed as a Snap.") + print("Use: snap remove hermes-agent") + print("To remove saved user data too, use: snap remove --purge hermes-agent") + return + from hermes_cli.uninstall import run_uninstall run_uninstall(args) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e5c955f24f218..077bfe3d79ff1 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2844,8 +2844,8 @@ def run_setup_wizard(args): hermes setup tools — just tool configuration hermes setup agent — just agent settings """ - from hermes_cli.config import is_managed, managed_error - if is_managed(): + from hermes_cli.config import is_config_managed, managed_error + if is_config_managed(): managed_error("run setup wizard") return ensure_hermes_home() diff --git a/packaging/snap/README.md b/packaging/snap/README.md new file mode 100644 index 0000000000000..48d51426d1038 --- /dev/null +++ b/packaging/snap/README.md @@ -0,0 +1,52 @@ +Snap packaging notes for Hermes Agent. + +This package targets strict confinement on Ubuntu and deliberately excludes the +desktop GUI. Both the classic CLI and the `--tui` terminal UI are supported: the +`tui` part builds the Node bundle and stages a Node >=20 runtime inside the snap +(core24's apt `nodejs` is v18, which the TUI's `node20` target rejects), so no +host Node install is needed. The snap entrypoints run through `hermes-snap`, which +sets: + +- `HERMES_HOME=$SNAP_USER_COMMON/hermes` +- `HERMES_MANAGED=snap` +- `HERMES_DISABLE_LAZY_INSTALLS=1` +- `HERMES_NODE=$SNAP/bin/node` (the bundled Node runtime, for `--tui`) +- bundled skills and optional skills paths under `$SNAP/usr/share/hermes-agent/` + +Build locally from the repository root: + +```bash +snapcraft pack +sudo snap install --dangerous hermes-agent_*.snap +``` + +Smoke-test after install: + +```bash +hermes-agent.hermes version +hermes-agent.hermes doctor +hermes-agent.hermes --tui # launches the terminal UI (uses the bundled Node) +snap start hermes-agent.gateway +snap stop hermes-agent.gateway +``` + +Command support notes: + +- Supported: `chat`, `model`, `fallback`, `secrets`, `migrate`, `gateway run`, + `proxy`, `lsp`, `setup`, `slack`, `send`, `login`, `logout`, `auth`, + `status`, `cron`, `webhook`, `portal`, `kanban`, `hooks`, `doctor`, + `security`, `dump`, `debug`, `backup`, `checkpoints`, `import`, `config`, + `pairing`, `skills`, `bundles`, `plugins`, `curator`, `memory`, `tools`, + `mcp`, `sessions`, `insights`, `claw`, `version`, `acp`, `profile`, + `completion`, `dashboard`, and `logs`. +- Snap-managed alternatives: `update` prints `snap refresh hermes-agent`, + `uninstall` prints `snap remove hermes-agent`, `postinstall` is a no-op with + setup guidance, and `gateway start|stop|restart|status` point at `snap` + service commands. +- Not targeted by this snap: `desktop` / GUI app packaging and macOS-only + `computer-use`. + +Strict confinement means host access is mediated by interfaces. The manifest +uses `home`, `network`, and `network-bind` for the core CLI/gateway/dashboard +flow. `removable-media` is declared for user workspaces under `/media`, `/mnt`, +and `/run/media`, but users may need to connect it manually. diff --git a/packaging/snap/hermes-snap b/packaging/snap/hermes-snap new file mode 100755 index 0000000000000..064d6f74f91e3 --- /dev/null +++ b/packaging/snap/hermes-snap @@ -0,0 +1,39 @@ +#!/bin/sh +set -eu + +# Strict snaps cannot write to the read-only install tree. Keep all Hermes +# mutable state in snap-managed user storage. +SNAP_COMMON_DIR="${SNAP_USER_COMMON:-${HOME}/snap/hermes-agent/common}" +HERMES_STATE_DIR="${SNAP_COMMON_DIR}/hermes" + +mkdir -p "${HERMES_STATE_DIR}" "${HERMES_STATE_DIR}/logs" "${HERMES_STATE_DIR}/cache" + +export HERMES_HOME="${HERMES_HOME:-${HERMES_STATE_DIR}}" +export HERMES_MANAGED="${HERMES_MANAGED:-snap}" +export HERMES_DISABLE_LAZY_INSTALLS="${HERMES_DISABLE_LAZY_INSTALLS:-1}" +export HERMES_SKIP_NODE_BOOTSTRAP="${HERMES_SKIP_NODE_BOOTSTRAP:-1}" + +if [ -n "${SNAP:-}" ]; then + export HERMES_BUNDLED_SKILLS="${HERMES_BUNDLED_SKILLS:-${SNAP}/usr/share/hermes-agent/skills}" + export HERMES_OPTIONAL_SKILLS="${HERMES_OPTIONAL_SKILLS:-${SNAP}/usr/share/hermes-agent/optional-skills}" + export HERMES_WEB_DIST="${HERMES_WEB_DIST:-${SNAP}/usr/share/hermes-agent/web_dist}" + export HERMES_TUI_DIR="${HERMES_TUI_DIR:-${SNAP}/usr/share/hermes-agent/tui}" + # The `tui` part stages a Node >=20 runtime at $SNAP/bin/node. Point Hermes + # at it explicitly so `hermes --tui` works without a host Node install. + export HERMES_NODE="${HERMES_NODE:-${SNAP}/bin/node}" + export PATH="${SNAP}/bin:${SNAP}/usr/bin:${SNAP}/usr/local/bin:${PATH}" +fi + +command_name="${1:-hermes}" +if [ "$#" -gt 0 ]; then + shift +fi + +case "${command_name}" in + hermes|hermes-agent|hermes-acp) + exec "${command_name}" "$@" + ;; + *) + exec hermes "${command_name}" "$@" + ;; +esac diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000000000..cc3709c5e5d68 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,171 @@ +name: hermes-agent +base: core24 +adopt-info: hermes +summary: Self-improving AI agent +description: | + Hermes Agent is a tool-using AI agent with a CLI, TUI, messaging gateway, + scheduled jobs, persistent skills, and local/server workflows. + +grade: devel +confinement: strict +license: MIT + +platforms: + amd64: + arm64: + +# The `ffmpeg` stage-package (used for audio transcode) pulls in a large web +# of optional codec / output-driver libraries — text-mode video (caca), +# theora, teletext (zvbi), JACK, flite/sphinx, the FFTW parallel variants, +# mesa/GL/X, and the full ICU family. Hermes uses ffmpeg for audio only, so +# none are linked and the library linter reports them as "unused" (plus caca's +# GL plugin as a missing GLU/glut dependency). They surface one-at-a-time as +# each soname is silenced, so rather than chase every entry we disable the +# (purely advisory) library linter outright. A genuinely missing *runtime* +# dependency would still be caught by the install smoke tests (CLI, TUI, +# dashboard, gateway). Revisit narrowing this before moving to grade: stable. +lint: + ignore: + - library + +apps: + hermes: + command: bin/hermes-snap hermes + plugs: + - home + - network + - network-bind + - removable-media + hermes-agent: + command: bin/hermes-snap hermes-agent + plugs: + - home + - network + - network-bind + - removable-media + hermes-acp: + command: bin/hermes-snap hermes-acp + plugs: + - home + - network + - network-bind + gateway: + command: bin/hermes-snap gateway run + daemon: simple + restart-condition: on-failure + plugs: + - home + - network + - network-bind + - removable-media + +parts: + hermes: + plugin: python + source: . + python-packages: + - .[cli,pty,mcp,web,acp,google,youtube,messaging,anthropic,bedrock,azure-identity] + build-packages: + - gcc + - git + - libffi-dev + - python3-dev + stage-packages: + - bash + - ca-certificates + - curl + - ffmpeg + - git + - iputils-ping + - openssh-client + - procps + - python3 + - python3-venv + - ripgrep + - xz-utils + override-build: | + craftctl default + # Snap version tracks the Python package version (pyproject.toml). core24's + # build env ships Python 3.12, so tomllib is available. + craftctl set version="$(python3 -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"])')" + mkdir -p "$CRAFT_PART_INSTALL/usr/share/hermes-agent" + cp -a skills "$CRAFT_PART_INSTALL/usr/share/hermes-agent/skills" + cp -a optional-skills "$CRAFT_PART_INSTALL/usr/share/hermes-agent/optional-skills" + # NOTE: the dashboard assets (usr/share/hermes-agent/web_dist) and the TUI + # bundle + Node runtime (usr/share/hermes-agent/tui) are produced by the + # dedicated `web` and `tui` parts below. web_dist/tui_dist are build + # artifacts absent from a clean checkout, so copying them here is unreliable. + + # The TUI is a Node bundle (esbuild → dist/entry.js, target node20). core24's + # apt `nodejs` is v18, which is too old (package.json requires node >=20), so we + # fetch the official Node 20 binary: use it to build the bundle and stage it as + # the runtime that `hermes --tui` execs. The esbuild output is self-contained + # (bundle: true, no externals), so only the `node` binary is needed at runtime. + tui: + plugin: nil + source: ui-tui + build-packages: + - curl + - xz-utils + override-build: | + set -eu + NODE_VERSION=20.18.1 + case "${CRAFT_ARCH_BUILD_FOR}" in + amd64) NODE_ARCH=x64 ;; + arm64) NODE_ARCH=arm64 ;; + *) echo "Unsupported build arch: ${CRAFT_ARCH_BUILD_FOR}" >&2; exit 1 ;; + esac + NODE_PKG="node-v${NODE_VERSION}-linux-${NODE_ARCH}" + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_PKG}.tar.xz" \ + -o "${CRAFT_PART_BUILD}/node.tar.xz" + mkdir -p "${CRAFT_PART_BUILD}/nodejs" + tar -xJf "${CRAFT_PART_BUILD}/node.tar.xz" -C "${CRAFT_PART_BUILD}/nodejs" --strip-components=1 + export PATH="${CRAFT_PART_BUILD}/nodejs/bin:${PATH}" + npm ci + npm run build + # Stage the built bundle where HERMES_TUI_DIR ($SNAP/usr/share/hermes-agent/tui) + # expects it: /dist/entry.js. + mkdir -p "${CRAFT_PART_INSTALL}/usr/share/hermes-agent/tui/dist" + cp -a dist/. "${CRAFT_PART_INSTALL}/usr/share/hermes-agent/tui/dist/" + # Keep package.json beside dist/ so Node resolves the bundle as ESM + # ("type": "module"). Without it, `node dist/entry.js` is parsed as + # CommonJS and the bundle's top-level `import` throws (matches nix/tui.nix). + cp -a package.json "${CRAFT_PART_INSTALL}/usr/share/hermes-agent/tui/package.json" + # Stage the Node runtime so `hermes --tui` can exec it from $SNAP/bin/node. + install -D -m0755 "${CRAFT_PART_BUILD}/nodejs/bin/node" "${CRAFT_PART_INSTALL}/bin/node" + + # The dashboard is a Vite/React app whose static bundle is served by the + # Python web server (uvicorn), so Node is only needed at BUILD time (not + # staged at runtime). web_dist is a build artifact absent from a clean + # checkout, so we build it here and stage it where HERMES_WEB_DIST points. + web: + plugin: nil + source: web + build-packages: + - curl + - xz-utils + override-build: | + set -eu + NODE_VERSION=20.18.1 + case "${CRAFT_ARCH_BUILD_FOR}" in + amd64) NODE_ARCH=x64 ;; + arm64) NODE_ARCH=arm64 ;; + *) echo "Unsupported build arch: ${CRAFT_ARCH_BUILD_FOR}" >&2; exit 1 ;; + esac + NODE_PKG="node-v${NODE_VERSION}-linux-${NODE_ARCH}" + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_PKG}.tar.xz" \ + -o "${CRAFT_PART_BUILD}/node.tar.xz" + mkdir -p "${CRAFT_PART_BUILD}/nodejs" + tar -xJf "${CRAFT_PART_BUILD}/node.tar.xz" -C "${CRAFT_PART_BUILD}/nodejs" --strip-components=1 + export PATH="${CRAFT_PART_BUILD}/nodejs/bin:${PATH}" + npm ci + # `npx vite build` (skipping `tsc -b` type-check) mirrors nix/web.nix. + npx vite build --outDir dist + mkdir -p "${CRAFT_PART_INSTALL}/usr/share/hermes-agent/web_dist" + cp -a dist/. "${CRAFT_PART_INSTALL}/usr/share/hermes-agent/web_dist/" + + snap-launcher: + plugin: dump + source: packaging/snap + organize: + hermes-snap: bin/hermes-snap diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index f394c29e92e5a..0625f9bfa62ad 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -189,6 +189,47 @@ def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path): assert "Command Installation" in out assert "Venv entry point not found" in out + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") + def test_managed_install_skips_venv_entry_point_check(self, monkeypatch, tmp_path): + """Package-managed installs (e.g. Snap) own the command and a read-only + tree, so doctor must not emit the pip/venv reinstall warning.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + + project = tmp_path / "project" + project.mkdir(exist_ok=True) + # No venv entry point — on an unmanaged install this would warn. + + monkeypatch.setenv("HERMES_MANAGED", "snap") + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except Exception: + pass + try: + import httpx + monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200)) + except Exception: + pass + + out = _run_doctor(fix=False) + assert "Command Installation" in out + assert "Managed by Snap" in out + assert "Venv entry point not found" not in out + assert "pip install -e" not in out + @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only") def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path): """The check finds entry points in .venv/ as well as venv/.""" diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c6baa71563240..16c5c5bf47f64 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1780,6 +1780,63 @@ def test_uninstall_in_container_prints_docker_guidance(self, monkeypatch, capsys out = capsys.readouterr().out assert "docker" in out.lower() + def test_managed_snap_gateway_install_prints_snap_guidance(self, monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + args = SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None) + gateway_cli.gateway_command(args) + + captured = capsys.readouterr() + assert "declared by the Hermes Snap package" in captured.out + assert "snap start hermes-agent.gateway" in captured.out + assert "snap services hermes-agent.gateway" in captured.out + + def test_managed_snap_gateway_uninstall_prints_snap_guidance(self, monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + args = SimpleNamespace(gateway_command="uninstall", system=False) + gateway_cli.gateway_command(args) + + captured = capsys.readouterr() + assert "declared by the Hermes Snap package" in captured.out + assert "snap stop hermes-agent.gateway" in captured.out + assert "snap remove hermes-agent" in captured.out + + def test_managed_snap_gateway_start_prints_snap_service_guidance(self, monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + args = SimpleNamespace(gateway_command="start", system=False) + gateway_cli.gateway_command(args) + + captured = capsys.readouterr() + assert "managed by Snap" in captured.out + assert "snap start hermes-agent.gateway" in captured.out + + def test_managed_snap_gateway_status_prints_snap_service_guidance(self, monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + args = SimpleNamespace(gateway_command="status", deep=False, system=False, full=False) + gateway_cli.gateway_command(args) + + captured = capsys.readouterr() + assert "managed by Snap" in captured.out + assert "snap services hermes-agent.gateway" in captured.out + + def test_managed_snap_gateway_setup_is_not_blocked(self, monkeypatch): + monkeypatch.setenv("HERMES_MANAGED", "snap") + called = False + + def fake_setup(): + nonlocal called + called = True + + monkeypatch.setattr(gateway_cli, "gateway_setup", fake_setup) + + args = SimpleNamespace(gateway_command="setup") + gateway_cli.gateway_command(args) + + assert called is True + def test_start_in_container_prints_docker_guidance(self, monkeypatch, capsys): """'hermes gateway start' inside Docker exits 0 with container guidance.""" import pytest diff --git a/tests/hermes_cli/test_managed_installs.py b/tests/hermes_cli/test_managed_installs.py index 9dda45f4ffea1..87b769e11dd8d 100644 --- a/tests/hermes_cli/test_managed_installs.py +++ b/tests/hermes_cli/test_managed_installs.py @@ -3,10 +3,11 @@ from hermes_cli.config import ( format_managed_message, + is_config_managed, get_managed_system, recommended_update_command, ) -from hermes_cli.main import cmd_update +from hermes_cli.main import cmd_postinstall, cmd_uninstall, cmd_update from tools.skills_hub import OptionalSkillSource @@ -14,9 +15,25 @@ def test_get_managed_system_homebrew(monkeypatch): monkeypatch.setenv("HERMES_MANAGED", "homebrew") assert get_managed_system() == "Homebrew" + assert is_config_managed() is False assert recommended_update_command() == "brew upgrade hermes-agent" +def test_get_managed_system_nixos_is_config_managed(monkeypatch): + monkeypatch.setenv("HERMES_MANAGED", "nixos") + + assert get_managed_system() == "NixOS" + assert is_config_managed() is True + + +def test_get_managed_system_snap(monkeypatch): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + assert get_managed_system() == "Snap" + assert is_config_managed() is False + assert recommended_update_command() == "snap refresh hermes-agent" + + def test_format_managed_message_homebrew(monkeypatch): monkeypatch.setenv("HERMES_MANAGED", "homebrew") @@ -26,6 +43,15 @@ def test_format_managed_message_homebrew(monkeypatch): assert "brew upgrade hermes-agent" in message +def test_format_managed_message_snap(monkeypatch): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + message = format_managed_message("update Hermes Agent") + + assert "managed by Snap" in message + assert "snap refresh hermes-agent" in message + + def test_recommended_update_command_defaults_to_hermes_update(monkeypatch): monkeypatch.delenv("HERMES_MANAGED", raising=False) @@ -51,6 +77,44 @@ def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys): assert "brew upgrade hermes-agent" in captured.err +def test_cmd_update_blocks_managed_snap(monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + with patch("hermes_cli.main.subprocess.run") as mock_run: + cmd_update(SimpleNamespace()) + + assert not mock_run.called + captured = capsys.readouterr() + assert "managed by Snap" in captured.err + assert "snap refresh hermes-agent" in captured.err + + +def test_cmd_postinstall_snap_prints_guidance(monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + with patch("hermes_cli.main.cmd_setup") as mock_setup: + cmd_postinstall(SimpleNamespace()) + + assert not mock_setup.called + captured = capsys.readouterr() + assert "installed as a Snap" in captured.out + assert "hermes setup" in captured.out + assert "snap refresh hermes-agent" in captured.out + + +def test_cmd_uninstall_snap_prints_guidance(monkeypatch, capsys): + monkeypatch.setenv("HERMES_MANAGED", "snap") + + with patch("hermes_cli.main._require_tty"), \ + patch("hermes_cli.uninstall.run_uninstall") as mock_uninstall: + cmd_uninstall(SimpleNamespace()) + + assert not mock_uninstall.called + captured = capsys.readouterr() + assert "installed as a Snap" in captured.out + assert "snap remove hermes-agent" in captured.out + + def test_optional_skill_source_honors_env_override(monkeypatch, tmp_path): optional_dir = tmp_path / "optional-skills" optional_dir.mkdir() diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py new file mode 100644 index 0000000000000..34aad85493f32 --- /dev/null +++ b/tests/test_snap_packaging.py @@ -0,0 +1,129 @@ +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SNAPCRAFT = REPO_ROOT / "snap" / "snapcraft.yaml" +LAUNCHER = REPO_ROOT / "packaging" / "snap" / "hermes-snap" +NOTES = REPO_ROOT / "packaging" / "snap" / "README.md" + + +def _load_manifest(): + assert SNAPCRAFT.exists(), f"missing snapcraft manifest: {SNAPCRAFT}" + return yaml.safe_load(SNAPCRAFT.read_text(encoding="utf-8")) + + +def test_snapcraft_manifest_is_strict_and_ubuntu_cli_scoped(): + """The manifest must be strict-confined, CLI/gateway-scoped, and GUI-free.""" + manifest = _load_manifest() + + assert manifest["name"] == "hermes-agent" + assert manifest["confinement"] == "strict" + assert "apps" in manifest + assert {"hermes", "hermes-agent", "hermes-acp", "gateway"} <= set(manifest["apps"]) + assert manifest["apps"]["gateway"]["daemon"] == "simple" + # No desktop/GUI app is packaged in the snap. + assert "desktop" not in manifest["apps"] + assert "apps/desktop" not in SNAPCRAFT.read_text(encoding="utf-8") + + +def test_snapcraft_apps_declare_network_and_home_plugs(): + """Every app needs home + network access to be usable under confinement.""" + manifest = _load_manifest() + + for name, app in manifest["apps"].items(): + plugs = set(app.get("plugs", [])) + assert {"home", "network", "network-bind"} <= plugs, ( + f"app {name!r} is missing required plugs; has {sorted(plugs)}" + ) + + # The gateway daemon must recover from crashes. + assert manifest["apps"]["gateway"]["restart-condition"] == "on-failure" + + +def test_snapcraft_version_is_derived_not_literal_git(): + """Version must be adopted from the build (pyproject), not the literal 'git'.""" + manifest = _load_manifest() + + # `version: git` is not a real snapcraft auto-version directive — it would + # ship a snap literally versioned "git". We adopt the version instead. + assert manifest.get("version") != "git" + assert manifest.get("adopt-info") == "hermes" + assert "craftctl set version" in SNAPCRAFT.read_text(encoding="utf-8") + + +def test_snapcraft_bundles_modern_node_runtime_for_tui(): + """The TUI needs node >=20; the snap must build+stage its own Node runtime. + + Guards against regressing to a snap that bundles an unrunnable TUI (no Node) + or relies on core24's apt nodejs (v18, too old). + """ + manifest = _load_manifest() + text = SNAPCRAFT.read_text(encoding="utf-8") + + assert "tui" in manifest["parts"], "missing dedicated `tui` part" + tui = manifest["parts"]["tui"] + assert tui["source"] == "ui-tui" + # A Node 20.x runtime is fetched and the bundle is built + staged. + assert "NODE_VERSION=20" in text + assert "nodejs.org/dist" in text + assert "npm run build" in text + assert "bin/node" in text + # package.json must be staged beside dist/ so Node treats the bundle as ESM + # ("type": "module"); without it `node dist/entry.js` parses as CommonJS. + assert "tui/package.json" in text + + +def test_snapcraft_builds_and_stages_dashboard_web_assets(): + """The dashboard's static bundle is a build artifact, so the snap must build + and stage it (web_dist) rather than relying on a pre-built checkout.""" + manifest = _load_manifest() + text = SNAPCRAFT.read_text(encoding="utf-8") + + assert "web" in manifest["parts"], "missing dedicated `web` part" + web = manifest["parts"]["web"] + assert web["source"] == "web" + assert "vite build" in text + assert "web_dist" in text + + +def test_snap_launcher_sets_snap_safe_runtime_environment(): + """The launcher must redirect mutable state and expose bundled assets.""" + assert LAUNCHER.exists(), f"missing launcher: {LAUNCHER}" + launcher = LAUNCHER.read_text(encoding="utf-8") + + # State is redirected into snap-managed writable storage. + assert 'SNAP_COMMON_DIR="${SNAP_USER_COMMON:-' in launcher + assert 'HERMES_HOME="${HERMES_HOME:-${HERMES_STATE_DIR}}"' in launcher + # The install is flagged as snap-managed. + assert 'HERMES_MANAGED="${HERMES_MANAGED:-snap}"' in launcher + # Confinement-incompatible auto-installs are disabled. + assert 'HERMES_DISABLE_LAZY_INSTALLS="${HERMES_DISABLE_LAZY_INSTALLS:-1}"' in launcher + assert 'HERMES_SKIP_NODE_BOOTSTRAP="${HERMES_SKIP_NODE_BOOTSTRAP:-1}"' in launcher + # Bundled assets and the staged Node runtime are wired up. + assert "HERMES_BUNDLED_SKILLS" in launcher + assert "HERMES_OPTIONAL_SKILLS" in launcher + assert 'HERMES_NODE="${HERMES_NODE:-${SNAP}/bin/node}"' in launcher + + +def test_snap_launcher_routes_commands_to_hermes(): + """The launcher must dispatch entrypoints / subcommands to the CLI.""" + launcher = LAUNCHER.read_text(encoding="utf-8") + + # Direct entrypoints exec themselves; everything else routes through `hermes`. + assert 'exec "${command_name}" "$@"' in launcher + assert 'exec hermes "${command_name}" "$@"' in launcher + + +def test_snap_packaging_notes_cover_command_alternatives(): + """The notes must document the snap-specific command rewrites.""" + assert NOTES.exists(), f"missing packaging notes: {NOTES}" + notes = NOTES.read_text(encoding="utf-8") + + assert "update" in notes + assert "snap refresh hermes-agent" in notes + assert "uninstall" in notes + assert "snap remove hermes-agent" in notes + assert "gateway start|stop|restart|status" in notes + assert "computer-use" in notes diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 132b16d10d018..f088b1b523961 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6557,7 +6557,7 @@ def _(rid, params: dict) -> dict: """ try: from hermes_cli.auth import PROVIDER_REGISTRY - from hermes_cli.config import is_managed, save_env_value + from hermes_cli.config import is_config_managed, save_env_value from hermes_cli.inventory import build_models_payload, load_picker_context slug = (params.get("slug") or "").strip() @@ -6565,7 +6565,7 @@ def _(rid, params: dict) -> dict: if not slug or not api_key: return _err(rid, 4001, "slug and api_key are required") - if is_managed(): + if is_config_managed(): return _err(rid, 4006, "managed install — credentials are read-only") pconfig = PROVIDER_REGISTRY.get(slug)