Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions honcho_integration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,27 @@
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():
Expand All @@ -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",
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
Expand All @@ -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()}")
Expand Down
190 changes: 190 additions & 0 deletions tests/honcho_integration/test_config_isolation.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading