diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b9..362ed729d65 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None: * exactly ONE interactive provider is registered — with two or more we can't pick for the user, so the ``/login`` chooser must render; with zero there's nothing to redirect to; + * that provider is OAuth-style, not a password form provider. Password + providers must render ``/login`` so the user can enter credentials; * the one-shot loop-guard marker is ABSENT. Its presence means we already bounced to the portal once and came back still unauthenticated (no portal session) — auto-redirecting again would @@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + if getattr(provider, "supports_password", False): + return None + prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote @@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token): if new_session is not None: return new_session, provider.name return None - diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 9e80f2583c5..ee595be7d82 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""): status_code=404, detail=f"Provider does not support interactive login: {provider!r}", ) + if getattr(p, "supports_password", False): + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + login_url = f"{_prefix(request)}/login" + if safe_next: + login_url = f"{login_url}?next={quote(safe_next, safe='')}" + return RedirectResponse(url=login_url, status_code=302) try: ls = p.start_login(redirect_uri=_redirect_uri(request)) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index db6b6a1ec8d..fa270747528 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9087,6 +9087,54 @@ def _xai_device_poller(session_id: str) -> None: sess["error_message"] = str(e) +def _http_response_error_detail(resp: Any) -> str: + """Best-effort extraction of a short provider error detail.""" + try: + payload = resp.json() + except Exception: + payload = None + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + parts = [ + str(error.get(key, "")).strip() + for key in ("message", "error_description", "code", "type") + if str(error.get(key, "")).strip() + ] + if parts: + return ": ".join(parts) + if isinstance(error, str) and error.strip(): + return error.strip() + for key in ("detail", "message", "error_description"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + text = str(getattr(resp, "text", "") or "").strip() + return text[:500] + + +def _codex_device_code_start_error(resp: Any) -> str: + """Dashboard-facing OpenAI Codex device-code start failure.""" + status = getattr(resp, "status_code", "unknown") + detail = _http_response_error_detail(resp) + lower = detail.lower() + if "device" in lower and ("authori" in lower or "enable" in lower): + message = ( + "OpenAI rejected the device-code login request. Your OpenAI " + "account may need device-code authorization enabled before Hermes " + "can start this dashboard login. Enable device-code authorization " + "in OpenAI, then return here and click Login again." + ) + else: + message = ( + "OpenAI rejected the device-code login request. Please try Login " + "again from the dashboard after checking your OpenAI account settings." + ) + if detail: + return f"{message} (HTTP {status}: {detail})" + return f"{message} (HTTP {status})" + + def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. @@ -9119,7 +9167,7 @@ def _codex_full_login_worker(session_id: str) -> None: headers={"Content-Type": "application/json"}, ) if resp.status_code != 200: - raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}") + raise RuntimeError(_codex_device_code_start_error(resp)) device_data = resp.json() user_code = device_data.get("user_code", "") device_auth_id = device_data.get("device_auth_id", "") diff --git a/tests/hermes_cli/test_dashboard_auth_password_login.py b/tests/hermes_cli/test_dashboard_auth_password_login.py index b9508c34e49..1fa59480115 100644 --- a/tests/hermes_cli/test_dashboard_auth_password_login.py +++ b/tests/hermes_cli/test_dashboard_auth_password_login.py @@ -198,6 +198,24 @@ class TestProviderListFlag: prov = {p["name"]: p for p in resp.json()["providers"]} assert prov["testpw"]["supports_password"] is True + def test_password_provider_html_redirects_to_login_form(self, gated_app): + resp = gated_app.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/login?next=%2F" + + login = gated_app.get(resp.headers["location"]) + assert login.status_code == 200 + assert '