Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-04 12:47:53 -05:00
182 changed files with 9843 additions and 974 deletions
+283
View File
@@ -896,3 +896,286 @@ def test_refresh_non_reuse_error_keeps_original_description():
assert "Refresh session has been revoked" in str(exc_info.value)
# Must not have been rewritten with the reuse message.
assert "external process" not in str(exc_info.value).lower()
# =============================================================================
# Shared Nous token store — cross-profile persistence (Codex-style auto-import)
# =============================================================================
@pytest.fixture
def shared_store_env(tmp_path, monkeypatch):
"""Redirect HERMES_SHARED_AUTH_DIR to a tmp_path.
Required for every test that exercises the shared Nous store — the
in-auth.py seat belt refuses to touch the real user's shared store
under pytest, so tests that forget this fixture fail loudly instead
of corrupting real state.
"""
shared_dir = tmp_path / "shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(shared_dir))
return shared_dir
def test_shared_store_seat_belt_refuses_real_home_under_pytest(monkeypatch):
"""Without HERMES_SHARED_AUTH_DIR override, the seat belt must trip.
Mirrors the existing ``_auth_file_path`` seat belt: forgetting to
redirect this store in a test must fail loudly instead of silently
writing to the user's real ``~/.hermes/shared/`` across CI runs.
"""
from hermes_cli.auth import _nous_shared_store_path
monkeypatch.delenv("HERMES_SHARED_AUTH_DIR", raising=False)
with pytest.raises(RuntimeError, match="shared Nous auth store"):
_nous_shared_store_path()
def test_shared_store_honors_env_override(tmp_path, monkeypatch):
"""HERMES_SHARED_AUTH_DIR must redirect the path."""
from hermes_cli.auth import _nous_shared_store_path, NOUS_SHARED_STORE_FILENAME
custom_dir = tmp_path / "custom_shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(custom_dir))
path = _nous_shared_store_path()
assert path == custom_dir / NOUS_SHARED_STORE_FILENAME
def test_shared_store_read_missing_returns_none(shared_store_env):
"""Missing file → ``_read_shared_nous_state()`` returns None."""
from hermes_cli.auth import _read_shared_nous_state
assert _read_shared_nous_state() is None
def test_shared_store_read_malformed_returns_none(shared_store_env):
"""Unreadable / non-JSON file → None, not an exception."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ not json")
assert _read_shared_nous_state() is None
def test_shared_store_read_missing_required_fields_returns_none(shared_store_env):
"""Payload without refresh_token → None (nothing worth importing)."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"_schema": 1, "access_token": "abc"}))
assert _read_shared_nous_state() is None
def test_shared_store_write_and_read_roundtrip(shared_store_env):
"""Write → read must preserve refresh_token + OAuth URLs."""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
_write_shared_nous_state,
)
_write_shared_nous_state(_full_state_fixture())
path = _nous_shared_store_path()
assert path.is_file()
# Permissions should be 0600 where the platform supports it.
mode = path.stat().st_mode & 0o777
assert mode == 0o600 or mode == 0o644 # 0o644 on platforms without chmod
loaded = _read_shared_nous_state()
assert loaded is not None
assert loaded["refresh_token"] == "refresh-tok"
assert loaded["access_token"] == "access-tok"
assert loaded["portal_base_url"] == "https://portal.example.com"
assert loaded["inference_base_url"] == "https://inference.example.com/v1"
# Volatile agent_key MUST NOT be persisted to the shared store
# (24h TTL, profile-specific — only long-lived OAuth tokens are
# cross-profile useful).
assert "agent_key" not in loaded
def test_shared_store_write_skips_when_refresh_token_missing(shared_store_env):
"""Write is a no-op when refresh_token is absent (nothing to share)."""
from hermes_cli.auth import _nous_shared_store_path, _write_shared_nous_state
state = dict(_full_state_fixture())
state["refresh_token"] = ""
_write_shared_nous_state(state)
assert not _nous_shared_store_path().is_file()
def test_persist_nous_credentials_mirrors_to_shared_store(
tmp_path, monkeypatch, shared_store_env,
):
"""persist_nous_credentials must populate BOTH per-profile auth.json
AND the shared store, so a future profile's `hermes auth add nous
--type oauth` can one-tap import instead of redoing device-code.
"""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
persist_nous_credentials,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
persist_nous_credentials(_full_state_fixture())
# Per-profile auth.json populated
payload = json.loads((hermes_home / "auth.json").read_text())
assert "nous" in payload.get("providers", {})
# Shared store populated with the same refresh_token
shared = _read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# Shared file path lives under the tmp override, NOT the real home
assert str(_nous_shared_store_path()).startswith(str(shared_store_env))
def test_try_import_shared_returns_none_when_store_missing(shared_store_env):
"""No shared store → no rehydrate (fall through to device-code)."""
from hermes_cli.auth import _try_import_shared_nous_state
assert _try_import_shared_nous_state() is None
def test_try_import_shared_returns_none_on_refresh_failure(
shared_store_env, monkeypatch,
):
"""If the portal rejects the stored refresh_token (revoked, expired,
portal down), _try_import_shared_nous_state must return None so the
login flow falls back to a fresh device-code run.
"""
from hermes_cli import auth as auth_mod
# Seed the shared store
auth_mod._write_shared_nous_state(_full_state_fixture())
# Make refresh fail
def _boom(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _boom)
assert auth_mod._try_import_shared_nous_state() is None
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
"""Happy path: stored refresh_token is accepted, forced refresh+mint
returns a fresh access_token + agent_key, and the returned dict has
every field persist_nous_credentials() needs.
"""
from hermes_cli import auth as auth_mod
auth_mod._write_shared_nous_state(_full_state_fixture())
def _fake_refresh(state, **kwargs):
# Simulate portal returning fresh tokens + a new agent_key
assert kwargs.get("force_refresh") is True
assert kwargs.get("force_mint") is True
return {
**state,
"access_token": "fresh-access-tok",
"refresh_token": "fresh-refresh-tok", # rotated
"agent_key": "new-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
assert result["access_token"] == "fresh-access-tok"
assert result["refresh_token"] == "fresh-refresh-tok"
assert result["agent_key"] == "new-agent-key"
# Preserved from shared state
assert result["portal_base_url"] == "https://portal.example.com"
assert result["client_id"] == "hermes-cli"
def test_shared_store_survives_across_profile_switch(
tmp_path, monkeypatch, shared_store_env,
):
"""End-to-end: profile A logs in → shared store populated → profile B
(different HERMES_HOME) sees the same shared state and can rehydrate
without re-running device-code.
"""
from hermes_cli import auth as auth_mod
# Profile A: login, which mirrors to shared store
profile_a = tmp_path / "profile_a"
profile_a.mkdir(parents=True, exist_ok=True)
(profile_a / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_a))
auth_mod.persist_nous_credentials(_full_state_fixture())
# Profile A's auth.json has nous
a_payload = json.loads((profile_a / "auth.json").read_text())
assert "nous" in a_payload.get("providers", {})
# Profile B: fresh HERMES_HOME, no auth yet, but the shared store
# persists — _read_shared_nous_state() must still return the tokens.
profile_b = tmp_path / "profile_b"
profile_b.mkdir(parents=True, exist_ok=True)
(profile_b / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_b))
# B's own auth.json has no nous
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" not in b_payload.get("providers", {})
# But the shared store is visible
shared = auth_mod._read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# And a successful rehydrate + persist lands nous into profile B
def _fake_refresh(state, **kwargs):
return {
**state,
"access_token": "b-access-tok",
"refresh_token": "b-refresh-tok",
"agent_key": "b-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
auth_mod.persist_nous_credentials(result)
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" in b_payload.get("providers", {})
assert b_payload["providers"]["nous"]["refresh_token"] == "b-refresh-tok"
# Shared store was updated with the rotated refresh_token too
shared_after = auth_mod._read_shared_nous_state()
assert shared_after is not None
assert shared_after["refresh_token"] == "b-refresh-tok"
+73
View File
@@ -471,6 +471,32 @@ class TestImport:
with pytest.raises(SystemExit):
run_import(args)
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only")
def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch):
"""Secret files must end up at 0600 after restore (zipfile drops mode bits)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: openrouter\n",
".env": "OPENROUTER_API_KEY=sk-secret\n",
"auth.json": '{"providers": {"nous": "token"}}',
"state.db": b"SQLite format 3\x00",
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"):
mode = (hermes_home / rel).stat().st_mode & 0o777
assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600"
# ---------------------------------------------------------------------------
# Round-trip test
@@ -1348,6 +1374,53 @@ class TestPreUpdateBackup:
from hermes_cli.backup import create_pre_update_backup
assert create_pre_update_backup(hermes_home=tmp_path / "does-not-exist") is None
def test_keep_zero_does_not_delete_freshly_created_backup(self, hermes_home):
"""Regression: ``backup_keep: 0`` previously triggered ``backups[0:]``
in the pruner — wiping the just-created zip and leaving the user
with no recovery point. The floor (keep>=1) preserves the new file
regardless of misconfiguration; users who don't want backups should
set ``pre_update_backup: false`` instead.
"""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=0)
assert out is not None
assert out.exists(), (
"keep=0 silently deleted the freshly-created backup; floor "
"should preserve the just-written file."
)
def test_keep_negative_does_not_delete_freshly_created_backup(self, hermes_home):
"""Mirror coverage: any value <1 should be floored, not literally
applied as a slice index."""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=-3)
assert out is not None
assert out.exists()
def test_keep_zero_still_prunes_older_backups(self, hermes_home):
"""The floor preserves the new backup but should NOT regress the
rotation behaviour for older zips: a third call with keep=0 must
still remove pre-existing backups beyond the (floored) limit of 1.
"""
import time as _t
from hermes_cli.backup import create_pre_update_backup
first = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
second = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
third = create_pre_update_backup(hermes_home=hermes_home, keep=0)
remaining = {
p.name for p in (hermes_home / "backups").iterdir()
if p.name.startswith("pre-update-")
}
assert third.name in remaining, "Floor must preserve the new backup"
assert first.name not in remaining and second.name not in remaining, (
f"keep=0 floor of 1 should still prune older backups; "
f"remaining={remaining}"
)
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
+20
View File
@@ -236,6 +236,13 @@ class TestTelegramBotCommands:
tg_name = cmd.name.replace("-", "_")
assert tg_name not in names
def test_excludes_commands_with_required_args(self):
names = {name for name, _ in telegram_bot_commands()}
assert "background" not in names
assert "queue" not in names
assert "steer" not in names
assert "background" in GATEWAY_KNOWN_COMMANDS
class TestSlackSubcommandMap:
def test_returns_dict(self):
@@ -1661,6 +1668,19 @@ class TestPluginCommandEnumeration:
names = {name for name, _desc in telegram_bot_commands()}
assert "metricas" in names
def test_plugin_command_with_required_args_excluded_from_telegram_menu(self, monkeypatch):
"""Telegram BotCommand selections cannot supply required arguments."""
self._patch_plugin_commands(monkeypatch, {
"background-job": {
"handler": lambda _a: "ok",
"description": "Run a background job",
"args_hint": "<prompt>",
"plugin": "jobs-plugin",
}
})
names = {name for name, _desc in telegram_bot_commands()}
assert "background_job" not in names
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
"""/hermes metricas must route through the Slack subcommand map."""
self._patch_plugin_commands(monkeypatch, {
+9 -1
View File
@@ -114,6 +114,12 @@ def test_status_shows_most_and_least_used_sections(curator_status_env):
env["make_skill"]("top-dog")
env["make_skill"]("middling")
env["make_skill"]("never-used")
# Mark all three as agent-created so they enter the curator's catalog.
# Under the provenance-marker semantics, skills must be explicitly opted
# into curator management (normally via the background-review fork when
# it creates a skill through skill_manage).
for n in ("top-dog", "middling", "never-used"):
env["skill_usage"].mark_agent_created(n)
# Bump use_count differentially. All three counters (use/view/patch) feed
# into activity_count, so bumping use alone is enough to make activity
@@ -150,7 +156,9 @@ def test_status_hides_most_active_when_all_zero(curator_status_env):
env = curator_status_env
env["make_skill"]("a")
env["make_skill"]("b")
# No bumps.
# Mark both as agent-created so the catalog lists them. No bumps.
env["skill_usage"].mark_agent_created("a")
env["skill_usage"].mark_agent_created("b")
out = _capture_status(env["curator_cli"])
@@ -56,7 +56,6 @@ class TestCustomProviderModelSwitch:
"sk-test",
"https://vllm.example.com/v1",
timeout=8.0,
api_mode=None,
)
def test_can_switch_to_different_model(self, config_home):
@@ -141,12 +140,18 @@ class TestCustomProviderModelSwitch:
"api_mode": "anthropic_messages",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]), \
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"***",
"https://proxy.example.com/anthropic",
timeout=8.0,
api_mode="anthropic_messages",
)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
@@ -215,7 +220,6 @@ class TestCustomProviderModelSwitch:
"sk-live-example-provider",
"https://api.example-provider.test/v1",
timeout=8.0,
api_mode=None,
)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
+213
View File
@@ -273,6 +273,101 @@ class TestCaptureLogSnapshot:
assert "rotated agent data" in snap.full_text
# ---------------------------------------------------------------------------
# Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS)
# ---------------------------------------------------------------------------
# A vendor-prefixed token used across redaction tests. Long enough to clear
# the redactor's `floor` parameter so it actually masks rather than fully blanks.
_REDACT_FIXTURE_TOKEN = "sk-proj-A1B2C3D4E5F6G7H8I9J0aA"
class TestCaptureLogSnapshotRedaction:
"""Pin upload-time redaction at the _capture_log_snapshot boundary."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Critical: ensure the user has NOT opted in to redaction. The whole
# point of this PR is that share-time redaction works for users who
# never set this env var.
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text("")
return home
def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10)
# Both views the upload uses must be sanitized.
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_redact_false_passes_through(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("agent", tail_lines=10, redact=False)
# Original token survives when the caller opts out.
assert _REDACT_FIXTURE_TOKEN in snap.tail_text
assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "")
def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret):
"""Regression test: redact_sensitive_text short-circuits without force=True.
If a future refactor drops `force=True` from `_redact_log_text`, this
test fails immediately. Without `force=True`, the redactor returns the
input unchanged when HERMES_REDACT_SECRETS is unset, and the feature
ships silently broken for its target audience.
"""
import os
from hermes_cli.debug import _capture_log_snapshot
# Belt-and-suspenders: confirm the env var is genuinely unset for this
# test so we know we're exercising the force=True path.
assert os.environ.get("HERMES_REDACT_SECRETS", "") == ""
snap = _capture_log_snapshot("agent", tail_lines=10)
assert _REDACT_FIXTURE_TOKEN not in snap.tail_text
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_capture_default_log_snapshots_threads_redact(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50)
# Default threads redact=True to all three captured logs.
assert _REDACT_FIXTURE_TOKEN not in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN not in (snaps["agent"].full_text or "")
def test_capture_default_log_snapshots_no_redact_passes_through(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_default_log_snapshots
snaps = _capture_default_log_snapshots(50, redact=False)
assert _REDACT_FIXTURE_TOKEN in snaps["agent"].tail_text
assert _REDACT_FIXTURE_TOKEN in (snaps["agent"].full_text or "")
# ---------------------------------------------------------------------------
# Debug report collection
# ---------------------------------------------------------------------------
@@ -556,6 +651,124 @@ class TestRunDebugShare:
assert "all failed" in out.err
# ---------------------------------------------------------------------------
# Share-time redaction wiring + visible banner
# ---------------------------------------------------------------------------
class TestRunDebugShareRedaction:
"""End-to-end: --no-redact flag, banner injection, default behavior."""
@pytest.fixture
def hermes_home_with_secret(self, tmp_path, monkeypatch):
"""Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
logs_dir = home / "logs"
logs_dir.mkdir()
(logs_dir / "agent.log").write_text(
f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n"
)
(logs_dir / "errors.log").write_text("")
(logs_dir / "gateway.log").write_text(
f"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\n"
)
return home
def test_default_share_redacts_uploaded_content(
self, hermes_home_with_secret, capsys
):
"""The uploaded report and full-log pastes do not contain the raw token."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# At least the report plus one full log paste reached the upload path.
assert len(captured) >= 2
for content in captured:
assert _REDACT_FIXTURE_TOKEN not in content, (
"raw token leaked into upload-bound content"
)
def test_default_share_includes_redaction_banner(
self, hermes_home_with_secret, capsys
):
"""Each upload-bound paste carries the visible redaction banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = False
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
for content in captured:
assert "redacted at upload time" in content, (
"redaction banner missing from upload-bound content"
)
def test_no_redact_flag_disables_redaction_and_banner(
self, hermes_home_with_secret, capsys
):
"""--no-redact preserves original log content and omits the banner."""
from hermes_cli.debug import run_debug_share
args = MagicMock()
args.lines = 50
args.expire = 7
args.local = False
args.no_redact = True
captured: list[str] = []
def fake_upload(content, expiry_days=7):
captured.append(content)
return f"https://paste.rs/{len(captured)}"
with patch("hermes_cli.dump.run_dump"), \
patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload):
run_debug_share(args)
# The agent.log paste should now contain the raw token.
assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), (
"expected raw token in --no-redact upload"
)
# No banner anywhere when redaction is disabled.
for content in captured:
assert "redacted at upload time" not in content, (
"banner present with --no-redact"
)
# ---------------------------------------------------------------------------
# run_debug router
# ---------------------------------------------------------------------------
+40
View File
@@ -481,6 +481,46 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
)
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
(home / "config.yaml").write_text(
"model:\n"
" provider: kimi-coding-cn\n"
" default: kimi-k2.6\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
+4
View File
@@ -310,6 +310,10 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey
def fake_run(cmd, **kwargs):
if cmd[:4] == ["ps", "-A", "eww", "-o"]:
return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
if cmd[:3] == ["ps", "-o", "ppid="]:
# _get_ancestor_pids() walks up the tree; return "no parent" so
# the loop terminates cleanly.
return SimpleNamespace(returncode=1, stdout="", stderr="")
raise AssertionError(f"Unexpected command: {cmd}")
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
+57
View File
@@ -107,6 +107,61 @@ class TestSystemdServiceRefresh:
]
def test_run_gateway_refreshes_outdated_unit_on_boot(self, tmp_path, monkeypatch):
"""run_gateway() should refresh the systemd unit on boot so that
restart settings take effect even when the process was respawned
via exit-code-75 (bypassing `hermes gateway restart`)."""
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("old unit\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n")
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
calls = []
def fake_run(cmd, check=True, **kwargs):
calls.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
# Prevent run_gateway from actually starting the gateway
def fake_start_gateway(**kwargs):
import asyncio
f = asyncio.Future()
f.set_result(True)
return f
monkeypatch.setattr("gateway.run.start_gateway", fake_start_gateway)
gateway_cli.run_gateway()
assert unit_path.read_text(encoding="utf-8") == "new unit\n"
assert ["systemctl", "--user", "daemon-reload"] in calls
class TestRequireServiceInstalled:
def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys):
unit_path = tmp_path / "hermes-gateway.service"
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
with pytest.raises(SystemExit) as exc_info:
gateway_cli._require_service_installed("start")
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "not installed" in out
assert "hermes gateway install" in out
def test_passes_when_unit_exists(self, tmp_path, monkeypatch):
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("[Unit]\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
gateway_cli._require_service_installed("start")
class TestGeneratedSystemdUnits:
def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
unit = gateway_cli.generate_systemd_unit(system=False)
@@ -487,6 +542,7 @@ class TestGatewaySystemServiceRouting:
calls = []
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system)))
monkeypatch.setattr(
"gateway.status.get_running_pid",
@@ -541,6 +597,7 @@ class TestGatewaySystemServiceRouting:
def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys):
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None)
monkeypatch.setattr(
"gateway.status.read_runtime_status",
+483
View File
@@ -0,0 +1,483 @@
"""Tests for the multi-board kanban layer (``hermes kanban boards …``).
Covers the pieces added when boards became a first-class concept:
* Slug validation and normalisation.
* Path resolution for ``default`` (legacy ``<root>/kanban.db``) vs
named boards (``<root>/kanban/boards/<slug>/kanban.db``).
* Current-board persistence via ``<root>/kanban/current`` and
``HERMES_KANBAN_BOARD`` env var.
* ``connect(board=)`` isolation writes on one board don't leak.
* ``create_board`` / ``list_boards`` / ``remove_board`` round trip.
* CLI surface: ``hermes kanban boards list/create/switch/rm``.
* ``_default_spawn`` injects ``HERMES_KANBAN_BOARD`` into worker env.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
# Ensure the worktree (not the stale global clone) is first on sys.path.
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with no prior kanban state.
The autouse hermetic conftest already nukes credentials + TZ; this
fixture layers a per-test HERMES_HOME plus a path-init cache reset
so each test sees a truly empty board set.
"""
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for var in (
"HERMES_KANBAN_DB",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_HOME",
"HERMES_KANBAN_BOARD",
):
monkeypatch.delenv(var, raising=False)
# Also reset hermes_constants cache so get_default_hermes_root() re-reads.
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
# Kanban module-level init cache must not leak between tests.
kb._INITIALIZED_PATHS.clear()
return home
# ---------------------------------------------------------------------------
# Slug validation
# ---------------------------------------------------------------------------
class TestSlugValidation:
@pytest.mark.parametrize("good", [
"default", "atm10-server", "hermes-agent", "proj_1", "a",
"very-long-but-still-ok-slug-with-hyphens-and-numbers-1234",
])
def test_accepts_valid(self, good):
assert kb._normalize_board_slug(good) == good
@pytest.mark.parametrize("bad", [
"-leading-hyphen", "_leading_underscore",
"with/slash", "with space",
"has.dot", "has?question",
"..", "../etc", "foo\x00bar",
])
def test_rejects_invalid(self, bad):
with pytest.raises(ValueError):
kb._normalize_board_slug(bad)
def test_empty_returns_none(self):
assert kb._normalize_board_slug(None) is None
assert kb._normalize_board_slug("") is None
assert kb._normalize_board_slug(" ") is None
def test_auto_lowercases(self):
# Uppercase is auto-downcased (friendlier than rejecting). ``Default``
# → ``default``, ``ATM10`` → ``atm10``. The on-disk slug is always
# lowercase regardless of what the user typed.
assert kb._normalize_board_slug("Default") == "default"
assert kb._normalize_board_slug("ATM10-Server") == "atm10-server"
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
class TestPathResolution:
def test_default_board_legacy_path(self, fresh_home):
"""The default board's DB lives at ``<root>/kanban.db`` for back-compat."""
assert kb.kanban_db_path() == fresh_home / "kanban.db"
assert kb.kanban_db_path(board="default") == fresh_home / "kanban.db"
def test_named_board_under_boards_dir(self, fresh_home):
p = kb.kanban_db_path(board="atm10-server")
assert p == fresh_home / "kanban" / "boards" / "atm10-server" / "kanban.db"
def test_workspaces_per_board(self, fresh_home):
assert kb.workspaces_root() == fresh_home / "kanban" / "workspaces"
# Uppercase input gets auto-downcased to the on-disk slug.
assert kb.workspaces_root(board="projA") == (
fresh_home / "kanban" / "boards" / "proja" / "workspaces"
)
def test_logs_per_board(self, fresh_home):
assert kb.worker_logs_dir() == fresh_home / "kanban" / "logs"
assert kb.worker_logs_dir(board="other") == (
fresh_home / "kanban" / "boards" / "other" / "logs"
)
def test_env_var_db_override_still_wins(self, fresh_home, tmp_path, monkeypatch):
"""``HERMES_KANBAN_DB`` pins the file regardless of board= arg."""
forced = tmp_path / "custom.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(forced))
assert kb.kanban_db_path() == forced
assert kb.kanban_db_path(board="ignored") == forced
def test_env_var_workspaces_override(self, fresh_home, tmp_path, monkeypatch):
forced = tmp_path / "ws"
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(forced))
assert kb.workspaces_root(board="any") == forced
# ---------------------------------------------------------------------------
# Current-board resolution
# ---------------------------------------------------------------------------
class TestCurrentBoard:
def test_default_when_unset(self, fresh_home):
assert kb.get_current_board() == "default"
def test_env_var_takes_precedence(self, fresh_home, monkeypatch):
# Create the board so the env-var value is honoured (get_current_board
# trusts env-var validity, but the resolution chain doesn't require
# the board to exist; we just test that env trumps).
kb.create_board("envboard")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envboard")
assert kb.get_current_board() == "envboard"
def test_file_pointer_honoured(self, fresh_home):
kb.create_board("filepick")
kb.set_current_board("filepick")
assert kb.get_current_board() == "filepick"
def test_env_beats_file(self, fresh_home, monkeypatch):
kb.create_board("a")
kb.create_board("b")
kb.set_current_board("a")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "b")
assert kb.get_current_board() == "b"
def test_invalid_env_falls_through(self, fresh_home, monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_BOARD", "!!bad!!")
# Should not crash — falls through to default.
assert kb.get_current_board() == "default"
def test_clear_current_board(self, fresh_home):
kb.create_board("x")
kb.set_current_board("x")
kb.clear_current_board()
assert kb.get_current_board() == "default"
def test_kanban_db_path_reads_current(self, fresh_home):
"""kanban_db_path() with no args respects the on-disk pointer."""
kb.create_board("my-proj")
kb.set_current_board("my-proj")
expected = fresh_home / "kanban" / "boards" / "my-proj" / "kanban.db"
assert kb.kanban_db_path() == expected
# ---------------------------------------------------------------------------
# Board CRUD
# ---------------------------------------------------------------------------
class TestBoardCRUD:
def test_create_and_list(self, fresh_home):
assert [b["slug"] for b in kb.list_boards()] == ["default"]
kb.create_board("foo", name="Foo Board", description="test")
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "foo"]
def test_create_is_idempotent(self, fresh_home):
kb.create_board("bar")
kb.create_board("bar") # no error
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "bar"]
def test_create_writes_metadata(self, fresh_home):
meta = kb.create_board(
"baz",
name="Baz",
description="desc",
icon="📦",
color="#abcdef",
)
assert meta["slug"] == "baz"
assert meta["name"] == "Baz"
assert meta["icon"] == "📦"
# Round-trip via read_board_metadata.
again = kb.read_board_metadata("baz")
assert again["name"] == "Baz"
assert again["description"] == "desc"
assert again["icon"] == "📦"
def test_remove_archive(self, fresh_home):
kb.create_board("toremove")
res = kb.remove_board("toremove")
assert res["action"] == "archived"
assert Path(res["new_path"]).exists()
assert "toremove" not in [b["slug"] for b in kb.list_boards()]
def test_remove_hard_delete(self, fresh_home):
kb.create_board("nuke")
d = kb.board_dir("nuke")
assert d.exists()
res = kb.remove_board("nuke", archive=False)
assert res["action"] == "deleted"
assert not d.exists()
def test_remove_default_forbidden(self, fresh_home):
with pytest.raises(ValueError, match="default"):
kb.remove_board("default")
def test_remove_nonexistent_raises(self, fresh_home):
with pytest.raises(ValueError, match="does not exist"):
kb.remove_board("nosuch")
def test_remove_clears_current_pointer(self, fresh_home):
kb.create_board("pinned")
kb.set_current_board("pinned")
kb.remove_board("pinned")
assert kb.get_current_board() == "default"
def test_rename_updates_metadata(self, fresh_home):
kb.create_board("slug-immutable")
kb.write_board_metadata("slug-immutable", name="New Display Name")
assert kb.read_board_metadata("slug-immutable")["name"] == "New Display Name"
# Slug must not change.
assert kb.board_exists("slug-immutable")
# ---------------------------------------------------------------------------
# Connection isolation
# ---------------------------------------------------------------------------
class TestConnectionIsolation:
def test_tasks_do_not_leak_across_boards(self, fresh_home):
kb.create_board("alpha")
kb.create_board("beta")
with kb.connect(board="alpha") as conn:
kb.create_task(conn, title="alpha-task-1", assignee="dev")
kb.create_task(conn, title="alpha-task-2", assignee="dev")
with kb.connect(board="beta") as conn:
kb.create_task(conn, title="beta-only", assignee="dev")
with kb.connect(board="alpha") as conn:
a = kb.list_tasks(conn)
with kb.connect(board="beta") as conn:
b = kb.list_tasks(conn)
with kb.connect(board="default") as conn:
d = kb.list_tasks(conn)
assert {t.title for t in a} == {"alpha-task-1", "alpha-task-2"}
assert {t.title for t in b} == {"beta-only"}
assert d == []
def test_connect_without_args_uses_current(self, fresh_home):
kb.create_board("curr")
kb.set_current_board("curr")
with kb.connect() as conn:
kb.create_task(conn, title="implicit", assignee="x")
with kb.connect(board="curr") as conn:
tasks = kb.list_tasks(conn)
assert [t.title for t in tasks] == ["implicit"]
def test_connect_env_var_overrides_current(self, fresh_home, monkeypatch):
kb.create_board("persist")
kb.create_board("envwin")
kb.set_current_board("persist")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envwin")
with kb.connect() as conn:
kb.create_task(conn, title="via-env", assignee="x")
with kb.connect(board="envwin") as conn:
assert [t.title for t in kb.list_tasks(conn)] == ["via-env"]
with kb.connect(board="persist") as conn:
assert kb.list_tasks(conn) == []
# ---------------------------------------------------------------------------
# Worker spawn env injection
# ---------------------------------------------------------------------------
class TestWorkerSpawnEnv:
"""Ensure the dispatcher pins ``HERMES_KANBAN_BOARD`` / DB / workspaces on spawn.
We monkey-patch ``subprocess.Popen`` to capture the child env without
actually spawning anything.
"""
def test_default_spawn_sets_env_vars(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 12345
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
kb.create_board("spawntest")
task = kb.Task(
id="t_abc",
title="worker test",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by="user",
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board="spawntest")
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "spawntest"
assert env["HERMES_KANBAN_TASK"] == "t_abc"
# DB path should match the per-board DB, not the legacy default.
expected_db = fresh_home / "kanban" / "boards" / "spawntest" / "kanban.db"
assert env["HERMES_KANBAN_DB"] == str(expected_db)
expected_ws = fresh_home / "kanban" / "boards" / "spawntest" / "workspaces"
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(expected_ws)
def test_default_board_spawn_keeps_legacy_paths(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 1
def fake_popen(cmd, *args, **kwargs):
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
task = kb.Task(
id="t_def",
title="",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board=None)
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "default"
assert env["HERMES_KANBAN_DB"] == str(fresh_home / "kanban.db")
# ---------------------------------------------------------------------------
# CLI surface
# ---------------------------------------------------------------------------
def _cli(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run ``hermes kanban …`` with PYTHONPATH pinned to the worktree."""
env = dict(os.environ)
env["PYTHONPATH"] = str(_WORKTREE)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "kanban"] + args,
env=env,
capture_output=True,
text=True,
cwd=str(_WORKTREE),
timeout=30,
)
class TestCLI:
def test_boards_list_default_only(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
res = _cli(["boards", "list", "--json"], env_extra=env)
assert res.returncode == 0, res.stderr
data = json.loads(res.stdout)
slugs = [b["slug"] for b in data]
assert slugs == ["default"]
assert data[0]["is_current"] is True
def test_boards_create_and_switch(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r1 = _cli(
["boards", "create", "myproj", "--name", "My Project", "--switch"],
env_extra=env,
)
assert r1.returncode == 0, r1.stderr
assert "created" in r1.stdout
assert "Switched" in r1.stdout
r2 = _cli(["boards", "list", "--json"], env_extra=env)
data = json.loads(r2.stdout)
cur = [b for b in data if b["is_current"]][0]
assert cur["slug"] == "myproj"
def test_per_board_task_isolation_via_cli(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
assert _cli(["boards", "create", "projA"], env_extra=env).returncode == 0
assert _cli(["boards", "create", "projB"], env_extra=env).returncode == 0
# Create one task on each via --board.
r = _cli(["--board", "projA", "create", "Task A", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
r = _cli(["--board", "projB", "create", "Task B", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
# list on each board only shows its own.
listA = _cli(["--board", "projA", "list", "--json"], env_extra=env)
listB = _cli(["--board", "projB", "list", "--json"], env_extra=env)
listD = _cli(["list", "--json"], env_extra=env)
titlesA = [t["title"] for t in json.loads(listA.stdout)]
titlesB = [t["title"] for t in json.loads(listB.stdout)]
titlesD = [t["title"] for t in json.loads(listD.stdout)]
assert titlesA == ["Task A"]
assert titlesB == ["Task B"]
assert titlesD == []
def test_board_flag_rejects_unknown(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r = _cli(["--board", "ghost", "list"], env_extra=env)
# main.py's dispatcher doesn't propagate return codes today, so we
# assert the user-visible signal: a stderr error message. Whether
# the exit code stays 0 is a separate (pre-existing) issue.
assert "does not exist" in r.stderr
def test_boards_rm_archives(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
_cli(["boards", "create", "rmme"], env_extra=env)
r = _cli(["boards", "rm", "rmme"], env_extra=env)
assert r.returncode == 0, r.stderr
assert "archived" in r.stdout
# Default board list no longer shows it.
res = _cli(["boards", "list", "--json"], env_extra=env)
slugs = [b["slug"] for b in json.loads(res.stdout)]
assert "rmme" not in slugs
@@ -902,12 +902,13 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
"""list_profiles_on_disk returns directories under ~/.hermes/profiles/
that contain a config.yaml."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
profiles = tmp_path / ".hermes" / "profiles"
profiles.mkdir(parents=True)
(profiles / "researcher").mkdir()
(profiles / "researcher" / "config.yaml").write_text("model: {}\n")
(profiles / "writer").mkdir()
(profiles / "writer" / "config.yaml").write_text("model: {}\n")
for name in ("researcher", "writer"):
d = profiles / name
d.mkdir()
(d / "config.yaml").write_text("model: {}\n")
(profiles / "empty_dir").mkdir()
# A stray file; should be ignored.
(profiles / "stray.txt").write_text("noise")
@@ -916,6 +917,20 @@ def test_list_profiles_on_disk(tmp_path, monkeypatch):
assert names == ["researcher", "writer"]
def test_list_profiles_on_disk_custom_root(tmp_path, monkeypatch):
"""list_profiles_on_disk respects a custom HERMES_HOME root."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
profiles = tmp_path / "profiles"
profiles.mkdir(parents=True)
for name in ("researcher", "writer"):
d = profiles / name
d.mkdir()
(d / "config.yaml").write_text("model: {}\n")
names = kb.list_profiles_on_disk()
assert names == ["researcher", "writer"]
def test_known_assignees_merges_disk_and_board(tmp_path, monkeypatch):
"""known_assignees unions profiles on disk with currently-assigned
names, and reports per-status counts."""
+292
View File
@@ -252,6 +252,22 @@ def test_assign_reassigns_when_not_running(kanban_home):
assert kb.get_task(conn, t).assignee == "b"
def test_assignee_normalized_to_lowercase_on_create_and_assign(kanban_home):
"""Dashboard/CLI may pass title-cased profile labels; DB + spawn use canonical id."""
with kb.connect() as conn:
tid = kb.create_task(conn, title="cased", assignee="Jules")
assert kb.get_task(conn, tid).assignee == "jules"
assert kb.assign_task(conn, tid, "Librarian")
assert kb.get_task(conn, tid).assignee == "librarian"
def test_list_tasks_assignee_filter_case_insensitive(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="q", assignee="jules")
found = kb.list_tasks(conn, assignee="Jules")
assert len(found) == 1 and found[0].id == tid
def test_archive_hides_from_default_list(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x")
@@ -436,3 +452,279 @@ def test_tenant_propagates_to_events(kanban_home):
# The "created" event should have tenant in its payload.
created = [e for e in events if e.kind == "created"]
assert created and created[0].payload.get("tenant") == "biz-a"
# ---------------------------------------------------------------------------
# Shared-board path resolution (issue #19348)
#
# The kanban board is a cross-profile coordination primitive: a worker
# spawned with `hermes -p <profile>` must read/write the same kanban.db
# as the dispatcher that claimed the task. These tests exercise the
# path-resolution layer directly and would have caught the regression
# where `kanban_db_path()` resolved to the active profile's HERMES_HOME.
# ---------------------------------------------------------------------------
class TestSharedBoardPaths:
"""`kanban_home`/`kanban_db_path`/`workspaces_root`/`worker_log_path`
must anchor at the **shared root**, not the active profile's HERMES_HOME."""
def _set_home(self, monkeypatch, tmp_path, hermes_home):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False)
def test_default_install_anchors_at_home_dot_hermes(
self, tmp_path, monkeypatch
):
# Standard install: HERMES_HOME == ~/.hermes, no profile active.
default_home = tmp_path / ".hermes"
default_home.mkdir()
self._set_home(monkeypatch, tmp_path, default_home)
assert kb.kanban_home() == default_home
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
assert (
kb.worker_log_path("t_demo")
== default_home / "kanban" / "logs" / "t_demo.log"
)
def test_profile_worker_resolves_to_shared_root(
self, tmp_path, monkeypatch
):
# Reproduces the bug: dispatcher uses ~/.hermes/kanban.db,
# worker spawned with -p <profile> previously resolved to
# ~/.hermes/profiles/<profile>/kanban.db. After the fix both
# converge on ~/.hermes/kanban.db.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "nehemiahkanban"
profile_home.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, profile_home)
# All four resolvers must anchor at the shared root, not the
# profile-local HERMES_HOME.
assert kb.kanban_home() == default_home
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
assert (
kb.worker_log_path("t_0d214f19")
== default_home / "kanban" / "logs" / "t_0d214f19.log"
)
# Sanity: the profile-local path that used to be returned is
# explicitly NOT what we resolve to anymore.
assert kb.kanban_db_path() != profile_home / "kanban.db"
def test_dispatcher_and_profile_worker_converge(
self, tmp_path, monkeypatch
):
# End-to-end convergence: resolve the path under each side's
# HERMES_HOME and confirm equality. This is the property the
# dispatcher/worker handoff actually depends on.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "coder"
profile_home.mkdir(parents=True)
# Dispatcher's perspective.
self._set_home(monkeypatch, tmp_path, default_home)
dispatcher_db = kb.kanban_db_path()
dispatcher_ws = kb.workspaces_root()
dispatcher_log = kb.worker_log_path("t_handoff")
# Worker's perspective (profile activated by `hermes -p coder`).
monkeypatch.setenv("HERMES_HOME", str(profile_home))
worker_db = kb.kanban_db_path()
worker_ws = kb.workspaces_root()
worker_log = kb.worker_log_path("t_handoff")
assert dispatcher_db == worker_db
assert dispatcher_ws == worker_ws
assert dispatcher_log == worker_log
def test_docker_custom_hermes_home_uses_env_path_directly(
self, tmp_path, monkeypatch
):
# Docker / custom deployment: HERMES_HOME points outside ~/.hermes.
# `get_default_hermes_root()` returns env_home directly when it
# is not a `<root>/profiles/<name>` shape and not under
# `Path.home() / ".hermes"`.
custom_root = tmp_path / "opt" / "hermes"
custom_root.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, custom_root)
assert kb.kanban_home() == custom_root
assert kb.kanban_db_path() == custom_root / "kanban.db"
def test_docker_profile_layout_uses_grandparent(
self, tmp_path, monkeypatch
):
# Docker profile shape: HERMES_HOME=/opt/hermes/profiles/coder;
# `get_default_hermes_root()` walks up to /opt/hermes because
# the immediate parent dir is named "profiles".
custom_root = tmp_path / "opt" / "hermes"
profile = custom_root / "profiles" / "coder"
profile.mkdir(parents=True)
self._set_home(monkeypatch, tmp_path, profile)
assert kb.kanban_home() == custom_root
assert kb.kanban_db_path() == custom_root / "kanban.db"
def test_explicit_override_via_hermes_kanban_home(
self, tmp_path, monkeypatch
):
# Explicit override: HERMES_KANBAN_HOME beats every other
# resolution rule.
default_home = tmp_path / ".hermes"
profile_home = default_home / "profiles" / "any"
profile_home.mkdir(parents=True)
override = tmp_path / "shared-board"
override.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(override))
assert kb.kanban_home() == override
assert kb.kanban_db_path() == override / "kanban.db"
assert kb.workspaces_root() == override / "kanban" / "workspaces"
def test_empty_override_falls_through(self, tmp_path, monkeypatch):
# Empty/whitespace override is treated as unset.
default_home = tmp_path / ".hermes"
default_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", " ")
assert kb.kanban_home() == default_home
def test_dispatcher_and_worker_share_a_real_database(
self, tmp_path, monkeypatch
):
# Belt-and-suspenders: round-trip a task across the two
# HERMES_HOME perspectives via a real SQLite file. Without the
# fix the worker would open a different file and see no rows.
default_home = tmp_path / ".hermes"
default_home.mkdir()
profile_home = default_home / "profiles" / "nehemiahkanban"
profile_home.mkdir(parents=True)
# Dispatcher creates the board and a task.
self._set_home(monkeypatch, tmp_path, default_home)
kb.init_db()
with kb.connect() as conn:
task_id = kb.create_task(conn, title="cross-profile")
# Worker switches to the profile HERMES_HOME and reads.
monkeypatch.setenv("HERMES_HOME", str(profile_home))
with kb.connect() as conn:
task = kb.get_task(conn, task_id)
assert task is not None
assert task.title == "cross-profile"
def test_hermes_kanban_db_pin_beats_kanban_home(
self, tmp_path, monkeypatch
):
# HERMES_KANBAN_DB pins the file path directly and beats both
# HERMES_KANBAN_HOME and the `get_default_hermes_root()` path.
# This is the env the dispatcher injects into workers.
default_home = tmp_path / ".hermes"
default_home.mkdir()
umbrella = tmp_path / "umbrella"
umbrella.mkdir()
pinned_db = tmp_path / "pinned" / "board.db"
pinned_db.parent.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
monkeypatch.setenv("HERMES_KANBAN_DB", str(pinned_db))
assert kb.kanban_db_path() == pinned_db
# workspaces_root still follows HERMES_KANBAN_HOME -- the pins
# are independent.
assert kb.workspaces_root() == umbrella / "kanban" / "workspaces"
def test_hermes_kanban_workspaces_root_pin_beats_kanban_home(
self, tmp_path, monkeypatch
):
# HERMES_KANBAN_WORKSPACES_ROOT pins the workspaces root directly.
default_home = tmp_path / ".hermes"
default_home.mkdir()
umbrella = tmp_path / "umbrella"
umbrella.mkdir()
pinned_ws = tmp_path / "pinned-workspaces"
pinned_ws.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_HOME", str(umbrella))
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(pinned_ws))
assert kb.workspaces_root() == pinned_ws
# kanban_db_path still follows HERMES_KANBAN_HOME.
assert kb.kanban_db_path() == umbrella / "kanban.db"
def test_empty_per_path_overrides_fall_through(
self, tmp_path, monkeypatch
):
# Empty/whitespace pins are treated as unset, same as
# HERMES_KANBAN_HOME.
default_home = tmp_path / ".hermes"
default_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("HERMES_KANBAN_DB", " ")
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", "")
assert kb.kanban_db_path() == default_home / "kanban.db"
assert kb.workspaces_root() == default_home / "kanban" / "workspaces"
def test_dispatcher_spawn_injects_kanban_db_and_workspaces_root(
self, tmp_path, monkeypatch
):
# The dispatcher's `_default_spawn` must inject HERMES_KANBAN_DB
# and HERMES_KANBAN_WORKSPACES_ROOT into the worker env so the
# worker converges on the dispatcher's paths even when the
# `-p <profile>` flag rewrites HERMES_HOME.
default_home = tmp_path / ".hermes"
default_home.mkdir()
self._set_home(monkeypatch, tmp_path, default_home)
captured = {}
class _FakePopen:
def __init__(self, cmd, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
self.pid = 4242
monkeypatch.setattr("subprocess.Popen", _FakePopen)
task = kb.Task(
id="t_dispatch_env",
title="x",
body=None,
assignee="coder",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(tmp_path / "ws"))
env = captured["env"]
assert env["HERMES_KANBAN_DB"] == str(default_home / "kanban.db")
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(
default_home / "kanban" / "workspaces"
)
assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
@@ -0,0 +1,55 @@
"""Regression tests for OpenAI Codex model validation when the listing lags behind
actually usable backend model IDs.
The bug: `/model` and `switch_model()` reject `gpt-5.3-codex-spark` because the
OpenAI Codex listing omits it, even though direct runtime calls with
`--provider openai-codex -m gpt-5.3-codex-spark` succeed.
"""
from unittest.mock import patch
from hermes_cli.model_switch import switch_model
from hermes_cli.models import validate_requested_model
def test_openai_codex_unknown_but_plausible_model_is_accepted_with_warning():
"""If the Codex listing is incomplete, `/model` should soft-accept the model
with a warning instead of hard-rejecting it.
"""
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"],
):
result = validate_requested_model("gpt-5.3-codex-spark", "openai-codex")
assert result["accepted"] is True
assert result["persist"] is True
assert result["recognized"] is False
assert "gpt-5.3-codex-spark" in result["message"]
assert "OpenAI Codex model listing" in result["message"]
assert "Similar models" in result["message"]
assert "gpt-5.3-codex" in result["message"]
def test_switch_model_allows_openai_codex_model_missing_from_listing():
"""switch_model() should succeed for Codex models that the runtime accepts
even when the listing has not caught up yet.
"""
with patch(
"hermes_cli.models.provider_model_ids",
return_value=["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"],
):
result = switch_model(
"gpt-5.3-codex-spark",
current_provider="openai-codex",
current_model="gpt-5.4",
current_base_url="",
current_api_key="",
user_providers=None,
)
assert result.success is True
assert result.new_model == "gpt-5.3-codex-spark"
assert result.target_provider == "openai-codex"
assert result.warning_message
assert "OpenAI Codex model listing" in result.warning_message
+9 -1
View File
@@ -508,7 +508,7 @@ class TestPromptPluginEnvVars:
class TestCursesRadiolist:
"""Test the curses_radiolist function (non-TTY fallback path)."""
"""Test the curses_radiolist function."""
def test_non_tty_returns_default(self):
from hermes_cli.curses_ui import curses_radiolist
@@ -524,6 +524,14 @@ class TestCursesRadiolist:
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=1)
assert result == 1
def test_keyboard_interrupt_returns_cancel_value(self):
from hermes_cli.curses_ui import curses_radiolist
with patch("sys.stdin") as mock_stdin, patch("curses.wrapper", side_effect=KeyboardInterrupt):
mock_stdin.isatty.return_value = True
result = curses_radiolist("Pick", ["x", "y"], selected=0, cancel_returns=-1)
assert result == -1
# ── Provider discovery helpers ───────────────────────────────────────────
+28
View File
@@ -15,6 +15,7 @@ from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
get_profile_dir,
create_profile,
@@ -58,6 +59,24 @@ def profile_env(tmp_path, monkeypatch):
# TestValidateProfileName
# ===================================================================
class TestNormalizeProfileName:
"""Tests for normalize_profile_name()."""
def test_title_case_normalized(self):
assert normalize_profile_name("Jules") == "jules"
assert normalize_profile_name(" Librarian ") == "librarian"
def test_default_case_insensitive(self):
assert normalize_profile_name("Default") == "default"
assert normalize_profile_name("DEFAULT") == "default"
def test_empty_raises(self):
with pytest.raises(ValueError, match="cannot be empty"):
normalize_profile_name("")
with pytest.raises(ValueError, match="cannot be empty"):
normalize_profile_name(" ")
class TestValidateProfileName:
"""Tests for validate_profile_name()."""
@@ -66,6 +85,11 @@ class TestValidateProfileName:
# Should not raise
validate_profile_name(name)
def test_uppercase_rejected(self):
# validate_profile_name is strict — callers normalize first, then validate.
with pytest.raises(ValueError):
validate_profile_name("Jules")
@pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"])
def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError):
@@ -107,6 +131,10 @@ class TestGetProfileDir:
result = get_profile_dir("coder")
assert result == tmp_path / ".hermes" / "profiles" / "coder"
def test_named_profile_matching_is_case_insensitive(self, profile_env):
tmp_path = profile_env
assert get_profile_dir("Coder") == tmp_path / ".hermes" / "profiles" / "coder"
# ===================================================================
# TestCreateProfile
+32
View File
@@ -613,3 +613,35 @@ def test_offer_launch_chat_falls_back_to_module(monkeypatch):
setup_mod._offer_launch_chat()
assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
def test_setup_slack_saves_home_channel(monkeypatch):
"""_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
saved = {}
prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"])
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
setup_mod._setup_slack()
assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
def test_setup_slack_home_channel_empty_not_saved(monkeypatch):
"""_setup_slack() does not save SLACK_HOME_CHANNEL when left blank."""
saved = {}
prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""])
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
setup_mod._setup_slack()
assert "SLACK_HOME_CHANNEL" not in saved
+27
View File
@@ -2,10 +2,13 @@
from unittest.mock import patch
import pytest
from hermes_cli.tools_config import (
_DEFAULT_OFF_TOOLSETS,
_apply_toolset_change,
_configure_provider,
_reconfigure_provider,
_get_platform_tools,
_platform_toolset_summary,
_reconfigure_tool,
@@ -898,3 +901,27 @@ def test_get_effective_configurable_toolsets_dedupes_bundled_plugins():
assert len(spotify_rows) == 1, spotify_rows
# Built-in label wins over the plugin label.
assert spotify_rows[0][1] == "🎵 Spotify"
@pytest.mark.parametrize("provider,config_key,expected", [
# managed provider → use_gateway True
({"name": "T", "tts_provider": "elevenlabs", "managed_nous_feature": "tts", "env_vars": []}, "tts", True),
({"name": "B", "browser_provider": "browserbase", "managed_nous_feature": "browser", "env_vars": []}, "browser", True),
({"name": "W", "web_backend": "tavily", "managed_nous_feature": "web", "env_vars": []}, "web", True),
# self-hosted provider → use_gateway False
({"name": "T", "tts_provider": "elevenlabs", "env_vars": []}, "tts", False),
({"name": "B", "browser_provider": "browserbase", "env_vars": []}, "browser", False),
({"name": "W", "web_backend": "tavily", "env_vars": []}, "web", False),
])
def test_reconfigure_provider_syncs_use_gateway(provider, config_key, expected):
config = {}
_reconfigure_provider(provider, config)
assert config[config_key]["use_gateway"] is expected
def test_reconfigure_browser_provider_overwrites_stale_use_gateway():
# Switching from managed (use_gateway=True) to self-hosted must clear the stale flag.
config = {"browser": {"cloud_provider": "managed-browser", "use_gateway": True}}
provider = {"name": "Browserbase", "browser_provider": "browserbase", "env_vars": []}
_reconfigure_provider(provider, config)
assert config["browser"]["use_gateway"] is False
+33
View File
@@ -69,6 +69,39 @@ def test_no_install_when_only_optional_peer_package_missing_from_hidden_lock(tmp
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_no_install_when_only_peer_annotation_differs(tmp_path: Path, main_mod) -> None:
"""npm 9 drops the ``peer`` flag from the hidden lock on dev-deps that are
*also* declared as peers. That's a cosmetic difference — the package is
installed at the requested version so it must not trigger a reinstall.
Regression for the TUI-in-Docker failure where 16 such mismatches caused
`Installing TUI dependencies` EACCES on every launch.
"""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{'
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
'}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
"""The peer-drop tolerance must not mask a real version skew."""
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
)
(tmp_path / "node_modules" / ".package-lock.json").write_text(
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
)
assert main_mod._tui_need_npm_install(tmp_path) is True
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
_touch_ink(tmp_path)
(tmp_path / "package-lock.json").write_text("{}")