hermes-agent/tests/honcho_plugin/test_oauth_flow.py
Aakash Kattelu c0170311c9 fix(honcho): default device-code poll interval to 5s when AS omits it
RFC 8628 §3.2 makes the device-authorization `interval` optional with a
client-side default of 5 seconds. request_device_code required it, so a
compliant AS that omitted it hit the malformed-response path and the flow
could never complete. Fall back to 5s and cover it with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:53:12 -07:00

610 lines
24 KiB
Python

"""End-to-end test for the zero-CLI Honcho OAuth flow against a fake AS.
Stands up a real local authorization server (no network, no browser) and drives
the full path: begin → /authorize 302 → loopback :8765 callback → token
exchange → install_grant → forced-expiry refresh with rotation. This is the
deterministic "real smoke test" for the consumer flow.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from plugins.memory.honcho import oauth, oauth_flow
class _FakeAS(BaseHTTPRequestHandler):
"""Minimal OAuth 2.1 AS: /authorize 302s to the callback; /oauth/token mints."""
# Rotation counter shared across requests so refresh returns a new token.
issued = {"n": 0}
# Scripted outcomes for device-grant token polls; "ok" mints, anything else
# is returned as a 400 OAuth error code.
device_responses: list[str] = []
# Last form posted to /oauth/device_authorization, for assertions.
last_device_form: dict = {}
# Whether AS metadata advertises the device grant.
advertise_device = True
def _send_json(self, status: int, body: dict) -> None:
payload = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def do_GET(self): # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/.well-known/oauth-authorization-server":
if not _FakeAS.advertise_device:
self.send_response(404)
self.end_headers()
return
self._send_json(200, {
"grant_types_supported": [
"authorization_code", "refresh_token", oauth_flow.DEVICE_GRANT_TYPE,
],
})
return
if parsed.path != "/authorize":
self.send_response(404)
self.end_headers()
return
q = parse_qs(parsed.query)
redirect = q["redirect_uri"][0]
# The redirect must be the IP literal matching the bound host — a
# `localhost` redirect can resolve to ::1 and miss the IPv4 listener.
# Host must be the IP literal (port may fall back off :8765).
assert redirect.startswith("http://127.0.0.1:") and "/callback" in redirect, redirect
# Consent shows a home-relative display path — never an absolute path
# that would leak the username / home layout off the machine.
cp = q["config_path"][0]
assert cp.endswith("honcho.json"), q.get("config_path")
assert not cp.startswith("/"), cp
state = q["state"][0]
location = f"{redirect}?code=test-auth-code&state={state}"
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
def do_POST(self): # noqa: N802
parsed = urlparse(self.path)
length = int(self.headers.get("Content-Length", 0))
form = parse_qs(self.rfile.read(length).decode())
if parsed.path == "/oauth/device_authorization":
_FakeAS.last_device_form = {k: v[0] for k, v in form.items()}
base = f"http://{self.server.server_address[0]}:{self.server.server_address[1]}"
self._send_json(200, {
"device_code": "dev-code-1",
"user_code": "ABCD-EFGH",
"verification_uri": f"{base}/device",
"verification_uri_complete": f"{base}/device?user_code=ABCD-EFGH",
"expires_in": 600,
"interval": 0,
})
return
if parsed.path != "/oauth/token":
self.send_response(404)
self.end_headers()
return
grant_type = form["grant_type"][0]
if grant_type == oauth_flow.DEVICE_GRANT_TYPE:
outcome = _FakeAS.device_responses.pop(0) if _FakeAS.device_responses else "ok"
if outcome != "ok":
self._send_json(400, {"error": outcome, "error_description": f"scripted {outcome}"})
return
self.issued["n"] += 1
n = self.issued["n"]
self._send_json(200, {
"access_token": f"hch-at-{n}",
"refresh_token": f"hch-rt-{n}",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "write",
"config": {"peerName": "lyra"},
})
return
self.issued["n"] += 1
n = self.issued["n"]
body = {
"access_token": f"hch-at-{n}",
"refresh_token": f"hch-rt-{n}",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "write",
}
if grant_type == "authorization_code":
body["config"] = {
"peerName": "lyra",
"environment": "production",
"hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}},
}
payload = json.dumps(body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
return
@pytest.fixture
def fake_as(monkeypatch):
_FakeAS.issued["n"] = 0
_FakeAS.device_responses = []
_FakeAS.last_device_form = {}
_FakeAS.advertise_device = True
server = HTTPServer(("127.0.0.1", 0), _FakeAS)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
monkeypatch.setenv("HONCHO_OAUTH_AUTHORIZE_URL", f"{base}/authorize")
monkeypatch.setenv("HONCHO_OAUTH_TOKEN_URL", f"{base}/oauth/token")
monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "hermes-desktop")
try:
yield base
finally:
server.shutdown()
server.server_close()
def _browser_driver(authorize_url: str) -> None:
"""Stand in for the user's browser: follow /authorize's 302 into the callback.
Retries the callback GET so it can't lose the race to the loopback bind.
"""
resp = httpx.get(authorize_url, follow_redirects=False)
location = resp.headers["Location"]
for _ in range(50):
try:
httpx.get(location, timeout=2)
return
except httpx.ConnectError:
time.sleep(0.05)
raise RuntimeError("loopback callback never came up")
def test_full_loopback_flow_then_refresh(tmp_path, fake_as):
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"obsidian": {"workspace": "obsidian"}}}))
cred = oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
open_url=lambda url: _browser_driver(url),
timeout=10,
)
# Grant installed: token stored, config deep-merged, other host preserved.
assert cred.access_token == "hch-at-1"
saved = json.loads(config_path.read_text())
assert saved["hosts"]["hermes"]["apiKey"] == "hch-at-1"
assert saved["hosts"]["hermes"]["oauth"]["refreshToken"] == "hch-rt-1"
assert saved["hosts"]["hermes"]["recallMode"] == "hybrid"
assert saved["environment"] == "production"
assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"}
# Force expiry; ensure_fresh_token refreshes against the same AS and rotates.
token, refreshed = oauth.ensure_fresh_token(
config_path, "hermes", now=saved["hosts"]["hermes"]["oauth"]["expiresAt"] + 10
)
assert refreshed is True
assert token == "hch-at-2"
rotated = json.loads(config_path.read_text())["hosts"]["hermes"]["oauth"]
assert rotated["refreshToken"] == "hch-rt-2"
def test_state_mismatch_is_rejected(fake_as, tmp_path):
endpoints = oauth_flow.resolve_endpoints()
_, state = oauth_flow.begin_authorization(endpoints)
with pytest.raises(ValueError, match="unknown or expired"):
oauth_flow.complete_authorization(
endpoints, "code", "not-the-real-state",
config_path=tmp_path / "honcho.json", host="hermes",
)
def test_source_tags_the_authorize_link(fake_as):
endpoints = oauth_flow.resolve_endpoints()
url, _ = oauth_flow.begin_authorization(endpoints, source="hermes-cli")
assert "source=hermes-cli" in url
untagged, _ = oauth_flow.begin_authorization(endpoints)
assert "source=" not in untagged
def test_client_id_defaults_to_hermes_agent(monkeypatch):
# One client for every surface; the env var overrides for unusual deployments.
monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False)
common = {"environment": "production", "base_url": "https://api.honcho.dev"}
assert oauth_flow.resolve_endpoints(**common).client_id == "hermes-agent"
monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "custom-id")
assert oauth_flow.resolve_endpoints(**common).client_id == "custom-id"
def test_grant_persists_default_client_id(tmp_path, fake_as, monkeypatch):
# Drop the fixture's override so the default takes effect; the grant must
# store client_id=hermes-agent so refresh reuses the right client.
monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False)
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {}}))
oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
source="hermes-cli",
apply_config=False,
open_url=lambda url: _browser_driver(url),
timeout=10,
)
saved = json.loads(config_path.read_text())
assert saved["hosts"]["hermes"]["oauth"]["clientId"] == "hermes-agent"
def test_config_path_rides_the_authorize_link(fake_as):
endpoints = oauth_flow.resolve_endpoints()
url, _ = oauth_flow.begin_authorization(endpoints, config_path="~/.hermes/honcho.json")
q = parse_qs(urlparse(url).query)
assert q["config_path"][0] == "~/.hermes/honcho.json"
bare, _ = oauth_flow.begin_authorization(endpoints)
assert "config_path=" not in bare
def test_display_config_path_never_leaks_absolute_path():
from pathlib import Path
# Under home → collapsed to ~/…; outside home → bare filename only.
under_home = Path.home() / ".hermes" / "profiles" / "work" / "honcho.json"
assert oauth_flow._display_config_path(under_home) == "~/.hermes/profiles/work/honcho.json"
assert oauth_flow._display_config_path("/var/folders/tmp/honcho.json") == "honcho.json"
def test_cli_flow_stores_tokens_without_applying_config(tmp_path, fake_as):
# apply_config=False (the CLI path): grant config must NOT touch settings.
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"hermes": {"saveMessages": False}}}))
cred = oauth_flow.authorize_via_loopback(
config_path=config_path,
host="hermes",
source="hermes-cli",
apply_config=False,
open_url=lambda url: _browser_driver(url),
timeout=10,
)
saved = json.loads(config_path.read_text())
host = saved["hosts"]["hermes"]
assert host["apiKey"] == cred.access_token
assert host["oauth"]["refreshToken"] == cred.refresh_token
# Wizard-owned setting untouched; grant config keys absent.
assert host["saveMessages"] is False
assert "recallMode" not in host
assert "environment" not in saved
# consent peer name still surfaced (seeds the CLI wizard prompt) despite no merge
assert cred.consent_peer_name == "lyra"
# ── Device authorization grant (RFC 8628): headless / remote-VM path ──
class _FakeClock:
"""Injectable sleep + monotonic pair so poll loops run instantly."""
def __init__(self):
self.t = 0.0
self.sleeps: list[float] = []
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.t += seconds
def monotonic(self) -> float:
return self.t
def test_device_endpoint_derived_from_token_url(monkeypatch):
monkeypatch.delenv("HONCHO_OAUTH_DEVICE_AUTH_URL", raising=False)
monkeypatch.delenv("HONCHO_OAUTH_TOKEN_URL", raising=False)
cloud = oauth_flow.resolve_endpoints(environment="production", base_url="https://api.honcho.dev")
assert cloud.device_authorization_url == "https://api.honcho.dev/oauth/device_authorization"
local = oauth_flow.resolve_endpoints(environment="local", base_url=None)
assert local.device_authorization_url == "http://localhost:8000/oauth/device_authorization"
def test_device_endpoint_env_override(monkeypatch):
monkeypatch.setenv("HONCHO_OAUTH_DEVICE_AUTH_URL", "https://alt.example/oauth/device_authorization")
endpoints = oauth_flow.resolve_endpoints(environment="production", base_url=None)
assert endpoints.device_authorization_url == "https://alt.example/oauth/device_authorization"
def test_supports_device_login_from_metadata(fake_as):
endpoints = oauth_flow.resolve_endpoints()
assert oauth_flow.supports_device_login(endpoints) is True
_FakeAS.advertise_device = False
assert oauth_flow.supports_device_login(endpoints) is False
# Fail closed on an unreachable host.
dead = oauth_flow.OAuthEndpoints(
authorize_url="http://127.0.0.1:1/authorize",
token_url="http://127.0.0.1:1/oauth/token",
client_id="hermes-agent",
scope="write",
)
assert oauth_flow.supports_device_login(dead, timeout=0.2) is False
def test_request_device_code_parses_response_and_sends_identity(fake_as):
endpoints = oauth_flow.resolve_endpoints()
device = oauth_flow.request_device_code(endpoints, source="hermes-cli")
assert device.device_code == "dev-code-1"
assert device.user_code == "ABCD-EFGH"
assert device.verification_uri.endswith("/device")
assert device.verification_uri_complete.endswith("?user_code=ABCD-EFGH")
assert (device.expires_in, device.interval) == (600, 0)
assert _FakeAS.last_device_form["client_id"] == "hermes-desktop"
assert _FakeAS.last_device_form["scope"] == "write"
assert _FakeAS.last_device_form["source"] == "hermes-cli"
def test_request_device_code_defaults_interval_when_omitted(monkeypatch):
# RFC 8628 §3.2: interval is optional; a compliant AS may omit it, and the
# client must fall back to 5s rather than treating the response as malformed.
endpoints = oauth_flow.resolve_endpoints(environment="local")
monkeypatch.setattr(
oauth,
"_http_post_form_status",
lambda *a, **k: (200, {
"device_code": "dev-code-1",
"user_code": "ABCD-EFGH",
"verification_uri": "http://localhost:3000/device",
"expires_in": 600,
}),
)
device = oauth_flow.request_device_code(endpoints)
assert device.interval == 5
def test_full_device_flow_pending_then_approved(tmp_path, fake_as):
_FakeAS.device_responses = ["authorization_pending", "authorization_pending", "ok"]
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {"hermes": {"saveMessages": False}}}))
clock = _FakeClock()
cred = oauth_flow.authorize_via_device_code(
config_path=config_path,
host="hermes",
source="hermes-cli",
apply_config=False,
sleep=clock.sleep,
)
saved = json.loads(config_path.read_text())
host = saved["hosts"]["hermes"]
assert host["apiKey"] == cred.access_token == "hch-at-1"
assert host["oauth"]["refreshToken"] == "hch-rt-1"
assert host["oauth"]["clientId"] == "hermes-desktop"
assert host["oauth"]["tokenEndpoint"] == oauth_flow.resolve_endpoints().token_url
# Wizard-owned settings untouched; consent peer name still surfaced.
assert host["saveMessages"] is False
assert cred.consent_peer_name == "lyra"
assert len(clock.sleeps) == 3 # one wait per poll
def test_poll_backs_off_on_slow_down(fake_as):
_FakeAS.device_responses = ["slow_down", "slow_down", "ok"]
endpoints = oauth_flow.resolve_endpoints()
device = oauth_flow.DeviceCode(
device_code="dev-code-1", user_code="X", verification_uri="u",
verification_uri_complete="u?c", expires_in=600, interval=5,
)
clock = _FakeClock()
grant = oauth_flow.poll_for_token(endpoints, device, sleep=clock.sleep, monotonic=clock.monotonic)
assert grant["access_token"] == "hch-at-1"
assert clock.sleeps == [5, 10, 15]
def test_slow_down_interval_caps_at_60(fake_as):
_FakeAS.device_responses = ["slow_down", "ok"]
endpoints = oauth_flow.resolve_endpoints()
device = oauth_flow.DeviceCode(
device_code="dev-code-1", user_code="X", verification_uri="u",
verification_uri_complete="u?c", expires_in=600, interval=58,
)
clock = _FakeClock()
oauth_flow.poll_for_token(endpoints, device, sleep=clock.sleep, monotonic=clock.monotonic)
assert clock.sleeps == [58, 60] # 58 + 5 clamps to the 60s cap
@pytest.mark.parametrize(
("error", "exc"),
[("access_denied", oauth_flow.AccessDenied), ("expired_token", oauth_flow.DeviceCodeExpired)],
)
def test_poll_raises_typed_errors(fake_as, error, exc):
_FakeAS.device_responses = [error]
endpoints = oauth_flow.resolve_endpoints()
device = oauth_flow.DeviceCode(
device_code="dev-code-1", user_code="X", verification_uri="u",
verification_uri_complete="u?c", expires_in=600, interval=0,
)
clock = _FakeClock()
with pytest.raises(exc) as e:
oauth_flow.poll_for_token(endpoints, device, sleep=clock.sleep, monotonic=clock.monotonic)
assert e.value.error == error
def test_poll_times_out_at_deadline(fake_as):
_FakeAS.device_responses = ["authorization_pending"] * 10
endpoints = oauth_flow.resolve_endpoints()
device = oauth_flow.DeviceCode(
device_code="dev-code-1", user_code="X", verification_uri="u",
verification_uri_complete="u?c", expires_in=3, interval=1,
)
clock = _FakeClock()
with pytest.raises(oauth_flow.AuthorizationTimeout):
oauth_flow.poll_for_token(endpoints, device, sleep=clock.sleep, monotonic=clock.monotonic)
assert len(clock.sleeps) <= 3 # bounded by the deadline, not the script
def test_device_flow_browser_open_is_caller_opt_in(tmp_path, fake_as):
config_path = tmp_path / "honcho.json"
config_path.write_text(json.dumps({"hosts": {}}))
opened: list[str] = []
shown: list[oauth_flow.DeviceCode] = []
oauth_flow.authorize_via_device_code(
config_path=config_path, host="hermes",
display=shown.append, open_url=opened.append, sleep=lambda s: None,
)
assert opened == [shown[0].verification_uri_complete]
# No open_url → nothing opened; the flow still completes.
config_path.write_text(json.dumps({"hosts": {}}))
cred = oauth_flow.authorize_via_device_code(
config_path=config_path, host="hermes", sleep=lambda s: None,
)
assert cred.access_token
def test_callback_page_shows_error_on_denied_consent():
server, captured = oauth_flow._bind_loopback_server()
port = server.server_address[1]
result: dict = {}
def _deny():
for _ in range(50):
try:
result["resp"] = httpx.get(
f"http://127.0.0.1:{port}/callback"
"?error=access_denied&error_description=user+denied&state=x",
timeout=2,
)
return
except httpx.ConnectError:
time.sleep(0.05)
thread = threading.Thread(target=_deny, daemon=True)
thread.start()
with pytest.raises(ValueError, match="access_denied.*user denied"):
oauth_flow.capture_loopback_code(server, captured, timeout=5)
thread.join(timeout=5)
page = result["resp"].text
assert "Connected" not in page
assert "not completed" in page and "access_denied" in page
# ── Desktop "Connect" button path: background launcher, status, dispatch ──
@pytest.fixture
def reset_flow():
oauth_flow._status = oauth_flow.FlowStatus()
oauth_flow._flow_thread = None
yield
oauth_flow._status = oauth_flow.FlowStatus()
oauth_flow._flow_thread = None
def _wait_until(predicate, timeout=2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.02)
return False
def test_launcher_runs_flow_in_background_and_reports_connected(monkeypatch, reset_flow):
seen = {}
gate = threading.Event()
def fake(**kwargs):
seen.update(kwargs) # captures source default + eagerly-resolved path/host
gate.wait(2) # hold the flow open so the launcher returns while pending
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (True, "oauth"))
st = oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes")
assert st["state"] == "pending" # returns immediately, before the flow finishes
assert _wait_until(lambda: seen.get("source") == "hermes-desktop") # default source tag
assert seen["host"] == "hermes"
gate.set()
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected")
def test_launcher_reports_error_on_flow_failure(monkeypatch, reset_flow):
def boom(**kwargs):
raise RuntimeError("loopback bind failed")
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", boom)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None))
oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes")
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "error")
assert "loopback bind failed" in oauth_flow.get_flow_status()["detail"]
def test_launcher_is_idempotent_while_pending(monkeypatch, reset_flow):
block = threading.Event()
calls = []
def fake(**kwargs):
calls.append(1)
block.wait(2)
monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake)
monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None))
s1 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes")
assert _wait_until(lambda: len(calls) == 1) # first flow is running
s2 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes")
block.set()
assert s1["state"] == "pending" and s2["state"] == "pending"
assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected")
assert calls == [1] # the second call did not spawn a second flow
def test_get_flow_status_reports_stored_connection(tmp_path, monkeypatch, reset_flow):
from plugins.memory.honcho import client as honcho_client
cfgfile = tmp_path / "honcho.json"
monkeypatch.setattr(honcho_client, "resolve_config_path", lambda: cfgfile)
monkeypatch.setattr(honcho_client, "resolve_active_host", lambda: "hermes")
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
cfgfile.write_text(json.dumps({"hosts": {"hermes": {}}}))
assert oauth_flow.get_flow_status()["connected"] is False
cfgfile.write_text(json.dumps({"hosts": {"hermes": {"apiKey": "hch-v3-static"}}}))
s = oauth_flow.get_flow_status()
assert s["connected"] is True and s["auth"] == "apikey"
cfgfile.write_text(json.dumps({"hosts": {"hermes": {
"apiKey": "hch-at-tok",
"oauth": {"refreshToken": "hch-rt-x", "expiresAt": 9_999_999_999,
"clientId": "hermes-desktop", "tokenEndpoint": "http://x/oauth/token"},
}}}))
s = oauth_flow.get_flow_status()
assert s["connected"] is True and s["auth"] == "oauth"
def test_memory_oauth_router_dispatches_by_provider_convention():
# The generic seam behind the two routes: provider → plugins.memory.<p>.oauth_flow.
from fastapi import HTTPException
from hermes_cli.memory_oauth import _resolve_flow
mod = _resolve_flow("honcho")
assert hasattr(mod, "start_loopback_flow_background") and hasattr(mod, "get_flow_status")
for bad in ("builtin", "no-such-provider", "../etc"):
with pytest.raises(HTTPException) as exc:
_resolve_flow(bad)
assert exc.value.status_code == 404