Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("{}")
|
||||
|
||||
Reference in New Issue
Block a user