fix(cron): disable terminal toolset for cloud providers in cron jobs (#379)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m16s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m16s
Cron jobs like nightwatch-health-monitor SSH into remote VPSes. When the runtime provider is cloud (Nous, OpenRouter, Anthropic), SSH keys don't exist on the inference server — causing silent failures and wasted iterations. Changes: - cron/scheduler.py: Import is_local_endpoint from model_metadata. Build disabled_toolsets dynamically: append 'terminal' when the runtime base_url is NOT a local endpoint. Log when terminal is disabled for observability. Also warn when a job declares requires_local_infra=true but runs on cloud. - tests/test_cron_cloud_terminal.py: 14 tests verifying is_local_endpoint classification and disabled_toolsets logic. Behavior: Local (localhost/127/RFC-1918): terminal enabled, SSH works. Cloud (openrouter/nous/anthropic): terminal disabled, agent reports SSH unavailable instead of wasting iterations. Closes #379
This commit is contained in:
@@ -37,6 +37,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_time import now as _hermes_now
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -777,6 +778,29 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
},
|
||||
)
|
||||
|
||||
# Build disabled toolsets — always exclude cronjob/messaging/clarify
|
||||
# for cron sessions. When the runtime endpoint is cloud (not local),
|
||||
# also disable terminal so the agent does not attempt SSH or shell
|
||||
# commands that require local infrastructure (keys, filesystem).
|
||||
# Jobs that declare requires_local_infra=true also get terminal
|
||||
# disabled on cloud endpoints regardless of this check. #379
|
||||
_cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
_runtime_base_url = turn_route["runtime"].get("base_url", "")
|
||||
_is_cloud = not is_local_endpoint(_runtime_base_url)
|
||||
if _is_cloud:
|
||||
_cron_disabled.append("terminal")
|
||||
logger.info(
|
||||
"Job '%s': cloud provider detected (%s), disabling terminal toolset",
|
||||
job_name,
|
||||
turn_route["runtime"].get("provider", "unknown"),
|
||||
)
|
||||
if job.get("requires_local_infra") and _is_cloud:
|
||||
logger.warning(
|
||||
"Job '%s': requires_local_infra=true but running on cloud provider — "
|
||||
"terminal-dependent steps will fail gracefully",
|
||||
job_name,
|
||||
)
|
||||
|
||||
_agent_kwargs = _safe_agent_kwargs({
|
||||
"model": turn_route["model"],
|
||||
"api_key": turn_route["runtime"].get("api_key"),
|
||||
@@ -784,7 +808,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"provider": turn_route["runtime"].get("provider"),
|
||||
"api_mode": turn_route["runtime"].get("api_mode"),
|
||||
"acp_command": turn_route["runtime"].get("command"),
|
||||
"acp_args": turn_route["runtime"].get("args"),
|
||||
"acp_args": list(turn_route["runtime"].get("args") or []),
|
||||
"max_iterations": max_iterations,
|
||||
"reasoning_config": reasoning_config,
|
||||
"prefill_messages": prefill_messages,
|
||||
@@ -792,7 +816,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"providers_ignored": pr.get("ignore"),
|
||||
"providers_order": pr.get("order"),
|
||||
"provider_sort": pr.get("sort"),
|
||||
"disabled_toolsets": ["cronjob", "messaging", "clarify"],
|
||||
"disabled_toolsets": _cron_disabled,
|
||||
"tool_choice": "required",
|
||||
"quiet_mode": True,
|
||||
"skip_memory": True, # Cron system prompts would corrupt user representations
|
||||
|
||||
73
tests/test_cron_cloud_terminal.py
Normal file
73
tests/test_cron_cloud_terminal.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for cron scheduler cloud-provider terminal disabling (#379).
|
||||
|
||||
When a cron job runs on a cloud inference endpoint (Nous, OpenRouter, etc.),
|
||||
the terminal toolset must be disabled because SSH keys don't exist on cloud
|
||||
servers. Only local endpoints (localhost, 127.0.0.1, RFC-1918) retain
|
||||
terminal access.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
class TestIsLocalEndpoint:
|
||||
"""Verify is_local_endpoint correctly classifies endpoints."""
|
||||
|
||||
def test_localhost(self):
|
||||
assert is_local_endpoint("http://localhost:11434/v1") is True
|
||||
|
||||
def test_127_loopback(self):
|
||||
assert is_local_endpoint("http://127.0.0.1:8080/v1") is True
|
||||
|
||||
def test_0_0_0_0(self):
|
||||
assert is_local_endpoint("http://0.0.0.0:11434/v1") is True
|
||||
|
||||
def test_rfc1918_10(self):
|
||||
assert is_local_endpoint("http://10.0.0.5:8080/v1") is True
|
||||
|
||||
def test_rfc1918_192(self):
|
||||
assert is_local_endpoint("http://192.168.1.100:11434/v1") is True
|
||||
|
||||
def test_rfc1918_172(self):
|
||||
assert is_local_endpoint("http://172.16.0.1:8080/v1") is True
|
||||
|
||||
def test_cloud_openrouter(self):
|
||||
assert is_local_endpoint("https://openrouter.ai/api/v1") is False
|
||||
|
||||
def test_cloud_nous(self):
|
||||
assert is_local_endpoint("https://inference-api.nousresearch.com/v1") is False
|
||||
|
||||
def test_cloud_anthropic(self):
|
||||
assert is_local_endpoint("https://api.anthropic.com") is False
|
||||
|
||||
def test_empty_url(self):
|
||||
assert is_local_endpoint("") is False
|
||||
|
||||
def test_none_url(self):
|
||||
assert is_local_endpoint(None) is False
|
||||
|
||||
|
||||
class TestCronDisabledToolsetsLogic:
|
||||
"""Verify the disabled_toolsets logic matches scheduler expectations."""
|
||||
|
||||
def _build_disabled(self, base_url, job=None):
|
||||
"""Mirror the scheduler's disabled_toolsets logic."""
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
cron_disabled = ["cronjob", "messaging", "clarify"]
|
||||
if not is_local_endpoint(base_url):
|
||||
cron_disabled.append("terminal")
|
||||
return cron_disabled
|
||||
|
||||
def test_local_keeps_terminal(self):
|
||||
disabled = self._build_disabled("http://localhost:11434/v1")
|
||||
assert "terminal" not in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_cloud_disables_terminal(self):
|
||||
disabled = self._build_disabled("https://openrouter.ai/api/v1")
|
||||
assert "terminal" in disabled
|
||||
assert "cronjob" in disabled
|
||||
|
||||
def test_empty_url_disables_terminal(self):
|
||||
disabled = self._build_disabled("")
|
||||
assert "terminal" in disabled
|
||||
Reference in New Issue
Block a user