diff --git a/hermes_cli/config.py b/hermes_cli/config.py index acfd610191d..37a60e8801b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -141,6 +141,68 @@ def managed_error(action: str = "modify configuration"): print(format_managed_message(action), file=sys.stderr) +# ============================================================================= +# Container-aware CLI (NixOS container mode) +# ============================================================================= + +def _is_inside_container() -> bool: + """Detect if we're already running inside a Docker/Podman container.""" + # Standard Docker/Podman indicators + if os.path.exists("/.dockerenv"): + return True + # Podman uses /run/.containerenv + if os.path.exists("/run/.containerenv"): + return True + # Check cgroup for container runtime evidence (works for both Docker & Podman) + try: + with open("/proc/1/cgroup", "r") as f: + cgroup = f.read() + if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: + return True + except (OSError, IOError): + pass + return False + + +def get_container_exec_info() -> Optional[dict]: + """Read container mode metadata from HERMES_HOME/.container-mode. + + Returns a dict with keys: backend, container_name, hermes_bin + or None if container mode is not active or we're already inside the container. + + The .container-mode file is written by the NixOS activation script when + container.enable = true. It tells the host CLI to exec into the container + instead of running locally. + """ + if _is_inside_container(): + return None + + container_mode_file = get_hermes_home() / ".container-mode" + if not container_mode_file.exists(): + return None + + try: + info = {} + with open(container_mode_file, "r") as f: + for line in f: + line = line.strip() + if "=" in line and not line.startswith("#"): + key, _, value = line.partition("=") + info[key.strip()] = value.strip() + + backend = info.get("backend", "docker") + container_name = info.get("container_name", "hermes-agent") + hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") + + return { + "backend": backend, + "container_name": container_name, + "hermes_bin": hermes_bin, + } + except (OSError, IOError): + return None + + # ============================================================================= # Config paths # ============================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e1c8cb1cc45..4b13eb80008 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -528,6 +528,56 @@ def _resolve_last_cli_session() -> Optional[str]: return None +def _exec_in_container(container_info: dict, cli_args: list): + """Replace the current process with a command inside the managed container. + + Uses os.execvp to hand off to docker/podman exec, preserving the TTY + so the interactive CLI works seamlessly inside the container. + + Args: + container_info: dict with backend, container_name, hermes_bin + cli_args: the original CLI arguments (everything after 'hermes') + """ + import shutil + import subprocess + + backend = container_info["backend"] + container_name = container_info["container_name"] + hermes_bin = container_info["hermes_bin"] + + # Find the container runtime on PATH + runtime = shutil.which(backend) + if not runtime: + print(f"Warning: {backend} not found on PATH, falling back to host CLI.", + file=sys.stderr) + return # Fall through to normal CLI + + # Check if the container is actually running + try: + result = subprocess.run( + [runtime, "inspect", "--format", "{{.State.Running}}", container_name], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0 or result.stdout.strip().lower() != "true": + print(f"Warning: container '{container_name}' is not running, falling back to host CLI.", + file=sys.stderr) + return + except (subprocess.TimeoutExpired, OSError): + return # Fall through on any error + + # Filter out --host flag from forwarded args (it's not meaningful inside) + forwarded_args = [a for a in cli_args if a != "--host"] + + # Build the exec command + exec_cmd = [runtime, "exec", "-it", container_name, hermes_bin] + forwarded_args + + print(f"Routing to container '{container_name}' via {backend}...", + file=sys.stderr) + + # Replace the current process — this never returns on success + os.execvp(runtime, exec_cmd) + + def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: """Resolve a session name (title) or ID to a session ID. @@ -556,6 +606,21 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: def cmd_chat(args): """Run interactive chat CLI.""" + # ── Container-aware routing ────────────────────────────────────────── + # When NixOS container mode is active and we're on the host, exec into + # the managed container instead of running locally. --host bypasses this. + if not getattr(args, "host", False): + try: + from hermes_cli.config import get_container_exec_info + container_info = get_container_exec_info() + if container_info: + _exec_in_container(container_info, sys.argv[1:]) + # _exec_in_container calls os.execvp which replaces the process. + # If we get here, the exec failed. + sys.exit(1) + except Exception: + pass # Fall through to normal CLI on any detection error + # Resolve --continue into --resume with the latest CLI session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): @@ -4386,6 +4451,12 @@ For more help on a command: default=None, help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists." ) + chat_parser.add_argument( + "--host", + action="store_true", + default=False, + help="Run on the host even when NixOS container mode is active (bypass container exec)" + ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index b1be031df21..b507df48848 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -611,6 +611,22 @@ chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed chmod 0644 ${cfg.stateDir}/.hermes/.managed + # Container mode metadata — tells the host CLI to exec into the + # container instead of running locally. Removed when container mode + # is disabled so the host CLI falls back to native execution. + ${if cfg.container.enable then '' + cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF' +# Written by NixOS activation script. Do not edit manually. +backend=${cfg.container.backend} +container_name=${containerName} +hermes_bin=${containerDataDir}/current-package/bin/hermes +HERMES_CONTAINER_MODE_EOF + chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode + chmod 0644 ${cfg.stateDir}/.hermes/.container-mode + '' else '' + rm -f ${cfg.stateDir}/.hermes/.container-mode + ''} + # Seed auth file if provided ${lib.optionalString (cfg.authFile != null) '' ${if cfg.authFileForceOverwrite then '' diff --git a/tests/hermes_cli/test_container_aware_cli.py b/tests/hermes_cli/test_container_aware_cli.py new file mode 100644 index 00000000000..5458f6a9c19 --- /dev/null +++ b/tests/hermes_cli/test_container_aware_cli.py @@ -0,0 +1,275 @@ +"""Tests for container-aware CLI routing (NixOS container mode). + +When container.enable = true in the NixOS module, the activation script +writes a .container-mode metadata file. The host CLI detects this and +execs into the container instead of running locally. +""" +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli.config import ( + _is_inside_container, + get_container_exec_info, +) + + +# ============================================================================= +# _is_inside_container +# ============================================================================= + + +def test_is_inside_container_dockerenv(tmp_path): + """Detects /.dockerenv marker file.""" + with patch("os.path.exists") as mock_exists: + mock_exists.side_effect = lambda p: p == "/.dockerenv" + assert _is_inside_container() is True + + +def test_is_inside_container_containerenv(tmp_path): + """Detects Podman's /run/.containerenv marker.""" + with patch("os.path.exists") as mock_exists: + mock_exists.side_effect = lambda p: p == "/run/.containerenv" + assert _is_inside_container() is True + + +def test_is_inside_container_cgroup_docker(): + """Detects 'docker' in /proc/1/cgroup.""" + with patch("os.path.exists", return_value=False), \ + patch("builtins.open", create=True) as mock_open: + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = MagicMock(return_value=False) + mock_open.return_value.read = MagicMock( + return_value="12:memory:/docker/abc123\n" + ) + assert _is_inside_container() is True + + +def test_is_inside_container_false_on_host(): + """Returns False when none of the container indicators are present.""" + with patch("os.path.exists", return_value=False), \ + patch("builtins.open", side_effect=OSError("no such file")): + assert _is_inside_container() is False + + +# ============================================================================= +# get_container_exec_info +# ============================================================================= + + +@pytest.fixture +def container_env(tmp_path, monkeypatch): + """Set up a fake HERMES_HOME with .container-mode file.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + container_mode = hermes_home / ".container-mode" + container_mode.write_text( + "# Written by NixOS activation script. Do not edit manually.\n" + "backend=podman\n" + "container_name=hermes-agent\n" + "hermes_bin=/data/current-package/bin/hermes\n" + ) + return hermes_home + + +def test_get_container_exec_info_returns_metadata(container_env): + """Reads .container-mode and returns backend/name/bin.""" + with patch("hermes_cli.config._is_inside_container", return_value=False): + info = get_container_exec_info() + + assert info is not None + assert info["backend"] == "podman" + assert info["container_name"] == "hermes-agent" + assert info["hermes_bin"] == "/data/current-package/bin/hermes" + + +def test_get_container_exec_info_none_inside_container(container_env): + """Returns None when we're already inside a container.""" + with patch("hermes_cli.config._is_inside_container", return_value=True): + info = get_container_exec_info() + + assert info is None + + +def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch): + """Returns None when .container-mode doesn't exist (native mode).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with patch("hermes_cli.config._is_inside_container", return_value=False): + info = get_container_exec_info() + + assert info is None + + +def test_get_container_exec_info_defaults(): + """Falls back to defaults for missing keys.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + hermes_home = Path(tmpdir) / ".hermes" + hermes_home.mkdir() + (hermes_home / ".container-mode").write_text( + "# minimal file with no keys\n" + ) + + with patch("hermes_cli.config._is_inside_container", return_value=False), \ + patch("hermes_cli.config.get_hermes_home", return_value=hermes_home): + info = get_container_exec_info() + + assert info is not None + assert info["backend"] == "docker" + assert info["container_name"] == "hermes-agent" + assert info["hermes_bin"] == "/data/current-package/bin/hermes" + + +def test_get_container_exec_info_docker_backend(container_env): + """Correctly reads docker backend.""" + (container_env / ".container-mode").write_text( + "backend=docker\n" + "container_name=hermes-custom\n" + "hermes_bin=/opt/hermes/bin/hermes\n" + ) + + with patch("hermes_cli.config._is_inside_container", return_value=False): + info = get_container_exec_info() + + assert info["backend"] == "docker" + assert info["container_name"] == "hermes-custom" + assert info["hermes_bin"] == "/opt/hermes/bin/hermes" + + +# ============================================================================= +# _exec_in_container +# ============================================================================= + + +def test_exec_in_container_calls_execvp(): + """Verifies os.execvp is called with the correct command.""" + from hermes_cli.main import _exec_in_container + + container_info = { + "backend": "podman", + "container_name": "hermes-agent", + "hermes_bin": "/data/current-package/bin/hermes", + } + + with patch("shutil.which", return_value="/usr/bin/podman"), \ + patch("subprocess.run") as mock_run, \ + patch("os.execvp") as mock_exec: + # Simulate running container + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "true\n" + mock_run.return_value = mock_result + + _exec_in_container(container_info, ["chat", "-m", "claude-sonnet-4"]) + + mock_exec.assert_called_once_with( + "/usr/bin/podman", + ["/usr/bin/podman", "exec", "-it", "hermes-agent", + "/data/current-package/bin/hermes", "chat", "-m", "claude-sonnet-4"] + ) + + +def test_exec_in_container_strips_host_flag(): + """The --host flag is not forwarded into the container.""" + from hermes_cli.main import _exec_in_container + + container_info = { + "backend": "podman", + "container_name": "hermes-agent", + "hermes_bin": "/data/current-package/bin/hermes", + } + + with patch("shutil.which", return_value="/usr/bin/podman"), \ + patch("subprocess.run") as mock_run, \ + patch("os.execvp") as mock_exec: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "true\n" + mock_run.return_value = mock_result + + _exec_in_container(container_info, ["chat", "--host", "-q", "hello"]) + + # --host should be stripped + exec_args = mock_exec.call_args[0][1] + assert "--host" not in exec_args + assert "-q" in exec_args + assert "hello" in exec_args + + +def test_exec_in_container_fallback_no_runtime(capsys): + """Falls back gracefully when container runtime is not found.""" + from hermes_cli.main import _exec_in_container + + container_info = { + "backend": "podman", + "container_name": "hermes-agent", + "hermes_bin": "/data/current-package/bin/hermes", + } + + with patch("shutil.which", return_value=None), \ + patch("os.execvp") as mock_exec: + _exec_in_container(container_info, ["chat"]) + + # Should NOT call execvp — graceful fallback + mock_exec.assert_not_called() + + captured = capsys.readouterr() + assert "not found on PATH" in captured.err + + +def test_exec_in_container_fallback_container_not_running(capsys): + """Falls back when container exists but is not running.""" + from hermes_cli.main import _exec_in_container + + container_info = { + "backend": "docker", + "container_name": "hermes-agent", + "hermes_bin": "/data/current-package/bin/hermes", + } + + with patch("shutil.which", return_value="/usr/bin/docker"), \ + patch("subprocess.run") as mock_run, \ + patch("os.execvp") as mock_exec: + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "false\n" + mock_run.return_value = mock_result + + _exec_in_container(container_info, ["chat"]) + + mock_exec.assert_not_called() + + captured = capsys.readouterr() + assert "not running" in captured.err + + +def test_exec_in_container_fallback_inspect_fails(): + """Falls back when docker inspect fails entirely.""" + from hermes_cli.main import _exec_in_container + + container_info = { + "backend": "docker", + "container_name": "hermes-agent", + "hermes_bin": "/data/current-package/bin/hermes", + } + + with patch("shutil.which", return_value="/usr/bin/docker"), \ + patch("subprocess.run") as mock_run, \ + patch("os.execvp") as mock_exec: + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_run.return_value = mock_result + + _exec_in_container(container_info, ["chat"]) + + mock_exec.assert_not_called() diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 4db4939868b..e53f8c96c82 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -122,6 +122,17 @@ services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ]; Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory. ::: +:::info Container-aware CLI +When `container.enable = true` and `addToSystemPackages = true`, running `hermes chat` on the host **automatically routes into the managed container**. This means your interactive CLI session runs inside the same environment as the gateway service — with access to all container-installed packages and tools. + +- The routing is transparent: `hermes chat` detects container mode and does `podman exec` / `docker exec` under the hood +- All CLI flags are forwarded: `-m`, `--resume`, `--query`, etc. work as normal +- Use `hermes chat --host` to bypass container routing and run directly on the host +- If the container isn't running, the CLI falls back to host execution automatically + +Other `hermes` subcommands (`version`, `config`, `sessions`, `setup`) always run on the host since they only need access to shared state files. +::: + ### Verify It Works After `nixos-rebuild switch`, check that the service is running: