mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
* remove Vercel AI Gateway provider and Vercel Sandbox terminal backend Both Vercel-hosted integrations are removed end-to-end. Users on the AI Gateway should switch to OpenRouter or one of the other aggregators (Nous Portal, Kilo Code). Users on the Vercel Sandbox backend should switch to Docker, Modal, Daytona, or SSH. What's removed: - `plugins/model-providers/ai-gateway/` provider plugin - `hermes_cli/vercel_auth.py` Vercel-Sandbox auth helper - `tools/environments/vercel_sandbox.py` terminal backend - `ai-gateway` provider wiring across auth, doctor, setup, models, config, status, providers, main, web_server, model_normalize, dump - `vercel_sandbox` backend wiring across terminal_tool, file_tools, code_execution_tool, file_operations, approval, skills_tool, environments/local, credential_files, lazy_deps, prompt_builder, cli, gateway/run - `AI_GATEWAY_BASE_URL` constant, `_AI_GATEWAY_HEADERS` auxiliary-client header set, run_agent base-URL header/reasoning special-cases - `[vercel]` pyproject extra and `vercel`/`vercel-workers` from uv.lock - env vars: `AI_GATEWAY_API_KEY`, `AI_GATEWAY_BASE_URL`, `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, `VERCEL_OIDC_TOKEN`, `TERMINAL_VERCEL_RUNTIME` - Tests: deletes test_ai_gateway_models.py and test_vercel_sandbox_environment.py; scrubs references across 23 surviving test files (no entire tests deleted unless they were dedicated to AI Gateway / Sandbox) - Docs: provider tables, env-var reference, setup guides, security notes, tool config, terminal-backend tables — English plus zh-Hans i18n parity - `hermes-agent` skill: provider table entry and remote-backend list What stays (intentional): - `popular-web-designs/templates/vercel.md` — CSS design reference, unrelated to Vercel-the-AI-product - `x-vercel-id` in `stream_diag.py` headers — generic Vercel CDN response header, useful diag signal on any Vercel-hosted endpoint - `vercel-labs/agent-browser` URL in browser config — lightpanda browser project, different OSS effort - `userStories.json` historical contributor entry mentioning Vercel Sandbox — archive, not active docs Validation: - 1153 tests in the 22 targeted files pass (`scripts/run_tests.sh`) - Full repo `py_compile` clean - Live import of every touched module + invariant check (no `ai-gateway` in `PROVIDER_REGISTRY`, no `_AI_GATEWAY_HEADERS`, no `vercel_sandbox` in `_REMOTE_TERMINAL_BACKENDS`) * test: convert profile-count check from change-detector to invariant The hardcoded "== 34" assertion broke when ai-gateway was removed. Per AGENTS.md change-detector-test guidance, assert the relationship (registry count >= number of plugin dirs) instead of a literal count. Counts shift when providers are added/removed; that's expected.
250 lines
9.9 KiB
Python
250 lines
9.9 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
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"}
|