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).
242 lines
9.5 KiB
Python
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"
|