diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index ac9c2201c5be..e7d41d1cf525 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -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`) | diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index b517367704ef..7c2b87a8ba1c 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -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 -- 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: diff --git a/plugins/memory/honcho/oauth.py b/plugins/memory/honcho/oauth.py index 0926ab2f0cce..3d7cbbd02c82 100644 --- a/plugins/memory/honcho/oauth.py +++ b/plugins/memory/honcho/oauth.py @@ -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. diff --git a/plugins/memory/honcho/oauth_flow.py b/plugins/memory/honcho/oauth_flow.py index fad4cc9c86e2..e168364e1a33 100644 --- a/plugins/memory/honcho/oauth_flow.py +++ b/plugins/memory/honcho/oauth_flow.py @@ -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"
Connected to Honcho. You can close this tab and return to Hermes.
" ) +_CALLBACK_ERROR_HTML = ( + "" + "Honcho sign-in failed" + "" + "
Sign-in was not completed ({error}). You can close this tab and re-run setup.
" +) + 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. diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 217c37fb3a5b..8f94e8394f43 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -409,6 +409,9 @@ class TestSetupWizardDeploymentShape: monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + # No network probe / environment sniffing in tests. + monkeypatch.setattr(honcho_cli, "_device_login_available", lambda: False) + monkeypatch.setattr(honcho_cli, "_headless", lambda: (False, True)) # Gate detection is mocked so tests control whether the tree runs. # None → undetectable; list (possibly empty) → connected platforms. gw = None if gateway_platforms is None else list(gateway_platforms) @@ -791,3 +794,121 @@ class TestMigratePinKey: block = {"pinUserPeer": True} assert honcho_cli._migrate_pin_key(block) is False assert block == {"pinUserPeer": True} + + +class TestCmdSetupDeviceFlow: + """The cloud auth-method menu's device-code branch (RFC 8628).""" + + def _run_setup(self, monkeypatch, tmp_path, *, answers, device_available=True, + headless=(False, True), device_result=None, device_error=None): + """Run cmd_setup with the device flow stubbed; returns (cfg, calls, prompts).""" + import plugins.memory.honcho.cli as honcho_cli + import plugins.memory.honcho.oauth_flow as oauth_flow + from plugins.memory.honcho.oauth import OAuthCredential + + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{}") + cfg = {"apiKey": "***"} + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") + monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) + monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: []) + monkeypatch.setattr(honcho_cli, "_device_login_available", lambda: device_available) + monkeypatch.setattr(honcho_cli, "_headless", lambda: headless) + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: {"memory": {}}, raising=False, + ) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda c: None, raising=False, + ) + + class _FakeClientCfg: + def resolve_session_name(self): + return "hermes-test" + workspace_id = "hermes" + peer_name = "eri" + ai_peer = "hermetika" + observation_mode = "directional" + write_frequency = "async" + recall_mode = "hybrid" + session_strategy = "per-session" + + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: _FakeClientCfg(), + ) + monkeypatch.setattr("plugins.memory.honcho.client.reset_honcho_client", lambda: None) + monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda hcfg: object()) + + calls: list[dict] = [] + cred = OAuthCredential( + access_token="hch-at-x", refresh_token="hch-rt-x", expires_at=9_999_999_999, + client_id="hermes-agent", token_endpoint="http://x/oauth/token", + consent_peer_name="lyra", + ) + + def fake_device_flow(**kwargs): + calls.append(kwargs) + if device_error is not None: + raise device_error + return device_result or cred + + monkeypatch.setattr(oauth_flow, "authorize_via_device_code", fake_device_flow) + + prompts: list[tuple[str, str | None]] = [] + answer_iter = iter(answers) + def _scripted_prompt(label, default=None, secret=False): + prompts.append((label, default)) + try: + # Mirror the real _prompt: blank input falls back to the default. + return next(answer_iter) or (default or "") + except StopIteration: + return default if default is not None else "" + monkeypatch.setattr(honcho_cli, "_prompt", _scripted_prompt) + + honcho_cli.cmd_setup(SimpleNamespace()) + return cfg, calls, prompts + + def test_device_choice_runs_flow_and_stores_grant(self, monkeypatch, tmp_path): + cfg, calls, _ = self._run_setup(monkeypatch, tmp_path, answers=["cloud", "device"]) + assert len(calls) == 1 + assert calls[0]["apply_config"] is False + assert calls[0]["source"] == "hermes-cli" + host = cfg["hosts"]["hermes"] + assert host["apiKey"] == "hch-at-x" + assert host["oauth"]["refreshToken"] == "hch-rt-x" + assert host["peerName"] == "lyra" + + def test_headless_defaults_to_device(self, monkeypatch, tmp_path): + # Blank answer takes the prompt default, which flips to device on a + # remote/no-browser environment. + cfg, calls, prompts = self._run_setup( + monkeypatch, tmp_path, answers=["cloud", ""], headless=(True, False), + ) + method_prompts = [p for p in prompts if "apikey" in p[0]] + assert method_prompts[0][1] == "device" + assert len(calls) == 1 + assert calls[0]["open_url"] is None # never auto-open a browser headless + assert cfg["hosts"]["hermes"]["apiKey"] == "hch-at-x" + + def test_denied_device_flow_aborts_without_grant(self, monkeypatch, tmp_path): + from plugins.memory.honcho.oauth_flow import AccessDenied + + cfg, calls, _ = self._run_setup( + monkeypatch, tmp_path, answers=["cloud", "device"], + device_error=AccessDenied("access_denied", "user denied"), + ) + assert len(calls) == 1 + assert "apiKey" not in cfg.get("hosts", {}).get("hermes", {}) + + def test_device_option_hidden_when_not_advertised(self, monkeypatch, tmp_path): + _, calls, prompts = self._run_setup( + monkeypatch, tmp_path, answers=["cloud", "device"], device_available=False, + ) + method_prompts = [p for p in prompts if "OAuth or API key" in p[0]] + assert method_prompts and method_prompts[0][1] == "oauth" + assert calls == [] # 'device' answer falls through to the api-key path diff --git a/tests/honcho_plugin/test_oauth_flow.py b/tests/honcho_plugin/test_oauth_flow.py index 99c835ed139b..a52941a8e60d 100644 --- a/tests/honcho_plugin/test_oauth_flow.py +++ b/tests/honcho_plugin/test_oauth_flow.py @@ -24,9 +24,34 @@ class _FakeAS(BaseHTTPRequestHandler): # 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() @@ -50,13 +75,41 @@ class _FakeAS(BaseHTTPRequestHandler): 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 - length = int(self.headers.get("Content-Length", 0)) - form = parse_qs(self.rfile.read(length).decode()) 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 = { @@ -85,6 +138,9 @@ class _FakeAS(BaseHTTPRequestHandler): @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) @@ -236,6 +292,195 @@ def test_cli_flow_stores_tokens_without_applying_config(tmp_path, fake_as): 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_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 ── diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index ea807aee488d..5aedf04bc7d1 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -70,6 +70,8 @@ hermes memory setup # select "honcho" — runs the Honcho-specific post-s The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. +**Headless / remote machines:** for cloud auth on a box without a browser (SSH, remote VM), pick **device** at the wizard's auth-method prompt. The CLI prints a short code and a verification link; open the link in a browser on any other machine, approve, and setup completes — no API key copy-paste. The wizard defaults to this option automatically when it detects no usable local browser. + **Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).