@@ -1307,6 +1307,103 @@ def test_bad_prefill_messages_is_logged(self, caplog, tmp_path):
13071307 f"Expected 'failed to parse prefill messages' warning in logs, got: { [r .message for r in caplog .records ]} "
13081308
13091309
1310+ class TestRunJobConfigEnvVarExpansion :
1311+ """Verify that ${VAR} references in config.yaml are expanded when running cron jobs."""
1312+
1313+ _RUNTIME = {
1314+ "api_key" : "test-key" ,
1315+ "base_url" : "https://example.invalid/v1" ,
1316+ "provider" : "openrouter" ,
1317+ "api_mode" : "chat_completions" ,
1318+ }
1319+
1320+ def test_model_env_ref_in_config_yaml_is_expanded (self , tmp_path , monkeypatch ):
1321+ """${VAR} in config.yaml model: is expanded using env after .env is loaded."""
1322+ (tmp_path / "config.yaml" ).write_text ("model: ${_HERMES_TEST_CRON_MODEL}\n " )
1323+ monkeypatch .setenv ("_HERMES_TEST_CRON_MODEL" , "gpt-4o-mini-cron-test" )
1324+
1325+ job = {"id" : "env-job" , "name" : "env test" , "prompt" : "hi" }
1326+ fake_db = MagicMock ()
1327+
1328+ with patch ("cron.scheduler._hermes_home" , tmp_path ), \
1329+ patch ("cron.scheduler._resolve_origin" , return_value = None ), \
1330+ patch ("dotenv.load_dotenv" ), \
1331+ patch ("hermes_state.SessionDB" , return_value = fake_db ), \
1332+ patch ("hermes_cli.runtime_provider.resolve_runtime_provider" ,
1333+ return_value = self ._RUNTIME ), \
1334+ patch ("run_agent.AIAgent" ) as mock_agent_cls :
1335+ mock_agent = MagicMock ()
1336+ mock_agent .run_conversation .return_value = {"final_response" : "ok" }
1337+ mock_agent_cls .return_value = mock_agent
1338+ success , _ , _ , error = run_job (job )
1339+
1340+ assert success is True
1341+ assert error is None
1342+ kwargs = mock_agent_cls .call_args .kwargs
1343+ assert kwargs ["model" ] == "gpt-4o-mini-cron-test" , (
1344+ f"Expected model='gpt-4o-mini-cron-test', got { kwargs ['model' ]!r} . "
1345+ "config.yaml ${VAR} was not expanded in the cron execution path."
1346+ )
1347+
1348+ def test_fallback_model_env_ref_in_config_yaml_is_expanded (self , tmp_path , monkeypatch ):
1349+ """${VAR} in config.yaml fallback_providers model: is expanded."""
1350+ (tmp_path / "config.yaml" ).write_text (
1351+ "fallback_providers:\n "
1352+ " - provider: openrouter\n "
1353+ " model: ${_HERMES_TEST_CRON_FALLBACK}\n "
1354+ )
1355+ monkeypatch .setenv ("_HERMES_TEST_CRON_FALLBACK" , "gpt-4o-fallback-test" )
1356+
1357+ job = {"id" : "fb-job" , "name" : "fallback test" , "prompt" : "hi" }
1358+ fake_db = MagicMock ()
1359+
1360+ with patch ("cron.scheduler._hermes_home" , tmp_path ), \
1361+ patch ("cron.scheduler._resolve_origin" , return_value = None ), \
1362+ patch ("dotenv.load_dotenv" ), \
1363+ patch ("hermes_state.SessionDB" , return_value = fake_db ), \
1364+ patch ("hermes_cli.runtime_provider.resolve_runtime_provider" ,
1365+ return_value = self ._RUNTIME ), \
1366+ patch ("run_agent.AIAgent" ) as mock_agent_cls :
1367+ mock_agent = MagicMock ()
1368+ mock_agent .run_conversation .return_value = {"final_response" : "ok" }
1369+ mock_agent_cls .return_value = mock_agent
1370+ run_job (job )
1371+
1372+ kwargs = mock_agent_cls .call_args .kwargs
1373+ fb = kwargs .get ("fallback_model" ) or []
1374+ fb_list = fb if isinstance (fb , list ) else [fb ]
1375+ expanded = [e .get ("model" ) for e in fb_list if isinstance (e , dict )]
1376+ assert "gpt-4o-fallback-test" in expanded , (
1377+ f"Expected expanded fallback model in { expanded !r} . "
1378+ "config.yaml ${VAR} in fallback_providers was not expanded."
1379+ )
1380+
1381+ def test_unexpanded_ref_passthrough_when_var_unset (self , tmp_path , monkeypatch ):
1382+ """When the env var is not set, the literal ${VAR} is kept verbatim (not crashed)."""
1383+ (tmp_path / "config.yaml" ).write_text ("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n " )
1384+ monkeypatch .delenv ("_HERMES_TEST_CRON_UNSET_VAR" , raising = False )
1385+
1386+ job = {"id" : "unset-job" , "name" : "unset var test" , "prompt" : "hi" }
1387+ fake_db = MagicMock ()
1388+
1389+ with patch ("cron.scheduler._hermes_home" , tmp_path ), \
1390+ patch ("cron.scheduler._resolve_origin" , return_value = None ), \
1391+ patch ("dotenv.load_dotenv" ), \
1392+ patch ("hermes_state.SessionDB" , return_value = fake_db ), \
1393+ patch ("hermes_cli.runtime_provider.resolve_runtime_provider" ,
1394+ return_value = self ._RUNTIME ), \
1395+ patch ("run_agent.AIAgent" ) as mock_agent_cls :
1396+ mock_agent = MagicMock ()
1397+ mock_agent .run_conversation .return_value = {"final_response" : "ok" }
1398+ mock_agent_cls .return_value = mock_agent
1399+ success , _ , _ , error = run_job (job )
1400+
1401+ assert success is True
1402+ kwargs = mock_agent_cls .call_args .kwargs
1403+ # Unresolved refs are kept verbatim — _expand_env_vars contract
1404+ assert kwargs ["model" ] == "${_HERMES_TEST_CRON_UNSET_VAR}"
1405+
1406+
13101407class TestRunJobSkillBacked :
13111408 def test_run_job_preserves_skill_env_passthrough_into_worker_thread (self , tmp_path ):
13121409 job = {
0 commit comments