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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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,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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,6 +1,5 @@
|
||||
"""Tests for Google AI Studio (Gemini) provider integration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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,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 == []
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user