diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index b953b0abd..6875c445f 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' +import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, applyDesktopBootProgress, @@ -232,8 +233,10 @@ export function useGatewayBoot({ publish(conn) // Mint a fresh WS URL right before connecting. For OAuth gateways the // ticket is single-use with a short TTL, so the ticket baked into - // conn.wsUrl can already be stale; getGatewayWsUrl() re-mints it. - const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl + // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on + // failure, throws a reauth error rather than connecting with a dead + // ticket (which would surface as an opaque "connection closed"). + const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) if (cancelled) { diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 4ef96a4dc..baf651932 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $gatewayState, setConnection } from '@/store/session' export function useGatewayRequest() { @@ -14,6 +15,10 @@ export function useGatewayRequest() { const gatewayStateRef = useRef(gatewayState) const reconnectingRef = useRef | null>(null) + // Holds the reauth error from the most recent failed reconnect so + // requestGateway can surface the gateway's "session expired, sign in again" + // message instead of the opaque "connection closed" that triggered the retry. + const reauthErrorRef = useRef(null) useEffect(() => { gatewayStateRef.current = gatewayState @@ -41,17 +46,26 @@ export function useGatewayRequest() { return null } + reauthErrorRef.current = null + try { const conn = await desktop.getConnection() connectionRef.current = conn setConnection(conn) - // Re-mint the WS URL before reconnecting — OAuth tickets are single-use - // and short-lived, so the cached conn.wsUrl ticket is stale here. - const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl + // Re-mint the WS URL before reconnecting. OAuth tickets are single-use + // and short-lived, so the cached conn.wsUrl ticket is dead here; + // resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than + // connecting with a stale ticket. Stash it so requestGateway can show + // the actionable "sign in again" message. + const wsUrl = await resolveGatewayWsUrl(desktop, conn) await existing.connect(wsUrl) return existing - } catch { + } catch (error) { + if (isGatewayReauthRequired(error)) { + reauthErrorRef.current = error + } + connectionRef.current = null setConnection(null) @@ -84,6 +98,15 @@ export function useGatewayRequest() { const recovered = await ensureGatewayOpen() if (!recovered) { + // Prefer the reauth error from the failed reconnect (OAuth session + // expired) over the generic transport error that triggered the retry. + const reauthError = reauthErrorRef.current + reauthErrorRef.current = null + + if (reauthError) { + throw reauthError + } + throw error } diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts new file mode 100644 index 000000000..2884f08d2 --- /dev/null +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' + +import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url' + +const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' } +const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' } + +describe('resolveGatewayWsUrl', () => { + describe('oauth mode', () => { + it('uses the freshly minted URL', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh') + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh') + expect(getGatewayWsUrl).toHaveBeenCalledOnce() + }) + + it('throws a reauth error instead of falling back to the stale cached ticket', async () => { + const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired')) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf( + GatewayReauthRequiredError + ) + }) + + it('preserves the underlying mint failure as the cause', async () => { + const cause = new Error('401 cookie expired') + const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) + expect(error).toBeInstanceOf(GatewayReauthRequiredError) + expect((error as GatewayReauthRequiredError).cause).toBe(cause) + }) + + it('throws a reauth error when the preload cannot mint (no method)', async () => { + await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError) + }) + + it('never returns the stale cached ticket on failure', async () => { + const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom')) + const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw') + expect(result).toBe('threw') + expect(result).not.toBe(oauthConn.wsUrl) + }) + }) + + describe('token / local mode', () => { + it('uses the minted URL when available', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh') + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') + }) + + it('falls back to the cached URL when minting fails (token is long-lived)', async () => { + const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient')) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl) + }) + + it('falls back to the cached URL when the preload method is absent', async () => { + await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl) + }) + + it('treats a missing authMode as non-oauth (falls back safely)', async () => { + await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl) + }) + }) +}) + +describe('isGatewayReauthRequired', () => { + it('detects the dedicated error class', () => { + expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true) + }) + + it('detects plain objects tagged with needsOauthLogin (from the main process)', () => { + expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true) + }) + + it('rejects generic errors', () => { + expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false) + expect(isGatewayReauthRequired(null)).toBe(false) + expect(isGatewayReauthRequired('string')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/gateway-ws-url.ts b/apps/desktop/src/lib/gateway-ws-url.ts new file mode 100644 index 000000000..68726d496 --- /dev/null +++ b/apps/desktop/src/lib/gateway-ws-url.ts @@ -0,0 +1,85 @@ +import type { HermesConnection } from '@/global' + +/** + * The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket + * URL immediately before every `gateway.connect()`. For OAuth-gated remote + * gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked + * into the cached `conn.wsUrl` is stale (and, after the first connect, already + * consumed). For local/token gateways the URL carries a long-lived token and + * never needs re-minting. + * + * Resolution rules: + * + * - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall + * back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to + * fail with an opaque "connection closed" error. Instead, let the mint error + * propagate so the caller can surface the gateway's reauth message + * ("session has expired… Sign in again"). + * + * - token / local, or when the preload method is genuinely absent (older + * preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so + * the fallback is safe and preserves compatibility. + * + * The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so + * callers can distinguish "the user must re-authenticate" from a generic + * transport failure. + */ +export interface ResolveGatewayWsUrlDeps { + /** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */ + getGatewayWsUrl?: () => Promise +} + +export class GatewayReauthRequiredError extends Error { + readonly needsOauthLogin = true + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'GatewayReauthRequiredError' + } +} + +export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError { + return ( + error instanceof GatewayReauthRequiredError || + (typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true) + ) +} + +export async function resolveGatewayWsUrl( + desktop: ResolveGatewayWsUrlDeps, + conn: Pick +): Promise { + const mint = desktop.getGatewayWsUrl + + if (conn.authMode === 'oauth') { + if (!mint) { + // OAuth gateway but no way to mint a fresh ticket: the cached ticket is + // dead, so connecting with it cannot succeed. Surface a reauth error + // rather than silently attempting a doomed connect. + throw new GatewayReauthRequiredError( + 'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.' + ) + } + + try { + return await mint() + } catch (error) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', + { cause: error } + ) + } + } + + // token / local: the URL carries a long-lived token. Re-mint when available + // (cheap, keeps parity), but the cached URL is a safe fallback. + if (mint) { + const fresh = await mint().catch(() => null) + + if (fresh) { + return fresh + } + } + + return conn.wsUrl +}