Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13041,6 +13041,28 @@ def _normalize_dashboard_cron_updates(
return normalized


def _validate_dashboard_cron_bot_chat_deliver(updates: Dict[str, Any]) -> None:
"""Reject an unresolvable bot-chat profile in ``deliver``/``failure_deliver``.

The CLI and the ``cronjob`` tool both run every create/update through
``tools.cronjob_tools._validate_bot_chat_deliver`` — a named bot-chat
profile must exist on this machine, or the update is rejected up front
rather than only failing per-run at ``last_delivery_error`` time. The
dashboard's PUT /api/cron/jobs/{job_id} never went through that check
for either field (a pre-existing gap the ``deliver`` field already had
before ``failure_deliver`` inherited it) — wire it in here so a
dashboard-edited job gets the same fail-loud guarantee.
"""
from tools.cronjob_tools import _validate_bot_chat_deliver

for field in ("deliver", "failure_deliver"):
if field not in updates:
continue
error = _validate_bot_chat_deliver(updates[field])
if error:
raise HTTPException(status_code=400, detail=error)


def _validate_dashboard_cron_context_from(
refs: Optional[List[str]],
profile_name: str,
Expand Down Expand Up @@ -13416,6 +13438,7 @@ def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[st
body.updates,
profile_home,
)
_validate_dashboard_cron_bot_chat_deliver(updates)
if "context_from" in updates:
_validate_dashboard_cron_context_from(
updates.get("context_from"),
Expand Down
80 changes: 80 additions & 0 deletions tests/hermes_cli/test_web_server_cron_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,86 @@ async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatc
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]


@pytest.mark.asyncio
async def test_update_cron_job_rejects_unknown_bot_chat_failure_deliver(
isolated_profiles, monkeypatch
):
"""The dashboard update lane must reject an unresolvable bot-chat
``failure_deliver`` profile up front, the same as the CLI/tool ``cronjob``
update path (tools.cronjob_tools._validate_bot_chat_deliver) already does
— not silently persist it and only discover the bad name at 3am when a
failure needs to route through it."""
from hermes_cli import web_server

notified_profiles = []
monkeypatch.setattr(
web_server,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="bad-bot-chat-failure-deliver-job",
)

with pytest.raises(HTTPException) as exc:
await web_server.update_cron_job(
job["id"],
web_server.CronJobUpdate(
updates={"failure_deliver": "bot-chat:definitely-not-a-real-profile"}
),
profile="worker_alpha",
)

assert exc.value.status_code == 400
assert "bot-chat delivery profile" in exc.value.detail
assert "not found" in exc.value.detail
assert notified_profiles == []
persisted = web_server._call_cron_for_profile("worker_alpha", "get_job", job["id"])
assert persisted.get("failure_deliver") is None


@pytest.mark.asyncio
async def test_update_cron_job_rejects_unknown_bot_chat_deliver(
isolated_profiles, monkeypatch
):
"""Same rejection for the older ``deliver`` field — this gap predates
failure_deliver, so deliver must be covered too."""
from hermes_cli import web_server

notified_profiles = []
monkeypatch.setattr(
web_server,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="bad-bot-chat-deliver-job",
)

with pytest.raises(HTTPException) as exc:
await web_server.update_cron_job(
job["id"],
web_server.CronJobUpdate(
updates={"deliver": "bot-chat:definitely-not-a-real-profile"}
),
profile="worker_alpha",
)

assert exc.value.status_code == 400
assert "bot-chat delivery profile" in exc.value.detail
assert notified_profiles == []
persisted = web_server._call_cron_for_profile("worker_alpha", "get_job", job["id"])
assert persisted.get("deliver") == "local"


@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
from hermes_cli import web_server
Expand Down
Loading