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: