diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 00d515c7f6170..3ef83ad5efce4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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, @@ -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"), diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 3a8bda9a76055..d2d2a73ac8063 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -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