Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 35 additions & 9 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ def _reject_denylisted_env_var(key: str) -> None:
"homebrew": "Homebrew",
"nix": "NixOS",
"nixos": "NixOS",
"snap": "Snap",
"snapcraft": "Snap",
}


Expand Down Expand Up @@ -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)"


Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)):
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions packaging/snap/README.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions packaging/snap/hermes-snap
Original file line number Diff line number Diff line change
@@ -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
Loading