Merge origin/main into bb/gui

Adopt main's web/ dashboard layout (apps/dashboard removed; web/ restored),
keep bb/gui's desktop CLI/update workspace handling, and preserve main's
mTLS/URL validation MCP changes. Dashboard backend is aligned to main with
only the intended STT provider quarantine/ElevenLabs override reapplied.
This commit is contained in:
Brooklyn Nicholson
2026-05-29 20:40:08 -05:00
1205 changed files with 39074 additions and 9768 deletions
@@ -6,11 +6,8 @@ Claude Code credentials are available. The fast-path silently proceeds to
model selection with a broken token instead of offering re-auth.
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from hermes_cli.config import load_env, save_env_value
from hermes_cli.config import save_env_value
class TestStaleOAuthTokenDetection:
@@ -6,7 +6,6 @@ import pytest
from hermes_cli.auth import (
PROVIDER_REGISTRY,
ProviderConfig,
resolve_provider,
get_api_key_provider_status,
resolve_api_key_provider_credentials,
@@ -15,7 +15,6 @@ import os
import sys
from pathlib import Path
import pytest
def _run_apply_profile_override(
@@ -14,7 +14,6 @@ so the subparser only sets the attribute when the user explicitly provides it.
import argparse
import os
import sys
from unittest.mock import patch
import pytest
@@ -1,7 +1,6 @@
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
import json
import os
from pathlib import Path
from unittest.mock import patch
@@ -1,6 +1,5 @@
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
from pathlib import Path
from unittest.mock import patch
import pytest
+82 -3
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from hermes_cli.auth import (
AuthError,
@@ -17,8 +16,6 @@ from hermes_cli.auth import (
_save_codex_tokens,
_import_codex_cli_tokens,
_login_openai_codex,
get_codex_auth_status,
get_provider_auth_state,
refresh_codex_oauth_pure,
resolve_codex_runtime_credentials,
resolve_provider,
@@ -303,6 +300,88 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at"
def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):
"""Re-auth must also refresh ``manual:device_code`` pool entries.
Regression for #33538: a user who hit #33000 before the #33164 fix landed
would have run ``hermes auth add openai-codex`` as a workaround, leaving
a pool entry with ``source="manual:device_code"``. On every subsequent
re-auth via setup/model picker, the singleton-seeded ``device_code`` entry
got refreshed but the ``manual:device_code`` entry stayed stale, recreating
the same 401 token_invalidated symptom that #33164 was supposed to fix.
An interactive Codex device-code re-auth proves the user owns the ChatGPT
account, so it is safe to refresh every device-code-backed entry in the
pool — but NOT independent ``manual:api_key`` entries (separate accounts /
explicit API keys).
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "seeded",
"source": "device_code",
"auth_type": "oauth",
"access_token": "old-at",
"refresh_token": "old-rt",
},
{
"id": "auth-add",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "stale-manual-at",
"refresh_token": "stale-manual-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
{
"id": "api-key",
"source": "manual:api_key",
"auth_type": "api_key",
"access_token": "user-api-key",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens({"access_token": "fresh-at", "refresh_token": "fresh-rt"},
last_refresh="2026-05-28T00:00:00Z")
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
# Singleton-seeded device_code entry: refreshed and error markers cleared.
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "fresh-at"
assert seeded["refresh_token"] == "fresh-rt"
# manual:device_code entry: ALSO refreshed (the new behavior).
manual_dc = next(e for e in pool if e["source"] == "manual:device_code")
assert manual_dc["access_token"] == "fresh-at"
assert manual_dc["refresh_token"] == "fresh-rt"
assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z"
assert manual_dc["last_status"] is None
assert manual_dc["last_error_code"] is None
assert manual_dc["last_error_reason"] is None
# manual:api_key entry: untouched — independent credential.
api_key = next(e for e in pool if e["source"] == "manual:api_key")
assert api_key["access_token"] == "user-api-key"
assert "refresh_token" not in api_key or api_key.get("refresh_token") is None
def test_import_codex_cli_tokens(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)
+10 -10
View File
@@ -107,15 +107,15 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"scope": "inference:invoke inference:mint_agent_key",
"scope": "inference:invoke",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
"obtained_at": "2026-03-23T10:00:00+00:00",
"expires_at": "2026-03-23T11:00:00+00:00",
"expires_in": 3600,
"agent_key": "ak-test",
"agent_key_id": "ak-id",
"agent_key": token,
"agent_key_id": None,
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
"agent_key_expires_in": 1800,
"agent_key_reused": False,
@@ -155,17 +155,17 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
assert not any(item["source"] == "manual:device_code" for item in entries)
entry = device_code_entries[0]
assert entry["source"] == "device_code"
assert entry["agent_key"] == "ak-test"
assert entry["agent_key"] == token
assert entry["portal_base_url"] == "https://portal.example.com"
# `hermes auth add nous` must also populate providers.nous so the
# 401-recovery path (resolve_nous_runtime_credentials) can mint a fresh
# agent_key when the 24h TTL expires. If this mirror is missing, recovery
# 401-recovery path (resolve_nous_runtime_credentials) can refresh an
# invoke JWT when the token expires. If this mirror is missing, recovery
# raises "Hermes is not logged into Nous Portal" and the agent dies.
singleton = payload["providers"]["nous"]
assert singleton["access_token"] == token
assert singleton["refresh_token"] == "refresh-token"
assert singleton["agent_key"] == "ak-test"
assert singleton["agent_key"] == token
assert singleton["portal_base_url"] == "https://portal.example.com"
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
@@ -228,15 +228,15 @@ def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"scope": "inference:invoke inference:mint_agent_key",
"scope": "inference:invoke",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
"obtained_at": "2026-03-23T10:00:00+00:00",
"expires_at": "2026-03-23T11:00:00+00:00",
"expires_in": 3600,
"agent_key": "ak-test",
"agent_key_id": "ak-id",
"agent_key": token,
"agent_key_id": None,
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
"agent_key_expires_in": 1800,
"agent_key_reused": False,
@@ -11,7 +11,6 @@ import io
import contextlib
import socket
import pytest
from hermes_cli import auth as auth_mod
+258
View File
@@ -330,6 +330,107 @@ def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):
"""Bare-code paste (state=None) must complete login under manual_paste.
xAI's consent page renders the authorization code in-page rather than
redirecting through 127.0.0.1, so on remote/headless setups the only
value the user can obtain is the opaque code with no ``state=``
parameter. ``_parse_pasted_callback`` correctly returns
``state=None`` for that input. The login flow must accept this case
(PKCE still protects the exchange); historically it raised
``xai_state_mismatch``. Regression for the bare-code branch of #26923.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "bare-opaque-code",
"state": None,
"error": None,
"error_description": None,
},
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):
"""Loopback (manual_paste=False) must NOT accept ``state=None``.
The bare-code relaxation only applies to the manual-paste path,
where the user demonstrably has no way to supply ``state``. The
HTTP-server path always sees ``state`` populated from the real
callback query string, so missing state there means something is
wrong (a malformed callback, an attacker-supplied request) and
must still raise ``xai_state_mismatch``.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server",
lambda *_a, **_k: (
_StubServer(),
None,
{"code": "fake", "state": None, "error": None,
"error_description": None},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod, "_xai_wait_for_callback",
lambda *_a, **_k: {
"code": "fake",
"state": None,
"error": None,
"error_description": None,
},
)
monkeypatch.setattr(auth_mod, "_xai_validate_loopback_redirect_uri", lambda _u: None)
monkeypatch.setattr(auth_mod, "_print_loopback_ssh_hint", lambda *_a, **_k: None)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
"""Empty paste must surface as ``xai_code_missing``, not crash."""
monkeypatch.setattr(
@@ -363,6 +464,163 @@ def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
assert exc.value.code == "xai_code_missing"
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should offer the existing manual-paste path."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
captured: dict = {"state": None, "prompt_calls": 0}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kwargs):
captured["state"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
def _raise_timeout(*_a, **_k):
raise auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _raise_timeout)
def _fake_prompt(_redirect_uri):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": captured["state"],
"error": None,
"error_description": None,
}
monkeypatch.setattr(auth_mod, "_prompt_manual_callback_paste", _fake_prompt)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: True})()
)
monkeypatch.setattr(
auth_mod.httpx,
"post",
lambda *_a, **_k: _StubTokenResponse(
{
"access_token": "at-timeout",
"refresh_token": "rt-timeout",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
),
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=False)
rendered = buf.getvalue()
assert "xAI loopback callback timed out." in rendered
assert "--manual-paste" in rendered
assert captured["prompt_calls"] == 1
assert creds["tokens"]["access_token"] == "at-timeout"
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *_a, **_k: (_ for _ in ()).throw(
auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
),
)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: False})()
)
monkeypatch.setattr(
auth_mod,
"_prompt_manual_callback_paste",
lambda *_a, **_k: pytest.fail("manual-paste fallback should not run"),
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False)
assert exc.value.code == "xai_callback_timeout"
# ---------------------------------------------------------------------------
# _print_loopback_ssh_hint — now also mentions --manual-paste
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
"""Tests for is_provider_explicitly_configured()."""
import json
import os
import pytest
@@ -6,7 +6,6 @@ resolve_qwen_runtime_credentials, get_qwen_auth_status.
"""
import json
import os
import stat
import time
from pathlib import Path
@@ -24,7 +24,6 @@ from __future__ import annotations
import sys
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
+102 -1
View File
@@ -1029,7 +1029,6 @@ class TestProfileRestoration:
args = Namespace(zipfile=str(zip_path), force=True)
# Simulate profiles module not being available
import hermes_cli.backup as backup_mod
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
def fake_import(name, *a, **kw):
@@ -1680,3 +1679,105 @@ class TestPreMigrationBackup:
_t.sleep(1.05)
# Update backup must still be there
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
# ---------------------------------------------------------------------------
# Cron jobs auto-restore after silent migration loss (issue #34600)
# ---------------------------------------------------------------------------
class TestRestoreCronJobsIfEmptied:
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
post-migration safety net that restores from the pre-update snapshot."""
@staticmethod
def _seed_jobs(path: Path, jobs):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"jobs": jobs}))
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
from hermes_cli.backup import create_quick_snapshot
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
def test_restores_when_emptied_after_migration(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update: 3 real jobs.
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
snap_id = self._make_snapshot(hermes_home)
assert snap_id
# Migration silently empties the file (valid JSON, zero jobs).
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["restored"] is True
assert result["job_count"] == 3
assert result["snapshot_id"] == snap_id
# The live file now has the jobs back.
restored = json.loads(jobs_path.read_text())
assert len(restored["jobs"]) == 3
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
snap_id = self._make_snapshot(hermes_home)
# Healthy path: file unchanged after update.
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
# Pre-update genuinely had zero jobs; current is also empty.
self._seed_jobs(jobs_path, [])
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
def test_noop_when_live_file_unreadable(self, tmp_path):
"""An unparseable live file is left alone — that's a different failure
mode the user should see, not silently overwrite."""
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [{"id": "a"}])
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text("{ this is not valid json")
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is None
# File left untouched.
assert jobs_path.read_text() == "{ this is not valid json"
def test_noop_when_snapshot_id_missing(self, tmp_path):
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
self._seed_jobs(jobs_path, [])
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
still counted and restored."""
from hermes_cli.backup import restore_cron_jobs_if_emptied
hermes_home = tmp_path / ".hermes"
jobs_path = hermes_home / "cron" / "jobs.json"
jobs_path.parent.mkdir(parents=True, exist_ok=True)
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
snap_id = self._make_snapshot(hermes_home)
jobs_path.write_text(json.dumps({"jobs": []}))
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
assert result is not None
assert result["job_count"] == 2
+53
View File
@@ -61,3 +61,56 @@ def test_get_git_banner_state_reads_origin_and_head(tmp_path):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
"""Docker image case: no .git checkout — baked build SHA fills the gap.
``_resolve_repo_dir`` returns None when neither the running code's
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
case inside the published container, where .git is dockerignored).
The banner should still report the build SHA so support bug reports
can identify the running commit.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
state = banner.get_git_banner_state()
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
Banner correctly omits the upstream/local suffix in this case.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
state = banner.get_git_banner_state()
assert state is None
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
"""Shallow clone without origin/main → still surface build SHA if baked.
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
happens AND a baked SHA exists, return the baked one instead of None.
"""
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
# All git invocations fail (returncode=1, empty stdout).
failed = MagicMock(returncode=1, stdout="")
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}
-1
View File
@@ -2,7 +2,6 @@
from unittest.mock import patch
import pytest
_MOCK_SKILLS = [
@@ -16,12 +16,10 @@ Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
All Bedrock API calls are mocked no real AWS credentials needed.
"""
import os
from contextlib import contextmanager
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
@@ -95,7 +93,7 @@ class TestProviderModelIdsBedrock:
def test_falls_back_to_static_list_when_discovery_empty(self, monkeypatch):
"""When discover_bedrock_models() returns [], fall back to curated static list."""
from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
+78
View File
@@ -0,0 +1,78 @@
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
helper: missing file, malformed file, truncation, and error tolerance.
"""
from pathlib import Path
from unittest.mock import patch
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
"""Source installs: no file present → None, callers fall back to git."""
from hermes_cli import build_info
missing = tmp_path / ".hermes_build_sha" # never created
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
assert build_info.get_build_sha() is None
def test_get_build_sha_reads_baked_file(tmp_path):
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_respects_short_argument(tmp_path):
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
sha_file.write_text(full_sha + "\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha(short=12) == "abcdef123456"
assert build_info.get_build_sha(short=0) == full_sha
assert build_info.get_build_sha(short=-1) == full_sha
def test_get_build_sha_strips_whitespace(tmp_path):
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" abcdef1234567890\n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
"""A whitespace-only file is treated as absent."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" \n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() is None
def test_get_build_sha_swallows_read_errors(tmp_path):
"""Any IO exception from the read returns None — never raises."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
patch.object(Path, "read_text", side_effect=OSError("boom")):
assert build_info.get_build_sha() is None
-2
View File
@@ -1,8 +1,6 @@
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
import argparse
import sys
from pathlib import Path
import pytest
@@ -2,7 +2,6 @@
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
+37 -24
View File
@@ -133,11 +133,10 @@ class TestCmdUpdateBranchFallback:
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
@patch("hermes_cli.main._web_ui_build_needed", return_value=True)
@patch("shutil.which")
@patch("subprocess.run")
def test_update_refreshes_repo_and_tui_node_dependencies(
self, mock_run, mock_which, _mock_web_ui_build_needed, mock_args
self, mock_run, mock_which, mock_args
):
from hermes_cli import main as hm
@@ -145,7 +144,13 @@ class TestCmdUpdateBranchFallback:
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
with patch.object(hm, "_is_termux_env", return_value=False):
# The web UI build runs through _run_with_idle_timeout now (issue
# #33788) so it no longer appears in subprocess.run's call list.
# Mock it so the test doesn't actually shell out to ``tsc``.
import subprocess as _subprocess
build_ok = _subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch.object(hm, "_is_termux_env", return_value=False), \
patch.object(hm, "_run_with_idle_timeout", return_value=build_ok) as mock_idle:
cmd_update(mock_args)
npm_calls = [
@@ -154,35 +159,43 @@ class TestCmdUpdateBranchFallback:
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in three locations:
# 1. repo root — slash-command / TUI bridge deps
# 2. ui-tui/ — Ink TUI deps
# 3. apps/dashboard/ — install + "npm run build" for the web frontend
full_flags = [
# cmd_update runs npm commands in four locations:
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
# 3. web/ — npm install (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# Repo-root and ui-tui installs intentionally omit `--silent` and run
# without `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840). The web/ install
# keeps `--silent` because its build step is short and noisy.
update_flags = [
"/usr/bin/npm",
"ci",
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspaces=false",
]
app_flags = [
"/usr/bin/npm",
"ci",
"--silent",
"--no-fund",
"--no-audit",
"--progress=false",
]
# Repo root additionally passes --workspaces=false so npm does not
# recursively install every apps/* workspace (desktop, shared).
repo_flags = [*update_flags, "--workspaces=false"]
assert npm_calls[:2] == [
(full_flags, PROJECT_ROOT),
(app_flags, PROJECT_ROOT / "ui-tui"),
]
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "apps" / "dashboard"),
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "apps" / "dashboard"),
(repo_flags, PROJECT_ROOT),
(update_flags, PROJECT_ROOT / "ui-tui"),
]
if len(npm_calls) > 2:
# Only the web/ install is left in subprocess.run; the build moved
# to _run_with_idle_timeout to make Vite progress visible (#33788).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
]
# The web UI build itself went through the streaming helper.
mock_idle.assert_called_once()
idle_args, idle_kwargs = mock_idle.call_args
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: repo root + ui-tui installs must stream
# output (capture_output=False) so postinstall progress is visible
+185
View File
@@ -0,0 +1,185 @@
"""Tests for ``hermes update`` / ``--check`` inside the Docker container.
Background: ``.dockerignore`` excludes ``.git``, so the existing git-pull
update path can never succeed inside the published image. Before this
fix, ``hermes update`` would fall through to ``"✗ Not a git repository.
Please reinstall: curl ... install.sh"`` — that script installs a *new*
host-side Hermes, not an update to the running container, so the message
was actively misleading.
These tests pin the new behaviour: when ``detect_install_method`` reports
``"docker"`` (stamped by ``docker/stage2-hook.sh``), both the apply path
(``cmd_update``) and the check path (``_cmd_update_check``) print the
``docker pull`` guidance from ``format_docker_update_message`` and exit
with status 1, without running ``git fetch`` / ``subprocess.run``.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import _cmd_update_check, cmd_update
# ---------- cmd_update (apply path) ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update`` inside Docker → friendly message + exit 1, no git calls."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=False))
assert excinfo.value.code == 1
out = capsys.readouterr().out
# Spot-check the key guidance — exhaustive wording is locked in by the
# config-module test below to keep these CLI tests resilient to copy edits.
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
# No git invocations — the early-return must beat every git command.
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update --check`` inside Docker → same message + exit 1, no fetch."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=True, branch=None))
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_ignores_yes_and_force(
mock_run, _mock_method, _mock_managed, capsys
):
"""``--yes`` / ``--force`` don't bypass the Docker bail-out.
The point of the bail-out is "git pull will never work here", so even
a user trying to barge through with ``--yes --force`` should see the
docker-pull guidance.
"""
with pytest.raises(SystemExit):
cmd_update(SimpleNamespace(check=False, yes=True, force=True))
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- _cmd_update_check (check path, direct entry) ----------
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_direct_in_docker(mock_run, _mock_method, capsys):
"""Calling ``_cmd_update_check`` directly (no apply path) also bails."""
with pytest.raises(SystemExit) as excinfo:
_cmd_update_check()
assert excinfo.value.code == 1
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- Non-Docker installs unaffected ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch(
"subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout="0\n", stderr=""),
)
def test_cmd_update_on_git_install_does_not_print_docker_message(
_mock_run, _mock_method, _mock_managed, capsys
):
"""Source/git installs MUST NOT hit the Docker branch.
Regression guard: an over-eager detection refactor could accidentally
route git users through the docker-pull message. We swallow
SystemExit / unrelated errors from the rest of the update flow
those don't matter for this assertion; what matters is that the
docker text is absent.
``subprocess.run`` is mocked because the git path will otherwise shell
out to ``git fetch upstream`` / ``git fetch origin`` on CI runners
with no ``upstream`` remote configured this can hang past the 30s
pytest-timeout depending on git's network behaviour. The stub
returns a successful CompletedProcess-shaped object with ``"0\\n"``
stdout, which both keeps the flow shell-free AND parses cleanly as
the "0 commits behind" rev-list output the check path later parses
via ``int(rev_result.stdout.strip())``.
"""
try:
cmd_update(SimpleNamespace(check=True, branch=None))
except (SystemExit, Exception):
# Update flow may exit for unrelated reasons in a stubbed env —
# that's fine; we only care about the banner not appearing.
pass
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
def test_cmd_update_check_on_pip_install_still_uses_pypi(
_mock_pypi, _mock_method, capsys
):
"""PyPI installs route to PyPI check, not the Docker bail-out."""
_cmd_update_check()
out = capsys.readouterr().out
assert "Already up to date" in out
assert "doesn't apply inside the Docker container" not in out
# ---------- format_docker_update_message — content lock ----------
def test_format_docker_update_message_contents():
"""Lock in the high-value content of the Docker update message.
These are the bits a user actually needs to act on; if any of them
disappear in a copy edit, the message has lost its value. Specific
wording around them is free to evolve (we don't assert full text).
"""
from hermes_cli.config import format_docker_update_message
msg = format_docker_update_message()
# Primary command — the entire reason this message exists.
assert "docker pull nousresearch/hermes-agent:latest" in msg
# The four key concepts the message must cover:
assert "restart" in msg.lower(), "must explain that a restart is required"
assert "--version" in msg, "must show how to verify the new version"
assert ":latest" in msg, "must mention tag pinning caveat"
assert "HERMES_HOME" in msg or "/opt/data" in msg, (
"must address config persistence across upgrades"
)
# Acknowledges that forks exist (build-your-own-image escape hatch).
assert "fork" in msg.lower() or "Dockerfile" in msg
@@ -1,6 +1,5 @@
"""Tests for _coalesce_session_name_args — multi-word session name merging."""
import pytest
from hermes_cli.main import _coalesce_session_name_args
@@ -13,11 +13,8 @@ existing Codex CLI tokens via `hermes auth openai-codex`. The old
import base64
import json
import os
import sys
import time
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -2,14 +2,12 @@
from __future__ import annotations
from pathlib import Path
import pytest
from hermes_cli.codex_runtime_plugin_migration import (
MIGRATION_MARKER,
MIGRATION_END_MARKER,
MigrationReport,
_build_hermes_tools_mcp_entry,
_format_toml_value,
_looks_like_test_tempdir,
+1 -1
View File
@@ -1003,7 +1003,7 @@ class TestTelegramMenuCommands:
def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):
"""Skills disabled for telegram should not appear in the menu."""
from unittest.mock import patch, MagicMock
from unittest.mock import patch
# Set up a config with a telegram-specific disabled list
config_file = tmp_path / "config.yaml"
+1 -1
View File
@@ -2,7 +2,7 @@
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
import yaml
@@ -1,9 +1,7 @@
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
import os
import pytest
from hermes_cli.config import _expand_env_vars, load_config
from unittest.mock import patch as mock_patch
class TestExpandEnvVars:
@@ -1,6 +1,5 @@
"""Tests for config.yaml structure validation (validate_config_structure)."""
import pytest
from hermes_cli.config import validate_config_structure, ConfigIssue
+1 -2
View File
@@ -1,8 +1,7 @@
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
import os
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch
class TestTokenValidation:
@@ -12,12 +12,8 @@ Covers:
from __future__ import annotations
import io
from contextlib import redirect_stdout, redirect_stderr
from types import SimpleNamespace
from unittest.mock import patch
import pytest
def _ns(**kwargs):
+1 -2
View File
@@ -12,9 +12,8 @@ clamps with ``min(8, curses.COLORS - 1)``.
import curses
import re
from pathlib import Path
from unittest.mock import patch, MagicMock, call
from unittest.mock import patch, MagicMock
import pytest
# Path to the source files under test
@@ -6,8 +6,7 @@ immediately when provider_info had a saved ``model`` field, making it
impossible to switch models on multi-model endpoints.
"""
import os
from unittest.mock import patch, MagicMock, call
from unittest.mock import patch
import pytest
@@ -131,8 +131,13 @@ class TestRefreshTokenCookieDeprecation:
class TestApi401Envelope:
# NOTE: probe a gated route (``/api/sessions``) here rather than
# ``/api/status`` — status is in the shared ``PUBLIC_API_PATHS``
# allowlist (portal liveness probe) so it would 200 even without a
# cookie and never exercise the 401-envelope code path.
def test_no_cookie_returns_unauthenticated_envelope(self, gated_app):
r = gated_app.get("/api/status")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "unauthenticated"
@@ -141,7 +146,7 @@ class TestApi401Envelope:
def test_invalid_cookie_returns_session_expired_envelope(self, gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/status")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "session_expired"
@@ -151,7 +156,7 @@ class TestApi401Envelope:
"""Dead-cookie cleanup — Phase 6 requirement so the browser
doesn't keep replaying the stale token on every request."""
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/status")
r = gated_app.get("/api/sessions")
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c
@@ -1,7 +1,6 @@
"""Tests for the dashboard-auth cookie helpers."""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
@@ -56,10 +56,61 @@ def gated_app():
# ---------------------------------------------------------------------------
def test_gated_status_now_requires_auth(gated_app):
"""When gate is on, /api/status is NOT public — login bootstrap uses /api/auth/providers."""
def test_gated_status_is_public(gated_app):
"""``/api/status`` MUST be public under the OAuth gate.
Regression guard for the wildcard-subdomain rollout: NAS
(``fly-provider.ts`` ``getInstanceRuntimeStatus``) hits
``/api/status`` without a cookie as its sole liveness probe. A 401
here surfaces every healthy agent as STARTING/down in the portal
UI. The endpoint returns only version + gateway/auth-gate metadata
(no user data, no session content), so it stays in the shared
``PUBLIC_API_PATHS`` allowlist under both the legacy ``_SESSION_TOKEN``
gate and the OAuth gate.
The body also reports the gate's shape (``auth_required``,
``auth_providers``) so the SPA's StatusPage and external monitors
can distinguish loopback / gated / no-providers without a separate
round trip.
"""
r = gated_app.get("/api/status")
assert r.status_code == 401
assert r.status_code == 200, (
f"Expected 200, got {r.status_code}: {r.text}"
)
body = r.json()
assert body["auth_required"] is True
assert "version" in body
assert "gateway_state" in body
@pytest.mark.parametrize("path", [
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
"/api/dashboard/themes",
"/api/dashboard/plugins",
])
def test_other_public_api_paths_are_public_under_gate(gated_app, path):
"""The remaining ``PUBLIC_API_PATHS`` entries must also bypass the
gate. They're documented as non-sensitive read-only endpoints that
the SPA pre-loads before login (themes, config schema, model
metadata). A 401 / 302-to-login here would block the dashboard
shell from rendering pre-auth.
Accept any non-auth-failure status: 200 when the route succeeds,
or any route-specific error (e.g. 400 / 404 / 500 from a missing
dependency) but NEVER 401, and NEVER a 302 to ``/login``.
"""
r = gated_app.get(path, follow_redirects=False)
assert r.status_code != 401, (
f"{path} returned 401 under the OAuth gate — should be public"
)
if r.status_code == 302:
location = r.headers.get("location", "")
assert "/login" not in location, (
f"{path} redirected to {location} — should be public, "
"not bounced to /login"
)
def test_gated_html_redirects_to_login(gated_app):
@@ -98,7 +149,7 @@ def test_gated_static_asset_path_is_public(gated_app):
# ---------------------------------------------------------------------------
def test_full_login_round_trip_unlocks_api_status(gated_app):
def test_full_login_round_trip_unlocks_gated_api(gated_app):
# 1) Click "Sign in with Stub IdP" — /auth/login redirects to the stub
# with a PKCE cookie on the response.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
@@ -128,11 +179,16 @@ def test_full_login_round_trip_unlocks_api_status(gated_app):
assert any("hermes_session_at" in c for c in set_cookies)
assert any("hermes_session_rt" in c for c in set_cookies)
# 3) /api/status now succeeds because we're authenticated.
r3 = gated_app.get("/api/status")
assert r3.status_code == 200
body = r3.json()
assert "version" in body
# 3) A gated API route (``/api/sessions``) now succeeds because we
# have a valid session cookie. (We deliberately don't probe
# ``/api/status`` here — it's in the shared PUBLIC_API_PATHS
# allowlist and would 200 even without a login, so it can't
# distinguish "logged in" from "gate accidentally disabled".)
r3 = gated_app.get("/api/sessions")
assert r3.status_code == 200, (
f"Expected 200 for /api/sessions post-login, got {r3.status_code}: "
f"{r3.text}"
)
def test_login_unknown_provider_returns_404(gated_app):
@@ -177,7 +177,7 @@ class TestOAuthRedirectUriRespectsPrefix:
# The stub IDP's redirect_url echoes the redirect_uri back. The
# real IDP would consume it and later use it to redirect the
# user, so the byte-exact value MUST include the prefix.
from urllib.parse import urlparse, parse_qs, unquote
from urllib.parse import urlparse
# Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so
# we read up to the first ``?``.
redirect_uri = location.split("?", 1)[0]
@@ -59,19 +59,11 @@ def loopback_client():
web_server.app.state.auth_required = prev_required
def _login(client: TestClient) -> None:
"""Drive the stub OAuth round trip so the gated client is authed."""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}", follow_redirects=False
)
assert r2.status_code == 302
def test_status_reports_auth_required_in_gated_mode(gated_client):
_login(gated_client)
# No ``_login()`` call — ``/api/status`` is in the shared
# ``PUBLIC_API_PATHS`` allowlist precisely so external probes (and
# the SPA's pre-login bootstrap) can read the gate's shape without
# a cookie. Hit it cold.
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
@@ -14,7 +14,6 @@ pre-existing regression unrelated to dashboard-auth.
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -29,7 +28,6 @@ from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.ws_tickets import (
TicketInvalid,
_reset_for_tests,
consume_ticket,
mint_ticket,
@@ -15,7 +15,7 @@ from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.main import cmd_dashboard, _report_dashboard_status
from hermes_cli.main import cmd_dashboard
def _ns(**kw):
+1 -4
View File
@@ -1,10 +1,8 @@
"""Tests for ``hermes debug`` CLI command and debug utilities."""
import os
import sys
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch, call
from unittest.mock import MagicMock, patch
import pytest
@@ -337,7 +335,6 @@ class TestCaptureLogSnapshotRedaction:
redaction feature ships silently broken for users who opted out of
runtime redaction (e.g. developers working on the redactor itself).
"""
import os
# Force the runtime flag off so we're exercising the force=True path,
# not the default-on path.
-1
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from unittest.mock import patch
@@ -1,7 +1,5 @@
"""Tests for warn_deprecated_cwd_env_vars() migration warning."""
import os
import pytest
class TestDeprecatedCwdWarning:
+1 -1
View File
@@ -792,7 +792,7 @@ class TestGitHubTokenCheck:
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
from hermes_cli.doctor import run_doctor, _DHH
from hermes_cli.doctor import run_doctor
import io, contextlib
buf = io.StringIO()
@@ -1,6 +1,5 @@
"""Tests for the Command Installation check in hermes doctor."""
import os
import sys
import types
from argparse import Namespace
+118
View File
@@ -0,0 +1,118 @@
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
``hermes dump`` prints the running commit so support bug reports identify the
exact version. Source installs resolve it live via ``git rev-parse``; the
published Docker image excludes ``.git`` and falls back to the baked SHA
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
These tests cover both paths plus the failure modes (no git, no baked file).
"""
from unittest.mock import MagicMock, patch
def test_get_git_commit_uses_live_git_when_available(tmp_path):
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
# build_info should NOT be consulted when live git succeeds.
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
patch("hermes_cli.build_info.get_build_sha") as mock_build:
commit = dump._get_git_commit(repo_dir)
assert commit == "deadbeef"
mock_run.assert_called_once()
mock_build.assert_not_called()
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
"""Docker image case: live git returns non-zero → use baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "no-git-here"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
commit = dump._get_git_commit(repo_dir)
assert commit == "cafef00d"
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
empty = MagicMock(returncode=0, stdout="\n")
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
commit = dump._get_git_commit(repo_dir)
assert commit == "abcdef12"
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
commit = dump._get_git_commit(repo_dir)
assert commit == "feedface"
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
commit = dump._get_git_commit(repo_dir)
assert commit == "(unknown)"
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
"""Regression guard: live-git and baked-SHA outputs share the same shape.
Ben explicitly asked for identical output between Docker and source installs
so support tooling that parses ``hermes dump`` doesn't have to special-case
container builds. Both paths must return a bare 8-char SHA no prefix,
no suffix, no annotation.
"""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Live-git path.
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
live = dump._get_git_commit(repo_dir)
# Baked-SHA path.
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
baked = dump._get_git_commit(repo_dir)
assert live == baked == "b2f477a3"
# Same length, same charset — no decoration in either branch.
assert len(live) == 8
assert all(c in "0123456789abcdef" for c in live)
-1
View File
@@ -1,7 +1,6 @@
import importlib
import os
import sys
from pathlib import Path
from hermes_cli.env_loader import load_hermes_dotenv
-1
View File
@@ -1,7 +1,6 @@
"""Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration."""
from __future__ import annotations
import io
import types
from pathlib import Path
from unittest.mock import patch
-1
View File
@@ -2,7 +2,6 @@
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import patch, call
import pytest
@@ -12,7 +12,6 @@ Currently:
Windows path that works.
"""
import sys
class TestMatrixHiddenOnWindows:
@@ -7,7 +7,6 @@ host systemd/launchd/windows code path.
"""
from __future__ import annotations
from typing import Any
import pytest
+49 -3
View File
@@ -1321,7 +1321,6 @@ class TestSystemServiceIdentityRootHandling:
def test_auto_detected_root_is_rejected(self, monkeypatch):
"""When root is auto-detected (not explicitly requested), raise."""
import grp
monkeypatch.delenv("SUDO_USER", raising=False)
monkeypatch.setenv("USER", "root")
@@ -1343,7 +1342,6 @@ class TestSystemServiceIdentityRootHandling:
def test_non_root_user_passes_through(self, monkeypatch):
"""Normal non-root user works as before."""
import grp
monkeypatch.delenv("SUDO_USER", raising=False)
monkeypatch.setenv("USER", "nobody")
@@ -1706,7 +1704,12 @@ class TestSystemUnitPathRemapping:
assert str(root_home) not in unit
# Target user paths should be present
assert "/home/alice" in unit
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
# WorkingDirectory is anchored at the target user's HERMES_HOME (stable,
# always exists) — NOT the source checkout under it. Pinning cwd to the
# checkout is the rot bug fixed alongside this: a relocated/removed
# checkout would crash-loop the unit on CHDIR (status=200).
assert "WorkingDirectory=/home/alice/.hermes" in unit
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" not in unit
class TestDockerAwareGateway:
@@ -2533,3 +2536,46 @@ class TestGatewayCommandCatchesSystemScopeError:
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
assert "System gateway start requires root. Re-run with sudo." in out
assert "('" not in out # no tuple repr leaking through
class TestServiceWorkingDirIsStable:
"""The gateway service must anchor WorkingDirectory at a stable path
(HERMES_HOME), never the source checkout / worktree, so a relocated or
deleted checkout can't crash-loop the unit on CHDIR (status=200).
"""
def test_stable_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
assert Path(gateway_cli._stable_service_working_dir()) == home.resolve()
def test_stable_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
# HERMES_HOME points somewhere that does not exist -> fall back.
missing = tmp_path / "does-not-exist" / ".hermes"
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: missing)
assert gateway_cli._stable_service_working_dir() == str(gateway_cli.PROJECT_ROOT)
def test_user_unit_workingdirectory_is_hermes_home_not_checkout(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
unit = gateway_cli.generate_systemd_unit(system=False)
wd = [l for l in unit.splitlines() if l.startswith("WorkingDirectory=")]
assert wd, "unit has no WorkingDirectory line"
value = wd[0].split("=", 1)[1]
assert Path(value).resolve() == home.resolve()
# The bug class: never pin cwd inside a transient worktree checkout.
assert "/.worktrees/" not in value
def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch):
import re
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
plist = gateway_cli.generate_launchd_plist()
m = re.search(r"<key>WorkingDirectory</key>\s*<string>(.*?)</string>", plist)
assert m, "plist has no WorkingDirectory entry"
assert Path(m.group(1)).resolve() == home.resolve()
assert "/.worktrees/" not in m.group(1)
@@ -1,4 +1,3 @@
from pathlib import Path
from unittest.mock import patch
+218 -1
View File
@@ -481,4 +481,221 @@ def test_uninstall_access_denied_declined_keeps_task_and_cleans_files(monkeypatc
out = capsys.readouterr().out
assert "Skipped elevation" in out
assert "UAC is Windows' admin approval prompt" in out
assert "Scheduled Task still registered" in out
assert "Scheduled Task still registered" in out
# ---------------------------------------------------------------------------
# stop() drain semantics — issue #33778
#
# Background: on Windows, asyncio.add_signal_handler raises NotImplementedError,
# so the gateway's SIGTERM handler (which drains in-flight agents and writes
# resume_pending=True) never fires when `hermes gateway stop` kills the
# process. The fix: stop() writes the planned_stop_marker first, waits for
# the gateway's marker-watcher thread to drain + exit cleanly, then escalates
# to taskkill if drain times out.
# ---------------------------------------------------------------------------
def test_stop_writes_planned_stop_marker_before_killing(monkeypatch):
"""stop() must write the planned-stop marker BEFORE any kill signal.
Without this, the gateway's drain loop never runs on Windows and
sessions silently lose context across restarts.
"""
pid = 99999
events = []
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
# Stub the marker write so we can record the order of operations.
from gateway import status as status_mod
def fake_write_marker(target_pid):
events.append(("write_marker", target_pid))
return True
def fake_pid_exists(check_pid):
# Drain succeeds: pid "exits" right after the marker write.
return ("write_marker", pid) not in events
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
def fake_kill(**kwargs):
events.append(("kill", kwargs.get("force", False)))
return 0
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
gateway_windows.stop()
# Marker MUST be written before any kill.
kinds = [e[0] for e in events]
assert "write_marker" in kinds, "stop() never wrote the planned-stop marker"
marker_idx = kinds.index("write_marker")
kill_idx = kinds.index("kill") if "kill" in kinds else len(kinds)
assert marker_idx < kill_idx, (
f"stop() killed before writing the marker (events={events})"
)
def test_stop_waits_for_graceful_drain_before_force_kill(monkeypatch):
"""When drain succeeds, stop() should NOT force-kill the gateway.
drained=True means the gateway exited cleanly after seeing the
marker escalating to taskkill /F afterwards would be wasted
work and may emit confusing "killed N processes" output.
"""
pid = 88888
events = []
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
# Simulate the gateway exiting cleanly after one poll tick.
poll_count = [0]
def fake_pid_exists(check_pid):
poll_count[0] += 1
return poll_count[0] < 2 # alive on first poll, gone on second
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
def fake_kill(**kwargs):
events.append(("kill", kwargs.get("force", False)))
return 0
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
gateway_windows.stop()
# kill_gateway_processes is still called as the no-op sweep, but
# NOT with force=True — drain succeeded, gateway is already gone.
assert events == [("kill", False)], (
f"After clean drain, force kill should be disabled (events={events})"
)
def test_stop_escalates_to_force_kill_when_drain_times_out(monkeypatch):
"""When drain times out, stop() MUST escalate to force=True.
Drain timeout = gateway is stuck or unresponsive. Without the
taskkill /T /F escalation, the gateway stays alive and the next
`hermes gateway start` fails with "another instance is running".
"""
pid = 77777
events = []
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
# PID never exits — drain times out.
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
def fake_kill(**kwargs):
events.append(("kill", kwargs.get("force", False)))
return 1
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
# Tiny drain timeout to keep the test fast.
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 1.0)
gateway_windows.stop()
# When drain times out, kill is invoked with force=True so taskkill /T /F
# walks the process tree.
assert events == [("kill", True)], (
f"After drain timeout, kill must use force=True (events={events})"
)
def test_stop_no_running_gateway_skips_drain(monkeypatch):
"""When no gateway is running, skip the drain wait entirely."""
events = []
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "get_running_pid", lambda: None)
def fake_write_marker(target_pid):
events.append(("write_marker", target_pid))
return True
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
def fake_kill(**kwargs):
events.append(("kill", kwargs.get("force", False)))
return 0
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
gateway_windows.stop()
# With no PID to drain, no marker is written. Kill sweep still runs
# (defensive — covers the case where a stray gateway is alive without
# a PID file). force=True because drained=False.
assert ("write_marker", None) not in events
assert all(e[0] != "write_marker" for e in events), (
f"Should not write marker when no PID is running (events={events})"
)
assert events == [("kill", True)]
def test_drain_helper_handles_invalid_pid(monkeypatch):
"""_drain_gateway_pid returns False for invalid PIDs without crashing."""
assert gateway_windows._drain_gateway_pid(0, 5.0) is False
assert gateway_windows._drain_gateway_pid(-1, 5.0) is False
def test_drain_helper_returns_true_when_pid_exits_quickly(monkeypatch):
"""_drain_gateway_pid polls _pid_exists until it returns False."""
pid = 66666
poll_count = [0]
def fake_pid_exists(check_pid):
poll_count[0] += 1
return poll_count[0] < 3 # alive twice, then gone
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True
def test_drain_helper_returns_false_on_timeout(monkeypatch):
"""_drain_gateway_pid returns False when the PID never exits."""
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
assert gateway_windows._drain_gateway_pid(55555, drain_timeout=1.0) is False
def test_drain_helper_still_waits_if_marker_write_fails(monkeypatch):
"""Marker-write failures are swallowed; drain still polls for PID exit.
If the marker can't be written (disk full, permission error), the
gateway can't drain — but the wait still happens so a slow-shutdown
gateway from a different code path (e.g. SIGTERM working on this
platform after all) still gets observed cleanly.
"""
pid = 44444
def fake_write(target_pid):
raise OSError("disk full")
from gateway import status as status_mod
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write)
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
# Returns True because _pid_exists immediately says "gone".
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True
-2
View File
@@ -1,8 +1,6 @@
"""Tests for WSL detection and WSL-aware gateway behavior."""
import io
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch, MagicMock, mock_open
-1
View File
@@ -1,6 +1,5 @@
"""Tests for Google AI Studio (Gemini) provider integration."""
import os
import pytest
from unittest.mock import patch, MagicMock
+1 -2
View File
@@ -525,7 +525,6 @@ class TestGoalStateSubgoalsBackcompat:
def test_old_state_meta_row_loads_without_subgoals(self):
"""A goal serialized BEFORE the subgoals field existed must
round-trip with an empty list, not crash."""
import json
from hermes_cli.goals import GoalState
legacy = json.dumps({
@@ -647,7 +646,7 @@ class TestJudgeGoalWithSubgoals:
We don't actually call the model — we patch the aux client to
capture the prompt that would be sent.
"""
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from hermes_cli import goals
captured = {}
@@ -0,0 +1,96 @@
"""Tests for `_can_open_graphical_browser()` in hermes_cli.auth.
Guards the fix for the May 2026 report where `hermes auth add xai-oauth`
launched a text-mode browser (w3m) INSIDE the terminal on a headless Linux
box `_is_remote_session()` only checked SSH/cloud-shell env vars, so a plain
local box with no GUI browser still called `webbrowser.open()`, which resolved
to a console browser and hijacked the TTY.
The helper distinguishes "a real windowed browser will pop up" from "a console
browser will hijack the terminal" so OAuth callsites can fall back to printing
the URL / manual paste instead of auto-opening.
"""
from __future__ import annotations
import webbrowser
import pytest
from hermes_cli.auth import _can_open_graphical_browser
class _FakeController:
def __init__(self, name: str) -> None:
self.name = name
def open(self, *_a, **_kw): # pragma: no cover - never invoked
return True
@pytest.fixture(autouse=True)
def _clean_browser_env(monkeypatch):
"""Each test controls DISPLAY / WAYLAND_DISPLAY / BROWSER explicitly."""
for var in ("DISPLAY", "WAYLAND_DISPLAY", "BROWSER"):
monkeypatch.delenv(var, raising=False)
yield
def _force_platform_linux(monkeypatch):
monkeypatch.setattr("hermes_cli.auth.sys.platform", "linux")
def _force_resolved_browser(monkeypatch, name: str):
monkeypatch.setattr(webbrowser, "get", lambda *_a, **_kw: _FakeController(name))
def test_headless_linux_no_display_refuses(monkeypatch):
"""The reported bug: headless Linux, no display server → don't auto-open."""
_force_platform_linux(monkeypatch)
# Even if a GUI browser somehow resolved, no display means no GUI.
_force_resolved_browser(monkeypatch, "google-chrome")
assert _can_open_graphical_browser() is False
def test_browser_env_pointing_at_console_browser_refuses(monkeypatch):
"""$BROWSER=w3m must refuse even with a display server present."""
_force_platform_linux(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
monkeypatch.setenv("BROWSER", "/usr/bin/w3m")
assert _can_open_graphical_browser() is False
@pytest.mark.parametrize("console", ["w3m", "lynx", "links", "elinks", "browsh"])
def test_resolved_console_browser_refuses(monkeypatch, console):
"""When webbrowser resolves to a console browser, refuse to auto-open."""
_force_platform_linux(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
_force_resolved_browser(monkeypatch, console)
assert _can_open_graphical_browser() is False
def test_graphical_browser_with_display_allows(monkeypatch):
"""Real GUI browser + display server → auto-open is fine."""
_force_platform_linux(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
_force_resolved_browser(monkeypatch, "firefox")
assert _can_open_graphical_browser() is True
def test_webbrowser_get_raises_refuses(monkeypatch):
"""No resolvable browser at all → don't auto-open."""
_force_platform_linux(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
def _boom(*_a, **_kw):
raise webbrowser.Error("no browser")
monkeypatch.setattr(webbrowser, "get", _boom)
assert _can_open_graphical_browser() is False
def test_non_linux_with_gui_allows(monkeypatch):
"""macOS / Windows always have a usable default GUI browser."""
monkeypatch.setattr("hermes_cli.auth.sys.platform", "darwin")
_force_resolved_browser(monkeypatch, "MacOSX")
assert _can_open_graphical_browser() is True
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import io
import json
import sys
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
+1 -1
View File
@@ -237,7 +237,7 @@ class TestConfigWriting:
monkeypatch.setattr(
tools_config,
"get_nous_subscription_features",
lambda config: SimpleNamespace(
lambda config, **kwargs: SimpleNamespace(
features={"image_gen": SimpleNamespace(managed_by_nous=True)}
),
)
+5 -3
View File
@@ -21,7 +21,6 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from hermes_cli.inventory import (
ConfigContext,
@@ -158,8 +157,11 @@ def test_build_models_payload_returns_expected_shape():
def test_build_models_payload_does_not_call_provider_model_ids():
"""Curated lists must come from list_authenticated_providers, not
provider_model_ids that would pull TTS/embeddings/etc.
"""``build_models_payload`` is a thin shape adapter — it delegates the
actual curation to ``list_authenticated_providers`` (which DOES call
``cached_provider_model_ids`` internally for live discovery, with disk
caching). ``build_models_payload`` itself must not call the live fetcher
directly; the test pins that boundary.
"""
rows = [{"slug": "nous", "name": "Nous", "models": ["hermes-4-405b"],
"total_models": 1, "is_current": False, "is_user_defined": False,
@@ -0,0 +1,150 @@
"""Regression tests for #33488 (CLI max_in_progress / max_spawn / per-profile
config passthrough) and #29415 (kanban_swarm humanizer skill ref).
These two fixes are bundled because they're both small, both touch the
kanban dispatcher's CLI surface, and they each guard against a silent
operator footgun that only manifests in long-running setups.
"""
from __future__ import annotations
import argparse
import os
import sys
import tempfile
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def isolated_kanban_home(monkeypatch):
"""Spin up a fresh HERMES_HOME with a clean kanban DB."""
test_home = tempfile.mkdtemp(prefix="kanban_cli_passthrough_")
os.makedirs(os.path.join(test_home, "profiles", "default"), exist_ok=True)
monkeypatch.setenv("HERMES_HOME", test_home)
for mod in list(sys.modules.keys()):
if mod.startswith("hermes_cli") or mod.startswith("hermes_state") or mod == "hermes_constants":
del sys.modules[mod]
yield test_home
def test_cli_dispatch_passes_max_in_progress_from_config(isolated_kanban_home, monkeypatch):
"""#33488: hermes kanban dispatch must pass kanban.max_in_progress from
config to dispatch_once. Without this, the global concurrency cap is
unreachable from the CLI even though it works from the gateway."""
from hermes_cli import kanban as kb_cli
from hermes_cli import kanban_db
# Configure max_in_progress in the loaded config.
fake_config = {
"kanban": {
"max_in_progress": 3,
"max_spawn": 5,
"default_assignee": "default",
"max_in_progress_per_profile": 2,
}
}
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: fake_config
)
captured = {}
def fake_dispatch_once(conn, **kwargs):
captured.update(kwargs)
return kanban_db.DispatchResult()
monkeypatch.setattr(kanban_db, "dispatch_once", fake_dispatch_once)
args = argparse.Namespace(dry_run=True, max=None, failure_limit=2, json=False)
kb_cli._cmd_dispatch(args)
# Every config value must have reached dispatch_once.
assert captured.get("max_in_progress") == 3, (
f"CLI must pass kanban.max_in_progress from config; got {captured.get('max_in_progress')!r}"
)
assert captured.get("max_spawn") == 5, (
f"CLI must pass kanban.max_spawn from config when --max is not provided; got {captured.get('max_spawn')!r}"
)
assert captured.get("default_assignee") == "default"
assert captured.get("max_in_progress_per_profile") == 2
def test_cli_max_flag_overrides_config_max_spawn(isolated_kanban_home, monkeypatch):
"""--max on the CLI takes precedence over kanban.max_spawn in config.
The CLI flag is the explicit operator signal; config is the default."""
from hermes_cli import kanban as kb_cli
from hermes_cli import kanban_db
fake_config = {"kanban": {"max_spawn": 10}}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config)
captured = {}
monkeypatch.setattr(
kanban_db, "dispatch_once",
lambda conn, **kw: (captured.update(kw), kanban_db.DispatchResult())[1],
)
args = argparse.Namespace(dry_run=True, max=2, failure_limit=2, json=False)
kb_cli._cmd_dispatch(args)
assert captured.get("max_spawn") == 2, (
f"CLI --max=2 must override config kanban.max_spawn=10; got {captured.get('max_spawn')!r}"
)
def test_cli_invalid_max_in_progress_silently_disables(isolated_kanban_home, monkeypatch):
"""Invalid kanban.max_in_progress values (0, negative, non-int) should
silently fall through to None no crash, no surprise behavior."""
from hermes_cli import kanban as kb_cli
from hermes_cli import kanban_db
for bad_val in (0, -1, "abc", "1.5"):
fake_config = {"kanban": {"max_in_progress": bad_val}}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config)
captured = {}
monkeypatch.setattr(
kanban_db, "dispatch_once",
lambda conn, **kw: (captured.update(kw), kanban_db.DispatchResult())[1],
)
args = argparse.Namespace(dry_run=True, max=None, failure_limit=2, json=False)
kb_cli._cmd_dispatch(args)
assert captured.get("max_in_progress") is None, (
f"invalid max_in_progress={bad_val!r} should fall through to None, "
f"got {captured.get('max_in_progress')!r}"
)
def test_kanban_swarm_uses_existing_humanizer_skill():
"""#29415: kanban_swarm.py used to hardcode skills=['avoid-ai-writing'],
a skill that doesn't exist in any registry — synthesizer workers
crashed with 'Unknown skill(s): avoid-ai-writing' on every retry.
Verify the synthesizer card now uses the bundled 'humanizer' skill
which actually exists at skills/creative/humanizer/SKILL.md."""
import pathlib
swarm_path = (
pathlib.Path(__file__).resolve().parent.parent.parent
/ "hermes_cli" / "kanban_swarm.py"
)
src = swarm_path.read_text()
assert "avoid-ai-writing" not in src, (
"kanban_swarm.py must not reference 'avoid-ai-writing' — that "
"skill doesn't exist in any registry, crashing synthesizers (#29415)"
)
assert '"humanizer"' in src, (
"kanban_swarm.py should use the bundled 'humanizer' skill for "
"synthesizer cards (the original intent of 'avoid-ai-writing')"
)
# And the replacement skill must actually exist on disk.
skills_root = (
pathlib.Path(__file__).resolve().parent.parent.parent / "skills"
)
humanizer_path = skills_root / "creative" / "humanizer" / "SKILL.md"
assert humanizer_path.is_file(), (
f"humanizer skill missing at {humanizer_path}; the kanban_swarm fix "
"for #29415 requires this bundled skill to exist"
)
@@ -18,7 +18,6 @@ import threading
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
import pytest
+142 -5
View File
@@ -5,7 +5,9 @@ from __future__ import annotations
import concurrent.futures
import os
import sqlite3
import sys
import time
import types
import unittest.mock
from pathlib import Path
@@ -49,6 +51,43 @@ def test_init_creates_expected_tables(kanban_home):
assert {"tasks", "task_links", "task_comments", "task_events"} <= names
def test_connect_honors_kanban_busy_timeout_env(kanban_home, monkeypatch):
"""All kanban connections should use the explicit busy-timeout knob.
A worker stampede should wait for SQLite's writer lock instead of failing
immediately with ``database is locked`` during first-connect/WAL/schema
setup. The timeout must be queryable via PRAGMA so CLI, gateway, and tool
connections behave the same way.
"""
monkeypatch.setenv("HERMES_KANBAN_BUSY_TIMEOUT_MS", "123456")
with kb.connect() as conn:
row = conn.execute("PRAGMA busy_timeout").fetchone()
assert row[0] == 123456
def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypatch):
"""Windows must use a real process lock, not a no-op sidecar open."""
calls: list[tuple[int, int, int]] = []
fake_msvcrt = types.SimpleNamespace(
LK_LOCK=1,
LK_UNLCK=2,
locking=lambda fd, mode, nbytes: calls.append((fd, mode, nbytes)),
)
monkeypatch.setattr(kb, "_IS_WINDOWS", True)
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
db_path = tmp_path / "kanban.db"
with kb._cross_process_init_lock(db_path):
assert calls == [(calls[0][0], fake_msvcrt.LK_LOCK, 1)]
assert [call[1:] for call in calls] == [
(fake_msvcrt.LK_LOCK, 1),
(fake_msvcrt.LK_UNLCK, 1),
]
def test_connect_rejects_tls_record_in_sqlite_header(tmp_path, monkeypatch):
"""Kanban should classify TLS-looking page-0 clobbers before WAL setup."""
home = tmp_path / ".hermes"
@@ -2527,7 +2566,6 @@ def test_resolve_hermes_argv_module_actually_runs():
Run it as a real subprocess to catch that regression.
"""
import subprocess
import sys
import hermes_cli.kanban_db as kb
import shutil
import unittest.mock as mock
@@ -3106,7 +3144,6 @@ def test_detect_stale_skips_recently_started_task(kanban_home, monkeypatch):
def test_detect_stale_skips_when_timeout_zero(kanban_home, monkeypatch):
"""stale_timeout_seconds=0 disables stale detection entirely."""
import hermes_cli.kanban_db as _kb
with kb.connect() as conn:
t = kb.create_task(conn, title="disabled", assignee="worker")
@@ -3278,6 +3315,44 @@ def test_connect_refuses_corrupt_existing_file(tmp_path):
kb.connect(db_path=db_path)
def test_repeated_corrupt_open_reuses_single_backup(tmp_path):
"""Repeated quarantines of the same corrupt bytes must not amplify disk usage.
Regression for the gateway dispatcher's 5-min retry loop on shared kanban
DBs across multi-profile fleets: each retry on an unchanged corrupt file
used to create a fresh ``.corrupt.<timestamp>.bak`` until disk filled. The
content-addressed backup name is deterministic in the DB's sha256, so
N retries of the same bytes share one backup.
"""
db_path = tmp_path / "kanban.db"
original = _write_corrupt_db(db_path)
backups: set[Path] = set()
for _ in range(10):
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with pytest.raises(kb.KanbanDbCorruptError) as excinfo:
kb.connect(db_path=db_path)
assert excinfo.value.backup_path is not None
backups.add(excinfo.value.backup_path)
assert len(backups) == 1, f"expected 1 deterministic backup, got {len(backups)}"
(backup,) = backups
assert backup.exists()
assert backup.read_bytes() == original
# Mutate the corrupt bytes — fingerprint changes, separate backup preserved.
with db_path.open("r+b") as f:
f.seek(4096)
f.write(b"\xAB" * 64)
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with pytest.raises(kb.KanbanDbCorruptError) as excinfo2:
kb.connect(db_path=db_path)
second_backup = excinfo2.value.backup_path
assert second_backup is not None
assert second_backup != backup
assert second_backup.exists()
def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch):
"""A transient lock during the probe must not produce a .corrupt backup
and must not be reported as :class:`KanbanDbCorruptError`. Raw sqlite
@@ -3551,7 +3626,7 @@ def test_write_txn_preserves_original_exception_when_rollback_fails(kanban_home)
)
def test_write_txn_healthy_commit_no_exception(tmp_path):
"""Normal commit does not trigger the torn-extend check."""
from hermes_cli.kanban_db import connect, write_txn, create_task
from hermes_cli.kanban_db import connect, write_txn
db = tmp_path / "test.db"
conn = connect(db_path=db)
# Should not raise
@@ -3568,7 +3643,6 @@ def test_write_txn_healthy_commit_no_exception(tmp_path):
def test_write_txn_raises_on_truncated_file(tmp_path):
"""A mocked smaller file size triggers the torn-extend check."""
from hermes_cli.kanban_db import connect, write_txn
import hermes_cli.kanban_db as kanban_db_module
db = tmp_path / "test.db"
conn = connect(db_path=db)
# Get actual page size so we can fake a smaller file
@@ -3628,7 +3702,7 @@ def test_connect_sets_wal_autocheckpoint_100(tmp_path):
def test_write_txn_check_reads_correct_header_fields(tmp_path):
"""Synthetic DB file with mismatched header page_count triggers the check."""
import struct
from hermes_cli.kanban_db import connect, write_txn, _check_file_length_invariant
from hermes_cli.kanban_db import connect, _check_file_length_invariant
db = tmp_path / "synthetic.db"
conn = connect(db_path=db)
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
@@ -3805,3 +3879,66 @@ def test_dispatch_once_still_reaps_via_extracted_fn(kanban_home):
pids = kb.reap_worker_zombies()
assert pids == [99999]
# ---------------------------------------------------------------------------
# connect_closing(): context manager that actually closes the FD
# Regression coverage for #33159 (kanban.db FD leak — gateway crashes after
# ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but
# does NOT close, so `with kb.connect() as conn:` leaks the FD in
# long-lived processes (gateway run_slash, dashboard decompose handler).
# `connect_closing()` is the leak-safe replacement.
# ---------------------------------------------------------------------------
def test_connect_closing_closes_connection_on_exit(tmp_path):
"""The new context manager MUST actually close the underlying FD."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect_closing(db_path=db_path) as conn:
conn.execute("SELECT 1").fetchone()
# After exit, the connection MUST be closed — subsequent execute
# should raise ProgrammingError.
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_connect_closing_closes_on_exception(tmp_path):
"""Connection closed even when the body raises."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
captured = []
with pytest.raises(RuntimeError, match="boom"):
with kb.connect_closing(db_path=db_path) as conn:
captured.append(conn)
raise RuntimeError("boom")
with pytest.raises(sqlite3.ProgrammingError):
captured[0].execute("SELECT 1")
def test_connect_closing_yields_usable_connection(tmp_path):
"""Smoke test: schema is initialized and basic ops work."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect_closing(db_path=db_path) as conn:
tid = kb.create_task(conn, title="closing-cm test")
task = kb.get_task(conn, tid)
assert task is not None
assert task.title == "closing-cm test"
def test_bare_connect_does_not_close_on_context_exit(tmp_path):
"""Document the leak that connect_closing exists to prevent.
sqlite3.Connection's __exit__ commits/rollbacks but doesn't close.
This is the upstream behaviour we cannot change; the regression
guard is to make sure connect_closing() does the right thing.
"""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect(db_path=db_path) as conn:
pass
# Still usable after with-block exit (the leak).
conn.execute("SELECT 1").fetchone()
conn.close() # explicit close to avoid leaking THIS test
@@ -7,14 +7,12 @@ and the assignee-fallback logic.
from __future__ import annotations
import argparse
import json as jsonlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import kanban as kanban_cli
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_decompose as decomp
@@ -0,0 +1,154 @@
"""Regression tests for #27145 — kanban.default_assignee for unassigned ready tasks.
When the dispatcher hits an unassigned ready task and ``kanban.default_assignee``
is set, the dispatcher applies the assignment and spawns. Without the config,
the task is skipped (existing behavior preserved).
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import pytest
@pytest.fixture()
def isolated_kanban_home(monkeypatch):
"""Spin up a fresh HERMES_HOME with a clean kanban DB."""
test_home = tempfile.mkdtemp(prefix="kanban_default_assignee_test_")
monkeypatch.setenv("HERMES_HOME", test_home)
# Force-reimport so the fresh HERMES_HOME is picked up.
for mod in list(sys.modules.keys()):
if mod.startswith("hermes_cli") or mod.startswith("hermes_state") or mod == "hermes_constants":
del sys.modules[mod]
from hermes_cli import kanban_db
yield kanban_db, test_home
# Cleanup is best-effort; tempfile dir survives but pytest isolation
# gives each test its own monkeypatched HERMES_HOME so no cross-test
# contamination.
def _fake_spawn(*args, **kwargs):
"""Stand-in for the real worker spawn — returns a fake PID."""
return 12345
def test_unassigned_task_skipped_without_default_assignee(isolated_kanban_home):
"""Baseline: with no default_assignee, an unassigned ready task is
skipped via the existing `skipped_unassigned` bucket and the DB row
is untouched."""
kb, _home = isolated_kanban_home
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
task_id = kb.create_task(conn, title="t1", assignee=None)
with kb.connect_closing() as conn:
res = kb.dispatch_once(conn, spawn_fn=_fake_spawn, dry_run=False)
assert res.skipped_unassigned == [task_id]
assert not res.auto_assigned_default
assert not res.spawned
with kb.connect_closing() as conn:
row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone()
assert row["assignee"] is None
def test_unassigned_task_auto_assigned_with_default_assignee(isolated_kanban_home):
"""Core #27145 contract: with default_assignee set, an unassigned ready
task gets the assignment applied and dispatched on the same tick. The
DB row is mutated (assignee column + an 'assigned' event)."""
kb, _home = isolated_kanban_home
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
task_id = kb.create_task(conn, title="t1", assignee=None)
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=False,
default_assignee="default",
)
assert res.auto_assigned_default == [task_id]
assert not res.skipped_unassigned
assert len(res.spawned) == 1
assert res.spawned[0][0] == task_id
assert res.spawned[0][1] == "default"
with kb.connect_closing() as conn:
row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone()
assert row["assignee"] == "default"
# 'assigned' event emitted for the audit trail
with kb.connect_closing() as conn:
evs = list(conn.execute(
"SELECT kind, payload FROM task_events WHERE task_id = ? AND kind = 'assigned'",
(task_id,),
))
assert len(evs) == 1
payload = json.loads(evs[0][1])
assert payload["assignee"] == "default"
assert payload["source"] == "kanban.default_assignee"
def test_dry_run_with_default_assignee_reports_without_mutating(isolated_kanban_home):
"""Dry-run mode: reports what WOULD happen (task in auto_assigned_default,
spawn entry) but does NOT mutate the DB. Operators using
`hermes kanban dispatch --dry-run` see the routing decision before
committing."""
kb, _home = isolated_kanban_home
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
task_id = kb.create_task(conn, title="t1", assignee=None)
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=True,
default_assignee="default",
)
assert res.auto_assigned_default == [task_id]
assert len(res.spawned) == 1
with kb.connect_closing() as conn:
row = conn.execute("SELECT assignee FROM tasks WHERE id = ?", (task_id,)).fetchone()
# DB unchanged — dry_run did not commit the assignment.
assert row["assignee"] is None
def test_whitespace_default_assignee_treated_as_none(isolated_kanban_home):
"""Empty / whitespace-only default_assignee values must be treated as
'no fallback set' so a misconfigured kanban.default_assignee=' '
doesn't surprise operators by silently routing unassigned tasks."""
kb, _home = isolated_kanban_home
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
task_id = kb.create_task(conn, title="t1", assignee=None)
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=False,
default_assignee=" ",
)
assert task_id in res.skipped_unassigned
assert not res.auto_assigned_default
def test_explicitly_assigned_task_untouched_by_default_assignee(isolated_kanban_home):
"""A task with an explicit assignee must NOT be touched by the
default_assignee logic that fallback only applies to genuinely
unassigned rows."""
kb, _home = isolated_kanban_home
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
task_id = kb.create_task(conn, title="t1", assignee="default")
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=False,
default_assignee="someother",
)
assert task_id not in res.auto_assigned_default
assert any(s[0] == task_id and s[1] == "default" for s in res.spawned)
def test_dispatch_result_has_auto_assigned_default_field():
"""Schema-level invariant: DispatchResult exposes the
auto_assigned_default field so CLI / dashboard / gateway can surface
the new routing decisions."""
from hermes_cli.kanban_db import DispatchResult
r = DispatchResult()
assert hasattr(r, "auto_assigned_default")
assert r.auto_assigned_default == []
-1
View File
@@ -298,7 +298,6 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch):
"""
import hermes_cli.kanban_db as kb
from gateway.run import GatewayRunner
from unittest.mock import patch
runner = object.__new__(GatewayRunner)
@@ -0,0 +1,167 @@
"""Regression tests for #21582 — per-profile concurrency cap in dispatcher.
When ``kanban.max_in_progress_per_profile`` is set, no single profile
gets more than N workers running at once even if the global
``max_in_progress`` cap would allow it. Prevents one profile's local
model / API quota / browser pool from being overwhelmed by a fan-out.
"""
from __future__ import annotations
import os
import sys
import tempfile
import pytest
@pytest.fixture()
def isolated_kanban_home_with_profiles(monkeypatch):
"""Spin up a fresh HERMES_HOME with kanban DB + alpha/beta profiles."""
test_home = tempfile.mkdtemp(prefix="kanban_per_profile_cap_test_")
for prof in ("alpha", "beta", "default"):
os.makedirs(os.path.join(test_home, "profiles", prof), exist_ok=True)
monkeypatch.setenv("HERMES_HOME", test_home)
for mod in list(sys.modules.keys()):
if mod.startswith("hermes_cli") or mod.startswith("hermes_state") or mod == "hermes_constants":
del sys.modules[mod]
from hermes_cli import kanban_db
yield kanban_db
def _fake_spawn(*args, **kwargs):
return 12345
def test_no_cap_all_tasks_dispatched(isolated_kanban_home_with_profiles):
"""Baseline: with no per-profile cap, all ready tasks dispatch."""
kb = isolated_kanban_home_with_profiles
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
for i in range(5):
kb.create_task(conn, title=f"a{i}", assignee="alpha")
for i in range(3):
kb.create_task(conn, title=f"b{i}", assignee="beta")
with kb.connect_closing() as conn:
res = kb.dispatch_once(conn, spawn_fn=_fake_spawn, dry_run=True)
assert len(res.spawned) == 8
assert not res.skipped_per_profile_capped
def test_cap_2_balances_two_profiles(isolated_kanban_home_with_profiles):
"""With cap=2: 2 alpha + 2 beta dispatched; remaining 3 alpha + 1 beta
deferred to skipped_per_profile_capped."""
kb = isolated_kanban_home_with_profiles
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
for i in range(5):
kb.create_task(conn, title=f"a{i}", assignee="alpha")
for i in range(3):
kb.create_task(conn, title=f"b{i}", assignee="beta")
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=True,
max_in_progress_per_profile=2,
)
spawn_assignees = [s[1] for s in res.spawned]
capped_assignees = [c[1] for c in res.skipped_per_profile_capped]
assert spawn_assignees.count("alpha") == 2
assert spawn_assignees.count("beta") == 2
assert capped_assignees.count("alpha") == 3
assert capped_assignees.count("beta") == 1
def test_pre_existing_running_counts_against_cap(isolated_kanban_home_with_profiles):
"""A task already in 'running' status when dispatch_once starts counts
toward the per-profile cap. With 1 alpha pre-running and cap=1, NO new
alpha tasks should spawn; beta is independent so 1 beta spawns."""
kb = isolated_kanban_home_with_profiles
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
running_alpha = kb.create_task(conn, title="running alpha", assignee="alpha")
with kb.write_txn(conn):
conn.execute(
"UPDATE tasks SET status = 'running', claim_lock = 'test:1' WHERE id = ?",
(running_alpha,),
)
for i in range(2):
kb.create_task(conn, title=f"a{i}", assignee="alpha")
for i in range(2):
kb.create_task(conn, title=f"b{i}", assignee="beta")
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=True,
max_in_progress_per_profile=1,
)
spawn_assignees = [s[1] for s in res.spawned]
capped_assignees = [c[1] for c in res.skipped_per_profile_capped]
assert spawn_assignees.count("alpha") == 0
assert spawn_assignees.count("beta") == 1
assert capped_assignees.count("alpha") == 2
assert capped_assignees.count("beta") == 1
@pytest.mark.parametrize("cap", [0, -1, "abc", None])
def test_invalid_cap_treated_as_no_cap(isolated_kanban_home_with_profiles, cap):
"""Cap values that don't represent a positive int should be treated as
'no cap' silently falling through rather than crashing the dispatcher."""
kb = isolated_kanban_home_with_profiles
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
for i in range(3):
kb.create_task(conn, title=f"a{i}", assignee="alpha")
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=True,
max_in_progress_per_profile=cap,
)
assert not res.skipped_per_profile_capped
assert len(res.spawned) == 3
def test_capped_tasks_dispatched_on_subsequent_tick(isolated_kanban_home_with_profiles):
"""A task deferred this tick because its profile was at cap should be
eligible for dispatch on the next tick (after running tasks complete).
This verifies the cap is per-tick state, not a permanent block."""
kb = isolated_kanban_home_with_profiles
with kb.connect_closing() as conn:
kb.create_board(slug="default", name="Test")
ids = [kb.create_task(conn, title=f"a{i}", assignee="alpha") for i in range(3)]
# First tick: cap=1, only 1 alpha dispatched
with kb.connect_closing() as conn:
res1 = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=False,
max_in_progress_per_profile=1,
)
assert len(res1.spawned) == 1
assert len(res1.skipped_per_profile_capped) == 2
# Simulate the running task completing — set it back to done so the
# 'running' count drops
spawned_id = res1.spawned[0][0]
with kb.connect_closing() as conn:
with kb.write_txn(conn):
conn.execute(
"UPDATE tasks SET status = 'done', claim_lock = NULL WHERE id = ?",
(spawned_id,),
)
# Second tick: 1 more alpha should now dispatch
with kb.connect_closing() as conn:
res2 = kb.dispatch_once(
conn, spawn_fn=_fake_spawn, dry_run=False,
max_in_progress_per_profile=1,
)
assert len(res2.spawned) == 1
assert len(res2.skipped_per_profile_capped) == 1
assert res2.spawned[0][0] != spawned_id # different task this time
def test_dispatch_result_has_skipped_per_profile_capped_field():
"""Schema-level invariant: DispatchResult exposes the
skipped_per_profile_capped field as a list of
(task_id, assignee, current_running) tuples."""
from hermes_cli.kanban_db import DispatchResult
r = DispatchResult()
assert hasattr(r, "skipped_per_profile_capped")
assert r.skipped_per_profile_capped == []
-1
View File
@@ -1,4 +1,3 @@
import json
from hermes_cli import kanban_db as kb
from hermes_cli.kanban_swarm import (
@@ -0,0 +1,238 @@
"""Worker-side image enrichment for kanban tasks.
When a kanban task body contains a local image path or an ``http(s)://``
image URL, the worker must surface that image to the model on its first
user turn matching the CLI/gateway behaviour for inbound images.
The dispatcher spawns the worker as
``hermes -p <profile> chat -q "work kanban task <id>"``. The task body
itself never appears in argv; the worker has to read it from the kanban
DB during startup. These tests cover the round-trip:
task body kanban_db.get_task extract_image_refs
build_native_content_parts multimodal user turn
"""
from __future__ import annotations
import base64
from pathlib import Path
import pytest
from hermes_cli import kanban_db as kb
from agent.image_routing import (
build_native_content_parts,
extract_image_refs,
)
# Tiny 1×1 transparent PNG used to back any path the tests stick into a
# task body. extract_image_refs validates the path exists on disk, so the
# byte content has to be a real readable file (any image bytes will do).
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg=="
)
@pytest.fixture
def kanban_home(tmp_path: Path, monkeypatch):
"""Isolated HERMES_HOME with a fresh kanban DB for each test."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
def _add_task_with_body(body: str, *, title: str = "Look at this") -> str:
conn = kb.connect()
try:
task_id = kb.create_task(
conn,
title=title,
body=body,
assignee="worker-a",
tenant=None,
)
finally:
conn.close()
return task_id
def _read_body(task_id: str) -> str:
conn = kb.connect()
try:
task = kb.get_task(conn, task_id)
return (task.body if task is not None else "") or ""
finally:
conn.close()
class TestExtractFromTaskBody:
"""Read a real kanban task body and run it through extract_image_refs."""
def test_local_path_in_body_round_trips(self, kanban_home, tmp_path):
img = tmp_path / "screenshot.png"
img.write_bytes(_PNG)
tid = _add_task_with_body(
f"Please review the screenshot at {img} and confirm "
"the alignment is right."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == [str(img)]
assert urls == []
def test_url_in_body_round_trips(self, kanban_home):
tid = _add_task_with_body(
"The design lives at https://example.com/mock/v3.png — "
"make the implementation match it."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == []
assert urls == ["https://example.com/mock/v3.png"]
def test_mixed_path_and_url_in_body(self, kanban_home, tmp_path):
img = tmp_path / "current.png"
img.write_bytes(_PNG)
tid = _add_task_with_body(
f"Compare the current screenshot {img} against the design at "
"https://example.com/target.png and write a diff."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == [str(img)]
assert urls == ["https://example.com/target.png"]
def test_body_without_images_yields_nothing(self, kanban_home):
tid = _add_task_with_body(
"Refactor the auth module to use the new session helper."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == []
assert urls == []
def test_empty_body_is_safe(self, kanban_home):
tid = _add_task_with_body("")
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == []
assert urls == []
class TestBuildPartsFromTaskBody:
"""Verify the full pipeline produces a multimodal user turn."""
def test_local_path_becomes_native_image_part(self, kanban_home, tmp_path):
img = tmp_path / "design.png"
img.write_bytes(_PNG)
tid = _add_task_with_body(f"Check out {img} — what's broken?")
body = _read_body(tid)
paths, urls = extract_image_refs(body)
# Mirrors the cli.py wiring: pass the worker's literal -q argument
# (the dispatcher uses ``"work kanban task <id>"``) plus the
# extracted refs through build_native_content_parts.
parts, skipped = build_native_content_parts(
f"work kanban task {tid}",
paths,
image_urls=urls or None,
)
assert skipped == []
# text part + one image_url part
assert len(parts) == 2
assert parts[0]["type"] == "text"
assert parts[0]["text"].startswith(f"work kanban task {tid}")
assert f"[Image attached at: {img}]" in parts[0]["text"]
assert parts[1]["type"] == "image_url"
assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,")
def test_url_becomes_image_url_part(self, kanban_home):
tid = _add_task_with_body(
"Reference: https://example.com/target.jpg — match it."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
parts, skipped = build_native_content_parts(
f"work kanban task {tid}",
paths,
image_urls=urls or None,
)
assert skipped == []
assert len(parts) == 2
assert parts[0]["type"] == "text"
assert "[Image attached: https://example.com/target.jpg]" in parts[0]["text"]
assert parts[1] == {
"type": "image_url",
"image_url": {"url": "https://example.com/target.jpg"},
}
def test_body_with_both_yields_two_image_parts(self, kanban_home, tmp_path):
img = tmp_path / "local.png"
img.write_bytes(_PNG)
tid = _add_task_with_body(
f"Diff {img} vs https://example.com/target.png — explain it."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
parts, skipped = build_native_content_parts(
f"work kanban task {tid}",
paths,
image_urls=urls or None,
)
assert skipped == []
image_parts = [p for p in parts if p.get("type") == "image_url"]
assert len(image_parts) == 2
# Local file is embedded as a data URL; remote URL passes through.
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert image_parts[1]["image_url"]["url"] == "https://example.com/target.png"
def test_body_with_no_images_leaves_query_untouched(self, kanban_home):
tid = _add_task_with_body(
"Rewrite the README intro paragraph to focus on use cases."
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
parts, skipped = build_native_content_parts(
f"work kanban task {tid}",
paths,
image_urls=urls or None,
)
# No images → plain text-only return (single part, no list mutation).
assert skipped == []
assert len(parts) == 1
assert parts[0]["type"] == "text"
assert parts[0]["text"] == f"work kanban task {tid}"
def test_code_block_example_is_not_attached(self, kanban_home, tmp_path):
# Only the real image outside the fenced code block should attach.
real = tmp_path / "real.png"
real.write_bytes(_PNG)
tid = _add_task_with_body(
f"Real screenshot:\n{real}\n\n"
"Example we DON'T want attached:\n"
"```\n"
"image: /tmp/example_only.png\n"
"url: https://example.com/example.png\n"
"```\n"
)
body = _read_body(tid)
paths, urls = extract_image_refs(body)
assert paths == [str(real)]
assert urls == []
-3
View File
@@ -1,10 +1,7 @@
"""Tests for hermes_cli.logs — log viewing and filtering."""
import os
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from hermes_cli.logs import (
LOG_FILES,
+6 -7
View File
@@ -7,7 +7,6 @@ launch an MCP is mocked.
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
@@ -207,7 +206,7 @@ class TestManifestParsing:
class TestInstall:
def test_install_simple_stdio_writes_config(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
@@ -240,7 +239,7 @@ class TestInstall:
fake_clone.mkdir()
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
with patch.object(mcp_catalog, "_do_git_install", return_value=fake_clone):
@@ -263,7 +262,7 @@ class TestInstall:
monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val")
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import get_env_value, load_config
install_entry(_entry("demo"), enable=True)
@@ -278,7 +277,7 @@ class TestInstall:
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
@@ -297,7 +296,7 @@ class TestInstall:
_write_manifest(catalog_dir, "demo", body)
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import install_entry, get_entry, CatalogError
from hermes_cli.mcp_catalog import install_entry, CatalogError
# User hits enter — empty input, no default
monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "")
@@ -314,7 +313,7 @@ class TestInstall:
class TestUninstall:
def test_uninstall_removes_server_block(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import install_entry, get_entry, uninstall_entry
from hermes_cli.mcp_catalog import install_entry, uninstall_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
+55 -5
View File
@@ -6,12 +6,7 @@ any actual MCP servers or API keys.
"""
import argparse
import json
import os
import types
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
@@ -600,3 +595,58 @@ class TestMcpLogin:
out = capsys.readouterr().out
assert "no URL" in out or "not an OAuth" in out
def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
"""Probe lists tools without auth (Google Drive), but no token landed.
The server allows tools/list without auth (DCR 400'd), so the probe
succeeds yet no OAuth token exists. Login must NOT claim success it
should warn and point the user at pre-registered client_id config.
"""
_seed_config(tmp_path, {
"googledrive": {
"url": "https://drivemcp.googleapis.com/mcp/v1",
"auth": "oauth",
},
})
# Probe returns tools even though auth never completed.
monkeypatch.setattr(
"hermes_cli.mcp_config._probe_single_server",
lambda name, cfg: [("search_files", "d"), ("read_file_content", "d")],
)
# No token file is created → _oauth_tokens_present() returns False.
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="googledrive"))
out = capsys.readouterr().out
assert "no OAuth token was obtained" in out
assert "Authenticated" not in out
assert "client_id" in out
def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
"""Probe lists tools AND a token exists → report real success."""
_seed_config(tmp_path, {
"realserver": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
})
token_dir = tmp_path / "mcp-tokens"
# cmd_mcp_login wipes tokens before probing, then the real OAuth flow
# writes a fresh token during the probe. Simulate that: the mocked
# probe drops a token file, mirroring a successful authorization.
def mock_probe(name, cfg):
token_dir.mkdir(exist_ok=True)
(token_dir / "realserver.json").write_text('{"access_token": "x"}')
return [("a", "d"), ("b", "d"), ("c", "d")]
monkeypatch.setattr(
"hermes_cli.mcp_config._probe_single_server", mock_probe
)
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="realserver"))
out = capsys.readouterr().out
assert "Authenticated — 3 tool(s) available" in out
assert "no OAuth token" not in out
@@ -10,7 +10,6 @@ run silently.
from __future__ import annotations
from copy import deepcopy
from hermes_cli.config import DEFAULT_CONFIG
+1 -2
View File
@@ -1,7 +1,6 @@
"""Tests for MCP tools interactive configuration in hermes_cli.tools_config."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from hermes_cli.tools_config import _configure_mcp_tools_interactive
+1 -4
View File
@@ -8,10 +8,7 @@ Covers:
- Profile-scoped reset (uses HERMES_HOME)
"""
import os
import pytest
from argparse import Namespace
from pathlib import Path
@pytest.fixture
@@ -39,7 +36,7 @@ def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input="
Simulates what happens when `hermes memory reset` is run.
"""
from hermes_constants import get_hermes_home, display_hermes_home
from hermes_constants import get_hermes_home
mem_dir = get_hermes_home() / "memories"
files_to_reset = []
+84
View File
@@ -172,6 +172,90 @@ class TestFetchFailure:
assert result == manifest
class TestFallbackChain:
"""``_fetch_manifest_with_fallback`` walks ``DEFAULT_CATALOG_FALLBACK_URLS``
when the primary URL fails. Regression: the Docusaurus site behind Vercel
occasionally returns HTTP 403 + x-vercel-mitigated: challenge for urllib;
without a fallback URL the user's disk cache freezes and new model
releases (opus 4.8, etc.) never reach the picker.
"""
PRIMARY = "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json"
FALLBACK = (
"https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/website/static/api/model-catalog.json"
)
def test_uses_primary_when_it_succeeds(self, isolated_home):
from hermes_cli import model_catalog
calls: list[str] = []
def fake_fetch(url, timeout):
calls.append(url)
return _valid_manifest()
with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch):
result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0)
assert result is not None
assert calls == [self.PRIMARY], "fallback URLs must not be touched on primary success"
def test_falls_through_to_raw_github_on_primary_failure(self, isolated_home):
from hermes_cli import model_catalog
calls: list[str] = []
def fake_fetch(url, timeout):
calls.append(url)
if url == self.PRIMARY:
return None # simulate Vercel 403
return _valid_manifest()
with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch):
result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0)
assert result is not None
assert calls == [self.PRIMARY, self.FALLBACK]
def test_returns_none_when_all_urls_fail(self, isolated_home):
from hermes_cli import model_catalog
with patch.object(model_catalog, "_fetch_manifest", return_value=None) as fetch:
result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0)
assert result is None
# Primary + every fallback URL was attempted exactly once.
assert fetch.call_count == 1 + len(model_catalog.DEFAULT_CATALOG_FALLBACK_URLS)
def test_dedupes_when_primary_equals_fallback(self, isolated_home):
"""Operator who configured ``model_catalog.url`` to the raw GitHub URL
should not get a duplicate fetch from the fallback list."""
from hermes_cli import model_catalog
with patch.object(model_catalog, "_fetch_manifest", return_value=None) as fetch:
model_catalog._fetch_manifest_with_fallback(self.FALLBACK, 5.0)
assert fetch.call_count == 1, f"expected 1 call, got {fetch.call_count}"
def test_get_catalog_uses_fallback_chain(self, isolated_home):
"""End-to-end: ``get_catalog`` routes through the fallback helper so
a primary URL failure transparently produces a working catalog."""
from hermes_cli import model_catalog
manifest = _valid_manifest()
calls: list[str] = []
def fake_fetch(url, timeout):
calls.append(url)
if url == self.PRIMARY:
return None
return manifest
with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch):
result = model_catalog.get_catalog(force_refresh=True)
assert result == manifest
assert self.FALLBACK in calls
class TestCuratedAccessors:
def test_openrouter_returns_tuples(self, isolated_home):
from hermes_cli import model_catalog
-1
View File
@@ -8,7 +8,6 @@ import pytest
from hermes_cli.model_normalize import (
normalize_model_for_provider,
_DOT_TO_HYPHEN_PROVIDERS,
_AGGREGATOR_PROVIDERS,
_normalize_for_deepseek,
detect_vendor,
)
@@ -6,7 +6,6 @@ isinstance(model, dict)) to silently fail — leaving the provider unset and
falling back to auto-detection.
"""
import os
from unittest.mock import patch, MagicMock
import pytest
@@ -194,7 +193,6 @@ class TestProviderPersistsAfterModelSave:
# Patch fetch_api_models so the named custom flow returns one model;
# patch simple_term_menu to force the input() fallback; patch input to
# auto-select the first model from the fallback prompt.
from unittest.mock import MagicMock
fake_menu_module = MagicMock()
fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test")
with patch("hermes_cli.auth._save_model_choice"), \
@@ -403,6 +403,44 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk
assert models["custom:openai-2"] == ["gpt-4.6"]
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:gpt",
current_base_url="https://gateway.example.com",
user_providers={},
custom_providers=[
{
"name": "gpt",
"base_url": "https://gateway.example.com",
"key_env": "GPT_KEY",
"api_mode": "codex_responses",
"model": "gpt-5.5",
},
{
"name": "claude",
"base_url": "https://gateway.example.com",
"key_env": "CLAUDE_KEY",
"api_mode": "anthropic_messages",
"model": "claude-opus-4-8",
},
],
max_models=50,
)
custom = [p for p in providers if p.get("is_user_defined")]
by_slug = {p["slug"]: p for p in custom}
assert set(by_slug) == {"custom:gpt", "custom:claude"}
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
assert by_slug["custom:gpt"]["is_current"] is True
assert by_slug["custom:claude"]["is_current"] is False
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
"""After grouping six entries into one row, total_models must reflect
the full count, and every grouped model appears in the list."""
+52 -23
View File
@@ -2,6 +2,7 @@
from unittest.mock import patch, MagicMock
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli.models import (
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
is_nous_free_tier, partition_nous_models_by_tier,
@@ -308,6 +309,15 @@ class TestDetectProviderForModel:
class TestIsNousFreeTier:
"""Tests for is_nous_free_tier — account tier detection."""
def test_paid_service_access_allowed_true_is_not_free(self):
assert is_nous_free_tier({"paid_service_access": {"allowed": True}}) is False
def test_paid_service_access_allowed_false_is_free(self):
assert is_nous_free_tier({"paid_service_access": {"allowed": False}}) is True
def test_paid_service_access_paid_access_fallback(self):
assert is_nous_free_tier({"paid_service_access": {"paid_access": False}}) is True
def test_paid_plus_tier(self):
assert is_nous_free_tier({"subscription": {"plan": "Plus", "tier": 2, "monthly_charge": 20}}) is False
@@ -657,39 +667,58 @@ class TestCheckNousFreeTierCache:
def teardown_method(self):
_models_mod._free_tier_cache = None
@patch("hermes_cli.models.fetch_nous_account_tier")
@patch("hermes_cli.models.is_nous_free_tier", return_value=True)
def test_result_is_cached(self, mock_is_free, mock_fetch):
"""Second call within TTL returns cached result without API call."""
mock_fetch.return_value = {"subscription": {"monthly_charge": 0}}
with patch("hermes_cli.auth.get_provider_auth_state", return_value={"access_token": "tok"}), \
patch("hermes_cli.auth.resolve_nous_runtime_credentials"):
result1 = check_nous_free_tier()
result2 = check_nous_free_tier()
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_result_is_cached(self, mock_account):
"""Second call within TTL returns cached result without account lookup."""
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=False,
)
result1 = check_nous_free_tier()
result2 = check_nous_free_tier()
assert result1 is True
assert result2 is True
assert mock_fetch.call_count == 1
assert mock_account.call_count == 1
@patch("hermes_cli.models.fetch_nous_account_tier")
@patch("hermes_cli.models.is_nous_free_tier", return_value=False)
def test_cache_expires_after_ttl(self, mock_is_free, mock_fetch):
"""After TTL expires, the API is called again."""
mock_fetch.return_value = {"subscription": {"monthly_charge": 20}}
with patch("hermes_cli.auth.get_provider_auth_state", return_value={"access_token": "tok"}), \
patch("hermes_cli.auth.resolve_nous_runtime_credentials"):
result1 = check_nous_free_tier()
assert mock_fetch.call_count == 1
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_cache_expires_after_ttl(self, mock_account):
"""After TTL expires, account info is resolved again."""
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="jwt",
fresh=False,
paid_service_access=True,
)
result1 = check_nous_free_tier()
assert mock_account.call_count == 1
cached_result, cached_at = _models_mod._free_tier_cache
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
cached_result, cached_at = _models_mod._free_tier_cache
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
result2 = check_nous_free_tier()
assert mock_fetch.call_count == 2
result2 = check_nous_free_tier()
assert mock_account.call_count == 2
assert result1 is False
assert result2 is False
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
def test_force_fresh_bypasses_cache(self, mock_account):
mock_account.return_value = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
)
assert check_nous_free_tier() is False
assert check_nous_free_tier(force_fresh=True) is False
assert mock_account.call_count == 2
mock_account.assert_called_with(force_fresh=True)
def test_cache_ttl_is_short(self):
"""TTL should be short enough to catch upgrades quickly (<=5 min)."""
assert _FREE_TIER_CACHE_TTL <= 300
@@ -17,10 +17,8 @@ Merging is what lets new models (e.g. ``mimo-v2.5-pro`` on opencode-go)
appear in ``/model`` without a Hermes release.
"""
import os
from unittest.mock import patch
import pytest
from hermes_cli.models import (
_MODELS_DEV_PREFERRED,
@@ -6,10 +6,7 @@ httpx tries to encode the Authorization header as ASCII.
"""
import os
import sys
import tempfile
import pytest
from hermes_cli.config import _check_non_ascii_credential
+547
View File
@@ -0,0 +1,547 @@
"""Tests for normalized Nous Portal account entitlement helpers."""
from __future__ import annotations
import base64
import json
import time
from typing import Any
import pytest
from hermes_cli.nous_account import (
NousPaidServiceAccessInfo,
NousPortalAccountInfo,
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
reset_nous_portal_account_info_cache,
)
def _jwt(claims: dict[str, Any]) -> str:
def _part(payload: dict[str, Any]) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
def _state(token: str) -> dict[str, Any]:
return {
"access_token": token,
"portal_base_url": "https://portal.example.test",
"client_id": "hermes-cli",
}
def _account_payload(
*,
allowed: bool,
subscription: dict[str, Any] | None,
subscription_credits: float,
purchased_credits: float,
) -> dict[str, Any]:
return {
"user": {
"email": "alice@example.test",
"privy_did": "did:privy:alice",
},
"organisation": {
"id": "org_123",
},
"subscription": subscription,
"purchased_credits_remaining": purchased_credits,
"paid_service_access": {
"allowed": allowed,
"paid_access": allowed,
"reason": "usable_credits" if allowed else "no_usable_credits",
"organisation_id": "org_123",
"effective_at_ms": 123456789,
"has_active_subscription": subscription is not None,
"active_subscription_is_paid": bool(
subscription and subscription.get("monthly_charge", 0) > 0
),
"subscription_tier": subscription.get("tier") if subscription else None,
"subscription_monthly_charge": (
subscription.get("monthly_charge") if subscription else None
),
"subscription_credits_remaining": subscription_credits,
"purchased_credits_remaining": purchased_credits,
"total_usable_credits": subscription_credits + purchased_credits,
},
}
@pytest.fixture(autouse=True)
def _reset_cache():
reset_nous_portal_account_info_cache()
yield
reset_nous_portal_account_info_cache()
def test_valid_jwt_with_paid_access_true(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"client_id": "hermes-cli",
"product_id": "nous-hermes-agent",
"nous_client": "hermes-agent",
"exp": int(time.time()) + 900,
"paid_access": True,
"subscription_tier": 2,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.fresh is False
assert info.logged_in is True
assert info.user_id == "user_123"
assert info.org_id == "org_123"
assert info.product_id == "nous-hermes-agent"
assert info.paid_service_access is True
assert info.is_paid is True
assert info.is_free_tier is False
def test_valid_jwt_with_paid_access_false(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) + 900,
"paid_access": False,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.paid_service_access is False
assert info.is_paid is False
assert info.is_free_tier is True
def test_valid_jwt_missing_paid_access_is_unknown_not_paid(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) + 900,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
info = get_nous_portal_account_info()
assert info.source == "jwt"
assert info.paid_service_access is None
assert info.is_paid is False
assert info.is_free_tier is False
def test_expired_jwt_falls_back_to_fresh_account(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) - 60,
"paid_access": False,
}
)
payload = _account_payload(
allowed=True,
subscription={
"plan": "Tier 2",
"tier": 2,
"monthly_charge": 20,
"current_period_end": "2026-05-01T00:00:00.000Z",
"credits_remaining": 12.25,
"rollover_credits": 3.5,
},
subscription_credits=12.25,
purchased_credits=7.75,
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
info = get_nous_portal_account_info()
assert info.source == "account_api"
assert info.fresh is True
assert info.paid_service_access is True
assert info.subscription is not None
assert info.subscription.monthly_charge == 20
assert info.paid_service_access_info is not None
assert info.paid_service_access_info.total_usable_credits == 20
@pytest.mark.parametrize(
("payload", "expected_paid"),
[
(
_account_payload(
allowed=True,
subscription={
"plan": "Tier 2",
"tier": 2,
"monthly_charge": 20,
"current_period_end": "2026-05-01T00:00:00.000Z",
"credits_remaining": 12.25,
"rollover_credits": 3.5,
},
subscription_credits=12.25,
purchased_credits=7.75,
),
True,
),
(
_account_payload(
allowed=False,
subscription={
"plan": "Tier 2",
"tier": 2,
"monthly_charge": 20,
"current_period_end": "2026-05-01T00:00:00.000Z",
"credits_remaining": 0,
"rollover_credits": 0,
},
subscription_credits=0,
purchased_credits=0,
),
False,
),
(
_account_payload(
allowed=True,
subscription=None,
subscription_credits=0,
purchased_credits=7.75,
),
True,
),
(
_account_payload(
allowed=False,
subscription=None,
subscription_credits=0,
purchased_credits=0,
),
False,
),
],
)
def test_fresh_account_payload_normalization(monkeypatch, payload, expected_paid):
token = _jwt({"sub": "user_123", "org_id": "org_123", "exp": int(time.time()) + 900})
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
info = get_nous_portal_account_info(force_fresh=True)
assert isinstance(info, NousPortalAccountInfo)
assert info.source == "account_api"
assert info.fresh is True
assert info.email == "alice@example.test"
assert info.privy_did == "did:privy:alice"
assert info.org_id == "org_123"
assert info.paid_service_access is expected_paid
assert info.is_paid is expected_paid
assert info.is_free_tier is (not expected_paid)
def test_force_fresh_uses_account_api_even_when_jwt_is_valid(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) + 900,
"paid_access": False,
}
)
payload = _account_payload(
allowed=True,
subscription=None,
subscription_credits=0,
purchased_credits=5,
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: _state(token))
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: "fresh-token")
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
info = get_nous_portal_account_info(force_fresh=True)
assert info.source == "account_api"
assert info.paid_service_access is True
def test_no_oauth_token_reports_inference_key_present(monkeypatch):
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
class _Entry:
label = "manual-nous"
access_token = ""
agent_key = "opaque-runtime-key"
agent_key_expires_at = "2099-01-01T00:00:00+00:00"
expires_at = None
inference_base_url = "https://inference.example.test/v1"
base_url = "https://inference.example.test/v1"
priority = 0
@property
def runtime_api_key(self):
return self.agent_key
@property
def runtime_base_url(self):
return self.inference_base_url
class _Pool:
def has_credentials(self):
return True
def entries(self):
return [_Entry()]
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
info = get_nous_portal_account_info()
assert info.logged_in is False
assert info.source == "inference_key"
assert info.inference_credential_present is True
assert info.credential_source == "pool:manual-nous"
assert info.paid_service_access is None
def test_pool_oauth_entry_uses_jwt_snapshot(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"client_id": "hermes-cli",
"exp": int(time.time()) + 900,
"paid_access": True,
}
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
class _Entry:
label = "dashboard device_code"
auth_type = "oauth"
access_token = token
refresh_token = "refresh-token"
agent_key = "opaque-runtime-key"
agent_key_expires_at = "2099-01-01T00:00:00+00:00"
expires_at = "2099-01-01T00:00:00+00:00"
portal_base_url = "https://portal.example.test"
inference_base_url = "https://inference.example.test/v1"
base_url = "https://inference.example.test/v1"
priority = 0
@property
def runtime_api_key(self):
return self.agent_key
@property
def runtime_base_url(self):
return self.inference_base_url
class _Pool:
def has_credentials(self):
return True
def entries(self):
return [_Entry()]
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
info = get_nous_portal_account_info()
assert info.logged_in is True
assert info.source == "jwt"
assert info.paid_service_access is True
assert info.credential_source == "pool:dashboard device_code"
def test_pool_oauth_entry_force_fresh_uses_account_api(monkeypatch):
token = _jwt(
{
"sub": "user_123",
"org_id": "org_123",
"exp": int(time.time()) + 900,
"paid_access": False,
}
)
payload = _account_payload(
allowed=True,
subscription=None,
subscription_credits=0,
purchased_credits=3,
)
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider: {})
monkeypatch.setattr("hermes_cli.nous_account._fetch_nous_account_info", lambda *a, **kw: payload)
class _Entry:
label = "dashboard device_code"
auth_type = "oauth"
access_token = token
refresh_token = "refresh-token"
agent_key = "opaque-runtime-key"
agent_key_expires_at = "2099-01-01T00:00:00+00:00"
expires_at = "2099-01-01T00:00:00+00:00"
portal_base_url = "https://portal.example.test"
inference_base_url = "https://inference.example.test/v1"
base_url = "https://inference.example.test/v1"
priority = 0
@property
def runtime_api_key(self):
return self.agent_key
@property
def runtime_base_url(self):
return self.inference_base_url
class _Pool:
def has_credentials(self):
return True
def entries(self):
return [_Entry()]
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool())
info = get_nous_portal_account_info(force_fresh=True)
assert info.logged_in is True
assert info.source == "account_api"
assert info.fresh is True
assert info.paid_service_access is True
assert info.credential_source == "pool:dashboard device_code"
def test_entitlement_message_returns_none_for_paid_access():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
portal_base_url="https://portal.example.test",
)
assert format_nous_portal_entitlement_message(info, capability="paid models") is None
def test_entitlement_message_for_inference_key_without_portal_login():
info = NousPortalAccountInfo(
logged_in=False,
source="inference_key",
fresh=False,
inference_credential_present=True,
portal_base_url="https://portal.example.test",
)
message = format_nous_portal_entitlement_message(
info,
capability="managed tools",
)
assert message is not None
assert "Nous inference credentials are configured" in message
assert "cannot verify your Nous Portal paid access" in message
assert "Log in with `hermes model`" in message
def test_entitlement_message_for_active_paid_subscription_with_no_credits():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
portal_base_url="https://portal.example.test",
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="no_usable_credits",
has_active_subscription=True,
active_subscription_is_paid=True,
subscription_credits_remaining=0,
purchased_credits_remaining=0,
total_usable_credits=0,
),
)
message = format_nous_portal_entitlement_message(
info,
capability="managed tools",
)
assert message is not None
assert "credits are exhausted" in message
assert "managed tools" in message
assert "https://portal.example.test/billing" in message
def test_entitlement_message_for_no_subscription_or_credits():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
portal_base_url="https://portal.example.test",
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="no_usable_credits",
has_active_subscription=False,
subscription_credits_remaining=0,
purchased_credits_remaining=0,
total_usable_credits=0,
),
)
message = format_nous_portal_entitlement_message(info, capability="paid models")
assert message is not None
assert "no active subscription or usable credits" in message
assert "Subscribe or add credits" in message
def test_entitlement_message_for_unknown_entitlement_is_explicit():
info = NousPortalAccountInfo(
logged_in=True,
source="error",
fresh=False,
paid_service_access=None,
portal_base_url="https://portal.example.test",
error="account_api_timeout",
)
message = format_nous_portal_entitlement_message(info, capability="Tool Gateway")
assert message is not None
assert "could not verify" in message
assert "account_api_timeout" in message
assert "Run `hermes model`" in message
def test_entitlement_message_for_account_missing():
info = NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=False,
paid_service_access_info=NousPaidServiceAccessInfo(
allowed=False,
reason="account_missing",
),
)
message = format_nous_portal_entitlement_message(info, capability="Tool Gateway")
assert message is not None
assert "could not find a Nous Portal account or organisation" in message
@@ -1,8 +1,8 @@
"""Regression tests for Nous Portal inference_base_url host-allowlist validation.
A poisoned ``inference_base_url`` from the Portal refresh / agent-key-mint
response (network MITM, malicious response injection) would otherwise be
persisted to auth.json and forwarded the user's legitimate agent_key
A poisoned ``inference_base_url`` from a Portal refresh response (network
MITM, malicious response injection) would otherwise be persisted to
auth.json and forwarded with the user's legitimate invoke JWT
bearer on every subsequent proxy request, exfiltrating their inference
budget and opening a response-injection channel into the IDE / chat
client. ``_validate_nous_inference_url_from_network()`` blocks any URL
@@ -11,7 +11,7 @@ outside the allowlist at the source.
These tests verify:
1. The validator's host + scheme rules.
2. Each of the five NETWORK call sites in ``auth.py`` calls the validator
2. Each of the two NETWORK call sites in ``auth.py`` calls the validator
rather than the unrestricted ``_optional_base_url`` helper.
3. The proxy adapter applies the validator as belt-and-suspenders.
4. The env-var override path (``NOUS_INFERENCE_BASE_URL``) is NOT
@@ -22,7 +22,6 @@ These tests verify:
from __future__ import annotations
import logging
import pytest
from hermes_cli.auth import (
DEFAULT_NOUS_INFERENCE_URL,
@@ -125,7 +124,7 @@ class TestValidatorRules:
class TestCallSiteWiring:
"""Verify the validator is actually wired into all 5 NETWORK call sites.
"""Verify the validator is actually wired into all auth.py NETWORK call sites.
These are not behaviour-end-to-end tests (the surrounding code is
several hundred lines per site with extensive HTTP mocking
@@ -162,7 +161,7 @@ class TestCallSiteWiring:
)
def test_validator_wired_at_all_known_call_sites(self):
"""All 5 known NETWORK sites use the validator. If this count
"""All 2 known auth.py NETWORK sites use the validator. If this count
drops, someone removed protection; if it grows, audit the new
site to be sure validation is appropriate."""
source = self._read_auth_source()
@@ -172,8 +171,8 @@ class TestCallSiteWiring:
mint_count = source.count(
'_validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))'
)
assert refresh_count == 3, f"expected 3 refresh sites, found {refresh_count}"
assert mint_count == 2, f"expected 2 mint sites, found {mint_count}"
assert refresh_count == 2, f"expected 2 refresh sites, found {refresh_count}"
assert mint_count == 0, f"expected 0 mint sites, found {mint_count}"
def test_proxy_adapter_also_validates(self):
"""The Nous proxy adapter applies the validator as defense-in-depth
+55 -16
View File
@@ -1,14 +1,25 @@
"""Tests for Nous subscription feature detection."""
from hermes_cli.nous_account import NousPortalAccountInfo
from hermes_cli import nous_subscription as ns
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
return NousPortalAccountInfo(
logged_in=logged_in,
source="jwt" if logged_in else "none",
fresh=False,
paid_service_access=paid,
)
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -23,11 +34,34 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc
assert features.web.current_provider == "exa"
def test_get_nous_subscription_features_force_fresh_forwards_account_request(monkeypatch):
calls = []
def fake_account_info(*, force_fresh=False):
calls.append(force_fresh)
return _account(logged_in=True, paid=True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_portal_account_info", fake_account_info)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
features = ns.get_nous_subscription_features({}, force_fresh=True)
assert features.account_info is not None
assert features.account_info.paid_service_access is True
assert calls == [True]
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "terminal")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -46,8 +80,9 @@ def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monke
def test_get_nous_subscription_features_marks_browser_use_as_managed_when_gateway_ready(monkeypatch):
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -78,8 +113,9 @@ def test_get_nous_subscription_features_uses_direct_browserbase_when_no_managed_
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -103,8 +139,9 @@ def test_get_nous_subscription_features_prefers_camofox_over_managed_browser_use
env = {"CAMOFOX_URL": "http://localhost:9377"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -133,8 +170,9 @@ def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(m
}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -155,8 +193,9 @@ def test_get_nous_subscription_features_does_not_treat_quoted_false_as_gateway_o
env = {"EXA_API_KEY": "exa-test"}
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
ns, "get_nous_portal_account_info", lambda: _account(logged_in=True, paid=True)
)
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
@@ -179,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
monkeypatch.setattr(
ns,
"_get_gateway_direct_credentials",
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
)
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
@@ -191,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
assert "web" in has_direct
assert "web" not in already_managed
assert set(unconfigured) == {"image_gen", "tts", "browser"}
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
@@ -10,8 +10,6 @@ Covers:
"""
import os
import pytest
from unittest.mock import patch, MagicMock
# ---------------------------------------------------------------------------
@@ -1,6 +1,5 @@
"""Tests for Ollama Cloud provider integration."""
import os
import pytest
from unittest.mock import patch, MagicMock
@@ -7,11 +7,9 @@ resolution in list_authenticated_providers() Section 2 must bridge this gap.
Covers: #5223, #6492
"""
import json
import os
from unittest.mock import patch
import pytest
from hermes_cli.model_switch import list_authenticated_providers
+50 -1
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from unittest.mock import patch
@@ -60,3 +59,53 @@ def test_docker_detected_via_dockerenv(tmp_path):
def test_recommended_update_command_docker():
from hermes_cli.config import recommended_update_command_for_method
assert "docker pull" in recommended_update_command_for_method("docker")
def test_banner_warns_on_pip_install(tmp_path):
"""The welcome banner surfaces a warning when the install method is pip."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("pip\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
# Wide console so the warning isn't wrapped across lines in the panel.
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" in out
assert "instability" in out
def test_banner_no_pip_warning_on_git_install(tmp_path):
"""Git installs must not show the pip-install warning."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("git\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" not in out
@@ -8,13 +8,9 @@ Covers:
- Honcho register_cli() builds correct argparse tree
"""
import argparse
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from hermes_cli.plugins import (
PluginContext,
@@ -14,7 +14,7 @@ from typing import Any, Dict
import pytest
import yaml
from hermes_cli.plugins import PluginManager, PluginManifest
from hermes_cli.plugins import PluginManager
# ── Helpers ────────────────────────────────────────────────────────────────
-6
View File
@@ -1,7 +1,6 @@
"""Tests for the Hermes plugin system (hermes_cli.plugins)."""
import logging
import os
import sys
import types
from pathlib import Path
@@ -13,17 +12,13 @@ import yaml
from hermes_cli.plugins import (
ENTRY_POINTS_GROUP,
VALID_HOOKS,
LoadedPlugin,
PluginContext,
PluginManager,
PluginManifest,
get_plugin_manager,
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
resolve_plugin_command_result,
discover_plugins,
invoke_hook,
)
@@ -1309,7 +1304,6 @@ class TestPluginCommandResultResolution:
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
monkeypatch.setattr("hermes_cli.plugins._PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS", 0.1)
import pytest
with pytest.raises(TimeoutError):
resolve_plugin_command_result(_slow_handler())
-7
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import logging
import os
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -19,7 +17,6 @@ from hermes_cli.plugins_cmd import (
_resolve_git_executable,
_resolve_git_url,
_sanitize_plugin_name,
plugins_command,
)
@@ -259,7 +256,6 @@ class TestCmdInstall:
def test_install_requires_identifier(self):
from hermes_cli.plugins_cmd import cmd_install
import argparse
with pytest.raises(SystemExit):
cmd_install("")
@@ -433,7 +429,6 @@ class TestCopyExampleFiles:
"""Test example file copying."""
def test_copies_example_files(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock
console = MagicMock()
@@ -449,7 +444,6 @@ class TestCopyExampleFiles:
console.print.assert_called()
def test_skips_existing_files(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock
console = MagicMock()
@@ -466,7 +460,6 @@ class TestCopyExampleFiles:
assert real_file.read_text() == "existing: true"
def test_handles_copy_error_gracefully(self, tmp_path):
from hermes_cli.plugins_cmd import _copy_example_files
from unittest.mock import MagicMock, patch
console = MagicMock()
+88
View File
@@ -0,0 +1,88 @@
import argparse
import json
from hermes_cli import plugins_cmd
def _args(**kwargs):
defaults = {
"enabled": False,
"user": False,
"no_bundled": False,
"plain": False,
"json": False,
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
def test_filter_plugin_entries_enabled_only():
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("web-search-plus", "2.2.0", "Search", "git", None),
("old-plugin", "1.0.0", "Old", "user", None),
]
filtered = plugins_cmd._filter_plugin_entries(
entries,
_args(enabled=True),
enabled={"disk-cleanup", "web-search-plus"},
disabled={"old-plugin"},
)
assert [entry[0] for entry in filtered] == ["disk-cleanup", "web-search-plus"]
def test_filter_plugin_entries_no_bundled():
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("drawthings-grpc", "0.3.0", "Draw Things", "user", None),
("web-search-plus", "2.2.0", "Search", "git", None),
]
filtered = plugins_cmd._filter_plugin_entries(
entries,
_args(no_bundled=True),
enabled=set(),
disabled=set(),
)
assert [entry[0] for entry in filtered] == ["drawthings-grpc", "web-search-plus"]
def test_cmd_list_plain_compact_output(monkeypatch, capsys):
entries = [
("disk-cleanup", "2.0.0", "Bundled", "bundled", None),
("web-search-plus", "2.2.0", "Search", "git", None),
]
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries)
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"})
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
plugins_cmd.cmd_list(_args(plain=True, no_bundled=True))
out = capsys.readouterr().out
assert "web-search-plus" in out
assert "enabled" in out
assert "disk-cleanup" not in out
assert "Search" not in out # plain mode stays compact, no descriptions
def test_cmd_list_json_output(monkeypatch, capsys):
entries = [("web-search-plus", "2.2.0", "Search", "git", None)]
monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries)
monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"})
monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set())
plugins_cmd.cmd_list(_args(json=True))
payload = json.loads(capsys.readouterr().out)
assert payload == [
{
"name": "web-search-plus",
"status": "enabled",
"version": "2.2.0",
"description": "Search",
"source": "git",
}
]
@@ -10,7 +10,6 @@ mocking git would just test the mock.
from __future__ import annotations
import os
from pathlib import Path
import pytest
@@ -6,7 +6,6 @@ profiles; leaking credentials in the archive is a security issue.
"""
import tarfile
from pathlib import Path
from hermes_cli.profiles import export_profile, _DEFAULT_EXPORT_EXCLUDE_ROOT
+108 -1
View File
@@ -7,7 +7,6 @@ and shell completion generation.
import json
import io
import os
import tarfile
from pathlib import Path
from unittest.mock import patch, MagicMock
@@ -601,6 +600,114 @@ class TestAliasCollision:
assert result is not None
assert "reserved" in result.lower()
def test_uses_where_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="")
check_alias_collision("mybot")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "where"
def test_uses_which_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="")
check_alias_collision("mybot")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "which"
def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
wrapper_dir = profile_env / ".local" / "bin"
wrapper_dir.mkdir(parents=True, exist_ok=True)
bat_path = wrapper_dir / "mybot.bat"
bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0, stdout=str(bat_path),
)
result = check_alias_collision("mybot")
assert result is None # our own wrapper, safe to overwrite
# ===================================================================
# TestWrapperScript
# ===================================================================
class TestWrapperScript:
"""Tests for create_wrapper_script() and remove_wrapper_script()."""
def test_creates_sh_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.name == "mybot"
content = wrapper.read_text()
assert content.startswith("#!/bin/sh")
assert "hermes -p mybot" in content
def test_creates_bat_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.name == "mybot.bat"
content = wrapper.read_text()
assert "@echo off" in content
assert "hermes -p mybot" in content
assert "%*" in content
def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.exists()
removed = remove_wrapper_script("mybot")
assert removed is True
assert not wrapper.exists()
def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
wrapper = create_wrapper_script("mybot")
assert wrapper is not None
assert wrapper.exists()
removed = remove_wrapper_script("mybot")
assert removed is True
assert not wrapper.exists()
def test_remove_returns_false_when_absent(self, profile_env):
from hermes_cli.profiles import remove_wrapper_script
assert remove_wrapper_script("nonexistent") is False
def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):
# Custom alias name pointing at a differently-named profile: the file
# is named after the alias, the -p content references the profile.
monkeypatch.setattr("sys.platform", "darwin")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("rq", target="redqueen")
assert wrapper is not None
assert wrapper.name == "rq"
content = wrapper.read_text()
assert content.startswith("#!/bin/sh")
assert "hermes -p redqueen" in content
def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):
# Regression: custom-name aliases must still produce an executable
# .bat (not a clobbered #!/bin/sh) on Windows.
monkeypatch.setattr("sys.platform", "win32")
from hermes_cli.profiles import create_wrapper_script
wrapper = create_wrapper_script("rq", target="redqueen")
assert wrapper is not None
assert wrapper.name == "rq.bat"
content = wrapper.read_text()
assert "@echo off" in content
assert "hermes -p redqueen" in content
assert "%*" in content
assert "#!/bin/sh" not in content
# ===================================================================
# TestRenameProfile
@@ -31,7 +31,6 @@ These tests pin each layer of the new defence:
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch

Some files were not shown because too many files have changed in this diff Show More