Cron scheduler missing BSM secret resolution fixes
NousResearch/hermes-agent · cluster 33465 · cron
Data as of
Nothing open on GitHub. Fix merged in #56144. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #56144. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple fixes and a bug report addressing the cron scheduler's failure to resolve Bitwarden Secrets Manager secrets, with related hardening around null-safe delivery and environment loading.
Open issues0
Open PRs0
Fixes on record
-
#56144 fix(cron): null-safe deliver in cron list + re-resolve BSM secrets per run
files:
cron/scheduler.py,hermes_cli/cron.py,tests/cron/test_cron_provider_pin.py,tests/cron/test_scheduler.py,tests/hermes_cli/test_cron.pyShow the diff
-
cron/scheduler.py@@ -2225,12 +2225,23 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: try: # Re-read .env and config.yaml fresh every run so provider/key - # changes take effect without a gateway restart. - from dotenv import load_dotenv - try: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1") + # changes take effect without a gateway restart. Route through + # load_hermes_dotenv (not a bare load_dotenv) and reset the secret- + # source cache first: startup already applied external secrets and + # recorded this HERMES_HOME in _APPLIED_HOMES, so a naive reload would + # re-apply only the .env placeholder and never re-resolve a Bitwarden/ + # BSM-backed secret — leaving cron jobs 401'ing on the placeholder + # (#33465). Clearing the cache forces the re-pull; the resolved secret + # overrides the placeholder only when secrets.bitwarden.override_existing + # is set (mirrors startup), and the Bitwarden value-cache keeps the + # forced re-pull off the network. load_hermes_dotenv also handles the + # utf-8/latin-1 encoding fallback internally. + from hermes_cli.env_loader import ( + load_hermes_dotenv, + reset_secret_source_cache, + ) + reset_secret_source_cache() + load_hermes_dotenv(hermes_home=_get_hermes_home()) delivery_target = _resolve_delivery_target(job) if delivery_target: -
hermes_cli/cron.py@@ -137,7 +137,11 @@ def cron_list(show_all: bool = False): repeat_completed = repeat_info.get("completed", 0) repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞" - deliver = job.get("deliver", ["local"]) + # `deliver` may be present-but-null in the job record (same pitfall as + # `repeat` above), so coalesce to the default rather than relying on the + # dict-default, which only applies to a missing key. A null value would + # otherwise reach `", ".join(None)` and crash the whole listing (#32896). + deliver = job.get("deliver") or ["local"] if isinstance(deliver, str): deliver = [deliver] deliver_str = ", ".join(deliver) -
tests/cron/test_cron_provider_pin.py@@ -52,7 +52,8 @@ def _run_with_current_provider(job, current_provider, tmp_path): fake_db = MagicMock() with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -252,7 +253,8 @@ def _run_with_current_provider_and_model(job, current_provider, current_model, t with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._get_hermes_home", return_value=tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", -
tests/cron/test_scheduler.py@@ -966,7 +966,8 @@ def test_run_job_passes_session_db_and_cron_platform(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1012,7 +1013,8 @@ def test_run_job_suppresses_empty_turn_explainer(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1055,7 +1057,8 @@ def test_run_job_real_report_on_empty_reason_still_delivers(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1095,7 +1098,8 @@ def test_run_job_titles_cron_session_from_job_not_important_hint(self, tmp_path) with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1132,7 +1136,8 @@ def test_run_job_closes_agent_on_failure_to_prevent_fd_leak(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1169,7 +1174,8 @@ def test_run_job_reaps_stale_auxiliary_clients_per_tick(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1197,7 +1203,8 @@ def _make_run_job_patches(self, tmp_path): return fake_db, [ patch("cron.scheduler._hermes_home", tmp_path), patch("cron.scheduler._resolve_origin", return_value=None), - patch("dotenv.load_dotenv"), + patch("hermes_cli.env_loader.load_hermes_dotenv"), + patch("hermes_cli.env_loader.reset_secret_source_cache"), patch("hermes_state.SessionDB", return_value=fake_db), patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1218,7 +1225,7 @@ def test_run_job_passes_enabled_toolsets_to_agent(self, tmp_path): "enabled_toolsets": ["web", "terminal", "file"], } fake_db, patches = self._make_run_job_patches(tmp_path) - with patches[0], patches[1], patches[2], patches[3], patches[4], \ + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} @@ -1252,7 +1259,7 @@ def test_run_job_disabled_toolsets_layer_user_config_on_baseline(self, tmp_path) "enabled_toolsets": ["web", "terminal", "file"], } fake_db, patches = self._make_run_job_patches(tmp_path) - with patches[0], patches[1], patches[2], patches[3], patches[4], \ + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} @@ -1279,7 +1286,7 @@ def test_run_job_enabled_toolsets_resolves_from_platform_config_when_not_set(sel "prompt": "hello", } fake_db, patches = self._make_run_job_patches(tmp_path) - with patches[0], patches[1], patches[2], patches[3], patches[4], \ + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \ patch("run_agent.AIAgent") as mock_agent_cls: mock_agent = MagicMock() mock_agent.run_conversation.return_value = {"final_response": "ok"} @@ -1307,7 +1314,7 @@ def test_run_job_per_job_toolsets_win_over_platform_config(self, tmp_path): fake_db, patches = self._make_run_job_patches(tmp_path) # Even if the user has ``hermes tools`` configured to enable web+file # for cron, the per-job override wins. - with patches[0], patches[1], patches[2], patches[3], patches[4], \ + with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], \ patch("run_agent.AIAgent") as mock_agent_cls, \ patch( "hermes_cli.tools_config._get_platform_tools", @@ -1336,7 +1343,8 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1412,7 +1420,8 @@ def test_run_job_treats_agent_failure_flag_as_failure( with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1451,7 +1460,8 @@ def test_run_job_completed_true_without_failed_flag_succeeds(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1493,7 +1503,8 @@ def test_run_job_delivers_max_iteration_fallback_summary(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -1613,6 +1624,53 @@ def run_conversation(self, *args, **kwargs): assert os.getenv("HERMES_CRON_AUTO_DELIVER_THREAD_ID") is None fake_db.close.assert_called_once() + def test_run_job_resets_secret_source_cache_before_reload(self, tmp_path, monkeypatch): + """Each run must clear the secret-source cache before re-reading the + env, so a long-running gateway re-resolves Bitwarden/BSM-backed secrets + instead of leaving the startup .env placeholder in place (#33465). + + A bare ``load_dotenv`` re-load can't do this: startup already recorded + this HERMES_HOME in ``_APPLIED_HOMES``, so the external-secret pull + no-ops and only the placeholder is re-applied. The scheduler must call + ``reset_secret_source_cache()`` (forcing the re-pull) and route through + ``load_hermes_dotenv`` (which then re-applies external secret sources). + """ + job = {"id": "bsm-job", "name": "bsm", "prompt": "hello"} + fake_db = MagicMock() + call_order = [] + + def _record_reset(): + call_order.append("reset") + + def _record_load(*args, **kwargs): + call_order.append("load") + return [] + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("hermes_cli.env_loader.reset_secret_source_cache", _record_reset), \ + patch("hermes_cli.env_loader.load_hermes_dotenv", _record_load), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + success, _output, _final, error = run_job(job) + + assert success is True + assert error is None + # reset MUST precede the reload, else _APPLIED_HOMES no-ops the re-pull. + assert call_order[:2] == ["reset", "load"], call_order + def test_run_job_clears_stale_auto_delivery_thread_id_between_jobs(self, tmp_path, monkeypatch): jobs = [ { @@ -1709,7 +1767,8 @@ def test_bad_config_yaml_is_logged(self, caplog, tmp_path): # (>30s wall clock) under load. See PR #33661 follow-up. with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={"provider": "openrouter", "api_key": "x", "base_url": "https://example.invalid", @@ -1743,7 +1802,8 @@ def test_bad_prefill_messages_is_logged(self, caplog, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={"provider": "openrouter", "api_key": "x", "base_url": "https://example.invalid", @@ -1781,7 +1841,8 @@ def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1814,7 +1875,8 @@ def test_legacy_agent_prefill_messages_file_is_loaded(self, tmp_path, monkeypatc with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1844,7 +1906,8 @@ def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monke with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1873,7 +1936,8 @@ def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1917,7 +1981,8 @@ def test_null_job_model_falls_back_to_env(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1941,7 +2006,8 @@ def test_null_job_model_falls_back_to_config_default(self, tmp_path, monkeypatch with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1973,7 +2039,8 @@ def test_explicit_null_model_block_in_config_does_not_overwrite_env(self, tmp_pa with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -1996,7 +2063,8 @@ def test_no_model_anywhere_fails_with_actionable_error(self, tmp_path, monkeypat with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -2025,7 +2093,8 @@ def test_job_model_update_takes_effect_on_next_run(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -2051,7 +2120,8 @@ def test_config_model_as_plain_string(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -2081,7 +2151,8 @@ def test_config_model_alias_key_resolves(self, tmp_path, monkeypatch): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -2105,7 +2176,8 @@ def test_corrupt_config_yaml_does_not_crash_with_job_model(self, tmp_path, monke with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=self._RUNTIME), \ @@ -2147,7 +2219,8 @@ def _run_conversation(prompt): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -2207,7 +2280,8 @@ def _run_conversation(prompt): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ patch("tools.credential_files._resolve_hermes_home", return_value=tmp_path), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -2245,7 +2319,8 @@ def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", @@ -2291,7 +2366,8 @@ def _skill_view(name): with patch("cron.scheduler._hermes_home", tmp_path), \ patch("cron.scheduler._resolve_origin", return_value=None), \ - patch("dotenv.load_dotenv"), \ + patch("hermes_cli.env_loader.load_hermes_dotenv"), \ + patch("hermes_cli.env_loader.reset_secret_source_cache"), \ patch("hermes_state.SessionDB", return_value=fake_db), \ patch( "hermes_cli.runtime_provider.resolve_runtime_provider", -
tests/hermes_cli/test_cron.py@@ -122,6 +122,23 @@ def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys): out = capsys.readouterr().out assert "Repeat: ∞" in out + def test_list_does_not_crash_when_deliver_is_null(self, tmp_cron_dir, capsys): + """A job can be persisted with ``"deliver": null`` (present-but-null). + `cron list` must fall back to the default channel rather than crashing + on ``", ".join(None)`` — same dict-default pitfall as ``repeat`` (#32896). + """ + from cron.jobs import load_jobs, save_jobs + + create_job(prompt="No deliver", schedule="every 1h") + jobs = load_jobs() + jobs[0]["deliver"] = None + save_jobs(jobs) + + cron_command(Namespace(cron_command="list", all=True)) + + out = capsys.readouterr().out + assert "Deliver: local" in out + class TestGatewayNotRunningWarning: """`cron create` / `cron list` must warn when the gateway (and thus the
-
4 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1 · stale or abandoned 1 · other 1
- #33479 fix(cron): pull Bitwarden Secrets Manager secrets in run_job · stale or abandoned
-
#33527 fix(cron): harden ticker thread, null-safe deliver, and BSM env loader · merged via another change →
#56144 -
#33667 fix(cron): resolve BSM secrets in scheduler run_job (#33465) · other →
#33465,#33479,#33527 -
#34041 fix(cron): use load_hermes_dotenv so BSM secrets resolve for cron jobs · done elsewhere →
836732f,#33465,#56144
Closed issues · 1
- Closed#33465[Bug]: cron scheduler missing BSM resolution
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- cron
- Merged PRs
- #56144
- Closed issues
- 1
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 1 Jul 2026