hermes-agent/tests/hermes_cli/test_set_config_value.py
Siddharth Balyan 5af672c753
chore: remove Atropos RL environments and tinker-atropos integration (#26106)
* chore: remove Atropos RL environments, tools, tests, skill, and tinker-atropos submodule

Delete:
- environments/ (43 files — base env, agent loop, tool call parsers, benchmarks)
- rl_cli.py (standalone RL training CLI)
- tools/rl_training_tool.py (all 10 rl_* tools)
- tests: test_rl_training_tool, test_tool_call_parsers, test_managed_server_tool_support,
  test_agent_loop, test_agent_loop_vllm, test_agent_loop_tool_calling,
  test_terminalbench2_env_security
- optional-skills/mlops/hermes-atropos-environments/
- tinker-atropos git submodule + .gitmodules

* chore: remove RL/Atropos references from Python source

- toolsets.py: remove rl toolset block + update comment
- model_tools.py: remove rl_tools group + update async bridging comment
- hermes_cli/tools_config.py: remove RL display entry, _DEFAULT_OFF_TOOLSETS,
  setup block, and rl_training post-setup handler
- tools/budget_config.py: remove RL environment reference in docstring
- tests/test_model_tools.py: remove rl_tools from expected groups
- tests/run_agent/test_streaming_tool_call_repair.py: fix stale cross-reference

* chore: remove rl/yc-bench extras and tinker-atropos refs from pyproject.toml

- Remove rl extra (atroposlib, tinker, fastapi, uvicorn, wandb)
- Remove yc-bench extra
- Remove rl_cli from py-modules
- Remove [tool.ty.src] exclude for tinker-atropos
- Remove [tool.ruff] exclude for tinker-atropos
- Regenerate uv.lock

* chore: remove tinker-atropos from install/setup scripts

- setup-hermes.sh: remove entire tinker-atropos submodule install block
- scripts/install.sh: remove both tinker-atropos blocks (Termux + standard)
- scripts/install.ps1: remove tinker-atropos block
- nix/hermes-agent.nix: remove tinker-atropos pip install line

* chore: remove RL references from cli-config.yaml.example

* docs: remove Atropos/RL references from README, CONTRIBUTING, AGENTS.md

* docs: remove RL/Atropos references from website

- Delete: environments.md, rl-training.md, mlops-hermes-atropos-environments.md
- sidebars.ts: remove rl-training and environments sidebar entries
- optional-skills-catalog.md: remove hermes-atropos-environments row
- tools-reference.md: remove entire rl toolset section
- toolsets-reference.md: remove rl row + update example
- integrations/index.md: remove RL Training bullet
- architecture.md: remove environments/ from tree + RL section
- contributing.md: remove tinker-atropos setup
- updating.md: remove tinker-atropos install + stale submodule update

* chore: remove remaining RL/Atropos stragglers

- hermes_cli/config.py: remove TINKER_API_KEY + WANDB_API_KEY env var defs
- hermes_cli/doctor.py: remove Submodules check section (tinker-atropos)
- hermes_cli/setup.py: remove RL Training status check
- hermes_cli/status.py: remove Tinker + WandB from API key status display
- agent/display.py: remove both rl_* tool preview/activity blocks
- website/docs: remove RL references from providers.md + env-variables.md
- tests: remove TINKER_API_KEY from conftest, set_config_value, setup_script

* chore: remove RL training section from .env.example
2026-05-15 10:36:38 +05:30

257 lines
10 KiB
Python

"""Tests for set_config_value — verifying secrets route to .env and config to config.yaml."""
import argparse
import os
from pathlib import Path
from unittest.mock import patch, call
import pytest
from hermes_cli.config import set_config_value, config_command
@pytest.fixture(autouse=True)
def _isolated_hermes_home(tmp_path):
"""Point HERMES_HOME at a temp dir so tests never touch real config."""
env_file = tmp_path / ".env"
env_file.touch()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
yield tmp_path
def _read_env(tmp_path):
return (tmp_path / ".env").read_text()
def _read_config(tmp_path):
config_path = tmp_path / "config.yaml"
return config_path.read_text() if config_path.exists() else ""
# ---------------------------------------------------------------------------
# Explicit allowlist keys → .env
# ---------------------------------------------------------------------------
class TestExplicitAllowlist:
"""Keys in the hardcoded allowlist should always go to .env."""
@pytest.mark.parametrize("key", [
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"HONCHO_API_KEY",
"FIRECRAWL_API_KEY",
"BROWSERBASE_API_KEY",
"FAL_KEY",
"SUDO_PASSWORD",
"GITHUB_TOKEN",
"TELEGRAM_BOT_TOKEN",
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
])
def test_explicit_key_routes_to_env(self, key, _isolated_hermes_home):
set_config_value(key, "test-value-123")
env_content = _read_env(_isolated_hermes_home)
assert f"{key}=test-value-123" in env_content
# Must NOT appear in config.yaml
assert key not in _read_config(_isolated_hermes_home)
# ---------------------------------------------------------------------------
# Catch-all patterns → .env
# ---------------------------------------------------------------------------
class TestCatchAllPatterns:
"""Any key ending in _API_KEY or _TOKEN should route to .env."""
@pytest.mark.parametrize("key", [
"DAYTONA_API_KEY",
"ELEVENLABS_API_KEY",
"SOME_FUTURE_SERVICE_API_KEY",
"MY_CUSTOM_TOKEN",
"WHATSAPP_BOT_TOKEN",
])
def test_api_key_suffix_routes_to_env(self, key, _isolated_hermes_home):
set_config_value(key, "secret-456")
env_content = _read_env(_isolated_hermes_home)
assert f"{key}=secret-456" in env_content
assert key not in _read_config(_isolated_hermes_home)
def test_case_insensitive(self, _isolated_hermes_home):
"""Keys should be uppercased regardless of input casing."""
set_config_value("openai_api_key", "sk-test")
env_content = _read_env(_isolated_hermes_home)
assert "OPENAI_API_KEY=sk-test" in env_content
def test_terminal_ssh_prefix_routes_to_env(self, _isolated_hermes_home):
set_config_value("TERMINAL_SSH_PORT", "2222")
env_content = _read_env(_isolated_hermes_home)
assert "TERMINAL_SSH_PORT=2222" in env_content
# ---------------------------------------------------------------------------
# Non-secret keys → config.yaml
# ---------------------------------------------------------------------------
class TestConfigYamlRouting:
"""Regular config keys should go to config.yaml, NOT .env."""
def test_simple_key(self, _isolated_hermes_home):
set_config_value("model", "gpt-4o")
config = _read_config(_isolated_hermes_home)
assert "gpt-4o" in config
assert "model" not in _read_env(_isolated_hermes_home)
def test_nested_key(self, _isolated_hermes_home):
set_config_value("terminal.backend", "docker")
config = _read_config(_isolated_hermes_home)
assert "docker" in config
assert "terminal" not in _read_env(_isolated_hermes_home)
def test_terminal_image_goes_to_config(self, _isolated_hermes_home):
"""TERMINAL_DOCKER_IMAGE doesn't match _API_KEY or _TOKEN, so config.yaml."""
set_config_value("terminal.docker_image", "python:3.12")
config = _read_config(_isolated_hermes_home)
assert "python:3.12" in config
def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_hermes_home):
set_config_value("terminal.docker_mount_cwd_to_workspace", "true")
config = _read_config(_isolated_hermes_home)
env_content = _read_env(_isolated_hermes_home)
assert "docker_mount_cwd_to_workspace: 'true'" in config or "docker_mount_cwd_to_workspace: true" in config
assert (
"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content
or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content
)
def test_terminal_vercel_runtime_goes_to_config_and_env(self, _isolated_hermes_home):
set_config_value("terminal.vercel_runtime", "python3.13")
config = _read_config(_isolated_hermes_home)
env_content = _read_env(_isolated_hermes_home)
assert "vercel_runtime: python3.13" in config
assert "TERMINAL_VERCEL_RUNTIME=python3.13" in env_content
# ---------------------------------------------------------------------------
# Empty / falsy values — regression tests for #4277
# ---------------------------------------------------------------------------
class TestFalsyValues:
"""config set should accept empty strings and falsy values like '0'."""
def test_empty_string_routes_to_env(self, _isolated_hermes_home):
"""Blanking an API key should write an empty value to .env."""
set_config_value("OPENROUTER_API_KEY", "")
env_content = _read_env(_isolated_hermes_home)
assert "OPENROUTER_API_KEY=" in env_content
def test_empty_string_routes_to_config(self, _isolated_hermes_home):
"""Blanking a config key should write an empty string to config.yaml."""
set_config_value("model", "")
config = _read_config(_isolated_hermes_home)
assert "model: ''" in config or "model: \"\"" in config
def test_zero_routes_to_config(self, _isolated_hermes_home):
"""Setting a config key to '0' should write 0 to config.yaml."""
set_config_value("verbose", "0")
config = _read_config(_isolated_hermes_home)
assert "verbose: 0" in config
def test_config_command_rejects_missing_value(self):
"""config set with no value arg (None) should still exit."""
args = argparse.Namespace(config_command="set", key="model", value=None)
with pytest.raises(SystemExit):
config_command(args)
def test_config_command_accepts_empty_string(self, _isolated_hermes_home):
"""config set KEY '' should not exit — it should set the value."""
args = argparse.Namespace(config_command="set", key="model", value="")
config_command(args)
config = _read_config(_isolated_hermes_home)
assert "model" in config
# ---------------------------------------------------------------------------
# List navigation — regression tests for #17876
# ---------------------------------------------------------------------------
class TestListNavigation:
"""hermes config set must preserve YAML list fields when using numeric
indices. Before #17876, _set_nested would silently replace the entire
list with a dict, destroying every sibling entry.
"""
def _write_config(self, tmp_path, body):
(tmp_path / "config.yaml").write_text(body)
def test_indexed_set_preserves_sibling_list_entries(self, _isolated_hermes_home):
"""Setting custom_providers.0.api_key must not destroy entry 1."""
self._write_config(_isolated_hermes_home, (
"custom_providers:\n"
"- name: provider-a\n"
" api_key: old-a\n"
" base_url: https://a.example.com\n"
"- name: provider-b\n"
" api_key: old-b\n"
" base_url: https://b.example.com\n"
))
set_config_value("custom_providers.0.api_key", "new-a")
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
# The list must still be a list
assert isinstance(reloaded["custom_providers"], list)
assert len(reloaded["custom_providers"]) == 2
# Entry 0 was updated
assert reloaded["custom_providers"][0]["api_key"] == "new-a"
assert reloaded["custom_providers"][0]["name"] == "provider-a"
assert reloaded["custom_providers"][0]["base_url"] == "https://a.example.com"
# Entry 1 is untouched
assert reloaded["custom_providers"][1]["name"] == "provider-b"
assert reloaded["custom_providers"][1]["api_key"] == "old-b"
assert reloaded["custom_providers"][1]["base_url"] == "https://b.example.com"
def test_indexed_set_preserves_non_targeted_fields(self, _isolated_hermes_home):
"""Setting one field in a list entry must not drop other fields."""
self._write_config(_isolated_hermes_home, (
"custom_providers:\n"
"- name: provider-a\n"
" api_key: old\n"
" base_url: https://a.example.com\n"
" models:\n"
" foo: {}\n"
" bar: {}\n"
))
set_config_value("custom_providers.0.api_key", "rotated")
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
entry = reloaded["custom_providers"][0]
assert entry["api_key"] == "rotated"
assert entry["name"] == "provider-a"
assert entry["base_url"] == "https://a.example.com"
assert set(entry["models"].keys()) == {"foo", "bar"}
def test_deeper_nesting_through_list(self, _isolated_hermes_home):
"""Navigation path mixing dict → list → dict → scalar."""
self._write_config(_isolated_hermes_home, (
"platforms:\n"
" telegram:\n"
" allowlist:\n"
" - name: alice\n"
" role: admin\n"
" - name: bob\n"
" role: user\n"
))
set_config_value("platforms.telegram.allowlist.1.role", "admin")
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
allowlist = reloaded["platforms"]["telegram"]["allowlist"]
assert isinstance(allowlist, list)
assert allowlist[0] == {"name": "alice", "role": "admin"}
assert allowlist[1] == {"name": "bob", "role": "admin"}