Files
hermes-agent/tests/tools/test_managed_tool_gateway.py
teknium1 93889b770d fix(auth): named profiles no longer inherit the root profile's auth.json (#111724)
A named profile with no credentials of its own silently resolved the root
profile's provider state and credential pool, and a token refresh inside
that profile (xAI, Codex, Anthropic PKCE, Nous) wrote the rotated chain back
into the root store. An isolated service profile therefore acted, and
rotated tokens, as the owner with no way to switch it off.

Maintainer ruling: profiles without credentials are asked to set a provider,
never handed another profile's auth. Profiles are independent islands.

What changes
- `hermes_cli/auth.py`: `_load_provider_state*`, `read_credential_pool` and
  `_provider_state_transaction` read the active store only; the global-root
  resolver, its mtime memo and `_persist_provider_state_to_store` are gone.
- xAI / Codex / Nous-guest / pool refresh paths persist to the active store;
  the root write-through, the borrowed-row bookkeeping
  (`_borrowed_root_ids`, `persist_pool_entries`, `_update_root_pool_rows`)
  and the forked-grant heal are removed. `_write_hermes_oauth_credentials`
  loses its root `target`.
- `resolve_provider` / `agent_init` name the profile in the
  no-provider error and print `hermes -p <name> model` guidance.
- `hermes update` prints a one-time notice listing every named profile that
  has no provider of its own (`hermes_cli/profile_credential_audit.py`) so a
  bot never goes quiet unannounced.
- Desktop create dialog: the "Share keys & accounts" checkbox described the
  removed inheritance; it now mirrors API keys (`mirror_credentials`) and
  says OAuth logins need a sign-in. `share_auth` is accepted from older
  clients and ignored; `ProfileMirrored.auth` is a bool again.
- Docs: profiles.md, multi-profile-gateways.md isolation table,
  hermes_cli/AGENTS.md.

The fallback was added in 33bf5f62 so kanban/cron workers under a named
profile did not die with "No LLM provider configured" when the credential
lived only at root; that convenience is exactly the isolation hole the
ruling closes, and `--clone` / dashboard mirroring still copy API keys.

Tests: fallback/write-through/heal pins deleted; 4 invariants proven red on
base (profile never reads root; profile refresh never writes root; Nous
connector gate reads only the profile store; Anthropic pool never borrows or
rotates the root grant, root control still refreshes).
2026-09-16 14:34:59 -07:00

242 lines
9.5 KiB
Python

