mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
feat(egress): first-class x-api-key providers + hot reload via management API
Both wired against features the iron-proxy author (@mslipper) confirmed on PR #30179 — and both verified present in the pinned v0.39.0 source. Header-auth providers (match_headers): - New _HEADER_AUTH_PROVIDERS: Anthropic native (x-api-key), Azure OpenAI (api-key on *.openai.azure.com / *.cognitiveservices / *.services.ai), Gemini (x-goog-api-key + ?key= query param via match_query). - TokenMapping grows match_headers + alias_env_names; per-provider header sets flow into the secrets rules; mappings.json roundtrips them (legacy files load with the Authorization default). - GEMINI_API_KEY / GOOGLE_API_KEY collapse into ONE mapping (two require-rules on the same host would reject each other); the sandbox gets the token under both names, and the proxy child env mirrors the alias into the canonical name when only the alias is set. - Docker backend injects alias env names alongside canonical ones. - The fail-closed tier is now empty, so fail_on_uncovered_providers and discover_blocked_providers are deleted (dead toggle otherwise); _NON_BEARER_PROVIDERS shrinks to genuinely-unswappable signature auth (AWS SigV4, GCP service-account OAuth) — warn-only, as before. Management API (hot reload): - Generated proxy.yaml enables the v0.39 management listener: loopback only at tunnel_port+2, bearer key from HERMES_IRON_PROXY_MGMT_KEY. - Key minted at setup (management.token, 0600); start_proxy injects it (v0.39 refuses to start when api_key_env is empty). - hermes egress reload -> POST /v1/reload: re-reads proxy.yaml and atomically swaps the pipeline; 422 leaves the running ruleset untouched; actionable errors for not-running / pre-management config / key mismatch. Secrets changes still require restart (daemon env is read at spawn) — the CLI says so. Validation: 218/218 unit+CLI+docker tests; 3/3 gated live E2E against the real v0.39.0 binary (Authorization swap, x-api-key swap, live reload with token rotation on the same pid). Docs updated.
This commit is contained in:
parent
6ff7e78649
commit
86fcb2fe5f
10 changed files with 922 additions and 250 deletions
|
|
@ -471,16 +471,16 @@ def test_merge_mappings_rotate_mints_fresh_tokens():
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Uncovered provider detection (regression: non-bearer providers bypass)
|
||||
# Uncovered provider detection (regression: signature-auth providers bypass)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uncovered_providers_detects_anthropic_aws(hermes_home, monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
||||
def test_uncovered_providers_detects_aws_gcp_appdefault(hermes_home, monkeypatch):
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE")
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json")
|
||||
uncovered = ip.discover_uncovered_providers()
|
||||
assert "ANTHROPIC_API_KEY" in uncovered
|
||||
assert "AWS_ACCESS_KEY_ID" in uncovered
|
||||
assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered
|
||||
|
||||
|
||||
def test_uncovered_providers_explicit_names_empty():
|
||||
|
|
@ -496,6 +496,22 @@ def test_uncovered_providers_skips_bearer_providers(hermes_home, monkeypatch):
|
|||
assert "OPENROUTER_API_KEY" not in uncovered
|
||||
|
||||
|
||||
def test_uncovered_providers_skips_header_auth_providers(hermes_home, monkeypatch):
|
||||
"""Anthropic / Azure / Gemini moved from 'uncovered' to first-class
|
||||
header-auth swapped providers — they must no longer be reported as
|
||||
uncovered."""
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
|
||||
uncovered = ip.discover_uncovered_providers()
|
||||
assert "ANTHROPIC_API_KEY" not in uncovered
|
||||
assert "AZURE_OPENAI_API_KEY" not in uncovered
|
||||
assert "GEMINI_API_KEY" not in uncovered
|
||||
assert "GOOGLE_API_KEY" not in uncovered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery + lazy install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -1357,40 +1373,276 @@ def test_default_deny_includes_ipv4_mapped_v6(tmp_path):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v3: split LLM-specific blocked tier (P1 #3 + P2 non_bearer tiers)
|
||||
# Header-auth providers (x-api-key family) — match_headers + aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_blocked_providers_subset_of_uncovered(hermes_home, monkeypatch):
|
||||
"""The strict tier that BLOCKS start must be a subset of the
|
||||
uncovered-but-warn tier; the wizard surfaces ALL uncovered but only
|
||||
blocks the LLM-specific ones."""
|
||||
def test_header_auth_providers_discovered(hermes_home, monkeypatch):
|
||||
"""Anthropic / Azure / Gemini mint mappings with their native auth
|
||||
headers instead of landing in the uncovered list."""
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
|
||||
|
||||
ms = {m.real_env_name: m for m in ip.discover_provider_mappings()}
|
||||
assert "ANTHROPIC_API_KEY" in ms
|
||||
assert "AZURE_OPENAI_API_KEY" in ms
|
||||
assert "GEMINI_API_KEY" in ms
|
||||
|
||||
anth = ms["ANTHROPIC_API_KEY"]
|
||||
assert "x-api-key" in anth.match_headers
|
||||
assert "api.anthropic.com" in anth.upstream_hosts
|
||||
|
||||
azure = ms["AZURE_OPENAI_API_KEY"]
|
||||
assert "api-key" in azure.match_headers
|
||||
|
||||
gem = ms["GEMINI_API_KEY"]
|
||||
assert "x-goog-api-key" in gem.match_headers
|
||||
assert "GOOGLE_API_KEY" in gem.alias_env_names
|
||||
|
||||
|
||||
def test_gemini_alias_collapses_to_single_mapping(hermes_home, monkeypatch):
|
||||
"""GEMINI_API_KEY + GOOGLE_API_KEY are the same upstream credential —
|
||||
two require-rules on the same host would reject each other's requests,
|
||||
so exactly ONE mapping must be minted regardless of which names are
|
||||
set."""
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test")
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
|
||||
ms = [m for m in ip.discover_provider_mappings()
|
||||
if "generativelanguage" in " ".join(m.upstream_hosts)]
|
||||
assert len(ms) == 1
|
||||
assert ms[0].real_env_name == "GEMINI_API_KEY"
|
||||
|
||||
uncovered = set(ip.discover_uncovered_providers())
|
||||
blocked = set(ip.discover_blocked_providers())
|
||||
|
||||
# Strict subset: every blocked is also uncovered, but the reverse
|
||||
# doesn't hold.
|
||||
assert blocked.issubset(uncovered)
|
||||
# AWS / GCP appdefault present but NOT blocked (those are present on
|
||||
# most dev laptops for unrelated cloud tooling).
|
||||
assert "AWS_ACCESS_KEY_ID" in uncovered
|
||||
assert "AWS_ACCESS_KEY_ID" not in blocked
|
||||
assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered
|
||||
assert "GOOGLE_APPLICATION_CREDENTIALS" not in blocked
|
||||
# LLM-specific providers ARE blocked.
|
||||
assert "ANTHROPIC_API_KEY" in blocked
|
||||
assert "GEMINI_API_KEY" in blocked
|
||||
# GOOGLE_API_KEY is an alias for GEMINI_API_KEY (same generativelanguage
|
||||
# LLM endpoint) — it must be in the fail-closed tier, not warn-only,
|
||||
# or fail_on_uncovered_providers gives false coverage.
|
||||
assert "GOOGLE_API_KEY" in blocked
|
||||
def test_gemini_alias_only_still_mints_mapping(hermes_home, monkeypatch):
|
||||
"""An operator with ONLY GOOGLE_API_KEY set still gets Gemini coverage
|
||||
(mapping keyed on the canonical name)."""
|
||||
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
|
||||
ms = {m.real_env_name: m for m in ip.discover_provider_mappings()}
|
||||
assert "GEMINI_API_KEY" in ms
|
||||
assert "GOOGLE_API_KEY" in ms["GEMINI_API_KEY"].alias_env_names
|
||||
|
||||
|
||||
def test_build_proxy_config_emits_per_provider_match_headers(tmp_path):
|
||||
"""Header-auth mappings carry their native headers into the secrets
|
||||
rule; bearer mappings keep the Authorization default."""
|
||||
|
||||
bearer = _sample_mapping()
|
||||
anth = ip.TokenMapping(
|
||||
proxy_token=ip.mint_proxy_token("anthropic"),
|
||||
real_env_name="ANTHROPIC_API_KEY",
|
||||
upstream_hosts=("api.anthropic.com",),
|
||||
match_headers=("x-api-key", "Authorization"),
|
||||
)
|
||||
cfg = ip.build_proxy_config(
|
||||
mappings=[bearer, anth],
|
||||
ca_cert=tmp_path / "ca.crt",
|
||||
ca_key=tmp_path / "ca.key",
|
||||
)
|
||||
rules = {r["source"]["var"]: r for r in cfg["transforms"][1]["config"]["secrets"]}
|
||||
assert rules["OPENROUTER_API_KEY"]["replace"]["match_headers"] == ["Authorization"]
|
||||
assert rules["ANTHROPIC_API_KEY"]["replace"]["match_headers"] == [
|
||||
"x-api-key", "Authorization",
|
||||
]
|
||||
# Fail-closed still applies to header-auth providers.
|
||||
assert rules["ANTHROPIC_API_KEY"]["replace"]["require"] is True
|
||||
# Header-auth hosts land on the allowlist too.
|
||||
assert "api.anthropic.com" in cfg["transforms"][0]["config"]["domains"]
|
||||
|
||||
|
||||
def test_mappings_roundtrip_preserves_headers_and_aliases(hermes_home):
|
||||
m = ip.TokenMapping(
|
||||
proxy_token=ip.mint_proxy_token("gemini"),
|
||||
real_env_name="GEMINI_API_KEY",
|
||||
upstream_hosts=("generativelanguage.googleapis.com",),
|
||||
match_headers=("x-goog-api-key",),
|
||||
alias_env_names=("GOOGLE_API_KEY",),
|
||||
)
|
||||
ip.write_mappings([m])
|
||||
loaded = ip.load_mappings()
|
||||
assert loaded[0].match_headers == ("x-goog-api-key",)
|
||||
assert loaded[0].alias_env_names == ("GOOGLE_API_KEY",)
|
||||
|
||||
|
||||
def test_load_mappings_legacy_entries_default_to_bearer(hermes_home):
|
||||
"""mappings.json written before the match_headers/alias fields must
|
||||
load with the Authorization default (same behavior as at write time)."""
|
||||
|
||||
import json as _json
|
||||
state = ip._proxy_state_dir()
|
||||
(state / "mappings.json").write_text(_json.dumps({
|
||||
"version": 1,
|
||||
"tokens": [{
|
||||
"proxy_token": "openrouter-legacy-token",
|
||||
"env_name": "OPENROUTER_API_KEY",
|
||||
"upstream_hosts": ["openrouter.ai"],
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
loaded = ip.load_mappings()
|
||||
assert loaded[0].match_headers == ("Authorization",)
|
||||
assert loaded[0].alias_env_names == ()
|
||||
|
||||
|
||||
def test_subprocess_env_mirrors_alias_into_canonical(hermes_home, monkeypatch):
|
||||
"""When only the alias (GOOGLE_API_KEY) is exported, the proxy child
|
||||
env must still carry the canonical name the secrets rule reads."""
|
||||
|
||||
m = ip.TokenMapping(
|
||||
proxy_token=ip.mint_proxy_token("gemini"),
|
||||
real_env_name="GEMINI_API_KEY",
|
||||
upstream_hosts=("generativelanguage.googleapis.com",),
|
||||
match_headers=("x-goog-api-key",),
|
||||
alias_env_names=("GOOGLE_API_KEY",),
|
||||
)
|
||||
ip.write_mappings([m])
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "g-real-secret")
|
||||
env = ip._build_proxy_subprocess_env()
|
||||
assert env.get("GEMINI_API_KEY") == "g-real-secret"
|
||||
|
||||
|
||||
def test_subprocess_env_canonical_wins_over_alias(hermes_home, monkeypatch):
|
||||
m = ip.TokenMapping(
|
||||
proxy_token=ip.mint_proxy_token("gemini"),
|
||||
real_env_name="GEMINI_API_KEY",
|
||||
upstream_hosts=("generativelanguage.googleapis.com",),
|
||||
match_headers=("x-goog-api-key",),
|
||||
alias_env_names=("GOOGLE_API_KEY",),
|
||||
)
|
||||
ip.write_mappings([m])
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "g-canonical")
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "g-alias")
|
||||
env = ip._build_proxy_subprocess_env()
|
||||
assert env.get("GEMINI_API_KEY") == "g-canonical"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management API (hot reload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_proxy_config_enables_management_listener(tmp_path):
|
||||
cfg = ip.build_proxy_config(
|
||||
mappings=[_sample_mapping()],
|
||||
ca_cert=tmp_path / "ca.crt",
|
||||
ca_key=tmp_path / "ca.key",
|
||||
tunnel_port=9090,
|
||||
)
|
||||
mgmt = cfg["management"]
|
||||
# Loopback only — sandboxes must never reach the management surface.
|
||||
assert mgmt["listen"].startswith("127.0.0.1:")
|
||||
assert mgmt["listen"].endswith(":9092") # tunnel_port + 2
|
||||
assert mgmt["api_key_env"] == ip._MGMT_API_KEY_ENV
|
||||
|
||||
|
||||
def test_ensure_management_token_persists_and_is_stable(hermes_home):
|
||||
t1 = ip.ensure_management_token()
|
||||
t2 = ip.ensure_management_token()
|
||||
assert t1 == t2
|
||||
assert t1.startswith("hermes-mgmt-")
|
||||
p = ip._proxy_state_dir() / "management.token"
|
||||
assert p.exists()
|
||||
assert (p.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_ensure_management_token_force_rotates(hermes_home):
|
||||
t1 = ip.ensure_management_token()
|
||||
t2 = ip.ensure_management_token(force=True)
|
||||
assert t1 != t2
|
||||
|
||||
|
||||
def test_reload_proxy_refuses_when_not_running(hermes_home, monkeypatch):
|
||||
monkeypatch.setattr(ip, "_read_pid", lambda: None)
|
||||
with pytest.raises(RuntimeError, match="not running"):
|
||||
ip.reload_proxy()
|
||||
|
||||
|
||||
def test_reload_proxy_refuses_on_pre_management_config(hermes_home, monkeypatch):
|
||||
"""A config written before management support has no listener — the
|
||||
error must tell the operator to re-setup + restart once."""
|
||||
|
||||
monkeypatch.setattr(ip, "_read_pid", lambda: 4242)
|
||||
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
|
||||
# No proxy.yaml at all -> _read_management_listen_from_config is None.
|
||||
with pytest.raises(RuntimeError, match="restart"):
|
||||
ip.reload_proxy()
|
||||
|
||||
|
||||
def test_reload_proxy_posts_bearer_to_management_endpoint(hermes_home, monkeypatch):
|
||||
monkeypatch.setattr(ip, "_read_pid", lambda: 4242)
|
||||
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
|
||||
monkeypatch.setattr(
|
||||
ip, "_read_management_listen_from_config",
|
||||
lambda config_path=None: ("127.0.0.1", 9092),
|
||||
)
|
||||
ip.ensure_management_token()
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeResp:
|
||||
status = 200
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["method"] = req.get_method()
|
||||
captured["auth"] = req.get_header("Authorization")
|
||||
return _FakeResp()
|
||||
|
||||
import urllib.request
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
assert ip.reload_proxy() is True
|
||||
assert captured["url"] == "http://127.0.0.1:9092/v1/reload"
|
||||
assert captured["method"] == "POST"
|
||||
token = (ip._proxy_state_dir() / "management.token").read_text().strip()
|
||||
assert captured["auth"] == f"Bearer {token}"
|
||||
|
||||
|
||||
def test_start_proxy_injects_management_key_env(hermes_home, monkeypatch):
|
||||
"""When the generated config has a management listener, start_proxy
|
||||
must inject the bearer key env var — v0.39 refuses to start when
|
||||
api_key_env is empty."""
|
||||
|
||||
cfg_path = ip._proxy_state_dir() / "proxy.yaml"
|
||||
cfg = ip.build_proxy_config(
|
||||
mappings=[_sample_mapping()],
|
||||
ca_cert=hermes_home / "ca.crt",
|
||||
ca_key=hermes_home / "ca.key",
|
||||
http_listen=["127.0.0.1:9090"],
|
||||
)
|
||||
ip.write_proxy_config(cfg)
|
||||
(hermes_home / "bin").mkdir(parents=True, exist_ok=True)
|
||||
fake_bin = hermes_home / "bin" / "iron-proxy"
|
||||
fake_bin.write_text("#!/bin/sh\nsleep 60\n")
|
||||
fake_bin.chmod(0o755)
|
||||
|
||||
captured_env = {}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 99999
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def fake_popen(cmd, **kw):
|
||||
captured_env.update(kw.get("env") or {})
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(ip.subprocess, "Popen", fake_popen)
|
||||
monkeypatch.setattr(ip, "_write_pidfile_safely", lambda pf, pid: None)
|
||||
monkeypatch.setattr(ip, "_port_listening", lambda h, p: True)
|
||||
monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus(pid=99999, listening=True))
|
||||
|
||||
ip.start_proxy(binary=fake_bin, config_path=cfg_path, install_if_missing=False)
|
||||
assert captured_env.get(ip._MGMT_API_KEY_ENV)
|
||||
assert captured_env[ip._MGMT_API_KEY_ENV].startswith("hermes-mgmt-")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@ def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, b
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_start — fail_on_uncovered_providers + Bitwarden rotation wire-up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -212,21 +211,6 @@ def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch):
|
|||
assert rc == 1
|
||||
|
||||
|
||||
def test_cmd_start_refuses_on_uncovered_provider_when_strict(hermes_home, monkeypatch):
|
||||
"""fail_on_uncovered_providers=true + ANTHROPIC_API_KEY in env =
|
||||
refuse to start (real credential would otherwise leak into sandbox)."""
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = True
|
||||
save_config(cfg)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
|
|
@ -244,7 +228,6 @@ def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
|
|||
|
||||
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 0
|
||||
|
|
@ -262,7 +245,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
|
|||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {
|
||||
"enabled": True,
|
||||
"project_id": "test-proj-id",
|
||||
|
|
@ -284,7 +266,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
|
|||
return s
|
||||
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 0
|
||||
|
|
@ -301,7 +282,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch
|
|||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {
|
||||
"enabled": True,
|
||||
"project_id": "test-proj-id",
|
||||
|
|
@ -315,7 +295,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch
|
|||
pytest.fail("start_proxy should not be invoked when BWS token missing")
|
||||
monkeypatch.setattr(ip, "start_proxy", must_not_call)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 1
|
||||
|
|
@ -328,7 +307,6 @@ def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env
|
|||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "env"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
save_config(cfg)
|
||||
|
||||
captured: dict = {}
|
||||
|
|
@ -553,7 +531,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp
|
|||
cfg = load_config()
|
||||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
|
||||
save_config(cfg)
|
||||
|
||||
|
|
@ -561,7 +538,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp
|
|||
pytest.fail("start_proxy must not run when bitwarden mode is broken")
|
||||
monkeypatch.setattr(ip, "start_proxy", must_not_call)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 1
|
||||
|
|
@ -578,7 +554,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
|
|||
cfg.setdefault("proxy", {})["enabled"] = True
|
||||
cfg["proxy"]["credential_source"] = "bitwarden"
|
||||
cfg["proxy"]["allow_env_fallback"] = True
|
||||
cfg["proxy"]["fail_on_uncovered_providers"] = False
|
||||
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
|
||||
save_config(cfg)
|
||||
|
||||
|
|
@ -593,7 +568,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
|
|||
|
||||
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
|
||||
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
|
||||
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
|
||||
|
||||
rc = proxy_cli.cmd_start(_args())
|
||||
assert rc == 0
|
||||
|
|
|
|||
|
|
@ -166,3 +166,203 @@ def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypat
|
|||
pass
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
class _CaptureXApiKeyHandler(BaseHTTPRequestHandler):
|
||||
"""Records the x-api-key header of every incoming request."""
|
||||
|
||||
captured_key: Optional[str] = None
|
||||
|
||||
def do_GET(self):
|
||||
type(self).captured_key = self.headers.get("x-api-key")
|
||||
body = b'{"ok": true}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args, **kwargs):
|
||||
return
|
||||
|
||||
|
||||
def test_iron_proxy_swaps_x_api_key_header_end_to_end(hermes_home, monkeypatch):
|
||||
"""Header-auth providers: the secrets transform must swap the proxy
|
||||
token out of a NON-Authorization header (x-api-key — the Anthropic
|
||||
native scheme) on the pinned binary."""
|
||||
|
||||
if not __import__("shutil").which("curl"):
|
||||
pytest.skip("curl not available")
|
||||
if not __import__("shutil").which("openssl"):
|
||||
pytest.skip("openssl not available")
|
||||
|
||||
upstream_port = _free_port()
|
||||
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureXApiKeyHandler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
|
||||
try:
|
||||
binary = ip.install_iron_proxy()
|
||||
assert binary.exists()
|
||||
ca_crt, ca_key = ip.ensure_ca_cert()
|
||||
|
||||
real_secret = "sk-ant-real-value-cafebabe"
|
||||
monkeypatch.setenv("TEST_XAPI_KEY", real_secret)
|
||||
proxy_token = ip.mint_proxy_token("anthropic")
|
||||
|
||||
mapping = ip.TokenMapping(
|
||||
proxy_token=proxy_token,
|
||||
real_env_name="TEST_XAPI_KEY",
|
||||
upstream_hosts=("127.0.0.1",),
|
||||
match_headers=("x-api-key", "Authorization"),
|
||||
)
|
||||
|
||||
tunnel_port = _free_port()
|
||||
cfg = ip.build_proxy_config(
|
||||
mappings=[mapping],
|
||||
ca_cert=ca_crt,
|
||||
ca_key=ca_key,
|
||||
tunnel_port=tunnel_port,
|
||||
allowed_hosts=["127.0.0.1"],
|
||||
upstream_deny_cidrs=[],
|
||||
http_listen=[f"127.0.0.1:{tunnel_port}"],
|
||||
)
|
||||
ip.write_proxy_config(cfg)
|
||||
ip.write_mappings([mapping])
|
||||
|
||||
try:
|
||||
status = ip.start_proxy()
|
||||
except RuntimeError as exc:
|
||||
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
|
||||
assert status.pid is not None
|
||||
|
||||
for _ in range(50):
|
||||
if ip._port_listening("127.0.0.1", tunnel_port):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
pytest.fail("iron-proxy never started listening on the tunnel port")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"--silent",
|
||||
"--max-time", "10",
|
||||
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
|
||||
"-H", f"x-api-key: {proxy_token}",
|
||||
f"http://127.0.0.1:{upstream_port}/",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"curl failed: {result.stderr}"
|
||||
captured = _CaptureXApiKeyHandler.captured_key
|
||||
assert captured is not None, "upstream never received the request"
|
||||
assert real_secret in captured, (
|
||||
f"x-api-key header was not swapped — upstream saw: {captured!r}"
|
||||
)
|
||||
assert proxy_token not in captured, (
|
||||
f"Proxy token leaked through to upstream: {captured!r}"
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
ip.stop_proxy()
|
||||
except Exception:
|
||||
pass
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_iron_proxy_management_reload_end_to_end(hermes_home, monkeypatch):
|
||||
"""Real binary: the management listener comes up, an authenticated
|
||||
POST /v1/reload succeeds after a config edit, and the edited ruleset
|
||||
takes effect WITHOUT a restart (same pid)."""
|
||||
|
||||
if not __import__("shutil").which("curl"):
|
||||
pytest.skip("curl not available")
|
||||
if not __import__("shutil").which("openssl"):
|
||||
pytest.skip("openssl not available")
|
||||
|
||||
upstream_port = _free_port()
|
||||
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
|
||||
try:
|
||||
binary = ip.install_iron_proxy()
|
||||
assert binary.exists()
|
||||
ca_crt, ca_key = ip.ensure_ca_cert()
|
||||
|
||||
real_secret = "sk-real-reload-value-0badf00d"
|
||||
monkeypatch.setenv("TEST_RELOAD_KEY", real_secret)
|
||||
token_v1 = ip.mint_proxy_token("v1")
|
||||
|
||||
def _write_cfg(mapping):
|
||||
cfg = ip.build_proxy_config(
|
||||
mappings=[mapping],
|
||||
ca_cert=ca_crt,
|
||||
ca_key=ca_key,
|
||||
tunnel_port=tunnel_port,
|
||||
allowed_hosts=["127.0.0.1"],
|
||||
upstream_deny_cidrs=[],
|
||||
http_listen=[f"127.0.0.1:{tunnel_port}"],
|
||||
)
|
||||
ip.write_proxy_config(cfg)
|
||||
ip.write_mappings([mapping])
|
||||
|
||||
tunnel_port = _free_port()
|
||||
_write_cfg(ip.TokenMapping(
|
||||
proxy_token=token_v1,
|
||||
real_env_name="TEST_RELOAD_KEY",
|
||||
upstream_hosts=("127.0.0.1",),
|
||||
))
|
||||
|
||||
try:
|
||||
status = ip.start_proxy()
|
||||
except RuntimeError as exc:
|
||||
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
|
||||
pid_before = status.pid
|
||||
assert pid_before is not None
|
||||
|
||||
for _ in range(50):
|
||||
if ip._port_listening("127.0.0.1", tunnel_port):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
pytest.fail("iron-proxy never started listening")
|
||||
|
||||
# Rotate the sandbox-visible token in the config, then hot-reload.
|
||||
token_v2 = ip.mint_proxy_token("v2")
|
||||
_write_cfg(ip.TokenMapping(
|
||||
proxy_token=token_v2,
|
||||
real_env_name="TEST_RELOAD_KEY",
|
||||
upstream_hosts=("127.0.0.1",),
|
||||
))
|
||||
assert ip.reload_proxy() is True
|
||||
|
||||
# Same daemon (no restart) ...
|
||||
assert ip.get_status().pid == pid_before
|
||||
|
||||
# ... but the NEW token now swaps.
|
||||
_CaptureHandler.captured_auth = None
|
||||
result = subprocess.run(
|
||||
[
|
||||
"curl", "--silent", "--max-time", "10",
|
||||
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
|
||||
"-H", f"Authorization: Bearer {token_v2}",
|
||||
f"http://127.0.0.1:{upstream_port}/",
|
||||
],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"curl failed: {result.stderr}"
|
||||
captured = _CaptureHandler.captured_auth
|
||||
assert captured is not None and real_secret in captured, (
|
||||
f"post-reload token was not swapped — upstream saw: {captured!r}"
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
ip.stop_proxy()
|
||||
except Exception:
|
||||
pass
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue