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:
Teknium
2026-03-30 16:41:19 -07:00
committed by GitHub
parent 275fcc6673
commit 3d47af01c3
2 changed files with 212 additions and 8 deletions

View File

@@ -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()}")

View 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"]