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

0open issues
0open PRs
6items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56144 fix(cron): null-safe deliver in cron list + re-resolve BSM secrets per run

    merged to main

    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.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
cron
Merged PRs
#56144
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.