diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index ae09c37134..f6cbcedf66 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -10,16 +10,27 @@ import os import sys from pathlib import Path +from hermes_constants import get_hermes_home from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH HOST = "hermes" def _config_path() -> Path: - """Return the active Honcho config path (instance-local or global).""" + """Return the active Honcho config path for reading (instance-local or global).""" return resolve_config_path() +def _local_config_path() -> Path: + """Return the instance-local Honcho config path for writing. + + Always returns $HERMES_HOME/honcho.json so each profile/instance gets + its own config file. The global ~/.honcho/config.json is only used as + a read fallback (via resolve_config_path) for cross-app interop. + """ + return get_hermes_home() / "honcho.json" + + def _read_config() -> dict: path = _config_path() if path.exists(): @@ -31,7 +42,7 @@ def _read_config() -> dict: def _write_config(cfg: dict, path: Path | None = None) -> None: - path = path or _config_path() + path = path or _local_config_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", @@ -95,13 +106,13 @@ def cmd_setup(args) -> None: """Interactive Honcho setup wizard.""" cfg = _read_config() - active_path = _config_path() + write_path = _local_config_path() + read_path = _config_path() print("\nHoncho memory setup\n" + "─" * 40) print(" Honcho gives Hermes persistent cross-session memory.") - if active_path != GLOBAL_CONFIG_PATH: - print(f" Instance config: {active_path}") - else: - print(" Config is shared with other hosts at ~/.honcho/config.json") + print(f" Config: {write_path}") + if read_path != write_path and read_path.exists(): + print(f" (seeding from existing config at {read_path})") print() if not _ensure_sdk_installed(): @@ -189,7 +200,7 @@ def cmd_setup(args) -> None: hermes_host.setdefault("saveMessages", True) _write_config(cfg) - print(f"\n Config written to {active_path}") + print(f"\n Config written to {write_path}") # Test connection print(" Testing connection... ", end="", flush=True) @@ -237,6 +248,7 @@ def cmd_status(args) -> None: cfg = _read_config() active_path = _config_path() + write_path = _local_config_path() if not cfg: print(f" No Honcho config found at {active_path}") @@ -259,6 +271,8 @@ def cmd_status(args) -> None: print(f" Workspace: {hcfg.workspace_id}") print(f" Host: {hcfg.host}") print(f" Config path: {active_path}") + if write_path != active_path: + print(f" Write path: {write_path} (instance-local)") print(f" AI peer: {hcfg.ai_peer}") print(f" User peer: {hcfg.peer_name or 'not set'}") print(f" Session key: {hcfg.resolve_session_name()}") diff --git a/tests/honcho_integration/test_config_isolation.py b/tests/honcho_integration/test_config_isolation.py new file mode 100644 index 0000000000..4d9898e681 --- /dev/null +++ b/tests/honcho_integration/test_config_isolation.py @@ -0,0 +1,190 @@ +"""Tests for Honcho config profile isolation. + +Verifies that each Hermes profile writes to its own instance-local +honcho.json ($HERMES_HOME/honcho.json) rather than the shared global +~/.honcho/config.json. +""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from honcho_integration.cli import ( + _config_path, + _local_config_path, + _read_config, + _write_config, +) + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + """Create an isolated HERMES_HOME + real home for testing.""" + hermes_home = tmp_path / "profile_a" + hermes_home.mkdir() + global_dir = tmp_path / "home" / ".honcho" + global_dir.mkdir(parents=True) + global_config = global_dir / "config.json" + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home")) + # GLOBAL_CONFIG_PATH is a module-level constant cached at import time, + # so we must patch it in both the defining module and the importing module. + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config) + + return { + "hermes_home": hermes_home, + "global_config": global_config, + "local_config": hermes_home / "honcho.json", + } + + +class TestLocalConfigPath: + """_local_config_path always returns $HERMES_HOME/honcho.json.""" + + def test_returns_hermes_home_path(self, isolated_home): + assert _local_config_path() == isolated_home["local_config"] + + def test_differs_from_global(self, isolated_home): + from honcho_integration.client import GLOBAL_CONFIG_PATH + assert _local_config_path() != GLOBAL_CONFIG_PATH + + +class TestWriteConfigIsolation: + """_write_config defaults to the instance-local path.""" + + def test_write_creates_local_file(self, isolated_home): + cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}} + _write_config(cfg) + + assert isolated_home["local_config"].exists() + written = json.loads(isolated_home["local_config"].read_text()) + assert written["apiKey"] == "test-key" + + def test_write_does_not_touch_global(self, isolated_home): + # Pre-populate global config + isolated_home["global_config"].write_text( + json.dumps({"apiKey": "global-key"}) + ) + + cfg = {"apiKey": "profile-key"} + _write_config(cfg) + + # Global should be untouched + global_data = json.loads(isolated_home["global_config"].read_text()) + assert global_data["apiKey"] == "global-key" + + # Local should have the new value + local_data = json.loads(isolated_home["local_config"].read_text()) + assert local_data["apiKey"] == "profile-key" + + def test_explicit_path_override_still_works(self, isolated_home): + custom = isolated_home["hermes_home"] / "custom.json" + _write_config({"custom": True}, path=custom) + assert custom.exists() + assert not isolated_home["local_config"].exists() + + +class TestReadConfigFallback: + """_read_config falls back to global when no local file exists.""" + + def test_reads_local_when_exists(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + def test_falls_back_to_global(self, isolated_home): + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + # No local file exists + assert not isolated_home["local_config"].exists() + cfg = _read_config() + assert cfg["source"] == "global" + + def test_local_takes_priority_over_global(self, isolated_home): + isolated_home["local_config"].write_text( + json.dumps({"source": "local"}) + ) + isolated_home["global_config"].write_text( + json.dumps({"source": "global"}) + ) + cfg = _read_config() + assert cfg["source"] == "local" + + +class TestMultiProfileIsolation: + """Two profiles writing config don't interfere with each other.""" + + def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + profile_a = tmp_path / "profile_a" + profile_b = tmp_path / "profile_b" + profile_a.mkdir() + profile_b.mkdir() + + # Profile A writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + _write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}}) + + # Profile B writes its config + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + _write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}}) + + # Verify isolation + a_data = json.loads((profile_a / "honcho.json").read_text()) + b_data = json.loads((profile_b / "honcho.json").read_text()) + + assert a_data["hosts"]["hermes"]["peerName"] == "alice" + assert b_data["hosts"]["hermes"]["peerName"] == "bob" + + def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch): + """First setup reads global config, writes to local.""" + home = tmp_path / "home" + global_dir = home / ".honcho" + global_dir.mkdir(parents=True) + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + global_cfg_path = global_dir / "config.json" + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path) + + # Existing global config + global_config = global_dir / "config.json" + global_config.write_text(json.dumps({ + "apiKey": "shared-key", + "hosts": {"hermes": {"workspace": "shared-ws"}}, + })) + + profile = tmp_path / "new_profile" + profile.mkdir() + monkeypatch.setenv("HERMES_HOME", str(profile)) + + # Read seeds from global + cfg = _read_config() + assert cfg["apiKey"] == "shared-key" + + # Modify and write goes to local + cfg["hosts"]["hermes"]["peerName"] = "new-user" + _write_config(cfg) + + local_config = profile / "honcho.json" + assert local_config.exists() + local_data = json.loads(local_config.read_text()) + assert local_data["hosts"]["hermes"]["peerName"] == "new-user" + + # Global unchanged + global_data = json.loads(global_config.read_text()) + assert "peerName" not in global_data["hosts"]["hermes"]