Skip to content

Commit 3d47af0

Browse files
authored
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.
1 parent 275fcc6 commit 3d47af0

2 files changed

Lines changed: 212 additions & 8 deletions

File tree

honcho_integration/cli.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@
1010
import sys
1111
from pathlib import Path
1212

13+
from hermes_constants import get_hermes_home
1314
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
1415

1516
HOST = "hermes"
1617

1718

1819
def _config_path() -> Path:
19-
"""Return the active Honcho config path (instance-local or global)."""
20+
"""Return the active Honcho config path for reading (instance-local or global)."""
2021
return resolve_config_path()
2122

2223

24+
def _local_config_path() -> Path:
25+
"""Return the instance-local Honcho config path for writing.
26+
27+
Always returns $HERMES_HOME/honcho.json so each profile/instance gets
28+
its own config file. The global ~/.honcho/config.json is only used as
29+
a read fallback (via resolve_config_path) for cross-app interop.
30+
"""
31+
return get_hermes_home() / "honcho.json"
32+
33+
2334
def _read_config() -> dict:
2435
path = _config_path()
2536
if path.exists():
@@ -31,7 +42,7 @@ def _read_config() -> dict:
3142

3243

3344
def _write_config(cfg: dict, path: Path | None = None) -> None:
34-
path = path or _config_path()
45+
path = path or _local_config_path()
3546
path.parent.mkdir(parents=True, exist_ok=True)
3647
path.write_text(
3748
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
@@ -95,13 +106,13 @@ def cmd_setup(args) -> None:
95106
"""Interactive Honcho setup wizard."""
96107
cfg = _read_config()
97108

98-
active_path = _config_path()
109+
write_path = _local_config_path()
110+
read_path = _config_path()
99111
print("\nHoncho memory setup\n" + "─" * 40)
100112
print(" Honcho gives Hermes persistent cross-session memory.")
101-
if active_path != GLOBAL_CONFIG_PATH:
102-
print(f" Instance config: {active_path}")
103-
else:
104-
print(" Config is shared with other hosts at ~/.honcho/config.json")
113+
print(f" Config: {write_path}")
114+
if read_path != write_path and read_path.exists():
115+
print(f" (seeding from existing config at {read_path})")
105116
print()
106117

107118
if not _ensure_sdk_installed():
@@ -189,7 +200,7 @@ def cmd_setup(args) -> None:
189200
hermes_host.setdefault("saveMessages", True)
190201

191202
_write_config(cfg)
192-
print(f"\n Config written to {active_path}")
203+
print(f"\n Config written to {write_path}")
193204

194205
# Test connection
195206
print(" Testing connection... ", end="", flush=True)
@@ -237,6 +248,7 @@ def cmd_status(args) -> None:
237248
cfg = _read_config()
238249

239250
active_path = _config_path()
251+
write_path = _local_config_path()
240252

241253
if not cfg:
242254
print(f" No Honcho config found at {active_path}")
@@ -259,6 +271,8 @@ def cmd_status(args) -> None:
259271
print(f" Workspace: {hcfg.workspace_id}")
260272
print(f" Host: {hcfg.host}")
261273
print(f" Config path: {active_path}")
274+
if write_path != active_path:
275+
print(f" Write path: {write_path} (instance-local)")
262276
print(f" AI peer: {hcfg.ai_peer}")
263277
print(f" User peer: {hcfg.peer_name or 'not set'}")
264278
print(f" Session key: {hcfg.resolve_session_name()}")
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Tests for Honcho config profile isolation.
2+
3+
Verifies that each Hermes profile writes to its own instance-local
4+
honcho.json ($HERMES_HOME/honcho.json) rather than the shared global
5+
~/.honcho/config.json.
6+
"""
7+
8+
import json
9+
import os
10+
from pathlib import Path
11+
from unittest.mock import patch
12+
13+
import pytest
14+
15+
from honcho_integration.cli import (
16+
_config_path,
17+
_local_config_path,
18+
_read_config,
19+
_write_config,
20+
)
21+
22+
23+
@pytest.fixture
24+
def isolated_home(tmp_path, monkeypatch):
25+
"""Create an isolated HERMES_HOME + real home for testing."""
26+
hermes_home = tmp_path / "profile_a"
27+
hermes_home.mkdir()
28+
global_dir = tmp_path / "home" / ".honcho"
29+
global_dir.mkdir(parents=True)
30+
global_config = global_dir / "config.json"
31+
32+
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
33+
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home"))
34+
# GLOBAL_CONFIG_PATH is a module-level constant cached at import time,
35+
# so we must patch it in both the defining module and the importing module.
36+
import honcho_integration.client as _client_mod
37+
import honcho_integration.cli as _cli_mod
38+
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config)
39+
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config)
40+
41+
return {
42+
"hermes_home": hermes_home,
43+
"global_config": global_config,
44+
"local_config": hermes_home / "honcho.json",
45+
}
46+
47+
48+
class TestLocalConfigPath:
49+
"""_local_config_path always returns $HERMES_HOME/honcho.json."""
50+
51+
def test_returns_hermes_home_path(self, isolated_home):
52+
assert _local_config_path() == isolated_home["local_config"]
53+
54+
def test_differs_from_global(self, isolated_home):
55+
from honcho_integration.client import GLOBAL_CONFIG_PATH
56+
assert _local_config_path() != GLOBAL_CONFIG_PATH
57+
58+
59+
class TestWriteConfigIsolation:
60+
"""_write_config defaults to the instance-local path."""
61+
62+
def test_write_creates_local_file(self, isolated_home):
63+
cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}}
64+
_write_config(cfg)
65+
66+
assert isolated_home["local_config"].exists()
67+
written = json.loads(isolated_home["local_config"].read_text())
68+
assert written["apiKey"] == "test-key"
69+
70+
def test_write_does_not_touch_global(self, isolated_home):
71+
# Pre-populate global config
72+
isolated_home["global_config"].write_text(
73+
json.dumps({"apiKey": "global-key"})
74+
)
75+
76+
cfg = {"apiKey": "profile-key"}
77+
_write_config(cfg)
78+
79+
# Global should be untouched
80+
global_data = json.loads(isolated_home["global_config"].read_text())
81+
assert global_data["apiKey"] == "global-key"
82+
83+
# Local should have the new value
84+
local_data = json.loads(isolated_home["local_config"].read_text())
85+
assert local_data["apiKey"] == "profile-key"
86+
87+
def test_explicit_path_override_still_works(self, isolated_home):
88+
custom = isolated_home["hermes_home"] / "custom.json"
89+
_write_config({"custom": True}, path=custom)
90+
assert custom.exists()
91+
assert not isolated_home["local_config"].exists()
92+
93+
94+
class TestReadConfigFallback:
95+
"""_read_config falls back to global when no local file exists."""
96+
97+
def test_reads_local_when_exists(self, isolated_home):
98+
isolated_home["local_config"].write_text(
99+
json.dumps({"source": "local"})
100+
)
101+
cfg = _read_config()
102+
assert cfg["source"] == "local"
103+
104+
def test_falls_back_to_global(self, isolated_home):
105+
isolated_home["global_config"].write_text(
106+
json.dumps({"source": "global"})
107+
)
108+
# No local file exists
109+
assert not isolated_home["local_config"].exists()
110+
cfg = _read_config()
111+
assert cfg["source"] == "global"
112+
113+
def test_local_takes_priority_over_global(self, isolated_home):
114+
isolated_home["local_config"].write_text(
115+
json.dumps({"source": "local"})
116+
)
117+
isolated_home["global_config"].write_text(
118+
json.dumps({"source": "global"})
119+
)
120+
cfg = _read_config()
121+
assert cfg["source"] == "local"
122+
123+
124+
class TestMultiProfileIsolation:
125+
"""Two profiles writing config don't interfere with each other."""
126+
127+
def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch):
128+
home = tmp_path / "home"
129+
home.mkdir()
130+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
131+
132+
profile_a = tmp_path / "profile_a"
133+
profile_b = tmp_path / "profile_b"
134+
profile_a.mkdir()
135+
profile_b.mkdir()
136+
137+
# Profile A writes its config
138+
monkeypatch.setenv("HERMES_HOME", str(profile_a))
139+
_write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}})
140+
141+
# Profile B writes its config
142+
monkeypatch.setenv("HERMES_HOME", str(profile_b))
143+
_write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}})
144+
145+
# Verify isolation
146+
a_data = json.loads((profile_a / "honcho.json").read_text())
147+
b_data = json.loads((profile_b / "honcho.json").read_text())
148+
149+
assert a_data["hosts"]["hermes"]["peerName"] == "alice"
150+
assert b_data["hosts"]["hermes"]["peerName"] == "bob"
151+
152+
def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch):
153+
"""First setup reads global config, writes to local."""
154+
home = tmp_path / "home"
155+
global_dir = home / ".honcho"
156+
global_dir.mkdir(parents=True)
157+
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
158+
import honcho_integration.client as _client_mod
159+
import honcho_integration.cli as _cli_mod
160+
global_cfg_path = global_dir / "config.json"
161+
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
162+
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
163+
164+
# Existing global config
165+
global_config = global_dir / "config.json"
166+
global_config.write_text(json.dumps({
167+
"apiKey": "shared-key",
168+
"hosts": {"hermes": {"workspace": "shared-ws"}},
169+
}))
170+
171+
profile = tmp_path / "new_profile"
172+
profile.mkdir()
173+
monkeypatch.setenv("HERMES_HOME", str(profile))
174+
175+
# Read seeds from global
176+
cfg = _read_config()
177+
assert cfg["apiKey"] == "shared-key"
178+
179+
# Modify and write goes to local
180+
cfg["hosts"]["hermes"]["peerName"] = "new-user"
181+
_write_config(cfg)
182+
183+
local_config = profile / "honcho.json"
184+
assert local_config.exists()
185+
local_data = json.loads(local_config.read_text())
186+
assert local_data["hosts"]["hermes"]["peerName"] == "new-user"
187+
188+
# Global unchanged
189+
global_data = json.loads(global_config.read_text())
190+
assert "peerName" not in global_data["hosts"]["hermes"]

0 commit comments

Comments
 (0)