Files
hermes-agent/tests/hermes_cli/test_plugin_provider_requests.py
Teknium 4070f011a2 feat(plugins): core-made Codex requests so plugins never hold the token
Plugins that need the user's ChatGPT/Codex subscription (hermes-live-voice mints
an OpenAI Realtime client secret) vendored their own reader + refresher for
~/.codex/auth.json. Codex refresh tokens are single-use, so that second refresher
races Hermes's locked refresh and can revoke the user's whole token family.

hermes_cli.plugin_provider_requests.credentialed_provider_request() lets core make
the request instead: it resolves the credential through
resolve_codex_runtime_credentials (Hermes's locked refresh, profile-scoped),
attaches Authorization itself, sends only to chatgpt.com / api.openai.com, never
follows redirects, and returns status/headers/body. Callers are attributed by the
plugin directory on the call stack and must declare `requires_auth: [openai-codex]`
in plugin.yaml; the declaration shows at install, in `hermes plugins show`, and a
catalog re-pin that adds it asks for consent. A plain importable function so
dashboard plugin_api.py modules can use it.
2026-10-10 10:43:32 -07:00

146 lines
5.4 KiB
Python

"""Core makes Codex-signed requests for plugins; the token never reaches plugin code.
Real loopback HTTP servers stand in for the provider; a real plugin directory under the test
HERMES_HOME makes the call, and a fake Codex sign-in is seeded in that home's auth store.
"""
import base64
import importlib.util
import json
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from hermes_cli import plugin_provider_requests as ppr
def _seg(d: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
def _jwt() -> str:
seg = _seg
return f"{seg({'alg': 'none'})}.{seg({'exp': int(time.time()) + 86400, 'sub': 'fake'})}.sig"
TOKEN = _jwt()
class _Stub:
def __init__(self, status=200, location=""):
self.seen: list[dict] = []
stub = self
class Handler(BaseHTTPRequestHandler):
def _answer(self):
length = int(self.headers.get("Content-Length") or 0)
stub.seen.append({"path": self.path, "headers": dict(self.headers),
"body": self.rfile.read(length)})
self.send_response(status)
if location:
self.send_header("Location", location)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"value": "ek_ephemeral", "expires_at": 1}')
do_GET = do_POST = _answer
def log_message(self, *_):
pass
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
@pytest.fixture
def stubs(monkeypatch):
allowed, other = _Stub(), _Stub()
origins, resolve = ppr._PROVIDERS["openai-codex"]
# Test-only: the loopback stub joins the real allowlist; ``other`` stays outside it.
monkeypatch.setitem(ppr._PROVIDERS, "openai-codex",
(origins | {("http", "127.0.0.1", allowed.server.server_address[1])}, resolve))
yield allowed, other
allowed.close()
other.close()
def _home() -> Path:
return Path(os.environ["HERMES_HOME"])
def _sign_in():
(_home() / "auth.json").write_text(json.dumps({"version": 1, "providers": {"openai-codex": {
"auth_mode": "chatgpt", "tokens": {"access_token": TOKEN, "refresh_token": "fake-rt"}}}}))
def _plugin(declares: bool):
"""A dashboard-style plugin module (``dashboard/plugin_api.py``) loaded the way the dashboard does."""
root = _home() / "plugins" / ("voice" if declares else "undeclared")
(root / "dashboard").mkdir(parents=True)
(root / "plugin.yaml").write_text("name: voice\n" + ("requires_auth: [openai-codex]\n" if declares else ""))
(root / "dashboard" / "plugin_api.py").write_text(
"from hermes_cli.plugin_provider_requests import credentialed_provider_request\n"
"def mint(url):\n"
" return credentialed_provider_request('openai-codex', 'POST', url, json={'session': {}})\n")
spec = importlib.util.spec_from_file_location(f"hermes_dashboard_plugin_{root.name}", root / "dashboard" / "plugin_api.py")
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_token_attached_for_allowlisted_origin_and_never_returned(stubs):
allowed, _ = stubs
_sign_in()
result = _plugin(declares=True).mint(allowed.url + "/v1/realtime/client_secrets")
assert result.status == 200 and result.json()["value"] == "ek_ephemeral"
assert allowed.seen[0]["headers"]["Authorization"] == f"Bearer {TOKEN}"
assert json.loads(allowed.seen[0]["body"]) == {"session": {}}
assert TOKEN not in repr(result) and TOKEN not in json.dumps(result.headers)
def test_disallowed_origin_and_redirect_never_carry_the_token(stubs, monkeypatch):
_, other = stubs
_sign_in()
plugin = _plugin(declares=True)
with pytest.raises(PermissionError, match="Refusing"):
plugin.mint(other.url + "/steal")
assert other.seen == []
redirecting = _Stub(status=302, location=other.url + "/steal")
origins, resolve = ppr._PROVIDERS["openai-codex"]
monkeypatch.setitem(ppr._PROVIDERS, "openai-codex",
(origins | {("http", "127.0.0.1", redirecting.server.server_address[1])}, resolve))
try:
result = plugin.mint(redirecting.url + "/v1/realtime/client_secrets")
finally:
redirecting.close()
assert result.status == 302 and other.seen == []
def test_undeclared_plugin_and_non_plugin_callers_are_refused(stubs):
allowed, _ = stubs
_sign_in()
with pytest.raises(PermissionError, match="requires_auth"):
_plugin(declares=False).mint(allowed.url)
with pytest.raises(PermissionError, match="installed plugin"):
ppr.credentialed_provider_request("openai-codex", "GET", allowed.url)
assert allowed.seen == []
def test_not_signed_in_names_the_sign_in_command(stubs):
allowed, _ = stubs
with pytest.raises(ppr.ProviderNotSignedIn, match="hermes auth add openai-codex"):
_plugin(declares=True).mint(allowed.url)
assert allowed.seen == []