Gate tool-gateway behind an env var, so it's not in users' faces until we're ready. Even if users enable it, it'll be blocked server-side for now, until we unlock for non-admin users on tool-gateway.

This commit is contained in:
Robin Fernandes 2026-03-30 13:28:10 +09:00
parent e95965d76a
commit 1cbb1b99cc
35 changed files with 426 additions and 147 deletions

View file

@ -69,17 +69,6 @@ OPENCODE_GO_API_KEY=
# Get at: https://parallel.ai # Get at: https://parallel.ai
PARALLEL_API_KEY= PARALLEL_API_KEY=
# Tool-gateway config (Nous Subscribers only; preferred when available)
# Uses your Nous Subscriber OAuth access token from the Hermes auth store by default.
# Defaults to the Nous production gateway. Override for local dev.
#
# Derive vendor gateway URLs from a shared domain suffix:
# TOOL_GATEWAY_DOMAIN=nousresearch.com
# TOOL_GATEWAY_SCHEME=https
#
# Override the subscriber token (defaults to ~/.hermes/auth.json):
# TOOL_GATEWAY_USER_TOKEN=
# Firecrawl API Key - Web search, extract, and crawl # Firecrawl API Key - Web search, extract, and crawl
# Get at: https://firecrawl.dev/ # Get at: https://firecrawl.dev/
FIRECRAWL_API_KEY= FIRECRAWL_API_KEY=

View file

@ -426,10 +426,14 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
"""Build a compact Nous subscription capability block for the system prompt.""" """Build a compact Nous subscription capability block for the system prompt."""
try: try:
from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.nous_subscription import get_nous_subscription_features
from tools.tool_backend_helpers import managed_nous_tools_enabled
except Exception as exc: except Exception as exc:
logger.debug("Failed to import Nous subscription helper: %s", exc) logger.debug("Failed to import Nous subscription helper: %s", exc)
return "" return ""
if not managed_nous_tools_enabled():
return ""
valid_names = set(valid_tool_names or set()) valid_names = set(valid_tool_names or set())
relevant_tool_names = { relevant_tool_names = {
"web_search", "web_search",

View file

@ -6,6 +6,8 @@ import os
import re import re
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from utils import is_truthy_value
_COMPLEX_KEYWORDS = { _COMPLEX_KEYWORDS = {
"debug", "debug",
"debugging", "debugging",
@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
def _coerce_bool(value: Any, default: bool = False) -> bool: def _coerce_bool(value: Any, default: bool = False) -> bool:
if value is None: return is_truthy_value(value, default=default)
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _coerce_int(value: Any, default: int) -> int: def _coerce_int(value: Any, default: int) -> int:

View file

@ -17,19 +17,14 @@ from typing import Dict, List, Optional, Any
from enum import Enum from enum import Enum
from hermes_cli.config import get_hermes_home from hermes_cli.config import get_hermes_home
from utils import is_truthy_value
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _coerce_bool(value: Any, default: bool = True) -> bool: def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce bool-ish config values, preserving a caller-provided default.""" """Coerce bool-ish config values, preserving a caller-provided default."""
if value is None: return is_truthy_value(value, default=default)
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "on")
return bool(value)
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
@ -818,4 +813,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
except ValueError: except ValueError:
pass pass

View file

@ -22,6 +22,8 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
_IS_WINDOWS = platform.system() == "Windows" _IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS # Env var names written to .env that aren't in OPTIONAL_ENV_VARS
@ -39,7 +41,6 @@ _EXTRA_ENV_KEYS = frozenset({
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
}) })
import yaml import yaml
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
@ -959,6 +960,15 @@ OPTIONAL_ENV_VARS = {
}, },
} }
if not _managed_nous_tools_enabled():
for _hidden_var in (
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
""" """

View file

@ -11,6 +11,7 @@ from hermes_cli.config import get_env_value, load_config
from tools.managed_tool_gateway import is_managed_tool_gateway_ready from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import ( from tools.tool_backend_helpers import (
has_direct_modal_credentials, has_direct_modal_credentials,
managed_nous_tools_enabled,
normalize_browser_cloud_provider, normalize_browser_cloud_provider,
normalize_modal_mode, normalize_modal_mode,
resolve_openai_audio_api_key, resolve_openai_audio_api_key,
@ -156,6 +157,7 @@ def get_nous_subscription_features(
except Exception: except Exception:
nous_status = {} nous_status = {}
managed_tools_flag = managed_nous_tools_enabled()
nous_auth_present = bool(nous_status.get("logged_in")) nous_auth_present = bool(nous_status.get("logged_in"))
subscribed = provider_is_nous or nous_auth_present subscribed = provider_is_nous or nous_auth_present
@ -193,11 +195,11 @@ def get_nous_subscription_features(
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
direct_modal = has_direct_modal_credentials() direct_modal = has_direct_modal_credentials()
managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl") managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue") managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio") managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase") managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase")
managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal") managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
web_active = bool( web_active = bool(
@ -355,6 +357,9 @@ def get_nous_subscription_features(
def get_nous_subscription_explainer_lines() -> list[str]: def get_nous_subscription_explainer_lines() -> list[str]:
if not managed_nous_tools_enabled():
return []
return [ return [
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.", "Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.", "Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
@ -364,6 +369,9 @@ def get_nous_subscription_explainer_lines() -> list[str]:
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]: def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`.""" """Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
if not managed_nous_tools_enabled():
return set()
features = get_nous_subscription_features(config) features = get_nous_subscription_features(config)
if not features.provider_is_nous: if not features.provider_is_nous:
return set() return set()
@ -386,6 +394,9 @@ def apply_nous_managed_defaults(
*, *,
enabled_toolsets: Optional[Iterable[str]] = None, enabled_toolsets: Optional[Iterable[str]] = None,
) -> set[str]: ) -> set[str]:
if not managed_nous_tools_enabled():
return set()
features = get_nous_subscription_features(config) features = get_nous_subscription_features(config)
if not features.provider_is_nous: if not features.provider_is_nous:
return set() return set()

View file

@ -38,6 +38,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set from typing import Any, Callable, Dict, List, Optional, Set
from utils import env_var_enabled
try: try:
import yaml import yaml
except ImportError: # pragma: no cover yaml is optional at import time except ImportError: # pragma: no cover yaml is optional at import time
@ -65,7 +67,7 @@ _NS_PARENT = "hermes_plugins"
def _env_enabled(name: str) -> bool: def _env_enabled(name: str) -> bool:
"""Return True when an env var is set to a truthy opt-in value.""" """Return True when an env var is set to a truthy opt-in value."""
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} return env_var_enabled(name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -23,6 +23,7 @@ from hermes_cli.nous_subscription import (
get_nous_subscription_explainer_lines, get_nous_subscription_explainer_lines,
get_nous_subscription_features, get_nous_subscription_features,
) )
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -59,9 +60,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
def _print_nous_subscription_guidance() -> None: def _print_nous_subscription_guidance() -> None:
lines = get_nous_subscription_explainer_lines()
if not lines:
return
print() print()
print_header("Nous Subscription Tools") print_header("Nous Subscription Tools")
for line in get_nous_subscription_explainer_lines(): for line in lines:
print_info(line) print_info(line)
@ -663,7 +668,7 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status.append(("Modal Execution (direct Modal)", True, None)) tool_status.append(("Modal Execution (direct Modal)", True, None))
else: else:
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'")) tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
elif subscription_features.nous_auth_present: elif managed_nous_tools_enabled() and subscription_features.nous_auth_present:
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None)) tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
# Tinker + WandB (RL training) # Tinker + WandB (RL training)
@ -1912,7 +1917,7 @@ def _setup_tts_provider(config: dict):
choices = [] choices = []
providers = [] providers = []
if subscription_features.nous_auth_present: if managed_nous_tools_enabled() and subscription_features.nous_auth_present:
choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)") choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
providers.append("nous-openai") providers.append("nous-openai")
choices.extend( choices.extend(
@ -2137,6 +2142,8 @@ def setup_terminal_backend(config: dict):
from tools.tool_backend_helpers import normalize_modal_mode from tools.tool_backend_helpers import normalize_modal_mode
managed_modal_available = bool( managed_modal_available = bool(
managed_nous_tools_enabled()
and
get_nous_subscription_features(config).nous_auth_present get_nous_subscription_features(config).nous_auth_present
and is_managed_tool_gateway_ready("modal") and is_managed_tool_gateway_ready("modal")
) )

View file

@ -18,6 +18,7 @@ from hermes_cli.models import provider_label
from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.runtime_provider import resolve_requested_provider from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_constants import OPENROUTER_MODELS_URL from hermes_constants import OPENROUTER_MODELS_URL
from tools.tool_backend_helpers import managed_nous_tools_enabled
def check_mark(ok: bool) -> str: def check_mark(ok: bool) -> str:
if ok: if ok:
@ -190,26 +191,27 @@ def show_status(args):
# ========================================================================= # =========================================================================
# Nous Subscription Features # Nous Subscription Features
# ========================================================================= # =========================================================================
features = get_nous_subscription_features(config) if managed_nous_tools_enabled():
print() features = get_nous_subscription_features(config)
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD)) print()
if not features.nous_auth_present: print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
print(" Nous Portal ✗ not logged in") if not features.nous_auth_present:
else: print(" Nous Portal ✗ not logged in")
print(" Nous Portal ✓ managed tools available")
for feature in features.items():
if feature.managed_by_nous:
state = "active via Nous subscription"
elif feature.active:
current = feature.current_provider or "configured provider"
state = f"active via {current}"
elif feature.included_by_default and features.nous_auth_present:
state = "included by subscription, not currently selected"
elif feature.key == "modal" and features.nous_auth_present:
state = "available via subscription (optional)"
else: else:
state = "not configured" print(" Nous Portal ✓ managed tools available")
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") for feature in features.items():
if feature.managed_by_nous:
state = "active via Nous subscription"
elif feature.active:
current = feature.current_provider or "configured provider"
state = f"active via {current}"
elif feature.included_by_default and features.nous_auth_present:
state = "included by subscription, not currently selected"
elif feature.key == "modal" and features.nous_auth_present:
state = "available via subscription (optional)"
else:
state = "not configured"
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
# ========================================================================= # =========================================================================
# API-Key Providers # API-Key Providers

View file

@ -22,6 +22,7 @@ from hermes_cli.nous_subscription import (
apply_nous_managed_defaults, apply_nous_managed_defaults,
get_nous_subscription_features, get_nous_subscription_features,
) )
from tools.tool_backend_helpers import managed_nous_tools_enabled
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@ -737,6 +738,8 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
features = get_nous_subscription_features(config) features = get_nous_subscription_features(config)
visible = [] visible = []
for provider in cat.get("providers", []): for provider in cat.get("providers", []):
if provider.get("managed_nous_feature") and not managed_nous_tools_enabled():
continue
if provider.get("requires_nous_auth") and not features.nous_auth_present: if provider.get("requires_nous_auth") and not features.nous_auth_present:
continue continue
visible.append(provider) visible.append(provider)
@ -1234,9 +1237,10 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
config, config,
enabled_toolsets=new_enabled, enabled_toolsets=new_enabled,
) )
for ts_key in sorted(auto_configured): if managed_nous_tools_enabled():
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) for ts_key in sorted(auto_configured):
print(color(f"{label}: using your Nous subscription defaults", Colors.GREEN)) label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
print(color(f"{label}: using your Nous subscription defaults", Colors.GREEN))
# Walk through ALL selected tools that have provider options or # Walk through ALL selected tools that have provider options or
# need API keys. This ensures browser (Local vs Browserbase), # need API keys. This ensures browser (Local vs Browserbase),

View file

@ -96,7 +96,7 @@ from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad, convert_scratchpad_to_think, has_incomplete_scratchpad,
save_trajectory as _save_trajectory_to_file, save_trajectory as _save_trajectory_to_file,
) )
from utils import atomic_json_write from utils import atomic_json_write, env_var_enabled
HONCHO_TOOL_NAMES = { HONCHO_TOOL_NAMES = {
"honcho_context", "honcho_context",
@ -2005,7 +2005,7 @@ class AIAgent:
self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}") self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}")
if os.getenv("HERMES_DUMP_REQUEST_STDOUT", "").strip().lower() in {"1", "true", "yes", "on"}: if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)) print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
return dump_file return dump_file
@ -6052,7 +6052,7 @@ class AIAgent:
if self.api_mode == "codex_responses": if self.api_mode == "codex_responses":
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False) api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
if os.getenv("HERMES_DUMP_REQUESTS", "").strip().lower() in {"1", "true", "yes", "on"}: if env_var_enabled("HERMES_DUMP_REQUESTS"):
self._dump_api_request_debug(api_kwargs, reason="preflight") self._dump_api_request_debug(api_kwargs, reason="preflight")
# Always prefer the streaming path — even without stream # Always prefer the streaming path — even without stream

View file

@ -401,6 +401,7 @@ class TestBuildSkillsSystemPrompt:
class TestBuildNousSubscriptionPrompt: class TestBuildNousSubscriptionPrompt:
def test_includes_active_subscription_features(self, monkeypatch): def test_includes_active_subscription_features(self, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features", "hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures( lambda config=None: NousSubscriptionFeatures(
@ -424,6 +425,7 @@ class TestBuildNousSubscriptionPrompt:
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys" in prompt assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys" in prompt
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch): def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr( monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features", "hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures( lambda config=None: NousSubscriptionFeatures(
@ -445,6 +447,13 @@ class TestBuildNousSubscriptionPrompt:
assert "suggest Nous subscription as one option" in prompt assert "suggest Nous subscription as one option" in prompt
assert "Do not mention subscription unless" in prompt assert "Do not mention subscription unless" in prompt
def test_feature_flag_off_returns_empty_prompt(self, monkeypatch):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
prompt = build_nous_subscription_prompt({"web_search"})
assert prompt == ""
# ========================================================================= # =========================================================================
# Context files prompt builder # Context files prompt builder

View file

@ -183,6 +183,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys): def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch) _clear_provider_env(monkeypatch)
@ -270,6 +271,7 @@ def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch):
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = load_config() config = load_config()
@ -311,6 +313,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch): def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)

View file

@ -64,6 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path): def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
from hermes_cli import status as status_mod from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path) _patch_common_status_deps(monkeypatch, status_mod, tmp_path)
@ -100,3 +101,24 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
assert "Nous Subscription Features" in out assert "Nous Subscription Features" in out
assert "Browser automation" in out assert "Browser automation" in out
assert "active via Nous subscription" in out assert "active via Nous subscription" in out
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Subscription Features" not in out

View file

@ -248,6 +248,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
config = {"model": {"provider": "nous"}} config = {"model": {"provider": "nous"}}
monkeypatch.setattr( monkeypatch.setattr(
@ -260,6 +261,20 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
assert providers[0]["name"].startswith("Nous Subscription") assert providers[0]["name"].startswith("Nous Subscription")
def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
config = {"model": {"provider": "nous"}}
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_auth_status",
lambda: {"logged_in": True},
)
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
assert all(not provider["name"].startswith("Nous Subscription") for provider in providers)
def test_local_browser_provider_is_saved_explicitly(monkeypatch): def test_local_browser_provider_is_saved_explicitly(monkeypatch):
config = {} config = {}
local_provider = next( local_provider = next(
@ -275,6 +290,7 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
config = { config = {
"model": {"provider": "nous"}, "model": {"provider": "nous"},
"platform_toolsets": {"cli": []}, "platform_toolsets": {"cli": []},

View file

@ -277,6 +277,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys): def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
config = { config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"}, "model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "elevenlabs"}, "tts": {"provider": "elevenlabs"},
@ -315,6 +316,7 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys): def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
config = { config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"}, "model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"}, "tts": {"provider": "edge"},

View file

@ -0,0 +1,29 @@
"""Tests for shared truthy-value helpers."""
from utils import env_var_enabled, is_truthy_value
def test_is_truthy_value_accepts_common_truthy_strings():
assert is_truthy_value("true") is True
assert is_truthy_value(" YES ") is True
assert is_truthy_value("on") is True
assert is_truthy_value("1") is True
def test_is_truthy_value_respects_default_for_none():
assert is_truthy_value(None, default=True) is True
assert is_truthy_value(None, default=False) is False
def test_is_truthy_value_rejects_falsey_strings():
assert is_truthy_value("false") is False
assert is_truthy_value("0") is False
assert is_truthy_value("off") is False
def test_env_var_enabled_uses_shared_truthy_rules(monkeypatch):
monkeypatch.setenv("HERMES_TEST_BOOL", "YeS")
assert env_var_enabled("HERMES_TEST_BOOL") is True
monkeypatch.setenv("HERMES_TEST_BOOL", "no")
assert env_var_enabled("HERMES_TEST_BOOL") is False

View file

@ -45,6 +45,11 @@ def _restore_tool_and_agent_modules():
sys.modules.update(original_modules) sys.modules.update(original_modules)
@pytest.fixture(autouse=True)
def _enable_managed_nous_tools(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
def _install_fake_tools_package(): def _install_fake_tools_package():
_reset_modules(("tools", "agent")) _reset_modules(("tools", "agent"))

View file

@ -44,6 +44,11 @@ def _restore_tool_and_agent_modules():
sys.modules.update(original_modules) sys.modules.update(original_modules)
@pytest.fixture(autouse=True)
def _enable_managed_nous_tools(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
def _install_fake_tools_package(): def _install_fake_tools_package():
tools_package = types.ModuleType("tools") tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]

View file

@ -16,7 +16,14 @@ resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain(): def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
):
result = resolve_managed_tool_gateway( result = resolve_managed_tool_gateway(
"firecrawl", "firecrawl",
token_reader=lambda: "nous-token", token_reader=lambda: "nous-token",
@ -29,7 +36,14 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain()
def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
with patch.dict(os.environ, {"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/"}, clear=False): with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/",
},
clear=False,
):
result = resolve_managed_tool_gateway( result = resolve_managed_tool_gateway(
"browserbase", "browserbase",
token_reader=lambda: "nous-token", token_reader=lambda: "nous-token",
@ -40,7 +54,14 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
):
result = resolve_managed_tool_gateway( result = resolve_managed_tool_gateway(
"firecrawl", "firecrawl",
token_reader=lambda: None, token_reader=lambda: None,
@ -49,6 +70,16 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
assert result is None assert result is None
def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
)
assert result is None
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch): def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("HERMES_HOME", str(tmp_path))

View file

@ -7,6 +7,7 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool")
def _clear_terminal_env(monkeypatch): def _clear_terminal_env(monkeypatch):
"""Remove terminal env vars that could affect requirements checks.""" """Remove terminal env vars that could affect requirements checks."""
keys = [ keys = [
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"TERMINAL_ENV", "TERMINAL_ENV",
"TERMINAL_MODAL_MODE", "TERMINAL_MODAL_MODE",
"TERMINAL_SSH_HOST", "TERMINAL_SSH_HOST",
@ -73,13 +74,14 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
assert ok is False assert ok is False
assert any( assert any(
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" in record.getMessage() "Modal backend selected but no direct Modal credentials/config was found" in record.getMessage()
for record in caplog.records for record in caplog.records
) )
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path): def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
_clear_terminal_env(monkeypatch) _clear_terminal_env(monkeypatch)
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path))
@ -115,3 +117,21 @@ def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, ca
"TERMINAL_MODAL_MODE=direct" in record.getMessage() "TERMINAL_MODAL_MODE=direct" in record.getMessage()
for record in caplog.records for record in caplog.records
) )
def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkeypatch, caplog, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
with caplog.at_level(logging.ERROR):
ok = terminal_tool_module.check_terminal_requirements()
assert ok is False
assert any(
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage()
for record in caplog.records
)

View file

@ -28,6 +28,7 @@ class TestTerminalRequirements:
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names) assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path): def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)

View file

@ -11,6 +11,8 @@ Coverage:
import importlib import importlib
import json import json
import os import os
import sys
import types
import pytest import pytest
from unittest.mock import patch, MagicMock, AsyncMock from unittest.mock import patch, MagicMock, AsyncMock
@ -24,6 +26,7 @@ class TestFirecrawlClientConfig:
tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None tools.web_tools._firecrawl_client_config = None
for key in ( for key in (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL", "FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL", "FIRECRAWL_GATEWAY_URL",
@ -32,6 +35,7 @@ class TestFirecrawlClientConfig:
"TOOL_GATEWAY_USER_TOKEN", "TOOL_GATEWAY_USER_TOKEN",
): ):
os.environ.pop(key, None) os.environ.pop(key, None)
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
def teardown_method(self): def teardown_method(self):
"""Reset client after each test.""" """Reset client after each test."""
@ -39,6 +43,7 @@ class TestFirecrawlClientConfig:
tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None tools.web_tools._firecrawl_client_config = None
for key in ( for key in (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL", "FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL", "FIRECRAWL_GATEWAY_URL",
@ -293,6 +298,7 @@ class TestBackendSelection:
""" """
_ENV_KEYS = ( _ENV_KEYS = (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"PARALLEL_API_KEY", "PARALLEL_API_KEY",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL", "FIRECRAWL_API_URL",
@ -304,8 +310,10 @@ class TestBackendSelection:
) )
def setup_method(self): def setup_method(self):
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
for key in self._ENV_KEYS: for key in self._ENV_KEYS:
os.environ.pop(key, None) if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
os.environ.pop(key, None)
def teardown_method(self): def teardown_method(self):
for key in self._ENV_KEYS: for key in self._ENV_KEYS:
@ -417,11 +425,25 @@ class TestParallelClientConfig:
import tools.web_tools import tools.web_tools
tools.web_tools._parallel_client = None tools.web_tools._parallel_client = None
os.environ.pop("PARALLEL_API_KEY", None) os.environ.pop("PARALLEL_API_KEY", None)
fake_parallel = types.ModuleType("parallel")
class Parallel:
def __init__(self, api_key):
self.api_key = api_key
class AsyncParallel:
def __init__(self, api_key):
self.api_key = api_key
fake_parallel.Parallel = Parallel
fake_parallel.AsyncParallel = AsyncParallel
sys.modules["parallel"] = fake_parallel
def teardown_method(self): def teardown_method(self):
import tools.web_tools import tools.web_tools
tools.web_tools._parallel_client = None tools.web_tools._parallel_client = None
os.environ.pop("PARALLEL_API_KEY", None) os.environ.pop("PARALLEL_API_KEY", None)
sys.modules.pop("parallel", None)
def test_creates_client_with_key(self): def test_creates_client_with_key(self):
"""PARALLEL_API_KEY set → creates Parallel client.""" """PARALLEL_API_KEY set → creates Parallel client."""
@ -479,6 +501,7 @@ class TestCheckWebApiKey:
"""Test suite for check_web_api_key() unified availability check.""" """Test suite for check_web_api_key() unified availability check."""
_ENV_KEYS = ( _ENV_KEYS = (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"PARALLEL_API_KEY", "PARALLEL_API_KEY",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL", "FIRECRAWL_API_URL",
@ -490,8 +513,10 @@ class TestCheckWebApiKey:
) )
def setup_method(self): def setup_method(self):
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
for key in self._ENV_KEYS: for key in self._ENV_KEYS:
os.environ.pop(key, None) if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
os.environ.pop(key, None)
def teardown_method(self): def teardown_method(self):
for key in self._ENV_KEYS: for key in self._ENV_KEYS:

View file

@ -10,6 +10,7 @@ import requests
from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.base import CloudBrowserProvider
from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {} _pending_create_keys: Dict[str, str] = {}
@ -93,10 +94,15 @@ class BrowserbaseProvider(CloudBrowserProvider):
def _get_config(self) -> Dict[str, Any]: def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none() config = self._get_config_or_none()
if config is None: if config is None:
raise ValueError( message = (
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials " "Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials."
"or a managed Browserbase gateway configuration."
) )
if managed_nous_tools_enabled():
message = (
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID "
"credentials or a managed Browserbase gateway configuration."
)
raise ValueError(message)
return config return config
def create_session(self, task_id: str) -> Dict[str, object]: def create_session(self, task_id: str) -> Dict[str, object]:

View file

@ -39,6 +39,7 @@ from urllib.parse import urlencode
import fal_client import fal_client
from tools.debug_helpers import DebugSession from tools.debug_helpers import DebugSession
from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -416,9 +417,10 @@ def image_generate_tool(
# Check API key availability # Check API key availability
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()): if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
raise ValueError( message = "FAL_KEY environment variable not set"
"FAL_KEY environment variable not set and managed FAL gateway is unavailable" if managed_nous_tools_enabled():
) message += " and managed FAL gateway is unavailable"
raise ValueError(message)
# Validate other parameters # Validate other parameters
validated_params = _validate_parameters( validated_params = _validate_parameters(

View file

@ -9,6 +9,7 @@ from dataclasses import dataclass
from typing import Callable, Optional from typing import Callable, Optional
from hermes_cli.config import get_hermes_home from hermes_cli.config import get_hermes_home
from tools.tool_backend_helpers import managed_nous_tools_enabled
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com" _DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
_DEFAULT_TOOL_GATEWAY_SCHEME = "https" _DEFAULT_TOOL_GATEWAY_SCHEME = "https"
@ -131,6 +132,9 @@ def resolve_managed_tool_gateway(
token_reader: Optional[Callable[[], Optional[str]]] = None, token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[ManagedToolGatewayConfig]: ) -> Optional[ManagedToolGatewayConfig]:
"""Resolve shared managed-tool gateway config for a vendor.""" """Resolve shared managed-tool gateway config for a vendor."""
if not managed_nous_tools_enabled():
return None
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
resolved_token_reader = token_reader or read_nous_access_token resolved_token_reader = token_reader or read_nous_access_token

View file

@ -65,7 +65,12 @@ def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None:
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py # Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
from tools.environments.singularity import _get_scratch_dir from tools.environments.singularity import _get_scratch_dir
from tools.tool_backend_helpers import has_direct_modal_credentials, normalize_modal_mode from tools.tool_backend_helpers import (
coerce_modal_mode,
has_direct_modal_credentials,
managed_nous_tools_enabled,
normalize_modal_mode,
)
# Disk usage warning threshold (in GB) # Disk usage warning threshold (in GB)
@ -506,7 +511,7 @@ def _get_env_config() -> Dict[str, Any]:
return { return {
"env_type": env_type, "env_type": env_type,
"modal_mode": normalize_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), "modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"), "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
@ -541,9 +546,13 @@ def _get_env_config() -> Dict[str, Any]:
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
"""Resolve direct vs managed Modal backend selection.""" """Resolve direct vs managed Modal backend selection."""
requested_mode = coerce_modal_mode(modal_mode)
normalized_mode = normalize_modal_mode(modal_mode) normalized_mode = normalize_modal_mode(modal_mode)
has_direct = has_direct_modal_credentials() has_direct = has_direct_modal_credentials()
managed_ready = is_managed_tool_gateway_ready("modal") managed_ready = is_managed_tool_gateway_ready("modal")
managed_mode_blocked = (
requested_mode == "managed" and not managed_nous_tools_enabled()
)
if normalized_mode == "managed": if normalized_mode == "managed":
selected_backend = "managed" if managed_ready else None selected_backend = "managed" if managed_ready else None
@ -553,9 +562,11 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
selected_backend = "direct" if has_direct else "managed" if managed_ready else None selected_backend = "direct" if has_direct else "managed" if managed_ready else None
return { return {
"requested_mode": requested_mode,
"mode": normalized_mode, "mode": normalized_mode,
"has_direct": has_direct, "has_direct": has_direct,
"managed_ready": managed_ready, "managed_ready": managed_ready,
"managed_mode_blocked": managed_mode_blocked,
"selected_backend": selected_backend, "selected_backend": selected_backend,
} }
@ -636,6 +647,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
) )
if modal_state["selected_backend"] != "direct": if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag or "
"choose TERMINAL_MODAL_MODE=direct/auto."
)
if modal_state["mode"] == "managed": if modal_state["mode"] == "managed":
raise ValueError( raise ValueError(
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable." "Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
@ -644,9 +662,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
raise ValueError( raise ValueError(
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found." "Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
) )
raise ValueError( message = "Modal backend selected but no direct Modal credentials/config was found."
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found." if managed_nous_tools_enabled():
) message = (
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
)
raise ValueError(message)
return _ModalEnvironment( return _ModalEnvironment(
image=image, cwd=cwd, timeout=timeout, image=image, cwd=cwd, timeout=timeout,
@ -1283,25 +1304,48 @@ def check_terminal_requirements() -> bool:
return True return True
if modal_state["selected_backend"] != "direct": if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag "
"or choose TERMINAL_MODAL_MODE=direct/auto."
)
return False
if modal_state["mode"] == "managed": if modal_state["mode"] == "managed":
logger.error( logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed " "Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
"tool gateway is unavailable. Configure the managed gateway or choose " "tool gateway is unavailable. Configure the managed gateway or choose "
"TERMINAL_MODAL_MODE=direct/auto." "TERMINAL_MODAL_MODE=direct/auto."
) )
return False
elif modal_state["mode"] == "direct": elif modal_state["mode"] == "direct":
logger.error( if managed_nous_tools_enabled():
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " logger.error(
"Modal credentials/config were found. Configure Modal or choose " "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"TERMINAL_MODAL_MODE=managed/auto." "Modal credentials/config were found. Configure Modal or choose "
) "TERMINAL_MODAL_MODE=managed/auto."
)
else:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=auto."
)
return False
else: else:
logger.error( if managed_nous_tools_enabled():
"Modal backend selected but no direct Modal credentials/config or managed " logger.error(
"tool gateway was found. Configure Modal, set up the managed gateway, " "Modal backend selected but no direct Modal credentials/config or managed "
"or choose a different TERMINAL_ENV." "tool gateway was found. Configure Modal, set up the managed gateway, "
) "or choose a different TERMINAL_ENV."
return False )
else:
logger.error(
"Modal backend selected but no direct Modal credentials/config was found. "
"Configure Modal or choose a different TERMINAL_ENV."
)
return False
if importlib.util.find_spec("swerex") is None: if importlib.util.find_spec("swerex") is None:
logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'") logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'")

View file

@ -5,26 +5,40 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from utils import env_var_enabled
_DEFAULT_BROWSER_PROVIDER = "local" _DEFAULT_BROWSER_PROVIDER = "local"
_DEFAULT_MODAL_MODE = "auto" _DEFAULT_MODAL_MODE = "auto"
_VALID_MODAL_MODES = {"auto", "direct", "managed"} _VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled() -> bool:
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
def normalize_browser_cloud_provider(value: object | None) -> str: def normalize_browser_cloud_provider(value: object | None) -> str:
"""Return a normalized browser provider key.""" """Return a normalized browser provider key."""
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower() provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
return provider or _DEFAULT_BROWSER_PROVIDER return provider or _DEFAULT_BROWSER_PROVIDER
def normalize_modal_mode(value: object | None) -> str: def coerce_modal_mode(value: object | None) -> str:
"""Return a normalized modal execution mode.""" """Return the requested modal mode when valid, else the default."""
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower() mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
if mode in _VALID_MODAL_MODES: if mode in _VALID_MODAL_MODES:
return mode return mode
return _DEFAULT_MODAL_MODE return _DEFAULT_MODAL_MODE
def normalize_modal_mode(value: object | None) -> str:
"""Return a normalized modal execution mode."""
mode = coerce_modal_mode(value)
if mode == "managed" and not managed_nous_tools_enabled():
return "direct"
return mode
def has_direct_modal_credentials() -> bool: def has_direct_modal_credentials() -> bool:
"""Return True when direct Modal credentials/config are available.""" """Return True when direct Modal credentials/config are available."""
return bool( return bool(

View file

@ -33,8 +33,9 @@ from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from urllib.parse import urljoin from urllib.parse import urljoin
from utils import is_truthy_value
from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import resolve_openai_audio_api_key from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
@ -122,11 +123,7 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
if stt_config is None: if stt_config is None:
stt_config = _load_stt_config() stt_config = _load_stt_config()
enabled = stt_config.get("enabled", True) enabled = stt_config.get("enabled", True)
if isinstance(enabled, str): return is_truthy_value(enabled, default=True)
return enabled.strip().lower() in ("true", "1", "yes", "on")
if enabled is None:
return True
return bool(enabled)
def _has_openai_audio_backend() -> bool: def _has_openai_audio_backend() -> bool:
@ -586,9 +583,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
managed_gateway = resolve_managed_tool_gateway("openai-audio") managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None: if managed_gateway is None:
raise ValueError( message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" if managed_nous_tools_enabled():
) message += ", and the managed OpenAI audio gateway is unavailable"
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin( return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"

View file

@ -40,7 +40,7 @@ from urllib.parse import urljoin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import resolve_openai_audio_api_key from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lazy imports -- providers are imported only when actually used to avoid # Lazy imports -- providers are imported only when actually used to avoid
@ -565,9 +565,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
managed_gateway = resolve_managed_tool_gateway("openai-audio") managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None: if managed_gateway is None:
raise ValueError( message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" if managed_nous_tools_enabled():
) message += ", and the managed OpenAI audio gateway is unavailable"
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin( return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"

View file

@ -54,6 +54,7 @@ from tools.managed_tool_gateway import (
read_nous_access_token as _read_nous_access_token, read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway, resolve_managed_tool_gateway,
) )
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.url_safety import is_safe_url from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access from tools.website_policy import check_website_access
@ -152,12 +153,46 @@ def _has_direct_firecrawl_config() -> bool:
def _raise_web_backend_configuration_error() -> None: def _raise_web_backend_configuration_error() -> None:
"""Raise a clear error for unsupported web backend configuration.""" """Raise a clear error for unsupported web backend configuration."""
raise ValueError( message = (
"Web tools are not configured. " "Web tools are not configured. "
"Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, " "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance."
"or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
) )
if managed_nous_tools_enabled():
message += (
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
)
raise ValueError(message)
def _firecrawl_backend_help_suffix() -> str:
"""Return optional managed-gateway guidance for Firecrawl help text."""
if not managed_nous_tools_enabled():
return ""
return (
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
)
def _web_requires_env() -> list[str]:
"""Return tool metadata env vars for the currently enabled web backends."""
requires = [
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
]
if managed_nous_tools_enabled():
requires.extend(
[
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
]
)
return requires
def _get_firecrawl_client(): def _get_firecrawl_client():
@ -1410,10 +1445,8 @@ async def web_crawl_tool(
# web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
if not check_firecrawl_api_key(): if not check_firecrawl_api_key():
return json.dumps({ return json.dumps({
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
"or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, " f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.",
"or TOOL_GATEWAY_DOMAIN, "
"or use web_search + web_extract instead.",
"success": False, "success": False,
}, ensure_ascii=False) }, ensure_ascii=False)
@ -1754,9 +1787,8 @@ if __name__ == "__main__":
else: else:
print("❌ No web search backend configured") print("❌ No web search backend configured")
print( print(
"Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
"or, if you are a Nous Subscriber, login to Nous and use " f"{_firecrawl_backend_help_suffix()}"
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
) )
if not nous_available: if not nous_available:
@ -1867,16 +1899,7 @@ registry.register(
schema=WEB_SEARCH_SCHEMA, schema=WEB_SEARCH_SCHEMA,
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5), handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
check_fn=check_web_api_key, check_fn=check_web_api_key,
requires_env=[ requires_env=_web_requires_env(),
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
],
emoji="🔍", emoji="🔍",
) )
registry.register( registry.register(
@ -1886,16 +1909,7 @@ registry.register(
handler=lambda args, **kw: web_extract_tool( handler=lambda args, **kw: web_extract_tool(
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"), args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
check_fn=check_web_api_key, check_fn=check_web_api_key,
requires_env=[ requires_env=_web_requires_env(),
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
],
is_async=True, is_async=True,
emoji="📄", emoji="📄",
) )

View file

@ -9,6 +9,25 @@ from typing import Any, Union
import yaml import yaml
TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
def is_truthy_value(value: Any, default: bool = False) -> bool:
"""Coerce bool-ish values using the project's shared truthy string set."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in TRUTHY_STRINGS
return bool(value)
def env_var_enabled(name: str, default: str = "") -> bool:
"""Return True when an environment variable is set to a truthy value."""
return is_truthy_value(os.getenv(name, default), default=False)
def atomic_json_write( def atomic_json_write(
path: Union[str, Path], path: Union[str, Path],
data: Any, data: Any,

View file

@ -78,9 +78,6 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) |
| `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) |
| `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | | `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) |
| `TOOL_GATEWAY_DOMAIN` | Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, for example `nousresearch.com` -> `firecrawl-gateway.nousresearch.com` |
| `TOOL_GATEWAY_SCHEME` | Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts, `https` by default and `http` for local gateway testing |
| `TOOL_GATEWAY_USER_TOKEN` | Explicit Nous Subscriber access token for tool-gateway calls (optional; otherwise Hermes reads `~/.hermes/auth.json`) |
| `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) | | `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) |
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) |

View file

@ -725,9 +725,9 @@ If terminal commands fail immediately or the terminal tool is reported as disabl
- If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend.
- **Modal backend** - **Modal backend**
- Hermes can use either direct Modal credentials (`MODAL_TOKEN_ID` plus `MODAL_TOKEN_SECRET`, or `~/.modal.toml`) or a configured managed tool gateway with a Nous user token. - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file.
- Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox. - Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox.
- If neither direct credentials nor a managed gateway is present, Hermes will report that the Modal backend is not available. - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available.
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.

View file

@ -109,13 +109,6 @@ modal setup
hermes config set terminal.backend modal hermes config set terminal.backend modal
``` ```
Hermes can use Modal in two modes:
- **Direct Modal**: Hermes talks to your Modal account directly.
- **Managed Modal**: Hermes talks to a gateway that owns the vendor credentials.
In both cases, Modal is best treated as a task sandbox, not a deployment target. Persistent mode preserves filesystem state so later turns can resume your work, but Hermes may still clean up or recreate the live sandbox. Long-running servers and background processes are not guaranteed to survive idle cleanup, session teardown, or Hermes exit.
### Container Resources ### Container Resources
Configure CPU, memory, disk, and persistence for all container backends: Configure CPU, memory, disk, and persistence for all container backends: