feat(honcho): add OAuth device-code login (RFC 8628) for headless environments

Adds a device authorization grant flow alongside the existing loopback
OAuth flow, so `hermes setup` can connect to Honcho cloud from SSH and
other no-browser environments.

- oauth.py: new HTTP seams — _http_post_form_status (non-raising, since
  RFC 8628 polling reads the OAuth error off a 400) and _http_get_json
  for the RFC 8414 metadata probe
- oauth_flow.py: DeviceCode, request_device_code, poll_for_token with
  slow_down backoff (+5s, capped at 60s) bounded by expires_in, typed
  errors (AccessDenied, DeviceCodeExpired, AuthorizationTimeout), and
  supports_device_login (fail-closed metadata gate); device flow ends in
  the same install_grant tail as loopback so refresh/status work
  unchanged
- oauth_flow.py: loopback callback now serves a "sign-in was not
  completed" page on consent cancel instead of the success page
- cli.py: cloud menu offers oauth / device / apikey; the device option
  only appears when the host advertises the grant, and becomes the
  default when no browser is detected
- 18 new tests covering the full flow against a local fake AS, backoff
  schedule, error mapping, deadline bound, metadata gate, and wizard
  branches

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aakash Kattelu 2026-07-09 08:59:00 -07:00 • committed by Teknium
parent a61183b56f
commit 2aa359ea70
7 changed files with 742 additions and 19 deletions

View file

@ -17,10 +17,12 @@ hermes memory setup honcho # configure Honcho directly (works on a fresh insta
hermes memory setup # generic picker, choose Honcho from the list
```
For cloud, the wizard asks **OAuth or API key**. OAuth opens a browser
sign-in and stores the grant itself — nothing to copy; tokens refresh
automatically. The desktop app offers the same flow as a **Connect** link
next to the memory-provider dropdown.
For cloud, the wizard asks **OAuth, device code, or API key**. OAuth opens a
browser sign-in and stores the grant itself — nothing to copy; tokens refresh
automatically. On SSH/headless machines choose **device**: the CLI prints a
short code and a link you open in a browser on any other machine; setup
completes once you approve there. The desktop app offers the browser flow as
a **Connect** link next to the memory-provider dropdown.
Or manually:
```bash
@ -347,6 +349,7 @@ Presets:
| `HONCHO_OAUTH_DASHBOARD` | OAuth authorize origin (default: cloud dashboard; local-dev `localhost:3000`) |
| `HONCHO_OAUTH_AUTHORIZE_URL` | Full authorize URL (overrides the dashboard origin) |
| `HONCHO_OAUTH_TOKEN_URL` | Token endpoint (default: cloud API; local-dev `localhost:8000`) |
| `HONCHO_OAUTH_DEVICE_AUTH_URL` | Device-authorization endpoint (default: derived from the token URL) |
| `HONCHO_OAUTH_CLIENT_ID` | OAuth client (default `hermes-agent`) |
| `HONCHO_OAUTH_SCOPE` | Requested scope (default `write`) |

View file

@ -620,7 +620,7 @@ def cmd_setup(args) -> None:
print("\n No local JWT set. Local no-auth ready.")
use_oauth = False
if not is_local:
# --- Cloud: OAuth (browser) or API key ---
# --- Cloud: OAuth (browser), device code, or API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default
# Detect an existing OAuth grant so re-running setup reflects it instead
@ -628,15 +628,88 @@ def cmd_setup(args) -> None:
from plugins.memory.honcho.oauth import OAuthCredential
existing_oauth = OAuthCredential.from_host_block(hermes_host)
device_available = _device_login_available()
is_remote, can_browse = _headless()
print("\n Auth method:")
if existing_oauth is not None:
print(f" (currently connected via OAuth — client {existing_oauth.client_id})")
print(" oauth -- sign in via browser (recommended)")
print(" oauth -- sign in via browser on this machine (recommended)")
if device_available:
print(" device -- device code: approve from a browser on another machine (SSH / headless)")
print(" apikey -- paste an API key from https://app.honcho.dev")
method = _prompt("OAuth or API key?", default="oauth").strip().lower()
use_oauth = method in {"oauth", "o"}
if use_oauth:
default_method = "oauth"
if is_remote or not can_browse:
if device_available:
print(" (no usable local browser detected — device code recommended)")
default_method = "device"
else:
print(" (no usable local browser detected — browser sign-in may need an SSH tunnel to 127.0.0.1:8765)")
prompt_label = "oauth, device, or apikey?" if device_available else "OAuth or API key?"
method = _prompt(prompt_label, default=default_method).strip().lower()
use_oauth = method in {"oauth", "o"}
use_device = device_available and method in {"device", "d"}
if use_device:
from plugins.memory.honcho.oauth_flow import (
AccessDenied,
AuthorizationTimeout,
DeviceCode,
DeviceCodeExpired,
DeviceFlowError,
authorize_via_device_code,
)
def _show(device: DeviceCode) -> None:
print("\n To connect, on any device with a browser:")
print(f"\n 1. Open {device.verification_uri}")
print(f" 2. Enter {device.user_code}")
print(f"\n Or open directly:\n\n {device.verification_uri_complete}\n")
mins = max(1, device.expires_in // 60)
print(f" Waiting for approval (expires in {mins} min, Ctrl-C to cancel) ", end="", flush=True)
def _open_local(url: str) -> None:
import webbrowser
webbrowser.open(url)
print("\n Requesting device code…")
try:
cred = authorize_via_device_code(
config_path=write_path,
source="hermes-cli",
apply_config=False,
display=_show,
open_url=_open_local if can_browse and not is_remote else None,
on_poll=lambda: print(".", end="", flush=True),
)
except KeyboardInterrupt:
print("\n Cancelled. Re-run 'hermes honcho setup' to try again.\n")
return
except (AuthorizationTimeout, DeviceCodeExpired):
print("\n Device code expired before approval.")
print(" Re-run 'hermes honcho setup' to get a new code.\n")
return
except AccessDenied:
print("\n Sign-in was denied on the approval page.")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
except DeviceFlowError as e:
if e.error == "http_429":
print("\n Too many device-code requests — wait a minute and re-run setup.\n")
else:
print(f"\n Device sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
except Exception as e:
print(f"\n Device sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
print(" approved")
_apply_grant_to_host(hermes_host, cred)
print(" Authorized — token saved. Let's finish configuring.\n")
elif use_oauth:
# Sign in now, up front — the browser link is the whole point, so
# don't bury it behind the identity prompts. The grant's tokens are
# merged into the in-memory cfg so the wizard's final save preserves
@ -661,11 +734,7 @@ def cmd_setup(args) -> None:
print(f" OAuth sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
# Default the peer prompt to the name entered at consent.
if cred.consent_peer_name:
hermes_host["peerName"] = cred.consent_peer_name
_apply_grant_to_host(hermes_host, cred)
print(" Authorized — token saved. Let's finish configuring.\n")
else:
current_key = cfg.get("apiKey", "")
@ -974,6 +1043,35 @@ def cmd_setup(args) -> None:
print(" hermes honcho map <name> -- map this directory to a session name\n")
def _device_login_available() -> bool:
"""Whether the resolved host offers the RFC 8628 device grant. Fails closed."""
try:
from plugins.memory.honcho.oauth_flow import resolve_endpoints, supports_device_login
return supports_device_login(resolve_endpoints())
except Exception:
return False
def _headless() -> tuple[bool, bool]:
"""(is_remote, can_open_browser) — degrades safely if hermes_cli internals move."""
try:
from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session
return _is_remote_session(), _can_open_graphical_browser()
except Exception:
return False, True
def _apply_grant_to_host(hermes_host: dict, cred) -> None:
"""Store an OAuth grant on the host block; the wizard's final save persists it."""
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
# Default the peer prompt to the name entered at consent.
if cred.consent_peer_name:
hermes_host["peerName"] = cred.consent_peer_name
def _active_profile_name() -> str:
"""Return the active Hermes profile name (respects --target-profile override)."""
if _profile_override:

View file

@ -175,6 +175,36 @@ def _http_post_form(url: str, data: dict[str, str], timeout: float) -> dict[str,
return resp.json()
def _http_post_form_status(
url: str, data: dict[str, str], timeout: float
) -> tuple[int, dict[str, Any]]:
"""POST form-encoded ``data``; return ``(status, parsed JSON body)``.
Unlike ``_http_post_form``, 4xx does not raise — RFC 8628 polling reads the
OAuth error body off a 400. A non-JSON body parses to ``{}``.
"""
import httpx
resp = httpx.post(url, data=data, timeout=timeout)
try:
body = resp.json()
except ValueError:
body = {}
if not isinstance(body, dict):
body = {}
return resp.status_code, body
def _http_get_json(url: str, timeout: float) -> dict[str, Any]:
"""GET ``url`` and return the parsed JSON body. Raises on non-2xx/non-JSON."""
import httpx
resp = httpx.get(url, timeout=timeout)
resp.raise_for_status()
body = resp.json()
return body if isinstance(body, dict) else {}
def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCredential:
"""Run the refresh_token grant and return the rotated credential.

View file

@ -62,6 +62,7 @@ class OAuthEndpoints:
token_url: str # API /oauth/token
client_id: str
scope: str
device_authorization_url: str = "" # API /oauth/device_authorization
# Cloud (production) hosts; dashboard serves /authorize, API serves /oauth/token.
@ -107,11 +108,15 @@ def resolve_endpoints(
default_token = f"{base_url.rstrip('/')}/oauth/token"
dashboard = os.environ.get("HONCHO_OAUTH_DASHBOARD", default_dashboard).rstrip("/")
token_url = os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token)
# Device authorization rides the token endpoint's origin.
default_device = f"{token_url.rsplit('/', 1)[0]}/device_authorization"
return OAuthEndpoints(
authorize_url=os.environ.get("HONCHO_OAUTH_AUTHORIZE_URL", f"{dashboard}/authorize"),
token_url=os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token),
token_url=token_url,
client_id=os.environ.get("HONCHO_OAUTH_CLIENT_ID", _DEFAULT_CLIENT_ID),
scope=os.environ.get("HONCHO_OAUTH_SCOPE", "write"),
device_authorization_url=os.environ.get("HONCHO_OAUTH_DEVICE_AUTH_URL", default_device),
)
@ -240,6 +245,14 @@ _CALLBACK_HTML = (
b"<div>Connected to Honcho. You can close this tab and return to Hermes.</div>"
)
_CALLBACK_ERROR_HTML = (
"<!doctype html><meta charset=utf-8>"
"<title>Honcho sign-in failed</title>"
"<body style='font:14px ui-monospace,monospace;background:#0b0e14;color:#c9d1d9;"
"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
"<div>Sign-in was not completed ({error}). You can close this tab and re-run setup.</div>"
)
def _bind_loopback_server() -> tuple[HTTPServer, dict[str, str]]:
"""Bind the one-shot callback server, returning it and its capture dict.
@ -262,10 +275,17 @@ def _bind_loopback_server() -> tuple[HTTPServer, dict[str, str]]:
captured["code"] = (params.get("code") or [""])[0]
captured["state"] = (params.get("state") or [""])[0]
captured["error"] = (params.get("error") or [""])[0]
captured["error_description"] = (params.get("error_description") or [""])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(_CALLBACK_HTML)
if captured["error"]:
import html as _html
page = _CALLBACK_ERROR_HTML.format(error=_html.escape(captured["error"]))
self.wfile.write(page.encode("utf-8"))
else:
self.wfile.write(_CALLBACK_HTML)
def log_message(self, *args): # silence stdlib request logging
return
@ -296,7 +316,9 @@ def capture_loopback_code(
server.server_close()
if captured.get("error"):
raise ValueError(f"authorization denied: {captured['error']}")
detail = captured.get("error_description")
suffix = f" ({detail})" if detail else ""
raise ValueError(f"authorization denied: {captured['error']}{suffix}")
if "code" not in captured:
raise TimeoutError("no OAuth callback received before timeout")
return captured["code"], captured.get("state", "")
@ -352,6 +374,208 @@ def authorize_via_loopback(
)
# — Device authorization grant (RFC 8628), for headless / remote-VM clients —
# The loopback flow needs the browser on the same machine; here the CLI prints
# a short user code, the user approves from any browser (dashboard /device),
# and the device polls the token endpoint until the grant lands.
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
# RFC 8628 §3.5: slow_down adds 5s per response; cap matches the server's
# DEVICE_POLL_INTERVAL_MAX so a misbehaving clock can't inflate past it.
_SLOW_DOWN_STEP = 5
_POLL_INTERVAL_CAP = 60
# RFC 8414 authorization-server metadata; advertising the device grant is what
# distinguishes a host that can do device login from one that can't.
_AS_METADATA_PATH = "/.well-known/oauth-authorization-server"
class DeviceFlowError(RuntimeError):
"""A device-flow request failed. ``error`` is the RFC error code when known."""
def __init__(self, error: str, description: str | None = None):
self.error = error
self.description = description
super().__init__(f"{error}: {description}" if description else error)
class AccessDenied(DeviceFlowError):
"""The user denied the authorization request."""
class DeviceCodeExpired(DeviceFlowError):
"""The device code expired before the user approved it."""
class AuthorizationTimeout(DeviceFlowError):
"""Polling ran past the device code's lifetime with no decision."""
@dataclass(frozen=True)
class DeviceCode:
"""RFC 8628 §3.2 device authorization response."""
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: str
expires_in: int
interval: int
def supports_device_login(endpoints: OAuthEndpoints, *, timeout: float = 5.0) -> bool:
"""Whether the host advertises the device grant in its RFC 8414 metadata.
Fails closed: any connection error, non-200, or missing capability returns
False, so hosts without the device grant simply don't offer the option.
"""
origin = endpoints.token_url.rsplit("/oauth/", 1)[0]
try:
body = oauth._http_get_json(f"{origin}{_AS_METADATA_PATH}", timeout)
except Exception:
return False
grants = body.get("grant_types_supported")
return isinstance(grants, list) and DEVICE_GRANT_TYPE in grants
def request_device_code(
endpoints: OAuthEndpoints, *, source: str | None = None
) -> DeviceCode:
"""Request a device + user code pair (RFC 8628 §3.1)."""
if not endpoints.device_authorization_url:
raise ValueError("no device authorization endpoint resolved")
data = {"client_id": endpoints.client_id, "scope": endpoints.scope}
if source:
data["source"] = source
status, body = oauth._http_post_form_status(
endpoints.device_authorization_url, data, oauth._REFRESH_TIMEOUT_SECONDS
)
if status != 200:
error = str(body.get("error") or f"http_{status}")
raise DeviceFlowError(error, body.get("error_description"))
try:
verification_uri = body["verification_uri"]
return DeviceCode(
device_code=body["device_code"],
user_code=body["user_code"],
verification_uri=verification_uri,
verification_uri_complete=body.get(
"verification_uri_complete",
f"{verification_uri}?user_code={body['user_code']}",
),
expires_in=int(body["expires_in"]),
interval=int(body["interval"]),
)
except (KeyError, TypeError, ValueError) as e:
raise DeviceFlowError(
"invalid_response", f"malformed device authorization response: {e}"
) from e
def poll_for_token(
endpoints: OAuthEndpoints,
device: DeviceCode,
*,
on_poll: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> dict[str, object]:
"""Poll the token endpoint until the grant is approved (RFC 8628 §3.4/§3.5).
Sleeps ``interval`` before each poll, bumping it on ``slow_down``. Raises
``AccessDenied`` / ``DeviceCodeExpired`` on the terminal server outcomes and
``AuthorizationTimeout`` when ``expires_in`` elapses with no decision.
``sleep`` / ``monotonic`` are injectable for tests.
"""
import httpx
interval = max(1, min(device.interval, _POLL_INTERVAL_CAP))
deadline = monotonic() + max(1, device.expires_in)
while True:
if monotonic() + interval >= deadline:
raise AuthorizationTimeout(
"expired_token", "timed out waiting for approval"
)
sleep(interval)
if on_poll:
on_poll()
try:
status, body = oauth._http_post_form_status(
endpoints.token_url,
{
"grant_type": DEVICE_GRANT_TYPE,
"device_code": device.device_code,
"client_id": endpoints.client_id,
},
oauth._REFRESH_TIMEOUT_SECONDS,
)
except httpx.TransportError as e:
# A network blip mid-poll shouldn't kill a 10-minute wait.
logger.debug("device token poll transport error, retrying: %s", e)
continue
if status == 200:
if not body.get("access_token"):
raise DeviceFlowError("invalid_response", "token response missing access_token")
return body
error = str(body.get("error") or f"http_{status}")
description = body.get("error_description")
if error == "authorization_pending":
continue
if error == "slow_down":
interval = min(interval + _SLOW_DOWN_STEP, _POLL_INTERVAL_CAP)
continue
if error == "access_denied":
raise AccessDenied(error, description)
if error == "expired_token":
raise DeviceCodeExpired(error, description)
raise DeviceFlowError(error, description)
def authorize_via_device_code(
*,
config_path: Path | None = None,
host: str | None = None,
source: str | None = None,
apply_config: bool = True,
display: Callable[[DeviceCode], None] | None = None,
open_url: Callable[[str], None] | None = None,
on_poll: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep,
) -> oauth.OAuthCredential:
"""Drive the full device flow: request codes → show user code → poll → persist.
``display`` shows the user code + verification URL. ``open_url`` (if given)
receives ``verification_uri_complete`` — there is no default browser open,
since the approving browser may be on another machine.
"""
endpoints = resolve_endpoints()
path = config_path or resolve_config_path()
target_host = host or resolve_active_host()
device = request_device_code(endpoints, source=source)
if display:
display(device)
if open_url:
open_url(device.verification_uri_complete)
grant = poll_for_token(endpoints, device, on_poll=on_poll, sleep=sleep)
cred = oauth.install_grant(
path,
target_host,
grant,
client_id=endpoints.client_id,
token_endpoint=endpoints.token_url,
apply_config=apply_config,
)
from plugins.memory.honcho.client import reset_honcho_client
reset_honcho_client()
logger.info("Honcho OAuth device grant installed for host %s", target_host)
return cred
# — Background launcher + status, for the desktop "Connect" button —
# The flow blocks on a browser round-trip, so the web_server endpoint kicks it
# off in a thread and the UI polls status rather than holding the request open.