Merge remote-tracking branch 'origin/main' into bb/gui

# Conflicts:
#	apps/dashboard/src/i18n/af.ts
#	apps/dashboard/src/i18n/de.ts
#	apps/dashboard/src/i18n/es.ts
#	apps/dashboard/src/i18n/fr.ts
#	apps/dashboard/src/i18n/ga.ts
#	apps/dashboard/src/i18n/hu.ts
#	apps/dashboard/src/i18n/it.ts
#	apps/dashboard/src/i18n/ja.ts
#	apps/dashboard/src/i18n/ko.ts
#	apps/dashboard/src/i18n/pt.ts
#	apps/dashboard/src/i18n/ru.ts
#	apps/dashboard/src/i18n/tr.ts
#	apps/dashboard/src/i18n/uk.ts
#	apps/dashboard/src/i18n/zh-hant.ts
#	gateway/config.py
#	hermes_cli/main.py
#	plugins/strike-freedom-cockpit/README.md
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-11 16:40:09 -04:00
310 changed files with 43818 additions and 4220 deletions
+75
View File
@@ -81,6 +81,81 @@ class TestLoadConfigDefaults:
assert "max_turns" not in config
class TestLoadConfigParseFailure:
"""A YAML parse failure must NOT silently fall back to defaults.
Before issue #23570 this was a single ``print(...)`` that scrolled past
on the first invocation — users saw aux-fallback misbehavior with no clue
their config.yaml was being ignored. The helper must:
* log at WARNING (so ``hermes logs`` surfaces it)
* also write to stderr (so it's visible at startup even before
``setup_logging()`` has wired up file handlers)
* dedup on (path, mtime_ns, size) so concurrent loads don't spam
* re-warn after the user edits the file (different mtime)
"""
def test_logs_and_warns_on_parse_failure(self, tmp_path, caplog, capsys):
# Reset the dedup cache so this test isn't affected by other tests
# that may have warned about a different broken config.
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken tab indent:\n")
import logging
with caplog.at_level(logging.WARNING, logger="hermes_cli.config"):
config = load_config()
# Falls back to defaults — confirms the silent-fallback we're warning about
assert config["model"] == DEFAULT_CONFIG["model"]
# WARNING-level log was emitted with file path + reason
assert any(
str(tmp_path / "config.yaml") in rec.message
and "Falling back to default config" in rec.message
for rec in caplog.records
), f"expected WARNING log, got: {[r.message for r in caplog.records]}"
# stderr also got a user-visible message (with the ⚠️ marker so it
# stands out at hermes startup before logging is configured)
captured = capsys.readouterr()
assert "hermes config:" in captured.err
assert str(tmp_path / "config.yaml") in captured.err
def test_dedup_on_repeated_load_same_file(self, tmp_path, capsys):
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken:\n")
load_config()
first = capsys.readouterr().err
assert "hermes config:" in first
load_config()
second = capsys.readouterr().err
assert second == "", "second load should NOT re-warn (same file, same mtime)"
def test_rewarns_after_file_edit(self, tmp_path, capsys):
import time
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken:\n")
load_config()
capsys.readouterr() # discard first warning
# Edit the file (still broken, but different content) — mtime changes
time.sleep(0.05)
(tmp_path / "config.yaml").write_text("\tstill broken differently:\n")
load_config()
after_edit = capsys.readouterr().err
assert "hermes config:" in after_edit, "edited file should re-warn"
class TestSaveAndLoadRoundtrip:
def test_roundtrip(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+10
View File
@@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):
out = kc.run_slash("show")
assert "/kanban show" in out
assert "task_id" in out
def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):
kb.create_board("alpha")
kb.create_board("beta")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta")
kc.run_slash("--board alpha list")
assert os.environ.get("HERMES_KANBAN_BOARD") == "beta"
@@ -510,10 +510,12 @@ def test_notify_sub_crud(kanban_home):
tid = kb.create_task(conn, title="x")
kb.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="123", user_id="u1",
notifier_profile="default",
)
subs = kb.list_notify_subs(conn, tid)
assert len(subs) == 1
assert subs[0]["platform"] == "telegram"
assert subs[0]["notifier_profile"] == "default"
# Duplicate add is a no-op.
kb.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="123",
@@ -568,6 +570,57 @@ def test_notify_cursor_advances(kanban_home):
conn.close()
def test_notify_claim_is_single_owner_and_rewindable(kanban_home):
conn1 = kb.connect()
conn2 = kb.connect()
try:
tid = kb.create_task(conn1, title="x", assignee="w")
kb.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123")
kb.complete_task(conn1, tid, result="ok")
old_cursor, claimed_cursor, events = kb.claim_unseen_events_for_sub(
conn1,
task_id=tid,
platform="telegram",
chat_id="123",
kinds=["completed", "blocked"],
)
assert old_cursor == 0
assert claimed_cursor > old_cursor
assert [ev.kind for ev in events] == ["completed"]
# A concurrent notifier instance sees the advanced cursor and cannot
# claim/send the same event range.
_, _, duplicate_events = kb.claim_unseen_events_for_sub(
conn2,
task_id=tid,
platform="telegram",
chat_id="123",
kinds=["completed", "blocked"],
)
assert duplicate_events == []
assert kb.rewind_notify_cursor(
conn1,
task_id=tid,
platform="telegram",
chat_id="123",
claimed_cursor=claimed_cursor,
old_cursor=old_cursor,
) is True
_, retried_events = kb.unseen_events_for_sub(
conn2,
task_id=tid,
platform="telegram",
chat_id="123",
kinds=["completed", "blocked"],
)
assert [ev.kind for ev in retried_events] == ["completed"]
finally:
conn1.close()
conn2.close()
# ---------------------------------------------------------------------------
# GC + retention
# ---------------------------------------------------------------------------
@@ -2691,6 +2744,48 @@ def test_create_task_skills_rejects_comma_embedded(kanban_home):
conn.close()
def test_create_task_skills_rejects_toolset_names(kanban_home):
"""Toolset names belong in profile config, not per-task skills."""
conn = kb.connect()
try:
with pytest.raises(ValueError, match="toolset name"):
kb.create_task(
conn,
title="bad toolset skill",
assignee="x",
skills=["web", "translation"],
)
finally:
conn.close()
def test_create_task_skills_lists_all_toolset_typos(kanban_home):
"""When several toolset names are passed, the error names every one.
Agents that confuse skills with toolsets usually pass several at once
(``skills=["web", "browser", "terminal"]``). Listing only the first
mistake forces serial fix-then-retry; listing all of them lets the
caller correct in one round-trip.
"""
conn = kb.connect()
try:
with pytest.raises(ValueError) as exc_info:
kb.create_task(
conn,
title="three bad",
assignee="x",
skills=["web", "browser", "terminal"],
)
msg = str(exc_info.value)
assert "'web'" in msg
assert "'browser'" in msg
assert "'terminal'" in msg
# Plural noun form when multiple toolsets are flagged.
assert "are toolset names" in msg
finally:
conn.close()
def test_default_spawn_appends_per_task_skills(kanban_home, monkeypatch):
"""Dispatcher argv must carry one `--skills X` pair per task skill,
in addition to the built-in kanban-worker."""
@@ -3446,6 +3541,76 @@ def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):
conn.close()
def test_complete_can_retry_after_phantom_rejection(kanban_home):
"""A worker that hits the hallucinated-card gate must be able to
retry kanban_complete on the same task — both with a corrected
created_cards list and with an empty list (the documented escape
hatch). Regression test for #22923, where workers were believed to
be unrecoverable after the first rejection.
"""
conn = kb.connect()
try:
# Two parallel completing tasks so we can exercise both retry
# shapes without status interference.
parent_a = kb.create_task(conn, title="retry-empty", assignee="alice")
kb.claim_task(conn, parent_a)
parent_b = kb.create_task(conn, title="retry-corrected", assignee="alice")
kb.claim_task(conn, parent_b)
real = kb.create_task(
conn, title="real-child", assignee="x", created_by="alice",
)
# First attempt: phantom in the list rejects, task stays running.
with pytest.raises(kb.HallucinatedCardsError):
kb.complete_task(
conn, parent_a,
summary="oops",
created_cards=["t_phantomdeadbeef"],
)
assert kb.get_task(conn, parent_a).status == "running"
# Retry with [] (escape hatch): gate is skipped, completion lands.
ok = kb.complete_task(
conn, parent_a,
summary="retry without claims",
created_cards=[],
)
assert ok is True
assert kb.get_task(conn, parent_a).status == "done"
# Same flow on parent_b, but recover via a corrected list rather
# than the empty escape hatch.
with pytest.raises(kb.HallucinatedCardsError):
kb.complete_task(
conn, parent_b,
summary="oops",
created_cards=[real, "t_anotherphantom"],
)
assert kb.get_task(conn, parent_b).status == "running"
ok = kb.complete_task(
conn, parent_b,
summary="retry with corrected list",
created_cards=[real],
)
assert ok is True
assert kb.get_task(conn, parent_b).status == "done"
# Both audit events landed; the eventual completion event is
# also present on each task.
for parent in (parent_a, parent_b):
kinds = [
r["kind"] for r in conn.execute(
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
(parent,),
)
]
assert kinds.count("completion_blocked_hallucination") == 1
assert kinds.count("completed") == 1
finally:
conn.close()
def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
"""Successful completion whose summary references a ``t_<hex>`` id
that doesn't resolve emits a ``suspected_hallucinated_references``
+336 -5
View File
@@ -177,12 +177,9 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
host = _kb._claimer_id().split(":", 1)[0]
kb.claim_task(conn, t, claimer=f"{host}:worker")
killed: list[int] = []
state = {"alive": True}
def _signal(pid, sig):
def _signal(_pid, sig):
killed.append(sig)
if sig == signal.SIGTERM:
state["alive"] = False
kb._set_worker_pid(conn, t, 12345)
# Rewind claim_expires so it looks stale.
@@ -190,13 +187,96 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
(int(time.time()) - 3600, t),
)
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"])
# Worker PID has died — exactly the case ``release_stale_claims``
# should still reclaim (post-#23025: live PIDs are now extended).
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
reclaimed = kb.release_stale_claims(conn, signal_fn=_signal)
assert reclaimed == 1
assert kb.get_task(conn, t).status == "ready"
assert killed == [signal.SIGTERM]
def test_stale_claim_with_live_pid_extends_instead_of_reclaiming(
kanban_home, monkeypatch,
):
"""A stale-by-TTL claim whose worker PID is still alive should be
extended, not reclaimed (#23025). Slow models can spend longer than
``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call;
killing those healthy workers produces a respawn loop with zero
progress."""
import hermes_cli.kanban_db as _kb
with kb.connect() as conn:
t = kb.create_task(conn, title="x", assignee="a")
host = _kb._claimer_id().split(":", 1)[0]
kb.claim_task(conn, t, claimer=f"{host}:worker")
kb._set_worker_pid(conn, t, 12345)
old_expires = int(time.time()) - 60
conn.execute(
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
(old_expires, t),
)
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
killed: list[int] = []
reclaimed = kb.release_stale_claims(
conn, signal_fn=lambda _p, sig: killed.append(sig),
)
assert reclaimed == 0
task = kb.get_task(conn, t)
assert task.status == "running"
assert task.claim_expires is not None
assert task.claim_expires > old_expires
assert killed == [] # live worker not killed
kinds = [
r["kind"] for r in conn.execute(
"SELECT kind FROM task_events WHERE task_id = ?", (t,),
).fetchall()
]
assert "claim_extended" in kinds
assert "reclaimed" not in kinds
def test_stale_claim_reclaim_event_records_diagnostic_payload(
kanban_home, monkeypatch,
):
"""``reclaimed`` events should carry claim_expires, last_heartbeat_at,
and worker_pid so operators can diagnose why a claim went stale
(#23025: previous payload only had ``stale_lock`` which gives no
timing context)."""
import json
import hermes_cli.kanban_db as _kb
with kb.connect() as conn:
t = kb.create_task(conn, title="x", assignee="a")
host = _kb._claimer_id().split(":", 1)[0]
kb.claim_task(conn, t, claimer=f"{host}:worker")
kb._set_worker_pid(conn, t, 12345)
old_expires = int(time.time()) - 3600
hb_at = int(time.time()) - 1800
conn.execute(
"UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
"WHERE id = ?",
(old_expires, hb_at, t),
)
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
row = conn.execute(
"SELECT payload FROM task_events "
"WHERE task_id = ? AND kind = 'reclaimed'",
(t,),
).fetchone()
assert row is not None
payload = json.loads(row["payload"])
assert payload["claim_expires"] == old_expires
assert payload["last_heartbeat_at"] == hb_at
assert payload["worker_pid"] == 12345
assert payload["host_local"] is True
def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
"""A retry should get a fresh max-runtime window.
@@ -605,6 +685,57 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna
assert kb.get_task(conn, t).claim_lock is None
def test_dispatch_max_spawn_counts_existing_running_tasks(
kanban_home, all_assignees_spawnable
):
"""max_spawn is a live concurrency cap, not a per-tick spawn cap.
Without counting tasks already in ``running``, every dispatcher tick can
launch up to ``max_spawn`` more workers while previous workers are still
alive. Long-running boards then accumulate unbounded worker subprocesses.
"""
spawns = []
def fake_spawn(task, workspace):
spawns.append(task.id)
with kb.connect() as conn:
running_a = kb.create_task(conn, title="running-a", assignee="alice")
running_b = kb.create_task(conn, title="running-b", assignee="bob")
ready = kb.create_task(conn, title="ready", assignee="carol")
kb.claim_task(conn, running_a)
kb.claim_task(conn, running_b)
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
assert res.spawned == []
assert spawns == []
assert kb.get_task(conn, ready).status == "ready"
def test_dispatch_max_spawn_fills_remaining_capacity(
kanban_home, all_assignees_spawnable
):
"""When below cap, dispatch only fills available worker slots."""
spawns = []
def fake_spawn(task, workspace):
spawns.append(task.id)
with kb.connect() as conn:
running = kb.create_task(conn, title="running", assignee="alice")
ready_a = kb.create_task(conn, title="ready-a", assignee="bob")
ready_b = kb.create_task(conn, title="ready-b", assignee="carol")
kb.claim_task(conn, running)
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
assert len(res.spawned) == 1
assert spawns == [ready_a]
assert kb.get_task(conn, ready_a).status == "running"
assert kb.get_task(conn, ready_b).status == "ready"
def test_dispatch_reclaims_stale_before_spawning(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="x", assignee="alice")
@@ -1199,3 +1330,203 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
# Running migration on an already-migrated schema must not raise.
kb._migrate_add_optional_columns(conn)
conn.close()
# ---------------------------------------------------------------------------
# Dispatcher spawn invocation — _resolve_hermes_argv()
#
# Workers spawned by the dispatcher must use a `hermes` invocation that does
# not depend on PATH being set up correctly. cron jobs, systemd User= services,
# launchd jobs, and other detached processes routinely run with a stripped
# $PATH that doesn't include the venv's bin/, so a bare `["hermes", ...]`
# spawn fails with FileNotFoundError and the task gets stuck. The resolver
# prefers the PATH shim (familiar `ps` output) but falls back to the module
# form so the spawn keeps working when PATH is missing the shim.
# ---------------------------------------------------------------------------
def test_resolve_hermes_argv_prefers_path_shim(monkeypatch):
"""When `hermes` is on PATH, use the shim — preserves familiar ps output."""
import shutil
import hermes_cli.kanban_db as kb
monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/hermes")
argv = kb._resolve_hermes_argv()
assert argv == ["/usr/local/bin/hermes"]
def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeypatch):
"""When the shim is not on PATH, fall back to `python -m hermes_cli.main`.
Pins the correct module name (NOT `hermes` — there is no top-level
`hermes` package). Regression for #23198: the original PR shipped
`python -m hermes` which fails with `No module named hermes` on every
invocation.
"""
import shutil
import sys
import hermes_cli.kanban_db as kb
monkeypatch.setattr(shutil, "which", lambda name: None)
argv = kb._resolve_hermes_argv()
assert argv == [sys.executable, "-m", "hermes_cli.main"]
def test_resolve_hermes_argv_module_actually_runs():
"""The fallback module name must be importable + runnable.
A unit test that pins the literal string is necessary but not
sufficient — if `hermes_cli.main` ever loses `if __name__ == "__main__"`
handling or its argparse setup, `python -m hermes_cli.main --version`
would fail and so would every dispatcher spawn that hits the fallback.
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
with mock.patch.object(shutil, "which", return_value=None):
argv = kb._resolve_hermes_argv()
r = subprocess.run(argv + ["--version"], capture_output=True, text=True, timeout=30)
assert r.returncode == 0, (
f"`{' '.join(argv)} --version` failed (rc={r.returncode}); "
f"stderr={r.stderr[:200]!r}"
)
assert "Hermes Agent" in r.stdout, f"unexpected output: {r.stdout[:200]!r}"
# ---------------------------------------------------------------------------
# task_age — guard against corrupt timestamp values
#
# The Task dataclass declares ``created_at: int`` but rows come from sqlite
# without coercion at the boundary. A row that ever held a non-int (e.g. an
# unsubstituted ``'%s'`` from a logged format string, ``None``, an arbitrary
# string, or a float-as-string) used to crash ``task_age`` with ``ValueError``
# and turn ``GET /api/plugins/kanban/board`` into a 500 because the dashboard
# calls ``task_age`` unguarded for every task in the response.
#
# After the fix, ``_safe_int`` returns ``None`` on bad input and ``task_age``
# degrades gracefully (per-field ``None`` rather than a hard crash).
# ---------------------------------------------------------------------------
def _make_task(**overrides) -> "kb.Task":
"""Minimal Task with all required fields filled in. Override anything."""
defaults = dict(
id="t_age",
title="x",
body=None,
assignee=None,
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
defaults.update(overrides)
return kb.Task(**defaults)
def test_safe_int_accepts_int_and_int_string():
"""Sanity: well-typed values pass through."""
assert kb._safe_int(0) == 0
assert kb._safe_int(1700000000) == 1700000000
assert kb._safe_int("1700000000") == 1700000000
def test_safe_int_returns_none_on_corrupt_inputs():
"""All the failure modes that used to crash task_age."""
# None — common when the column was never written
assert kb._safe_int(None) is None
# Unsubstituted format string — the literal case the PR title cites
assert kb._safe_int("%s") is None
# Arbitrary non-numeric strings
assert kb._safe_int("abc") is None
assert kb._safe_int("") is None
# Float-ish strings: int("1.5") raises ValueError too — caller wants None.
assert kb._safe_int("1.5") is None
# Random object — covered by TypeError branch
assert kb._safe_int(object()) is None
def test_task_age_handles_corrupt_created_at():
"""Pre-fix this raised ValueError and 500'd /api/plugins/kanban/board."""
t = _make_task(created_at="%s")
age = kb.task_age(t)
assert age["created_age_seconds"] is None
assert age["started_age_seconds"] is None
assert age["time_to_complete_seconds"] is None
def test_task_age_handles_corrupt_started_and_completed():
"""All three timestamp fields share the same _safe_int treatment."""
t = _make_task(
created_at=1700000000,
started_at="garbage",
completed_at=None,
)
age = kb.task_age(t)
assert isinstance(age["created_age_seconds"], int)
assert age["started_age_seconds"] is None
assert age["time_to_complete_seconds"] is None
def test_task_age_well_formed_task():
"""Regression: the safe-int path must not change behavior for normal data."""
import time
now = int(time.time())
t = _make_task(
created_at=now - 60,
started_at=now - 30,
completed_at=now,
)
age = kb.task_age(t)
assert 55 <= age["created_age_seconds"] <= 65
assert 25 <= age["started_age_seconds"] <= 35
assert 25 <= age["time_to_complete_seconds"] <= 35
def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch):
"""Defense in depth: even if task_age ever raised, plugin_api must not 500.
The PR also added a try/except around the task_age call in
`plugins/kanban/dashboard/plugin_api.py::_task_dict`. Verify a single
corrupt row doesn't turn the whole board response into an error.
"""
# Set up an isolated kanban home so we can write a corrupt created_at.
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
kb._INITIALIZED_PATHS.clear()
kb.init_db()
# Insert a row with a non-int created_at (simulates the historical
# bug that produced corrupt rows).
conn = kb.connect()
try:
good_id = kb.create_task(conn, title="good")
# Now write a row with corrupt created_at directly.
conn.execute(
"UPDATE tasks SET created_at = ? WHERE id = ?",
("%s", good_id),
)
finally:
conn.close()
# Re-read and pass through task_age — must not raise.
conn = kb.connect()
try:
task = kb.get_task(conn, good_id)
finally:
conn.close()
age = kb.task_age(task)
assert age["created_age_seconds"] is None
+180 -4
View File
@@ -75,10 +75,13 @@ def test_hallucinated_cards_fires_on_blocked_event():
phantom_cards=["t_bad1", "t_bad2"],
verified_cards=["t_good1"]),
]
diags = kd.compute_task_diagnostics(task, events, [])
assert len(diags) == 1
d = diags[0]
assert d.kind == "hallucinated_cards"
# ``now=300`` keeps the synthetic event timestamps in scope without
# tripping the stranded_in_ready rule (events are 100/200 epoch
# which time.time() would treat as ~50yr old).
diags = kd.compute_task_diagnostics(task, events, [], now=300)
halluc = [d for d in diags if d.kind == "hallucinated_cards"]
assert len(halluc) == 1
d = halluc[0]
assert d.severity == "error"
assert d.data["phantom_ids"] == ["t_bad1", "t_bad2"]
# Generic recovery actions always available; comment action too.
@@ -379,3 +382,176 @@ def test_broken_rule_is_isolated(monkeypatch):
# The broken rule silently drops, the real one still fires.
kinds = [d.kind for d in diags]
assert "repeated_failures" in kinds
# ---------------------------------------------------------------------------
# stranded_in_ready
#
# Surfaces ready tasks that nobody has claimed within the threshold.
# Identity-agnostic by design: catches typo'd assignees, deleted profiles,
# down external worker pools, and misconfigured dispatchers in one rule.
# ---------------------------------------------------------------------------
def test_stranded_in_ready_fires_when_age_exceeds_threshold():
"""Default threshold = 30 min. A ready task promoted 45 min ago
with no claim should fire as a warning."""
now = 100_000
task = _task(status="ready", assignee="demo", claim_lock=None)
# 45 min = 2700s, threshold = 1800s.
events = [_event("created", ts=now - 45 * 60)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
assert len(stranded) == 1
assert stranded[0].severity == "warning"
assert stranded[0].data["age_seconds"] == 45 * 60
assert stranded[0].data["assignee"] == "demo"
def test_stranded_in_ready_silent_below_threshold():
"""A ready task only 10 min old should NOT fire."""
now = 100_000
task = _task(status="ready", assignee="demo", claim_lock=None)
events = [_event("created", ts=now - 10 * 60)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
def test_stranded_in_ready_skips_non_ready_status():
"""Tasks not in ready status are out of scope (running tasks have
their own crash / failure rules)."""
now = 100_000
for status in ("running", "blocked", "done", "todo", "triage"):
task = _task(status=status, assignee="demo")
events = [_event("created", ts=now - 6 * 3600)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == [], status
def test_stranded_in_ready_skips_unassigned_tasks():
"""Empty assignee = `skipped_unassigned` on the dispatcher already.
Don't double-flag here."""
now = 100_000
task = _task(status="ready", assignee="", claim_lock=None)
events = [_event("created", ts=now - 6 * 3600)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
def test_stranded_in_ready_skips_claimed_tasks():
"""A live claim_lock means a worker is on it — even an old one. Don't
second-guess: the run-level liveness signal owns that decision."""
now = 100_000
task = _task(
status="ready", assignee="demo", claim_lock="run_xyz",
)
events = [_event("created", ts=now - 6 * 3600)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
def test_stranded_in_ready_uses_latest_ready_transition():
"""When multiple ready-transition events exist, the rule should
age-from the most recent a task reclaimed 20 min ago is NOT
stranded for 6h even if it was first created 6h ago."""
now = 100_000
task = _task(status="ready", assignee="demo")
events = [
_event("created", ts=now - 6 * 3600), # 6 h ago
_event("reclaimed", ts=now - 20 * 60), # 20 min ago — wins
]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
def test_stranded_in_ready_severity_escalates_with_age():
"""warning → error → critical at 2x and 6x threshold."""
now = 100_000
task = _task(status="ready", assignee="demo")
# Default threshold = 1800s.
cases = [
(45 * 60, "warning"), # 1.5x → warning
(90 * 60, "error"), # 3x → error
(4 * 3600, "critical"), # 8x → critical
]
for age, expected in cases:
events = [_event("created", ts=now - age)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
assert len(stranded) == 1, f"age={age}"
assert stranded[0].severity == expected, (
f"age={age} expected {expected}, got {stranded[0].severity}"
)
def test_stranded_in_ready_respects_config_override():
"""Config override changes the threshold."""
now = 100_000
task = _task(status="ready", assignee="demo")
events = [_event("created", ts=now - 10 * 60)] # 10 min
# Default 30 min — wouldn't fire.
diags = kd.compute_task_diagnostics(task, events, [], now=now)
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
# Lower the threshold to 5 min — now it fires.
diags = kd.compute_task_diagnostics(
task, events, [], now=now,
config={"stranded_threshold_seconds": 5 * 60},
)
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
assert len(stranded) == 1
def test_stranded_in_ready_falls_back_to_created_at():
"""When events have no ready-transition kind, the rule falls back
to the task's ``created_at`` so an ancient stranded task isn't
invisible just because its events got pruned."""
now = 100_000
task = _task(
status="ready", assignee="demo", created_at=now - 4 * 3600,
)
# No qualifying events.
events = [_event("commented", ts=now - 100)]
diags = kd.compute_task_diagnostics(task, events, [], now=now)
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
assert len(stranded) == 1
assert stranded[0].data["age_seconds"] == 4 * 3600
def test_stranded_in_ready_works_on_real_db_row(kanban_home):
"""Round-trip through real kanban_db.connect() — confirms the rule
works on sqlite3.Row objects, not just dicts."""
import time as _t
conn = kb.connect()
try:
# Create a task and force its created_at into the past.
tid = kb.create_task(conn, title="stranded one", assignee="ghost")
old_ts = int(_t.time()) - 90 * 60 # 90 min old
conn.execute(
"UPDATE tasks SET status = 'ready', created_at = ? WHERE id = ?",
(old_ts, tid),
)
conn.commit()
task_row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (tid,)
).fetchone()
events = list(conn.execute(
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at",
(tid,),
).fetchall())
# Override created event timestamps too so age calc lines up.
conn.execute(
"UPDATE task_events SET created_at = ? WHERE task_id = ?",
(old_ts, tid),
)
conn.commit()
events = list(conn.execute(
"SELECT * FROM task_events WHERE task_id = ?", (tid,),
).fetchall())
diags = kd.compute_task_diagnostics(task_row, events, [])
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
assert len(stranded) == 1
assert stranded[0].data["assignee"] == "ghost"
finally:
conn.close()
+180 -2
View File
@@ -2,6 +2,7 @@ import asyncio
import pytest
from pathlib import Path
from types import SimpleNamespace
from hermes_cli import kanban_db as kb
from unittest.mock import AsyncMock, MagicMock, patch
@@ -76,7 +77,13 @@ async def test_notifier_unsubs_after_completed_event(kanban_home):
@pytest.mark.parametrize('kind', ["gave_up", "crashed", "timed_out"])
async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
"""
Event kind of gave_up, crashed, time_out would be cover, and remove subscription
Event kinds gave_up / crashed / timed_out send a notification but DO
NOT delete the subscription. The dispatcher may respawn the task and
fire the same event kind again (e.g. a worker that crashes, gets
reclaimed, and crashes a second time); the user must hear about the
second event too. Subscriptions are removed only when the task hits
a truly final status (done / archived) see the comment on
TERMINAL_KINDS in gateway/run.py and PR #21398.
"""
import hermes_cli.kanban_db as kb
from gateway.run import GatewayRunner
@@ -114,15 +121,27 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
timeout=10.0,
)
# The user is notified about the abnormal event...
fake_adapter.send.assert_called_once()
assert kind.replace('_', ' ') in fake_adapter.send.call_args[0][1]
# ...but the subscription survives so a respawn-then-same-event cycle
# reaches the user too. The cursor (last_event_id) advanced inside
# the same write txn as the claim, so the same event won't re-fire.
conn = kb.connect()
try:
subs = kb.list_notify_subs(conn, tid)
finally:
conn.close()
assert subs == [], "Subscription should be unsub after abnormal crash"
assert len(subs) == 1, (
f"Subscription should survive {kind!r} so the next cycle of the "
f"same event reaches the user; got {subs!r}"
)
assert int(subs[0]["last_event_id"]) >= 1, (
"Cursor should have advanced past the delivered event "
"(claim_unseen_events_for_sub advances atomically inside the "
"same write txn as the read)."
)
@pytest.mark.asyncio
@@ -301,3 +320,162 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch):
"_kanban_notifier_watcher must not call _kb.init_db(board=slug) — "
"see issue #21378."
)
@pytest.mark.asyncio
async def test_notifier_skips_subscription_owned_by_other_profile(kanban_home):
"""Each gateway keeps its watcher on, but only the subscribing profile claims."""
import hermes_cli.kanban_db as kb
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat1",
notifier_profile="default",
)
kb.complete_task(conn, tid, result="done")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._running = True
runner._kanban_sub_fail_counts = {}
runner._kanban_notifier_profile = "business-partner"
fake_adapter = MagicMock()
fake_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
tick_count = 0
async def _fast_sleep(_):
nonlocal tick_count
await _orig_sleep(0)
tick_count += 1
if tick_count >= 3:
runner._running = False
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
fake_adapter.send.assert_not_called()
conn = kb.connect()
try:
subs = kb.list_notify_subs(conn, tid)
finally:
conn.close()
assert len(subs) == 1
assert int(subs[0]["last_event_id"]) == 0, "wrong profile must not claim the event"
@pytest.mark.asyncio
async def test_notifier_delivers_subscription_owned_by_current_profile(kanban_home):
"""The gateway for the profile that created/subscribed the task reports it."""
import hermes_cli.kanban_db as kb
from gateway.run import GatewayRunner
from gateway.config import Platform
conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat1",
notifier_profile="default",
)
kb.complete_task(conn, tid, result="done")
finally:
conn.close()
runner = object.__new__(GatewayRunner)
runner._running = True
runner._kanban_sub_fail_counts = {}
runner._kanban_notifier_profile = "default"
fake_adapter = MagicMock()
async def _send_and_stop(chat_id, msg, metadata=None):
runner._running = False
fake_adapter.send = AsyncMock(side_effect=_send_and_stop)
runner.adapters = {Platform.TELEGRAM: fake_adapter}
_orig_sleep = asyncio.sleep
async def _fast_sleep(_):
await _orig_sleep(0)
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
await asyncio.wait_for(
runner._kanban_notifier_watcher(interval=1),
timeout=10.0,
)
fake_adapter.send.assert_called_once()
conn = kb.connect()
try:
subs = kb.list_notify_subs(conn, tid)
finally:
conn.close()
assert subs == []
@pytest.mark.asyncio
async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
"""`/kanban --board <slug> create ...` must subscribe on that board.
The gateway handler currently auto-subscribes after `/kanban create`,
but the create detection must still work when the shared `--board`
flag appears before the subcommand, and the subscription must land in
that board's DB rather than the ambient/default board.
"""
from gateway.run import GatewayRunner
from gateway.config import Platform
kb.create_board("projx")
runner = object.__new__(GatewayRunner)
source = SimpleNamespace(
platform=Platform.TELEGRAM,
chat_id="chat1",
thread_id="th1",
user_id="u1",
)
event = SimpleNamespace(
text='/kanban --board projx create "hello" --assignee alice',
source=source,
)
out = await GatewayRunner._handle_kanban_command(runner, event)
assert "subscribed" in out.lower()
conn = kb.connect(board="projx")
try:
subs = kb.list_notify_subs(conn)
tasks = kb.list_tasks(conn)
finally:
conn.close()
assert [t.title for t in tasks] == ["hello"]
assert len(subs) == 1
assert subs[0]["chat_id"] == "chat1"
assert subs[0]["thread_id"] == "th1"
conn = kb.connect(board="default")
try:
assert kb.list_notify_subs(conn) == []
finally:
conn.close()
+46
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from unittest.mock import patch
@@ -282,3 +283,48 @@ class TestIntegrationWithModelsModule:
result = get_curated_nous_model_ids()
assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"]
def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch):
"""The /model picker must surface the manifest's nous list, not the
in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this
fix, list_authenticated_providers() built the curated dict from
_PROVIDER_MODELS only so newly-added Portal models never reached
the slash-command picker until the next Hermes release.
"""
# We deliberately do NOT use the ``isolated_home`` fixture here:
# that fixture monkeypatches ``Path.home`` to ``tmp_path``, which
# trips the auth-store seat-belt in ``_auth_file_path()`` because
# ``HERMES_HOME / auth.json`` then resolves to the same path the
# seat-belt thinks is the "real" user store. Use the autouse
# ``_hermetic_environment`` HERMES_HOME directly instead.
import importlib
from hermes_cli import model_catalog
importlib.reload(model_catalog)
try:
from hermes_cli.model_switch import list_picker_providers
active_home = Path(os.environ["HERMES_HOME"])
(active_home / "auth.json").write_text(
json.dumps(
{
"providers": {"nous": {"access_token": "fake"}},
"credential_pool": {},
}
)
)
with patch.object(
model_catalog, "_fetch_manifest", return_value=_valid_manifest()
):
picker = list_picker_providers(
current_provider="nous", max_models=99
)
finally:
model_catalog.reset_cache()
nous_row = next((r for r in picker if r["slug"] == "nous"), None)
assert nous_row is not None, "nous row must appear when authed"
assert nous_row["models"] == [
"anthropic/claude-opus-4.7",
"moonshotai/kimi-k2.6",
]
+202
View File
@@ -0,0 +1,202 @@
"""Tests for session handoff (CLI to gateway platform).
The handoff state machine lives on the ``sessions`` table:
None "pending" "running" ("completed" | "failed")
CLI side calls ``request_handoff`` and poll-waits on ``get_handoff_state``.
Gateway side iterates ``list_pending_handoffs``, calls ``claim_handoff`` to
flip pending running, and finishes with ``complete_handoff`` or
``fail_handoff``.
"""
from __future__ import annotations
import time
import pytest
from hermes_state import SessionDB
class TestHandoffStateDB:
"""Test the handoff schema + helper methods on SessionDB."""
@pytest.fixture
def db(self, tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return SessionDB(db_path=home / "state.db")
def _make_session(self, db, session_id, source="cli", title=None):
"""Insert a session row directly for testing."""
def _do(conn):
conn.execute(
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
"VALUES (?, ?, ?, ?)",
(session_id, source, title, time.time()),
)
db._execute_write(_do)
def test_columns_exist(self, db):
db._conn.execute(
"SELECT handoff_state, handoff_platform, handoff_error "
"FROM sessions LIMIT 0"
)
def test_request_handoff_marks_pending(self, db):
sid = "sess-1"
self._make_session(db, sid)
assert db.request_handoff(sid, "telegram") is True
state = db.get_handoff_state(sid)
assert state == {
"state": "pending",
"platform": "telegram",
"error": None,
}
def test_request_handoff_rejects_in_flight(self, db):
sid = "sess-2"
self._make_session(db, sid)
assert db.request_handoff(sid, "telegram") is True
# Still pending → reject re-request
assert db.request_handoff(sid, "discord") is False
# And after gateway claims it (running) → still rejected
assert db.claim_handoff(sid) is True
assert db.request_handoff(sid, "discord") is False
def test_request_handoff_after_terminal_state_resets_error(self, db):
sid = "sess-3"
self._make_session(db, sid)
db.request_handoff(sid, "telegram")
db.claim_handoff(sid)
db.fail_handoff(sid, "earlier failure")
# User retries — should be allowed and clear the prior error.
assert db.request_handoff(sid, "discord") is True
state = db.get_handoff_state(sid)
assert state["state"] == "pending"
assert state["platform"] == "discord"
assert state["error"] is None
def test_list_pending_handoffs_excludes_running_and_terminal(self, db):
a, b, c, d = "sess-a", "sess-b", "sess-c", "sess-d"
for sid in (a, b, c, d):
self._make_session(db, sid)
db.request_handoff(a, "telegram")
db.request_handoff(b, "discord")
db.request_handoff(c, "telegram")
db.claim_handoff(c) # c is now running, not pending
db.request_handoff(d, "slack")
db.claim_handoff(d)
db.complete_handoff(d) # d is terminal
pending = db.list_pending_handoffs()
ids = [r["id"] for r in pending]
assert set(ids) == {a, b}
def test_claim_handoff_is_atomic(self, db):
sid = "sess-claim"
self._make_session(db, sid)
db.request_handoff(sid, "telegram")
# First claim wins
assert db.claim_handoff(sid) is True
# Second claim is a no-op (state is now "running", not "pending")
assert db.claim_handoff(sid) is False
assert db.get_handoff_state(sid)["state"] == "running"
def test_complete_handoff_clears_error(self, db):
sid = "sess-complete"
self._make_session(db, sid)
db.request_handoff(sid, "telegram")
db.claim_handoff(sid)
db.fail_handoff(sid, "transient")
# User retries; mock the watcher path
db.request_handoff(sid, "telegram")
db.claim_handoff(sid)
db.complete_handoff(sid)
state = db.get_handoff_state(sid)
assert state["state"] == "completed"
assert state["error"] is None
def test_fail_handoff_records_reason(self, db):
sid = "sess-fail"
self._make_session(db, sid)
db.request_handoff(sid, "telegram")
db.claim_handoff(sid)
db.fail_handoff(sid, "no home channel for telegram")
state = db.get_handoff_state(sid)
assert state["state"] == "failed"
assert state["error"] == "no home channel for telegram"
def test_fail_handoff_truncates_long_reasons(self, db):
sid = "sess-fail-long"
self._make_session(db, sid)
db.request_handoff(sid, "telegram")
db.claim_handoff(sid)
# 1000-character error string
big_err = "x" * 1000
db.fail_handoff(sid, big_err)
state = db.get_handoff_state(sid)
assert len(state["error"]) <= 500
def test_get_handoff_state_for_unknown_session(self, db):
assert db.get_handoff_state("does-not-exist") is None
def test_full_pending_to_completed_flow(self, db):
"""End-to-end sequence the CLI + gateway watcher follow."""
sid = "sess-flow"
self._make_session(db, sid, title="my session")
db.append_message(sid, "user", "Hello")
db.append_message(sid, "assistant", "Hi there!")
# CLI: request handoff
assert db.request_handoff(sid, "telegram") is True
assert db.get_handoff_state(sid)["state"] == "pending"
# Gateway watcher: discover + claim
pending = db.list_pending_handoffs()
assert len(pending) == 1
assert pending[0]["id"] == sid
assert db.claim_handoff(sid) is True
assert db.get_handoff_state(sid)["state"] == "running"
# Gateway uses get_messages to load the transcript (real flow uses
# session_store.switch_session which reads the same table).
messages = db.get_messages(sid)
assert [m["role"] for m in messages] == ["user", "assistant"]
# Gateway: mark completed
db.complete_handoff(sid)
assert db.get_handoff_state(sid)["state"] == "completed"
assert db.list_pending_handoffs() == []
class TestHandoffCommandRegistration:
"""Slash-command surface checks."""
def test_command_registered(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("handoff")
assert cmd is not None
assert cmd.name == "handoff"
assert cmd.category == "Session"
def test_command_is_cli_only(self):
"""`/handoff` is initiated from the CLI; gateway shouldn't expose it."""
from hermes_cli.commands import resolve_command, GATEWAY_KNOWN_COMMANDS
cmd = resolve_command("handoff")
assert cmd is not None
assert cmd.cli_only is True
assert "handoff" not in GATEWAY_KNOWN_COMMANDS
+111
View File
@@ -1946,6 +1946,117 @@ class TestNormaliseThemeExtensions:
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create a TestClient without the session token header."""
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_plugin_route_requires_auth(self):
"""Plugin API routes should return 401 without a valid session token."""
# Use a known plugin route (kanban board)
resp = self.client.get("/api/plugins/kanban/board")
assert resp.status_code == 401
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Use ``/api/plugins/example/hello`` from the example-dashboard plugin
a stable, side-effect-free GET that's always loaded in tests. With a
valid token the handler should run (200); without one the middleware
should 401 before the handler is reached.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
assert resp.status_code == 401
# With auth: handler runs.
resp = self.auth_client.get("/api/plugins/example/hello")
assert resp.status_code == 200
def test_plugin_post_requires_auth(self):
"""Plugin POST routes should return 401 without a valid session token."""
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
assert resp.status_code == 401
def test_plugin_patch_requires_auth(self):
"""Plugin PATCH routes should return 401 without a valid session token.
PATCH is the mutation method most commonly used by the dashboard for
kanban task edits explicitly cover it so a future middleware
regression that whitelists non-GET methods can't sneak through.
"""
resp = self.client.patch(
"/api/plugins/kanban/tasks/t_fake",
json={"title": "renamed"},
)
assert resp.status_code == 401
def test_plugin_delete_requires_auth(self):
"""Plugin DELETE routes should return 401 without a valid session token."""
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
assert resp.status_code == 401
def test_non_kanban_plugin_route_requires_auth(self):
"""Auth must be plugin-agnostic, not kanban-specific.
The middleware fix is at the gate level (no per-plugin allowlist),
so any plugin's API surface — kanban, hermes-achievements, future
plugins must require the session token. Hit a non-kanban plugin
path to lock that in.
"""
# Real plugin path (hermes-achievements is loaded by default).
resp = self.client.get("/api/plugins/hermes-achievements/overview")
assert resp.status_code == 401
# Same for an arbitrary plugin namespace that doesn't even exist —
# the middleware should 401 before routing decides 404, so an
# attacker can't fingerprint plugin names by status codes.
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
assert resp.status_code == 401
def test_plugin_websocket_unaffected_by_http_middleware(self):
"""The kanban /events WebSocket has its own ``?token=`` check;
the HTTP middleware change must not start gating WS upgrades.
Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
but pin the behavior so a future refactor that moves auth into a
shared layer can't silently break the WS auth contract.
"""
from starlette.websockets import WebSocketDisconnect
from hermes_cli.web_server import _SESSION_TOKEN
# Without a token the WS endpoint must close the upgrade itself
# (its own _check_ws_token), NOT 401 from the HTTP middleware.
try:
with self.client.websocket_connect(
"/api/plugins/kanban/events"
):
pass # if we got here without disconnect, the WS accepted us
except WebSocketDisconnect:
pass # expected — WS endpoint rejected via its own check
except Exception:
# The kanban plugin may not be mounted in this test environment,
# in which case the route doesn't exist at all (3xx/4xx during
# upgrade). That's fine for this regression — it only matters
# that the HTTP middleware didn't start intercepting WS upgrades.
pass
class TestDashboardPluginManifestExtensions:
"""Tests for the extended plugin manifest fields (tab.override,
tab.hidden, slots) read by _discover_dashboard_plugins()."""
+90 -1
View File
@@ -13,7 +13,7 @@ from unittest.mock import patch
import pytest
from hermes_cli.main import _web_ui_build_needed, _build_web_ui
from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic
def _touch(path: Path, offset: float = 0.0) -> None:
@@ -119,3 +119,92 @@ class TestBuildWebUISkipsWhenFresh:
assert result is True
assert mock_run.call_count == 2 # npm install + npm run build
def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
result = _run_npm_install_deterministic("/usr/bin/npm", web_dir)
assert result.returncode == 0
_, kwargs = mock_run.call_args
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run:
result = _build_web_ui(web_dir)
assert result is True
_, build_kwargs = mock_run.call_args_list[1]
assert build_kwargs["text"] is True
assert build_kwargs["encoding"] == "utf-8"
assert build_kwargs["errors"] == "replace"
class TestBuildWebUIRetryAndStaleFallback:
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
def test_retries_build_once_on_failure(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
Subprocess = __import__("subprocess")
# install: success; build attempt 1: fail; build attempt 2: success
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM")
build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._time.sleep") as mock_sleep, \
patch("hermes_cli.main.subprocess.run",
side_effect=[install_ok, build_fail, build_ok]) as mock_run:
result = _build_web_ui(web_dir)
assert result is True
assert mock_run.call_count == 3 # install + build + retry
mock_sleep.assert_called_once_with(3)
def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
web_dir, dist_dir = _make_web_dir(tmp_path)
# Stale dist exists but is older than source
_touch(dist_dir / "index.html", offset=-100)
_touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True
Subprocess = __import__("subprocess")
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._time.sleep"), \
patch("hermes_cli.main.subprocess.run",
side_effect=[install_ok, build_fail, build_fail]):
result = _build_web_ui(web_dir, fatal=True)
# MUST return True (serve stale) — issue #23817 — even with fatal=True,
# because cmd_dashboard passes fatal=True and is the primary caller.
assert result is True
out = capsys.readouterr().out
assert "serving stale dist as fallback" in out
assert "vite ENOMEM" in out # stderr surfaced to user
def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys):
web_dir, _ = _make_web_dir(tmp_path)
Subprocess = __import__("subprocess")
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._time.sleep"), \
patch("hermes_cli.main.subprocess.run",
side_effect=[install_ok, build_fail, build_fail]):
result = _build_web_ui(web_dir, fatal=True)
assert result is False
out = capsys.readouterr().out
assert "Web UI build failed" in out
assert "vite ENOMEM" in out
assert "Run manually" in out