import os
import json
from datetime import datetime, timedelta, timezone
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import sys
from unittest.mock import patch
from tools import managed_gateway_auth
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
assert MODULE_SPEC and MODULE_SPEC.loader
managed_tool_gateway = module_from_spec(MODULE_SPEC)
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
is_managed_tool_gateway_ready = managed_tool_gateway.is_managed_tool_gateway_ready
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
with patch.dict(
os.environ,
{
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
)
assert result is not None
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
assert result.nous_user_token == "nous-token"
assert result.managed_mode is True
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
with patch.dict(
os.environ,
{
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"browser-use",
token_reader=lambda: "nous-token",
)
assert result is not None
assert result.gateway_origin == "http://browser-use-gateway.localhost:3009"
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
with patch.dict(
os.environ,
{
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: None,
)
assert result is None
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
)
assert result is None
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "stale-token",
"refresh_token": "refresh-token",
"expires_at": expires_at,
}
}
}))
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
lambda refresh_skew_seconds=120: "fresh-token",
)
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"expires_at": expired_at,
}
}
}))
refresh_calls = []
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
refresh_calls.append(refresh_skew_seconds)
return "fresh-token"
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
_record_refresh,
)
with patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com"},
clear=False,
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
assert is_managed_tool_gateway_ready("modal") is True
assert refresh_calls == []
def test_connector_gateway_origin_pins_the_deployed_connectors_host():
# The connectors API is its own deployment on its own canonical host, so
# the default resolution must not land on the media/vendor origin.
with patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
clear=False,
):
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
assert managed_gateway_auth.connector_gateway_origin() == (
"https://connector-gateway.nousresearch.com"
)
def test_managed_gateway_origin_honors_the_harness_override():
# TOOL_GATEWAY_URL pins the full media origin (the e2e harness sets it to a
# loopback gateway), and the bearer gate must accept exactly that origin.
with patch.dict(os.environ, {"TOOL_GATEWAY_URL": "http://127.0.0.1:3009/"}, clear=False):
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
assert managed_gateway_auth.managed_gateway_origin() == "http://127.0.0.1:3009"
assert managed_gateway_auth.is_managed_nous_gateway_url(
"http://127.0.0.1:3009/api/vendorx/generations"
)
assert not managed_gateway_auth.is_managed_nous_gateway_url(
"https://tools.nousresearch.com/api/vendorx/generations"
)
def test_connector_gateway_origin_honors_its_own_override():
# CONNECTOR_GATEWAY_URL is the connectors host's own key: it moves the
# connectors origin without touching the media origin, and the bearer gate
# accepts the overridden origin.
with patch.dict(
os.environ,
{
"CONNECTOR_GATEWAY_URL": "http://127.0.0.1:3009/",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
):
os.environ.pop("TOOL_GATEWAY_URL", None)
assert managed_gateway_auth.connector_gateway_origin() == "http://127.0.0.1:3009"
assert managed_gateway_auth.managed_gateway_origin() == (
"https://tool-gateway.nousresearch.com"
)
assert managed_gateway_auth.is_managed_nous_gateway_url(
"http://127.0.0.1:3009/v1/connectors/search"
)
def test_default_bearer_gate_accepts_both_deployed_hosts_only():
# Exact (scheme, netloc) equality against each deployed origin. Both
# first-party hosts are in; the retired `tools.` host, subdomain cousins,
# and scheme downgrades are all out.
with patch.dict(
os.environ,
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
clear=False,
):
os.environ.pop("TOOL_GATEWAY_URL", None)
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
for trusted in (
"https://connector-gateway.nousresearch.com/v1/connectors/execute",
"https://tool-gateway.nousresearch.com/api/vendorx/generations",
):
assert managed_gateway_auth.is_managed_nous_gateway_url(trusted)
for untrusted in (
"https://tools.nousresearch.com/v1/connectors/execute",
"https://evil-connector-gateway.nousresearch.com.attacker.dev/v1/connectors",
"https://connector-gateway.nousresearch.com.attacker.dev/v1/connectors",
"http://connector-gateway.nousresearch.com/v1/connectors",
"http://tool-gateway.nousresearch.com/api/vendorx/generations",
):
assert not managed_gateway_auth.is_managed_nous_gateway_url(untrusted)
def test_read_nous_provider_state_reads_only_the_profiles_own_store(tmp_path, monkeypatch):
# Every profile owns its credentials (#111724): a named profile with an empty auth.json has
# no Nous identity, even when the root is signed in; its own login is what the gate sees.
root = tmp_path / ".hermes"
profile = root / "profiles" / "hermes-setup"
profile.mkdir(parents=True)
(root / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {"nous": {"auth_method": "anonymous", "access_token": "root-tok"}},
}))
monkeypatch.setenv("HERMES_HOME", str(profile))
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
import hermes_constants
from hermes_cli import auth as auth_mod
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
monkeypatch.setattr(auth_mod, "get_hermes_home", lambda: profile)
monkeypatch.setattr(auth_mod, "_auth_file_path", lambda: profile / "auth.json")
assert managed_tool_gateway._read_nous_provider_state() is None
(profile / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {"nous": {"auth_method": "anonymous", "access_token": "profile-tok"}},
}))
state = managed_tool_gateway._read_nous_provider_state()
assert state is not None and state["access_token"] == "profile-tok"