Files
hermes-agent/tests/plugins/test_honcho_client_config.py
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

257 lines
10 KiB
Python

"""Tests for Honcho client configuration."""
import json
import os
import stat
from pathlib import Path
import pytest
from plugins.memory.honcho.client import HonchoClientConfig
from plugins.memory.honcho import HonchoMemoryProvider
class TestHonchoClientConfigAutoEnable:
"""Test auto-enable behavior when API key is present."""
def test_auto_enables_when_api_key_present_no_explicit_enabled(self, tmp_path):
"""When API key exists and enabled is not set, should auto-enable."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
# Note: no "enabled" field
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is True # Auto-enabled because API key exists
def test_respects_explicit_enabled_false(self, tmp_path):
"""When enabled is explicitly False, should stay disabled even with API key."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"apiKey": "test-api-key-12345",
"enabled": False, # Explicitly disabled
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key == "test-api-key-12345"
assert cfg.enabled is False # Respects explicit setting
def test_disabled_when_no_api_key_and_no_explicit_enabled(self, tmp_path):
"""When no API key and enabled not set, should be disabled."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"workspace": "test",
# No apiKey, no enabled
}))
# Clear env var if set
env_key = os.environ.pop("HONCHO_API_KEY", None)
try:
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.api_key is None
assert cfg.enabled is False # No API key = not enabled
finally:
if env_key:
os.environ["HONCHO_API_KEY"] = env_key
def test_from_env_always_enabled(self, monkeypatch):
"""from_env() should always set enabled=True."""
monkeypatch.setenv("HONCHO_API_KEY", "env-test-key")
cfg = HonchoClientConfig.from_env()
assert cfg.api_key == "env-test-key"
assert cfg.enabled is True
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path, monkeypatch):
"""honcho.json is created atomically with 0o600, not chmod-after-write."""
import utils
calls = []
real_atomic = utils.atomic_json_write
def spy(path, data, **kwargs):
calls.append(kwargs.get("mode"))
return real_atomic(path, data, **kwargs)
monkeypatch.setattr(utils, "atomic_json_write", spy)
provider = HonchoMemoryProvider()
provider.save_config({"api_key": "hc-test-key"}, str(tmp_path))
assert calls == [0o600]
config_file = tmp_path / "honcho.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestLatencyFlagResolution:
def test_host_block_wins(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'queryRewrite': False,
'firstTurnBaseWait': 3,
'hosts': {'hermes': {
'queryRewrite': True,
'firstTurnBaseWait': 0,
'firstTurnDialecticWait': 0.5,
}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.query_rewrite is True
assert cfg.first_turn_base_wait == 0.0
assert cfg.first_turn_dialectic_wait == 0.5
def test_per_host_timeout_wins_over_global(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_TIMEOUT', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'timeout': 30,
'hosts': {'hermes': {'timeout': 5}},
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.timeout == 5.0
class TestHonchoBaseUrlSanitize:
def test_clean_base_url_accepted(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'apiKey': 'k',
'baseUrl': 'https://honcho.example.com',
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.base_url == 'https://honcho.example.com'
def test_nonprintable_base_url_dropped(self, tmp_path, monkeypatch):
monkeypatch.delenv('HONCHO_BASE_URL', raising=False)
config_path = tmp_path / 'config.json'
bad = 'https://honcho.example.com\x1b'
config_path.write_text(json.dumps({
'apiKey': 'k',
'baseUrl': bad,
}))
cfg = HonchoClientConfig.from_global_config(config_path=config_path)
assert cfg.base_url is None
def test_env_nonprintable_dropped(self, monkeypatch):
monkeypatch.setenv('HONCHO_BASE_URL', 'https://x.example\x1b')
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
cfg = HonchoClientConfig.from_env()
assert cfg.base_url is None
class TestProfileKeyIsolationWarning:
"""#36098 / #66125: a named-profile host block without apiKey does NOT
inherit the default host's key (isolation by design), but the failure
must be loud, not silent."""
def test_keyless_profile_block_warns_when_default_has_key(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {
'hermes': {'apiKey': 'shared-key'},
'hermes_coder': {'baseUrl': 'http://192.168.1.50:8000'},
},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
cfg = HonchoClientConfig.from_global_config(
host='hermes_coder', config_path=config_path,
)
assert cfg.api_key is None # isolation preserved — no silent inheritance
assert any('NOT inherited' in r.message for r in caplog.records)
def test_no_warning_when_profile_block_has_key(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {
'hermes': {'apiKey': 'shared-key'},
'hermes_coder': {'apiKey': 'coder-key'},
},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
cfg = HonchoClientConfig.from_global_config(
host='hermes_coder', config_path=config_path,
)
assert cfg.api_key == 'coder-key'
assert not any('NOT inherited' in r.message for r in caplog.records)
def test_no_warning_for_default_host(self, tmp_path, monkeypatch, caplog):
import logging
monkeypatch.delenv('HONCHO_API_KEY', raising=False)
config_path = tmp_path / 'config.json'
config_path.write_text(json.dumps({
'hosts': {'hermes': {'baseUrl': 'http://localhost:8000'}},
}))
with caplog.at_level(logging.WARNING, logger='plugins.memory.honcho.client'):
HonchoClientConfig.from_global_config(
host='hermes', config_path=config_path,
)
assert not any('NOT inherited' in r.message for r in caplog.records)
def test_save_config_refuses_to_replace_a_file_that_does_not_parse(tmp_path):
"""A corrupt honcho.json must not be rewritten from the new values alone."""
config_path = tmp_path / "honcho.json"
config_path.write_text("{ not json")
with pytest.raises(ValueError):
HonchoMemoryProvider().save_config({"api_key": "hc-test-key"}, str(tmp_path))
assert config_path.read_text() == "{ not json"
def test_save_config_merges_into_a_parseable_file(tmp_path):
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"other": {"apiKey": "keep-me"}}}))
HonchoMemoryProvider().save_config({"api_key": "hc-test-key"}, str(tmp_path))
data = json.loads(config_path.read_text())
assert data["hosts"]["other"]["apiKey"] == "keep-me" and data["api_key"] == "hc-test-key"
def test_save_config_holds_the_refresh_locks_so_a_rotation_survives(tmp_path, monkeypatch):
"""A refresh thread that wants the locks while save_config reads must land after its write, not under it."""
import threading
from plugins.memory.honcho import oauth
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"hermes": {"apiKey": "hch-at-old", "oauth": {"refreshToken": "hch-rt-old"}}}}))
rotated = oauth.OAuthCredential("hch-at-new", "hch-rt-new", 10_000, "hermes-desktop", "http://localhost:8000/oauth/token")
save_read, rotation_done = threading.Event(), threading.Event()
real_read = oauth._read_config_strict
def read_then_wait(path):
raw = real_read(path)
if not save_read.is_set():
save_read.set()
rotation_done.wait(0.5) # the refresh gets this window; only a held lock keeps it out
return raw
def rotate():
save_read.wait(2)
with oauth._refresh_lock, oauth._config_refresh_lock(config_path):
oauth._persist_credential(config_path, "hermes", rotated)
rotation_done.set()
monkeypatch.setattr(oauth, "_read_config_strict", read_then_wait)
thread = threading.Thread(target=rotate)
thread.start()
HonchoMemoryProvider().save_config({"logging": True}, str(tmp_path))
thread.join(2)
assert rotation_done.is_set()
data = json.loads(config_path.read_text())
assert data["hosts"]["hermes"]["apiKey"] == "hch-at-new" and data["logging"] is True