fix(honcho): write config to instance-local path for profile isolation (#4037)
Multiple agents/profiles running 'hermes honcho setup' all wrote to the shared global ~/.honcho/config.json, overwriting each other's configuration. Root cause: _write_config() defaulted to resolve_config_path() which returns the global path when no instance-local file exists yet (i.e. on first setup). Fix: _write_config() now defaults to _local_config_path() which always returns $HERMES_HOME/honcho.json. Each profile gets its own config file. Reading still falls back to global for cross-app interop and seeding. Also updates cmd_setup and cmd_status messaging to show the actual write path. Includes 10 new tests verifying profile isolation, global fallback reads, and multi-profile independence.
This commit is contained in:
@@ -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()}")
|
||||
|
||||
190
tests/honcho_integration/test_config_isolation.py
Normal file
190
tests/honcho_integration/test_config_isolation.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user