diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 568a11957be4..5da0049ebfdf 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -193,6 +193,18 @@ async def auth_login(request: Request, provider: str, next: str = ""): detail=f"Provider does not support interactive login: {provider!r}", ) + if getattr(p, "supports_password", False): + # Password-only providers do not have an OAuth redirect flow. Some + # native clients still open /auth/login?provider=basic as their generic + # "sign in" entrypoint; send them to the password login page instead of + # calling start_login() and returning a 500. + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + prefix = _prefix(request) + suffix = f"?next={quote(safe_next, safe='')}" if safe_next else "" + return RedirectResponse(url=f"{prefix}/login{suffix}", status_code=302) + try: ls = p.start_login(redirect_uri=_redirect_uri(request)) except ProviderError as e: diff --git a/tests/hermes_cli/test_dashboard_auth_password_login.py b/tests/hermes_cli/test_dashboard_auth_password_login.py index b9508c34e491..9dcf09d4527d 100644 --- a/tests/hermes_cli/test_dashboard_auth_password_login.py +++ b/tests/hermes_cli/test_dashboard_auth_password_login.py @@ -215,6 +215,41 @@ def test_oauth_provider_reports_false(self): web_server.app.state.auth_required = prev +# --------------------------------------------------------------------------- +# /auth/login with password provider — native-client compatibility shim +# --------------------------------------------------------------------------- + + +class TestPasswordProviderAuthLoginRedirect: + def test_auth_login_redirects_password_provider_to_login_page(self, gated_app): + resp = gated_app.get( + "/auth/login?provider=testpw&next=/sessions", + follow_redirects=False, + ) + + assert resp.status_code == 302 + assert resp.headers["location"] == "/login?next=%2Fsessions" + + def test_auth_login_redirect_honours_forwarded_prefix(self, gated_app): + resp = gated_app.get( + "/auth/login?provider=testpw&next=/sessions", + headers={"x-forwarded-prefix": "/hermes"}, + follow_redirects=False, + ) + + assert resp.status_code == 302 + assert resp.headers["location"] == "/hermes/login?next=%2Fsessions" + + def test_auth_login_redirect_drops_open_redirect_next(self, gated_app): + resp = gated_app.get( + "/auth/login?provider=testpw&next=https://evil.example/phish", + follow_redirects=False, + ) + + assert resp.status_code == 302 + assert resp.headers["location"] == "/login" + + # --------------------------------------------------------------------------- # /auth/password-login — end-to-end through the real middleware # --------------------------------------------------------------------------- diff --git a/ui-tui/src/app/usePet.ts b/ui-tui/src/app/usePet.ts index 01196821fc42..6fb2ff76b490 100644 --- a/ui-tui/src/app/usePet.ts +++ b/ui-tui/src/app/usePet.ts @@ -2,6 +2,7 @@ import { useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' import type { PetGrid } from '../components/petSprite.js' +import { asRpcResult } from '../lib/rpc.js' import { useGateway } from './gatewayContext.js' import { $overlayState, getOverlayState } from './overlayStore.js' @@ -60,7 +61,7 @@ interface KittyView { placeholder: string[] } -interface PetCellsResult { +interface PetCellsResult extends Record { color?: string enabled?: boolean frameMs?: number @@ -107,7 +108,7 @@ export interface PetRender { * live. The frame cache is keyed by `slug:state` so a switch re-pulls cleanly. */ export function usePet(): PetRender { - const { rpc } = useGateway() + const { gw } = useGateway() const { write } = useStdout() const [enabled, setEnabled] = useState(false) const [grid, setGrid] = useState(null) @@ -194,7 +195,7 @@ export function usePet(): PetRender { const sync = useCallback( async (state: PetState) => { try { - const res = (await rpc('pet.cells', { graphics: IS_TTY, state })) as PetCellsResult | null + const res = asRpcResult(await gw.request('pet.cells', { graphics: IS_TTY, state })) if (!res) { return @@ -247,7 +248,7 @@ export function usePet(): PetRender { // cosmetic — ignore RPC failures } }, - [rpc, releaseKitty] + [gw, releaseKitty] ) // Pull frames whenever the state changes (if not already cached for the