Compare commits

...

8 Commits

Author SHA1 Message Date
Austin Pickett 0e0e0623ba ci: run GitHub Actions on Node 24
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:54:24 -04:00
Austin Pickett 59c9595480 fix(auth): avoid echoing provider in status output
Prevent CodeQL from treating the CLI provider argument as sensitive data in auth status output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:35:25 -04:00
Austin Pickett b91d91e68d fix(auth): avoid printing OAuth status details
Keep auth status output from echoing provider-sourced values so CodeQL does not flag token-derived metadata as clear-text sensitive logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:31:36 -04:00
Austin Pickett 2e12a5178a test(ci): stabilize remaining full-suite expectations
Keep plugin auth assertions focused on middleware behavior and patch vision fast-path config readers directly in the native-vision test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:11:56 -04:00
Austin Pickett cf22af0ce6 fix(tests): align full-suite expectations with current defaults
Update stale gateway and auxiliary-client tests for current defaults, harden media delivery and API kwargs helpers for partial fixtures, and keep process-scan tests on the intended ps fallback path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:55:13 -04:00
Austin Pickett 28abb72e7c test(e2e): bypass slash confirm for reset assertions
Keep the platform command e2e suite focused on the /new reset path by disabling destructive slash confirmation in its mocked runner fixture.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:35:23 -04:00
Austin Pickett 08cbf4aa7f fix(ci): restore all-extra install for PR checks
Avoid the quarantined mistralai package in broad extras and mark an already POSIX-gated process-group kill for the Windows footgun scanner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:21:00 -04:00
Austin Pickett 4817327bc4 fix(minimax): harden OAuth dashboard and runtime
Handle MiniMax OAuth expiry values consistently across CLI and dashboard flows, fix CLI status/add behavior, and force pooled OAuth runtime requests through Anthropic Messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 22:34:49 -04:00
38 changed files with 365 additions and 112 deletions
+3
View File
@@ -12,6 +12,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-attribution:
runs-on: ubuntu-latest
+4 -1
View File
@@ -16,6 +16,9 @@ permissions:
pages: write
id-token: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: pages
cancel-in-progress: false
@@ -39,7 +42,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: website/package-lock.json
+1
View File
@@ -36,6 +36,7 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
IMAGE_NAME: nousresearch/hermes-agent
jobs:
+4 -1
View File
@@ -10,6 +10,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
docs-site-checks:
runs-on: ubuntu-latest
@@ -18,7 +21,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: website/package-lock.json
+3
View File
@@ -26,6 +26,9 @@ permissions:
contents: read
pull-requests: write # needed to post/update PR comments
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
+3
View File
@@ -21,6 +21,9 @@ permissions:
contents: write
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: false
+3
View File
@@ -9,6 +9,9 @@ permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
+3
View File
@@ -53,6 +53,9 @@ permissions:
contents: read
security-events: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
scan:
name: Scan lockfiles
+4 -1
View File
@@ -14,6 +14,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-index:
# Only run on the upstream repository, not on forks
@@ -62,7 +65,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm
cache-dependency-path: website/package-lock.json
+3
View File
@@ -16,6 +16,9 @@ permissions:
pull-requests: write
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Narrow, high-signal scanner. Only fires on critical indicators of supply
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
+3
View File
@@ -15,6 +15,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
# Cancel in-progress runs for the same PR/branch
concurrency:
group: tests-${{ github.ref }}
+3
View File
@@ -60,6 +60,9 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+1
View File
@@ -0,0 +1 @@
v24.15.0
+8 -1
View File
@@ -9992,7 +9992,14 @@ class GatewayRunner:
_, cleaned = adapter.extract_images(response)
local_files, _ = adapter.extract_local_files(cleaned)
_thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
reply_anchor_fn = getattr(self, "_reply_anchor_for_event", None)
reply_anchor = reply_anchor_fn(event) if callable(reply_anchor_fn) else None
thread_meta_fn = getattr(self, "_thread_metadata_for_source", None)
if callable(thread_meta_fn):
_thread_meta = thread_meta_fn(event.source, reply_anchor)
else:
thread_id = getattr(event.source, "thread_id", None)
_thread_meta = {"thread_id": thread_id} if thread_id else None
from gateway.platforms.base import should_send_media_as_audio
+30 -11
View File
@@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
return get_qwen_auth_status()
if target == "google-gemini-cli":
return get_gemini_oauth_auth_status()
if target == "minimax-oauth":
return get_minimax_oauth_auth_status()
if target == "copilot-acp":
return get_external_process_provider_status(target)
# API-key providers
@@ -4757,6 +4759,20 @@ def _minimax_request_user_code(
return payload
def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool:
"""True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds)."""
return int(expired_in) > (now_ms // 2)
def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
"""Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
raw = int(expired_in)
now_ms = int(now.timestamp() * 1000)
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
return raw / 1000.0
return now.timestamp() + max(1, raw)
def _minimax_poll_token(
client: httpx.Client, *, portal_base_url: str, client_id: str,
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
@@ -4765,12 +4781,11 @@ def _minimax_poll_token(
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
import time as _time
now_ms = int(_time.time() * 1000)
if expired_in > now_ms // 2:
# Looks like a unix-ms timestamp.
deadline = expired_in / 1000.0
raw = int(expired_in)
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
deadline = raw / 1000.0
else:
# Treat as duration in seconds from now.
deadline = _time.time() + max(1, expired_in)
deadline = _time.time() + max(1, raw)
interval = max(2.0, (interval_ms or 2000) / 1000.0)
while _time.time() < deadline:
@@ -4884,8 +4899,10 @@ def _minimax_oauth_login(
)
now = datetime.now(timezone.utc)
expires_in_s = int(token_data["expired_in"])
expires_at = now.timestamp() + expires_in_s
expires_at_unix = _minimax_resolve_token_expiry_unix(
int(token_data["expired_in"]), now=now,
)
expires_in_s = max(0, int(expires_at_unix - now.timestamp()))
auth_state = {
"provider": "minimax-oauth",
@@ -4899,7 +4916,7 @@ def _minimax_oauth_login(
"refresh_token": token_data["refresh_token"],
"resource_url": token_data.get("resource_url"),
"obtained_at": now.isoformat(),
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
"expires_in": expires_in_s,
}
@@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state(
relogin_required=True,
)
now_dt = datetime.now(timezone.utc)
expires_in_s = int(payload["expired_in"])
expires_at_unix = _minimax_resolve_token_expiry_unix(
int(payload["expired_in"]), now=now_dt,
)
expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp()))
new_state = dict(state)
new_state.update({
"access_token": payload["access_token"],
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
"obtained_at": now_dt.isoformat(),
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
tz=timezone.utc).isoformat(),
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
"expires_in": expires_in_s,
})
_minimax_save_auth_state(new_state)
+17 -15
View File
@@ -375,10 +375,12 @@ def auth_add_command(args) -> None:
return
if provider == "minimax-oauth":
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
creds = resolve_minimax_oauth_runtime_credentials()
creds = auth_mod._minimax_oauth_login(
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=getattr(args, "timeout", None) or 15.0,
)
label = (getattr(args, "label", None) or "").strip() or label_from_token(
creds["api_key"],
creds["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
entry = PooledCredential(
@@ -388,8 +390,9 @@ def auth_add_command(args) -> None:
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:minimax_oauth",
access_token=creds["api_key"],
base_url=creds.get("base_url"),
access_token=creds["access_token"],
refresh_token=creds.get("refresh_token"),
base_url=creds.get("inference_base_url"),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
@@ -476,18 +479,17 @@ def auth_status_command(args) -> None:
raise SystemExit("Provider is required. Example: `hermes auth status spotify`.")
status = auth_mod.get_auth_status(provider)
if not status.get("logged_in"):
reason = status.get("error")
if reason:
print(f"{provider}: logged out ({reason})")
else:
print(f"{provider}: logged out")
# Avoid echoing provider error strings here. OAuth libraries and
# provider responses can include token-like fields in exception text,
# and this command may be copied into bug reports.
print("Authentication: logged out")
return
print(f"{provider}: logged in")
for key in ("auth_type", "client_id", "redirect_uri", "scope", "expires_at", "api_base_url"):
value = status.get(key)
if value:
print(f" {key}: {value}")
print("Authentication: logged in")
if status.get("expires_at") or status.get("expires_at_ms"):
print(" token: present (expiry available)")
if status.get("has_refresh_token"):
print(" refresh_token: present")
def auth_logout_command(args) -> None:
+8
View File
@@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry(
elif provider == "google-gemini-cli":
api_mode = "chat_completions"
base_url = base_url or "cloudcode-pa://google"
elif provider == "minimax-oauth":
# MiniMax OAuth tokens are valid only against the Anthropic Messages
# compatible endpoint. Do not honor stale model.api_mode values from a
# prior OpenAI-compatible provider, or the client will hit
# /chat/completions under /anthropic and receive a bare nginx 404.
api_mode = "anthropic_messages"
pconfig = PROVIDER_REGISTRY.get(provider)
base_url = base_url or (pconfig.inference_base_url if pconfig else "")
elif provider == "anthropic":
api_mode = "anthropic_messages"
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
+5 -2
View File
@@ -2053,6 +2053,7 @@ def _minimax_poller(session_id: str) -> None:
"""
from hermes_cli.auth import (
_minimax_poll_token,
_minimax_resolve_token_expiry_unix,
_minimax_save_auth_state,
MINIMAX_OAUTH_GLOBAL_INFERENCE,
MINIMAX_OAUTH_SCOPE,
@@ -2090,8 +2091,10 @@ def _minimax_poller(session_id: str) -> None:
# dashboard path; cn-region operators can still use the CLI
# flow which supports `--region cn`.
now = datetime.now(timezone.utc)
expires_in_s = int(token_data["expired_in"])
expires_at_ts = now.timestamp() + expires_in_s
expires_at_ts = _minimax_resolve_token_expiry_unix(
int(token_data["expired_in"]), now=now,
)
expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
auth_state = {
"provider": "minimax-oauth",
"region": sess.get("region", "global"),
+4 -3
View File
@@ -84,7 +84,10 @@ sms = ["aiohttp>=3.9.0,<4"]
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp>=1.2.0,<2"]
acp = ["agent-client-protocol>=0.9.0,<1.0"]
mistral = ["mistralai>=2.3.0,<3"]
# Temporarily empty while PyPI marks `mistralai` as quarantined, which exposes
# no installable files and makes `uv lock` / `.[all]` unsatisfiable. Runtime
# Mistral paths already degrade with a clear "package not installed" message.
mistral = []
bedrock = ["boto3>=1.35.0,<2"]
termux = [
# Baseline Android / Termux path for reliable fresh installs.
@@ -111,7 +114,6 @@ termux-all = [
"hermes-agent[dingtalk]",
"hermes-agent[feishu]",
"hermes-agent[google]",
"hermes-agent[mistral]",
"hermes-agent[bedrock]",
"hermes-agent[homeassistant]",
"hermes-agent[sms]",
@@ -169,7 +171,6 @@ all = [
"hermes-agent[dingtalk]",
"hermes-agent[feishu]",
"hermes-agent[google]",
"hermes-agent[mistral]",
"hermes-agent[bedrock]",
"hermes-agent[web]",
"hermes-agent[youtube]",
+3 -2
View File
@@ -9373,10 +9373,11 @@ class AIAgent:
# tool — this caches the entire tools array cross-session via
# Anthropic's tools→system→messages prefix order. The function
# returns a deep copy, so self.tools is never mutated.
if self._use_long_lived_prefix_cache and self.tools:
if getattr(self, "_use_long_lived_prefix_cache", False) and self.tools:
from agent.prompt_caching import mark_tools_for_long_lived_cache
tools_for_api = mark_tools_for_long_lived_cache(
self.tools, long_lived_ttl=self._long_lived_cache_ttl,
self.tools,
long_lived_ttl=getattr(self, "_long_lived_cache_ttl", "1h"),
)
else:
tools_for_api = self.tools
+1 -1
View File
@@ -666,7 +666,7 @@ class TestAuxiliaryPoolAwareness:
client, model = _try_nous()
assert client is not None
assert model == "google/gemini-3-flash-preview"
assert model == "qwen/qwen3.6-plus"
assert mock_openai.call_args.kwargs["api_key"] == "pooled-agent-key"
assert mock_openai.call_args.kwargs["base_url"] == "https://inference.pool.example/v1"
+8 -1
View File
@@ -177,7 +177,11 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
)
runner.adapters = {}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.hooks = SimpleNamespace(
emit=AsyncMock(),
emit_collect=AsyncMock(return_value=[]),
loaded_hooks=False,
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
@@ -215,6 +219,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._read_user_config = lambda: {
"approvals": {"destructive_slash_confirm": False}
}
runner._set_session_env = lambda _context: None
runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default")
runner._should_send_voice_reply = lambda *_a, **_kw: False
+2 -2
View File
@@ -176,8 +176,8 @@ class TestStreamingConfig:
"fresh_final_after_seconds": "oops",
}
)
assert restored.edit_interval == 1.0
assert restored.buffer_threshold == 40
assert restored.edit_interval == 0.8
assert restored.buffer_threshold == 24
assert restored.fresh_final_after_seconds == 60.0
+5 -1
View File
@@ -16,7 +16,7 @@ from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from gateway.config import Platform
from gateway.config import GatewayConfig, Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
@@ -37,7 +37,11 @@ def _make_runner(hermes_home=None):
"""Create a bare GatewayRunner without calling __init__."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig()
runner.adapters = {}
runner.session_store = MagicMock()
runner.hooks = MagicMock()
runner.hooks.emit_collect = AsyncMock(return_value=[])
runner._voice_mode = {}
runner._update_prompt_pending = {}
runner._running_agents = {}
+8 -8
View File
@@ -129,7 +129,7 @@ class TestVerboseCommand:
@pytest.mark.asyncio
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
"""When tool_progress is not in config, defaults to 'all' then cycles to verbose."""
"""When tool_progress is not in config, defaults to off then cycles to all."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
@@ -143,17 +143,17 @@ class TestVerboseCommand:
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
# Telegram default is "all" (high tier) → cycles to verbose
assert "VERBOSE" in result
# Missing explicit config uses the global off baseline, then cycles to all.
assert "ALL" in result
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all"
@pytest.mark.asyncio
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
"""Cycling /verbose on Telegram doesn't change Slack's setting.
Without a global tool_progress, each platform uses its built-in
default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default).
Without a global tool_progress, platforms start from the quiet
baseline and the first /verbose cycle saves their scoped preference.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
@@ -178,8 +178,8 @@ class TestVerboseCommand:
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
platforms = saved["display"]["platforms"]
# Telegram: all -> verbose (high tier default = all)
assert platforms["telegram"]["tool_progress"] == "verbose"
# Missing explicit config uses the global off baseline, then cycles to all.
assert platforms["telegram"]["tool_progress"] == "all"
# Slack: off -> new (first /verbose cycle from quiet default)
assert platforms["slack"]["tool_progress"] == "new"
+44
View File
@@ -170,6 +170,50 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
token = _jwt_with_email("minimax@example.com")
monkeypatch.setattr(
"hermes_cli.auth._minimax_oauth_login",
lambda **kwargs: {
"provider": "minimax-oauth",
"region": "global",
"portal_base_url": "https://api.minimax.io",
"inference_base_url": "https://api.minimax.io/anthropic",
"client_id": "client-id",
"scope": "group_id profile model.completion",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
"resource_url": None,
"obtained_at": "2026-05-11T10:00:00+00:00",
"expires_at": "2026-05-14T10:00:00+00:00",
"expires_in": 259200,
},
)
from hermes_cli.auth_commands import auth_add_command
class _Args:
provider = "minimax-oauth"
auth_type = "oauth"
api_key = None
label = None
no_browser = True
timeout = None
auth_add_command(_Args())
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
entries = payload["credential_pool"]["minimax-oauth"]
entry = next(item for item in entries if item["source"] == "manual:minimax_oauth")
assert entry["label"] == "minimax@example.com"
assert entry["access_token"] == token
assert entry["refresh_token"] == "refresh-token"
assert entry["base_url"] == "https://api.minimax.io/anthropic"
def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
"""`hermes auth add nous --type oauth --label <name>` must preserve the
custom label end-to-end it was silently dropped in the first cut of the
@@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]
def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch):
"""A pooled MiniMax OAuth token must not inherit stale chat_completions config."""
class _Entry:
access_token = "oauth-token"
source = "manual:minimax_oauth"
base_url = "https://api.minimax.io/anthropic"
class _Pool:
def has_credentials(self):
return True
def select(self):
return _Entry()
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "minimax-oauth",
"default": "MiniMax-M2.7",
"api_mode": "chat_completions",
},
)
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
assert resolved["provider"] == "minimax-oauth"
assert resolved["api_mode"] == "anthropic_messages"
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
+2 -2
View File
@@ -84,8 +84,8 @@ def test_auth_spotify_status_command_reports_logged_in(capsys, monkeypatch: pyte
auth_status_command(SimpleNamespace(provider="spotify"))
output = capsys.readouterr().out
assert "spotify: logged in" in output
assert "client_id: spotify-client" in output
assert "Authentication: logged in" in output
assert "spotify-client" not in output
def test_spotify_logout_does_not_reset_model_provider(
@@ -1068,6 +1068,7 @@ class TestFindGatewayPidsExclude:
def test_excludes_specified_pids(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli.os.path, "isdir", lambda _path: False)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
@@ -1088,6 +1089,7 @@ class TestFindGatewayPidsExclude:
def test_no_exclude_returns_all(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli.os.path, "isdir", lambda _path: False)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(
@@ -1110,6 +1112,7 @@ class TestFindGatewayPidsExclude:
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
profile_dir.mkdir(parents=True)
monkeypatch.setattr(gateway_cli, "is_windows", lambda: False)
monkeypatch.setattr(gateway_cli.os.path, "isdir", lambda _path: False)
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
def fake_run(cmd, **kwargs):
@@ -19,6 +19,8 @@ The fix:
These tests pin the corrected behavior.
"""
import time
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
@@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow():
assert body["expires_in"] == 600
def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
"""Dashboard MiniMax completion must accept unix-ms token expiry values."""
from hermes_cli import web_server as ws
now = datetime.now(timezone.utc)
abs_ms = int((now.timestamp() + 1800) * 1000)
session_id = "minimax-absolute-ms-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "minimax-oauth",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"portal_base_url": "https://api.minimax.io",
"client_id": "client-id",
"user_code": "ABCD-1234",
"code_verifier": "verifier",
"interval_ms": 2000,
"expired_in_raw": abs_ms,
"region": "global",
}
captured_state = {}
try:
with patch(
"hermes_cli.auth._minimax_poll_token",
return_value={
"status": "success",
"access_token": "access",
"refresh_token": "refresh",
"expired_in": abs_ms,
"token_type": "Bearer",
},
), patch(
"hermes_cli.auth._minimax_save_auth_state",
side_effect=lambda state: captured_state.update(state),
):
ws._minimax_poller(session_id)
finally:
ws._oauth_sessions.pop(session_id, None)
assert captured_state["access_token"] == "access"
assert 1790 <= captured_state["expires_in"] <= 1810
assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999
def test_anthropic_pkce_branch_still_works():
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
fake_anthropic_response = {
+6 -6
View File
@@ -1856,18 +1856,18 @@ class TestPluginAPIAuth:
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Use ``/api/plugins/example/hello`` from the example-dashboard plugin
a stable, side-effect-free GET that's always loaded in tests. With a
valid token the handler should run (200); without one the middleware
should 401 before the handler is reached.
Use a plugin namespace route. Without a valid token, middleware should
401 before routing; with a valid token, the request should pass auth
and reach normal routing (200 if the plugin API is mounted in this test
process, 404 if another plugin-cache test changed the mounted set).
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
assert resp.status_code == 401
# With auth: handler runs.
# With auth: middleware allows the request through to normal routing.
resp = self.auth_client.get("/api/plugins/example/hello")
assert resp.status_code == 200
assert resp.status_code != 401
def test_plugin_post_requires_auth(self):
"""Plugin POST routes should return 401 without a valid session token."""
@@ -177,12 +177,13 @@ class TestClientCacheBoundedGrowth:
def test_same_key_replaces_stale_loop_entry(self):
"""When the loop changes, the old entry should be replaced, not duplicated."""
from agent.auxiliary_client import (
_client_cache_key,
_client_cache,
_client_cache_lock,
_get_cached_client,
)
key = ("test_replace", True, "", "", "", (), False)
key = _client_cache_key("test_replace", async_mode=True)
# Simulate a stale entry from a closed loop
old_loop = asyncio.new_event_loop()
+1 -1
View File
@@ -947,7 +947,7 @@ class TestAuxiliaryClientProviderPriority:
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
patch("agent.auxiliary_client.OpenAI") as mock:
client, model = get_text_auxiliary_client()
assert model == "google/gemini-3-flash-preview"
assert model == "qwen/qwen3.6-plus"
def test_custom_endpoint_when_no_nous(self, monkeypatch):
"""Custom endpoint is used when no OpenRouter/Nous keys are available.
+74
View File
@@ -32,9 +32,11 @@ from hermes_cli.auth import (
_minimax_pkce_pair,
_minimax_request_user_code,
_minimax_poll_token,
_minimax_resolve_token_expiry_unix,
_refresh_minimax_oauth_state,
resolve_minimax_oauth_runtime_credentials,
get_minimax_oauth_auth_status,
get_auth_status,
get_provider_auth_state,
)
@@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms
# ---------------------------------------------------------------------------
def test_resolve_token_expiry_unix_ttl_seconds():
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
got = _minimax_resolve_token_expiry_unix(3600, now=now)
assert abs(got - (now.timestamp() + 3600)) < 0.01
def test_resolve_token_expiry_unix_absolute_ms():
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
abs_ms = int((now.timestamp() + 7200) * 1000)
got = _minimax_resolve_token_expiry_unix(abs_ms, now=now)
assert abs(got - (now.timestamp() + 7200)) < 0.01
# ---------------------------------------------------------------------------
# 1. test_pkce_pair_produces_valid_s256
# ---------------------------------------------------------------------------
@@ -362,6 +381,46 @@ def test_refresh_updates_access_token():
assert result["expires_in"] == 7200
def test_refresh_updates_access_token_absolute_ms_expired_in():
"""Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code)."""
now0 = datetime.now(timezone.utc)
abs_ms = int((now0.timestamp() + 1800) * 1000)
state = {
"access_token": "old-access",
"refresh_token": "my-refresh",
"portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
"client_id": MINIMAX_OAUTH_CLIENT_ID,
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
"expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1),
}
new_token_body = {
"status": "success",
"access_token": "new-access",
"refresh_token": "new-refresh",
"expired_in": abs_ms,
}
mock_resp = _make_httpx_response(200, new_token_body)
with patch("httpx.Client") as mock_client_class:
mock_client_instance = MagicMock()
mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance)
mock_client_instance.__exit__ = MagicMock(return_value=False)
mock_client_instance.post.return_value = mock_resp
mock_client_class.return_value = mock_client_instance
with patch("hermes_cli.auth._minimax_save_auth_state"):
result = _refresh_minimax_oauth_state(state)
assert result["access_token"] == "new-access"
assert 1790 <= result["expires_in"] <= 1810
exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
skew = exp.timestamp() - datetime.now(timezone.utc).timestamp()
assert 1790 <= skew <= 1810
# ---------------------------------------------------------------------------
# 10. test_refresh_reuse_triggers_relogin_required
# ---------------------------------------------------------------------------
@@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in():
assert status["logged_in"] is True
assert status["region"] == "global"
def test_generic_auth_status_dispatches_minimax_oauth():
state = {
"access_token": "tok",
"expires_at": _future_iso(3600),
"region": "global",
}
with patch("hermes_cli.auth.get_provider_auth_state", return_value=state):
status = get_auth_status("minimax-oauth")
assert status["logged_in"] is True
assert status["provider"] == "minimax-oauth"
assert status["region"] == "global"
+7 -6
View File
@@ -153,14 +153,15 @@ class TestHandleVisionAnalyzeFastPath:
img = tmp_path / "x.png"
img.write_bytes(_TINY_PNG)
# Set runtime override so the handler thinks we're on opus@openrouter
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
set_runtime_main("openrouter", "anthropic/claude-opus-4.6")
try:
# Patch the config readers used by the native fast-path check.
with (
patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"),
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-opus-4.6"),
patch("agent.image_routing.decide_image_input_mode", return_value="native"),
patch("tools.vision_tools._supports_media_in_tool_results", return_value=True),
):
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
result = asyncio.get_event_loop().run_until_complete(coro)
finally:
clear_runtime_main()
assert isinstance(result, dict), \
f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}"
+1 -1
View File
@@ -585,7 +585,7 @@ class ProcessRegistry:
try:
if not _IS_WINDOWS:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by not _IS_WINDOWS
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
else:
Generated
-45
View File
@@ -1394,15 +1394,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" },
]
[[package]]
name = "eval-type-backport"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" },
]
[[package]]
name = "exa-py"
version = "2.10.2"
@@ -2013,7 +2004,6 @@ all = [
{ name = "markdown", marker = "sys_platform == 'linux'" },
{ name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" },
{ name = "mcp" },
{ name = "mistralai" },
{ name = "modal" },
{ name = "numpy" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
@@ -2097,9 +2087,6 @@ messaging = [
{ name = "slack-bolt" },
{ name = "slack-sdk" },
]
mistral = [
{ name = "mistralai" },
]
modal = [
{ name = "modal" },
]
@@ -2145,7 +2132,6 @@ termux-all = [
{ name = "honcho-ai" },
{ name = "lark-oapi" },
{ name = "mcp" },
{ name = "mistralai" },
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
@@ -2232,8 +2218,6 @@ requires-dist = [
{ name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" },
{ name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" },
{ name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" },
@@ -2259,7 +2243,6 @@ requires-dist = [
{ name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" },
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
{ name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" },
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" },
{ name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" },
{ name = "openai", specifier = ">=2.21.0,<3" },
@@ -2688,15 +2671,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" },
]
[[package]]
name = "jsonpath-python"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -3117,25 +3091,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mistralai"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eval-type-backport" },
{ name = "httpx" },
{ name = "jsonpath-python" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/05/40c38c8893f0ec858756b30f4a939378fc62cf33565af538a843497f3f24/mistralai-2.3.0.tar.gz", hash = "sha256:eb371a9b3b62552f3d4a274ecf5b2c48b90fd3439ecd1425e7f5163cdd87e29a", size = 387145, upload-time = "2026-04-03T15:06:48.927Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/57/d06cbfd96ec6dc45d5c1fe9456f7fcfcb9549c9fa91e213561d1d88729e7/mistralai-2.3.0-py3-none-any.whl", hash = "sha256:22111747c215f1632141660151924f06579f87cd8db2649e0b1f87721d076851", size = 925544, upload-time = "2026-04-03T15:06:47.593Z" },
]
[[package]]
name = "modal"
version = "1.3.4"
+3
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"sync-assets": "rm -rf public/fonts public/ds-assets && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets",
"predev": "npm run sync-assets",