mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 09:17:09 +08:00
Compare commits
3 Commits
fix/nix-sh
...
fix/oauth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f90afa03cc | ||
|
|
c6974fd108 | ||
|
|
c6dba918b3 |
@@ -1137,7 +1137,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
|||||||
main_model = _read_main_model()
|
main_model = _read_main_model()
|
||||||
if (main_provider and main_model
|
if (main_provider and main_model
|
||||||
and main_provider not in _AGGREGATOR_PROVIDERS
|
and main_provider not in _AGGREGATOR_PROVIDERS
|
||||||
and main_provider not in ("auto", "custom", "")):
|
and main_provider not in ("auto", "")):
|
||||||
client, resolved = resolve_provider_client(main_provider, main_model)
|
client, resolved = resolve_provider_client(main_provider, main_model)
|
||||||
if client is not None:
|
if client is not None:
|
||||||
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
|
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
|
||||||
|
|||||||
@@ -513,6 +513,13 @@ class CredentialPool:
|
|||||||
except Exception as wexc:
|
except Exception as wexc:
|
||||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||||
elif self.provider == "openai-codex":
|
elif self.provider == "openai-codex":
|
||||||
|
# Proactively sync from ~/.codex/auth.json before refresh.
|
||||||
|
# The Codex CLI (or another Hermes profile) may have already
|
||||||
|
# consumed our refresh_token. Syncing first avoids a
|
||||||
|
# "refresh_token_reused" error when the CLI has a newer pair.
|
||||||
|
synced = self._sync_codex_entry_from_cli(entry)
|
||||||
|
if synced is not entry:
|
||||||
|
entry = synced
|
||||||
refreshed = auth_mod.refresh_codex_oauth_pure(
|
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||||
entry.access_token,
|
entry.access_token,
|
||||||
entry.refresh_token,
|
entry.refresh_token,
|
||||||
@@ -598,6 +605,35 @@ class CredentialPool:
|
|||||||
# Credentials file had a valid (non-expired) token — use it directly
|
# Credentials file had a valid (non-expired) token — use it directly
|
||||||
logger.debug("Credentials file has valid token, using without refresh")
|
logger.debug("Credentials file has valid token, using without refresh")
|
||||||
return synced
|
return synced
|
||||||
|
# For openai-codex: the refresh_token may have been consumed by
|
||||||
|
# the Codex CLI between our proactive sync and the refresh call.
|
||||||
|
# Re-sync and retry once.
|
||||||
|
if self.provider == "openai-codex":
|
||||||
|
synced = self._sync_codex_entry_from_cli(entry)
|
||||||
|
if synced.refresh_token != entry.refresh_token:
|
||||||
|
logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
|
||||||
|
try:
|
||||||
|
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||||
|
synced.access_token,
|
||||||
|
synced.refresh_token,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
synced,
|
||||||
|
access_token=refreshed["access_token"],
|
||||||
|
refresh_token=refreshed["refresh_token"],
|
||||||
|
last_refresh=refreshed.get("last_refresh"),
|
||||||
|
last_status=STATUS_OK,
|
||||||
|
last_status_at=None,
|
||||||
|
last_error_code=None,
|
||||||
|
)
|
||||||
|
self._replace_entry(synced, updated)
|
||||||
|
self._persist()
|
||||||
|
return updated
|
||||||
|
except Exception as retry_exc:
|
||||||
|
logger.debug("Codex retry refresh also failed: %s", retry_exc)
|
||||||
|
elif not self._entry_needs_refresh(synced):
|
||||||
|
logger.debug("Codex CLI has valid token, using without refresh")
|
||||||
|
return synced
|
||||||
self._mark_exhausted(entry, None)
|
self._mark_exhausted(entry, None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def _attach_agent(
|
|||||||
session_completion_tokens=completion_tokens,
|
session_completion_tokens=completion_tokens,
|
||||||
session_total_tokens=total_tokens,
|
session_total_tokens=total_tokens,
|
||||||
session_api_calls=api_calls,
|
session_api_calls=api_calls,
|
||||||
|
get_rate_limit_state=lambda: None,
|
||||||
context_compressor=SimpleNamespace(
|
context_compressor=SimpleNamespace(
|
||||||
last_prompt_tokens=context_tokens,
|
last_prompt_tokens=context_tokens,
|
||||||
context_length=context_length,
|
context_length=context_length,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def _isolate_hermes_home(tmp_path, monkeypatch):
|
|||||||
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
||||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||||
|
# Avoid making real calls during tests if this key is set in the env files
|
||||||
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ def _make_timeout_error() -> httpx.TimeoutException:
|
|||||||
# cache_image_from_url (base.py)
|
# cache_image_from_url (base.py)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||||
class TestCacheImageFromUrl:
|
class TestCacheImageFromUrl:
|
||||||
"""Tests for gateway.platforms.base.cache_image_from_url"""
|
"""Tests for gateway.platforms.base.cache_image_from_url"""
|
||||||
|
|
||||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A clean 200 response caches the image and returns a path."""
|
"""A clean 200 response caches the image and returns a path."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ class TestCacheImageFromUrl:
|
|||||||
assert path.endswith(".jpg")
|
assert path.endswith(".jpg")
|
||||||
mock_client.get.assert_called_once()
|
mock_client.get.assert_called_once()
|
||||||
|
|
||||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ class TestCacheImageFromUrl:
|
|||||||
assert mock_client.get.call_count == 2
|
assert mock_client.get.call_count == 2
|
||||||
mock_sleep.assert_called_once()
|
mock_sleep.assert_called_once()
|
||||||
|
|
||||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ class TestCacheImageFromUrl:
|
|||||||
assert path.endswith(".jpg")
|
assert path.endswith(".jpg")
|
||||||
assert mock_client.get.call_count == 2
|
assert mock_client.get.call_count == 2
|
||||||
|
|
||||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""Timeout on every attempt raises after all retries are consumed."""
|
"""Timeout on every attempt raises after all retries are consumed."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ class TestCacheImageFromUrl:
|
|||||||
# 3 total calls: initial + 2 retries
|
# 3 total calls: initial + 2 retries
|
||||||
assert mock_client.get.call_count == 3
|
assert mock_client.get.call_count == 3
|
||||||
|
|
||||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||||
|
|
||||||
@@ -175,10 +176,11 @@ class TestCacheImageFromUrl:
|
|||||||
# cache_audio_from_url (base.py)
|
# cache_audio_from_url (base.py)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||||
class TestCacheAudioFromUrl:
|
class TestCacheAudioFromUrl:
|
||||||
"""Tests for gateway.platforms.base.cache_audio_from_url"""
|
"""Tests for gateway.platforms.base.cache_audio_from_url"""
|
||||||
|
|
||||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A clean 200 response caches the audio and returns a path."""
|
"""A clean 200 response caches the audio and returns a path."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
@@ -202,7 +204,7 @@ class TestCacheAudioFromUrl:
|
|||||||
assert path.endswith(".ogg")
|
assert path.endswith(".ogg")
|
||||||
mock_client.get.assert_called_once()
|
mock_client.get.assert_called_once()
|
||||||
|
|
||||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
@@ -232,7 +234,7 @@ class TestCacheAudioFromUrl:
|
|||||||
assert mock_client.get.call_count == 2
|
assert mock_client.get.call_count == 2
|
||||||
mock_sleep.assert_called_once()
|
mock_sleep.assert_called_once()
|
||||||
|
|
||||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
@@ -259,7 +261,7 @@ class TestCacheAudioFromUrl:
|
|||||||
assert path.endswith(".ogg")
|
assert path.endswith(".ogg")
|
||||||
assert mock_client.get.call_count == 2
|
assert mock_client.get.call_count == 2
|
||||||
|
|
||||||
def test_retries_on_500_then_succeeds(self, tmp_path, monkeypatch):
|
def test_retries_on_500_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A 500 response on the first attempt is retried; second attempt succeeds."""
|
"""A 500 response on the first attempt is retried; second attempt succeeds."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
@@ -286,7 +288,7 @@ class TestCacheAudioFromUrl:
|
|||||||
assert path.endswith(".ogg")
|
assert path.endswith(".ogg")
|
||||||
assert mock_client.get.call_count == 2
|
assert mock_client.get.call_count == 2
|
||||||
|
|
||||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""Timeout on every attempt raises after all retries are consumed."""
|
"""Timeout on every attempt raises after all retries are consumed."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
@@ -309,7 +311,7 @@ class TestCacheAudioFromUrl:
|
|||||||
# 3 total calls: initial + 2 retries
|
# 3 total calls: initial + 2 retries
|
||||||
assert mock_client.get.call_count == 3
|
assert mock_client.get.call_count == 3
|
||||||
|
|
||||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import base64
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -355,7 +355,8 @@ class TestMediaUpload:
|
|||||||
assert calls[3][1]["chunk_index"] == 2
|
assert calls[3][1]["chunk_index"] == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_download_remote_bytes_rejects_large_content_length(self):
|
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||||
|
async def test_download_remote_bytes_rejects_large_content_length(self, _mock_safe):
|
||||||
from gateway.platforms.wecom import WeComAdapter
|
from gateway.platforms.wecom import WeComAdapter
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
|
|||||||
@@ -628,14 +628,21 @@ class TestHasAnyProviderConfigured:
|
|||||||
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
||||||
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
||||||
from hermes_cli import config as config_module
|
from hermes_cli import config as config_module
|
||||||
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
hermes_home = tmp_path / ".hermes"
|
hermes_home = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||||
# Clear all provider env vars so earlier checks don't short-circuit
|
# Clear all provider env vars so earlier checks don't short-circuit
|
||||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||||
|
for pconfig in PROVIDER_REGISTRY.values():
|
||||||
|
if pconfig.auth_type == "api_key":
|
||||||
|
_all_vars.update(pconfig.api_key_env_vars)
|
||||||
|
for var in _all_vars:
|
||||||
monkeypatch.delenv(var, raising=False)
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||||
# Simulate valid Claude Code credentials
|
# Simulate valid Claude Code credentials
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||||
@@ -710,6 +717,7 @@ class TestHasAnyProviderConfigured:
|
|||||||
"""config.yaml model dict with empty default and no creds stays false."""
|
"""config.yaml model dict with empty default and no creds stays false."""
|
||||||
import yaml
|
import yaml
|
||||||
from hermes_cli import config as config_module
|
from hermes_cli import config as config_module
|
||||||
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
hermes_home = tmp_path / ".hermes"
|
hermes_home = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
config_file = hermes_home / "config.yaml"
|
config_file = hermes_home / "config.yaml"
|
||||||
@@ -719,9 +727,15 @@ class TestHasAnyProviderConfigured:
|
|||||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||||
|
for pconfig in PROVIDER_REGISTRY.values():
|
||||||
|
if pconfig.auth_type == "api_key":
|
||||||
|
_all_vars.update(pconfig.api_key_env_vars)
|
||||||
|
for var in _all_vars:
|
||||||
monkeypatch.delenv(var, raising=False)
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||||
from hermes_cli.main import _has_any_provider_configured
|
from hermes_cli.main import _has_any_provider_configured
|
||||||
assert _has_any_provider_configured() is False
|
assert _has_any_provider_configured() is False
|
||||||
|
|
||||||
@@ -941,9 +955,10 @@ class TestHuggingFaceModels:
|
|||||||
"""Every HF model should have a context length entry."""
|
"""Every HF model should have a context length entry."""
|
||||||
from hermes_cli.models import _PROVIDER_MODELS
|
from hermes_cli.models import _PROVIDER_MODELS
|
||||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||||
|
lower_keys = {k.lower() for k in DEFAULT_CONTEXT_LENGTHS}
|
||||||
hf_models = _PROVIDER_MODELS["huggingface"]
|
hf_models = _PROVIDER_MODELS["huggingface"]
|
||||||
for model in hf_models:
|
for model in hf_models:
|
||||||
assert model in DEFAULT_CONTEXT_LENGTHS, (
|
assert model.lower() in lower_keys, (
|
||||||
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,14 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
|||||||
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
||||||
|
# Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run
|
||||||
|
# import) from adding extra platforms. The loop in tools_command runs
|
||||||
|
# apply_nous_managed_defaults per platform; a second iteration sees values
|
||||||
|
# set by the first as "explicit" and skips them.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_enabled_platforms",
|
||||||
|
lambda: ["cli"],
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.nous_subscription.get_nous_auth_status",
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||||
lambda: {"logged_in": True},
|
lambda: {"logged_in": True},
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ class TestCmdUpdateLaunchdRestart:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
gateway_cli, "is_macos", lambda: False,
|
gateway_cli, "is_macos", lambda: False,
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gateway_cli, "is_linux", lambda: True,
|
||||||
|
)
|
||||||
|
|
||||||
mock_run.side_effect = _make_run_side_effect(
|
mock_run.side_effect = _make_run_side_effect(
|
||||||
commit_count="3",
|
commit_count="3",
|
||||||
|
|||||||
@@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults:
|
|||||||
from hermes_cli.config import DEFAULT_CONFIG
|
from hermes_cli.config import DEFAULT_CONFIG
|
||||||
|
|
||||||
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
||||||
assert DEFAULT_CONFIG["_config_version"] == 12
|
assert DEFAULT_CONFIG["_config_version"] == 13
|
||||||
|
|||||||
@@ -258,28 +258,30 @@ def _make_execute_only_env(forward_env=None):
|
|||||||
|
|
||||||
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||||
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
||||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
# Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN
|
||||||
|
# is in the copilot provider's api_key_env_vars and gets stripped).
|
||||||
|
env = _make_execute_only_env(["DATABASE_URL"])
|
||||||
|
|
||||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||||
|
|
||||||
args = env._build_init_env_args()
|
args = env._build_init_env_args()
|
||||||
args_str = " ".join(args)
|
args_str = " ".join(args)
|
||||||
|
|
||||||
assert "GITHUB_TOKEN=value_from_dotenv" in args_str
|
assert "DATABASE_URL=value_from_dotenv" in args_str
|
||||||
|
|
||||||
|
|
||||||
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||||
"""Shell env vars take priority over .env file values in init env args."""
|
"""Shell env vars take priority over .env file values in init env args."""
|
||||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
env = _make_execute_only_env(["DATABASE_URL"])
|
||||||
|
|
||||||
monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell")
|
monkeypatch.setenv("DATABASE_URL", "value_from_shell")
|
||||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||||
|
|
||||||
args = env._build_init_env_args()
|
args = env._build_init_env_args()
|
||||||
args_str = " ".join(args)
|
args_str = " ".join(args)
|
||||||
|
|
||||||
assert "GITHUB_TOKEN=value_from_shell" in args_str
|
assert "DATABASE_URL=value_from_shell" in args_str
|
||||||
assert "value_from_dotenv" not in args_str
|
assert "value_from_dotenv" not in args_str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class TestBaseEnvCompatibility:
|
|||||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||||
source = base_env_path.read_text()
|
source = base_env_path.read_text()
|
||||||
tree = ast.parse(source)
|
tree = ast.parse(source)
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ class TestBaseEnvCompatibility:
|
|||||||
|
|
||||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||||
source = base_env_path.read_text()
|
source = base_env_path.read_text()
|
||||||
|
|
||||||
assert 'tool_call_parser: str = Field(' in source
|
assert 'tool_call_parser: str = Field(' in source
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ class TestSendMatrix:
|
|||||||
url = call_kwargs[0][0]
|
url = call_kwargs[0][0]
|
||||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
||||||
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
||||||
assert call_kwargs[1]["json"] == {"msgtype": "m.text", "body": "hello matrix"}
|
payload = call_kwargs[1]["json"]
|
||||||
|
assert payload["msgtype"] == "m.text"
|
||||||
|
assert payload["body"] == "hello matrix"
|
||||||
|
|
||||||
def test_http_error(self):
|
def test_http_error(self):
|
||||||
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class TestValidateImageUrl:
|
|||||||
"""Tests for URL validation, including urlparse-based netloc check."""
|
"""Tests for URL validation, including urlparse-based netloc check."""
|
||||||
|
|
||||||
def test_valid_https_url(self):
|
def test_valid_https_url(self):
|
||||||
|
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||||
|
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||||
|
]):
|
||||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||||
|
|
||||||
def test_valid_http_url(self):
|
def test_valid_http_url(self):
|
||||||
@@ -56,9 +59,15 @@ class TestValidateImageUrl:
|
|||||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||||
|
|
||||||
def test_valid_url_with_port(self):
|
def test_valid_url_with_port(self):
|
||||||
|
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||||
|
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||||
|
]):
|
||||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||||
|
|
||||||
def test_valid_url_with_path_only(self):
|
def test_valid_url_with_path_only(self):
|
||||||
|
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||||
|
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||||
|
]):
|
||||||
assert _validate_image_url("https://example.com/") is True
|
assert _validate_image_url("https://example.com/") is True
|
||||||
|
|
||||||
def test_rejects_empty_string(self):
|
def test_rejects_empty_string(self):
|
||||||
@@ -441,6 +450,11 @@ class TestVisionRequirements:
|
|||||||
(tmp_path / "auth.json").write_text(
|
(tmp_path / "auth.json").write_text(
|
||||||
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}'
|
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}'
|
||||||
)
|
)
|
||||||
|
# config.yaml must reference the codex provider so vision auto-detect
|
||||||
|
# falls back to the active provider via _read_main_provider().
|
||||||
|
(tmp_path / "config.yaml").write_text(
|
||||||
|
'model:\n default: gpt-4o\n provider: openai-codex\n'
|
||||||
|
)
|
||||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class TestWebCrawlTavily:
|
|||||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||||
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
||||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||||
|
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||||
from tools.web_tools import web_crawl_tool
|
from tools.web_tools import web_crawl_tool
|
||||||
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
||||||
@@ -244,6 +245,7 @@ class TestWebCrawlTavily:
|
|||||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||||
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
||||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||||
|
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||||
from tools.web_tools import web_crawl_tool
|
from tools.web_tools import web_crawl_tool
|
||||||
asyncio.get_event_loop().run_until_complete(
|
asyncio.get_event_loop().run_until_complete(
|
||||||
|
|||||||
Reference in New Issue
Block a user