diff --git a/README.md b/README.md index ddfb33d9..5b82ae90 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup | Thing | How it finds it | |---|---| -| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR` env, then `$HERMES_HOME/hermes-agent` (Windows default `%LOCALAPPDATA%\hermes\hermes-agent`, POSIX default `~/.hermes/hermes-agent`), then sibling `../hermes-agent` | +| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR`, then known checkout paths, the `hermes` launcher on `PATH`, and finally the installed `run_agent` module exposed by `HERMES_WEBUI_PYTHON` | | Python executable | Agent venv first, then `.venv` in this repo, then system `python3` | | State directory | `HERMES_WEBUI_STATE_DIR` env, then `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | | Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir | @@ -355,7 +355,7 @@ Full list of environment variables: | Variable | Default | Description | |---|---|---| -| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the hermes-agent checkout | +| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the Hermes Agent source or installed module root | | `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable | | `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) | | `HERMES_WEBUI_PORT` | `8787` | Port | @@ -401,35 +401,38 @@ Then add the module and configure it in `nixosModules`: { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + hermes-agent.url = "github:NousResearch/hermes-agent"; hermes-webui.url = "github:nesquena/hermes-webui"; }; - outputs = { self, nixpkgs, hermes-webui, ... }: { + outputs = { self, nixpkgs, hermes-agent, hermes-webui, ... }: { nixosConfigurations. = nixpkgs.lib.nixosSystem { modules = [ + hermes-agent.nixosModules.default hermes-webui.nixosModules.default + ({ pkgs, ... }: { + services.hermes-agent.enable = true; + services.hermes-webui = { + enable = true; + host = "127.0.0.1"; + port = 8787; + stateDir = "/var/lib/hermes-webui"; + user = "hermes"; + group = "hermes"; + hermesHome = "/var/lib/hermes/.hermes"; + agent.package = hermes-agent.packages.${pkgs.stdenv.hostPlatform.system}.default; + environmentFiles = [ "/run/secrets/hermes-webui.env" ]; + }; + }) ]; }; }; } ``` -Service example: - -```nix -services.hermes-webui = { - enable = true; - host = "127.0.0.1"; - port = 8787; - stateDir = "/var/lib/hermes-webui"; - agent.dir = "/var/lib/hermes/hermes-agent"; - environmentFiles = [ "/run/secrets/hermes-webui.env" ]; -}; -``` - The module defaults to `127.0.0.1`. Set `host = "0.0.0.0"` and `openFirewall = true` only when you want direct network access, and pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`. -You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. This path depends on package metadata that the current published Hermes Agent flake may not expose yet, so explicit `agent.dir` and `agent.python` are the reliable integration options for existing agent packages. When the package exposes `passthru.hermesAgentDir`, the module derives `HERMES_WEBUI_AGENT_DIR` from that path. When it exposes `passthru.hermesVenv`, the module derives `HERMES_WEBUI_PYTHON` from the venv interpreter so bootstrap can use agent dependencies without creating a local `.venv`. +The published Hermes Agent package exposes `passthru.hermesVenv`, so the module derives `HERMES_WEBUI_PYTHON` from its interpreter. Bootstrap then locates the installed `run_agent.py` through that interpreter without importing Agent code and exports its parent as `HERMES_WEBUI_AGENT_DIR`. Packages may expose `passthru.hermesAgentDir` as a direct path instead. Use `agent.dir` and `agent.python` as explicit overrides for custom package layouts. When WebUI reads shared Hermes Agent state, run the service as a user that can already read that state. For a co-located Hermes Agent service, set `user` and `group` to the agent service account; the module only creates the default `hermes-webui` account and never changes ownership of an existing `hermesHome`. diff --git a/bootstrap.py b/bootstrap.py index 5dbf38ca..cf665fd8 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -182,6 +182,31 @@ def _agent_dir_from_hermes_cli() -> Path | None: return None +def _agent_dir_from_python(python_exe: str) -> Path | None: + script = ( + 'import importlib.util\n' + 'spec = importlib.util.find_spec("run_agent")\n' + 'print(spec.origin if spec else "")\n' + ) + try: + check = subprocess.run( + [python_exe, "-c", script], + capture_output=True, + text=True, + ) + except OSError: + return None + if check.returncode != 0: + return None + lines = check.stdout.splitlines() + if not lines: + return None + origin = Path(lines[0].strip()) + if not origin.is_absolute() or origin.name != "run_agent.py" or not origin.is_file(): + return None + return origin.parent.resolve() + + def discover_agent_dir() -> Path | None: home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser() candidates = [ @@ -202,7 +227,10 @@ def discover_agent_dir() -> Path | None: candidate = Path(raw).expanduser().resolve() if candidate.exists() and (candidate / "run_agent.py").exists(): return candidate - return _agent_dir_from_hermes_cli() + agent_dir = _agent_dir_from_hermes_cli() + if agent_dir: + return agent_dir + return _agent_dir_from_python(discover_launcher_python(None)) def discover_launcher_python(agent_dir: Path | None) -> str: diff --git a/flake.nix b/flake.nix index 475fda1a..f25048b9 100644 --- a/flake.nix +++ b/flake.nix @@ -41,8 +41,32 @@ } ]; }; + packageOnlyAgentVenv = pkgs.runCommand "hermes-agent-package-only-venv-${system}" { } '' + mkdir -p "$out/bin" + touch "$out/bin/python3" + ''; + packageOnlyAgentPackage = pkgs.runCommand "hermes-agent-package-only-${system}" { + passthru.hermesVenv = packageOnlyAgentVenv; + } '' + touch "$out" + ''; + packageOnlyModuleConfig = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + hermesModule + { + services.hermes-webui = { + enable = true; + package = package; + agent.package = packageOnlyAgentPackage; + }; + } + ]; + }; moduleServiceEnvironment = nixpkgs.lib.concatStringsSep "\n" moduleConfig.config.systemd.services.hermes-webui.serviceConfig.Environment; envProbe = pkgs.writeText "hermes-webui-nixos-env-${system}.txt" moduleServiceEnvironment; + packageOnlyServiceEnvironment = nixpkgs.lib.concatStringsSep "\n" packageOnlyModuleConfig.config.systemd.services.hermes-webui.serviceConfig.Environment; + packageOnlyEnvProbe = pkgs.writeText "hermes-webui-nixos-package-only-env-${system}.txt" packageOnlyServiceEnvironment; in { module-env-mapping = pkgs.runCommand "hermes-webui-nixos-module-${system}" { @@ -52,6 +76,8 @@ grep -q 'HERMES_WEBUI_PORT=8787' ${envProbe} grep -q 'HERMES_WEBUI_STATE_DIR=/var/lib/hermes-webui' ${envProbe} grep -q 'HERMES_WEBUI_AGENT_DIR=/var/lib/hermes-agent' ${envProbe} + grep -q 'HERMES_WEBUI_PYTHON=${packageOnlyAgentVenv}/bin/python3' ${packageOnlyEnvProbe} + ! grep -q 'HERMES_WEBUI_AGENT_DIR=' ${packageOnlyEnvProbe} touch "$out" ''; runtime-layout = pkgs.runCommand "hermes-webui-runtime-layout-${system}" { diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index c49def76..751aabba 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -147,7 +147,7 @@ in package = lib.mkOption { type = lib.types.nullOr lib.types.package; default = null; - description = "Package to derive HERMES_WEBUI_AGENT_DIR from passthru.hermesAgentDir and HERMES_WEBUI_PYTHON from passthru.hermesVenv."; + description = "Package to derive HERMES_WEBUI_PYTHON from passthru.hermesVenv and optionally HERMES_WEBUI_AGENT_DIR from passthru.hermesAgentDir."; }; dir = lib.mkOption { diff --git a/tests/test_bootstrap_discover_agent.py b/tests/test_bootstrap_discover_agent.py index ebc8dd24..5e432375 100644 --- a/tests/test_bootstrap_discover_agent.py +++ b/tests/test_bootstrap_discover_agent.py @@ -1,16 +1,17 @@ -"""Tests for `discover_agent_dir` shebang-based fallback. +"""Tests for `discover_agent_dir` launcher and interpreter fallbacks. When the standard candidate paths (`~/.hermes/hermes-agent`, `~/hermes-agent`, `/hermes-agent`, `HERMES_WEBUI_AGENT_DIR`) don't match, bootstrap -should fall back to introspecting the `hermes` console-script's shebang — -that's a reliable pointer to the install root because the installer writes the -venv-relative interpreter path there. +bootstrap checks the `hermes` launcher, then asks the configured Python for the +installed `run_agent` module without importing it. """ from __future__ import annotations import textwrap +import pytest + import bootstrap @@ -75,6 +76,8 @@ def _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes_path): monkeypatch.setattr(bootstrap.shutil, "which", lambda name: str(hermes_path) if name == "hermes" else None) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "no-such-hermes-home")) monkeypatch.delenv("HERMES_WEBUI_AGENT_DIR", raising=False) + monkeypatch.delenv("HERMES_WEBUI_PYTHON", raising=False) + monkeypatch.setattr(bootstrap, "_agent_dir_from_python", lambda _python: None) # Force REPO_ROOT.parent to a dir that won't accidentally contain a # `hermes-agent` sibling on the dev machine running these tests. monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path / "isolated-repo-root") @@ -133,6 +136,11 @@ def test_explicit_candidate_takes_precedence_over_shebang(monkeypatch, tmp_path) hermes = _make_hermes_cli(tmp_path, str(venv_python)) _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes) monkeypatch.setenv("HERMES_WEBUI_AGENT_DIR", str(explicit_install)) + monkeypatch.setattr( + bootstrap, + "_agent_dir_from_python", + lambda _python: (_ for _ in ()).throw(AssertionError("Python probe must not run")), + ) assert bootstrap.discover_agent_dir() == explicit_install.resolve() @@ -189,3 +197,112 @@ def test_bash_wrapper_without_agent_target_returns_none(monkeypatch, tmp_path): _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes) assert bootstrap.discover_agent_dir() is None + + +def test_discovers_installed_agent_dir_from_configured_python(monkeypatch, tmp_path): + agent_dir = tmp_path / "site-packages" + python_exe = str(tmp_path / "venv" / "python") + run_agent = agent_dir / "run_agent.py" + agent_dir.mkdir() + run_agent.write_text("raise AssertionError('must not import run_agent')", encoding="utf-8") + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + return bootstrap.subprocess.CompletedProcess(argv, 0, stdout=f"{run_agent}\n", stderr="") + + monkeypatch.setattr(bootstrap.subprocess, "run", fake_run) + + assert bootstrap._agent_dir_from_python(python_exe) == agent_dir.resolve() + argv, kwargs = calls[0] + assert argv[:2] == [python_exe, "-c"] + assert 'find_spec("run_agent")' in argv[2] + assert "import run_agent" not in argv[2] + assert kwargs == {"capture_output": True, "text": True} + + +def test_python_probe_returns_none_when_run_agent_spec_is_missing(monkeypatch, tmp_path): + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 0, stdout="\n", stderr=""), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +def test_python_probe_rejects_malformed_relative_origin(monkeypatch, tmp_path): + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 0, stdout="run_agent.py\n", stderr=""), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +def test_python_probe_rejects_non_file_origin(monkeypatch, tmp_path): + missing = tmp_path / "site-packages" / "run_agent.py" + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 0, stdout=f"{missing}\n", stderr=""), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +@pytest.mark.parametrize("error", [FileNotFoundError(), PermissionError()]) +def test_python_probe_returns_none_when_interpreter_cannot_start(monkeypatch, tmp_path, error): + monkeypatch.setattr(bootstrap.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(error)) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +def test_python_probe_returns_none_on_nonzero_exit(monkeypatch, tmp_path): + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 1, stdout="", stderr="probe failed"), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +def test_python_probe_accepts_valid_origin_with_benign_stderr(monkeypatch, tmp_path): + run_agent = tmp_path / "site-packages" / "run_agent.py" + run_agent.parent.mkdir() + run_agent.write_text("", encoding="utf-8") + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 0, stdout=f"{run_agent}\n", stderr="warning\n"), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) == run_agent.parent.resolve() + + +def test_python_probe_rejects_existing_file_with_wrong_name(monkeypatch, tmp_path): + origin = tmp_path / "site-packages" / "agent_entry.py" + origin.parent.mkdir() + origin.write_text("", encoding="utf-8") + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda argv, **kwargs: bootstrap.subprocess.CompletedProcess(argv, 0, stdout=f"{origin}\n", stderr=""), + ) + + assert bootstrap._agent_dir_from_python(str(tmp_path / "python")) is None + + +def test_hermes_cli_takes_precedence_over_configured_python(monkeypatch, tmp_path): + install, venv_python = _make_agent_install(tmp_path) + hermes = _make_hermes_cli(tmp_path, str(venv_python)) + _isolate_discover_agent_dir(monkeypatch, tmp_path, hermes) + monkeypatch.setattr( + bootstrap, + "_agent_dir_from_python", + lambda _python: (_ for _ in ()).throw(AssertionError("Python probe must not run")), + ) + + assert bootstrap.discover_agent_dir() == install.resolve() diff --git a/tests/test_bootstrap_foreground.py b/tests/test_bootstrap_foreground.py index 4aa200ae..d0ba7e4e 100644 --- a/tests/test_bootstrap_foreground.py +++ b/tests/test_bootstrap_foreground.py @@ -92,6 +92,8 @@ def clean_env(monkeypatch): "HERMES_WEBUI_HOST", "HERMES_WEBUI_PORT", "HERMES_WEBUI_AGENT_DIR", + "HERMES_WEBUI_PYTHON", + "HERMES_WEBUI_DISABLE_LOCAL_VENV", "HERMES_WEBUI_STATE_DIR", "HERMES_WEBUI_SERVER_CWD", "HERMES_HOME", @@ -531,3 +533,63 @@ class TestForegroundExecutabilityGuard: bs.main() # execv must NOT have been called when the guard fires assert len(execv_calls) == 0 + + +def test_package_python_discovers_agent_before_skip_install_gate(import_bootstrap, clean_env, monkeypatch, tmp_path): + bs = import_bootstrap + agent_dir = tmp_path / "site-packages" + agent_dir.mkdir() + (agent_dir / "run_agent.py").write_text("class AIAgent:\n pass\n", encoding="utf-8") + state_dir = tmp_path / "state" + python_exe = sys.executable + execv_calls = [] + + monkeypatch.setenv("HERMES_WEBUI_PYTHON", python_exe) + monkeypatch.setenv("HERMES_WEBUI_DISABLE_LOCAL_VENV", "1") + monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setenv("PYTHONPATH", str(agent_dir)) + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(bs, "REPO_ROOT", tmp_path / "webui") + monkeypatch.setattr(bs.Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--skip-agent-install"]) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "access", lambda path, mode: True) + monkeypatch.setattr(os, "chdir", lambda path: None) + + def fake_execv(path, argv): + execv_calls.append((path, argv)) + raise SystemExit(0) + + monkeypatch.setattr(os, "execv", fake_execv) + + with patch.object(bs, "install_hermes_agent") as mock_install, patch.object(bs.venv, "EnvBuilder") as mock_builder, pytest.raises(SystemExit): + bs.main() + + assert execv_calls == [(python_exe, [python_exe, str(bs.REPO_ROOT / "server.py")])] + assert os.environ["HERMES_WEBUI_AGENT_DIR"] == str(agent_dir.resolve()) + mock_install.assert_not_called() + mock_builder.assert_not_called() + + +def test_package_python_without_agent_stays_fail_closed(import_bootstrap, clean_env, monkeypatch, tmp_path): + bs = import_bootstrap + python_exe = sys.executable + + monkeypatch.setenv("HERMES_WEBUI_PYTHON", python_exe) + monkeypatch.setenv("HERMES_WEBUI_DISABLE_LOCAL_VENV", "1") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(bs, "REPO_ROOT", tmp_path / "webui") + monkeypatch.setattr(bs.Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--skip-agent-install"]) + + with patch.object(bs, "_agent_dir_from_python", return_value=None) as mock_probe, patch.object(bs, "install_hermes_agent") as mock_install, patch.object(bs.venv, "EnvBuilder") as mock_builder, patch.object(os, "execv") as mock_execv, pytest.raises(RuntimeError, match="Hermes Agent was not found"): + bs.main() + + mock_probe.assert_called_once_with(python_exe) + mock_install.assert_not_called() + mock_builder.assert_not_called() + mock_execv.assert_not_called() diff --git a/tests/test_nix_flake_module_source.py b/tests/test_nix_flake_module_source.py index 06be6089..f022b35f 100644 --- a/tests/test_nix_flake_module_source.py +++ b/tests/test_nix_flake_module_source.py @@ -52,6 +52,19 @@ def test_nixos_module_uses_explicit_agent_dir_passthru_when_available(): assert legacy_agent_share_dir not in MODULE_NIX +def test_flake_checks_package_with_only_hermes_venv_metadata(): + assert "packageOnlyAgentPackage" in FLAKE_NIX + assert "passthru.hermesVenv = packageOnlyAgentVenv;" in FLAKE_NIX + assert "HERMES_WEBUI_PYTHON=${packageOnlyAgentVenv}/bin/python3" in FLAKE_NIX + assert "! grep -q 'HERMES_WEBUI_AGENT_DIR=' ${packageOnlyEnvProbe}" in FLAKE_NIX + + +def test_readme_wires_published_agent_flake_package(): + assert 'hermes-agent.url = "github:NousResearch/hermes-agent";' in README + assert "agent.package = hermes-agent.packages.${pkgs.stdenv.hostPlatform.system}.default;" in README + assert 'hermesHome = "/var/lib/hermes/.hermes";' in README + + def test_nixos_module_does_not_chown_existing_hermes_home(): assert "d ${cfg.hermesHome}" not in MODULE_NIX