From f3af066b8545cbc81dc75f41fec57c6a9d71972b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 04:08:14 -0400 Subject: [PATCH] fix(desktop): treat connected-but-expired remote sessions as reauth Add isRemoteReauthError so an auth-shaped boot error counts as a remote-reauth failure even when the session indicator still reads connected (a stale refresh cookie / failed ws-ticket mint). Wire the boot error into the overlay's reauth check so those sessions route to Sign in instead of the local-only recovery buttons. Co-authored-by: Tony Antunez <57689194+smtony@users.noreply.github.com> --- .../src/components/boot-failure-overlay.tsx | 6 ++-- .../components/boot-failure-reauth.test.ts | 30 +++++++++++++++-- .../src/components/boot-failure-reauth.ts | 33 +++++++++++++++---- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index c9cd059cd80e..0cfd3316f7fc 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -67,7 +67,7 @@ export function BootFailureOverlay() { ?.getRecentLogs() .then(res => setLogs(res.lines ?? [])) .catch(() => undefined) - }, [visible]) + }, [boot.error, visible]) // Resolve whether this boot failure is a remote-gateway reauth so we can // offer the actionable "Sign in" path instead of the local-only recovery @@ -104,7 +104,7 @@ export function BootFailureOverlay() { setRemoteFailure(isRemoteConfig(config)) - if (!isRemoteReauthFailure(config)) { + if (!isRemoteReauthFailure(config, boot.error)) { return } @@ -128,7 +128,7 @@ export function BootFailureOverlay() { return () => { cancelled = true } - }, [visible]) + }, [boot.error, visible]) if (!visible || suppressed) { return null diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 9a973639dda1..5d198c96e416 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest' import type { DesktopConnectionConfig } from '@/global' -import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' +import { + deriveProviderShape, + isRemoteConfig, + isRemoteReauthError, + isRemoteReauthFailure, + signInLabel +} from './boot-failure-reauth' function config(overrides: Partial = {}): DesktopConnectionConfig { return { @@ -37,8 +43,14 @@ describe('isRemoteReauthFailure', () => { expect(isRemoteReauthFailure(config())).toBe(true) }) - it('false when the oauth session is still connected', () => { - expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false) + it('false when connected and the boot error is not auth-shaped', () => { + expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Python exploded')).toBe(false) + }) + + it('true when the indicator reads connected but the boot error is auth-shaped (expired session)', () => { + expect( + isRemoteReauthFailure(config({ remoteOauthConnected: true }), 'Your remote gateway session has expired.') + ).toBe(true) }) it('false for a local gateway', () => { @@ -69,6 +81,18 @@ describe('isRemoteReauthFailure', () => { }) }) +describe('isRemoteReauthError', () => { + it('recognizes auth-shaped boot errors', () => { + expect(isRemoteReauthError('Your remote gateway session has expired.')).toBe(true) + expect(isRemoteReauthError('OAuth: please sign in')).toBe(true) + }) + + it('ignores non-auth boot errors and nullish', () => { + expect(isRemoteReauthError('Hermes background process exited during startup.')).toBe(false) + expect(isRemoteReauthError(null)).toBe(false) + }) +}) + describe('deriveProviderShape', () => { it('generic copy when there are no providers', () => { expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 95ea90ad72c0..63b805faccbe 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -34,12 +34,33 @@ export function isRemoteConfig(config: DesktopConnectionConfig | null | undefine return Boolean(config && (config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) } -// A remote, gated (oauth-bucket), not-currently-connected gateway is a -// remote-reauth boot failure: the access cookie lapsed and the local Retry/Repair -// buttons can't fix it — only re-establishing the remote session can. 'cloud' -// counts as remote (it resolves to a remote oauth backend). -export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { - return isRemoteConfig(config) && config!.remoteAuthMode === 'oauth' && !config!.remoteOauthConnected +// True when a boot error is auth-shaped — the refresh token was rejected or the +// remote couldn't mint a websocket ticket. The Settings indicator can still read +// "connected" (a stale RT cookie exists), so the error text is part of the +// signal; without it a connected-but-expired session drops into the local-only +// recovery buttons for a problem only reauth can fix. +export function isRemoteReauthError(error: string | null | undefined): boolean { + const text = String(error || '').toLowerCase() + + return ( + text.includes('remote gateway session has expired') || + text.includes('gateway sign-in required') || + text.includes('needs oauth login') || + (text.includes('oauth') && (text.includes('not signed in') || text.includes('sign in'))) + ) +} + +// A remote, gated (oauth-bucket) gateway is a remote-reauth boot failure when the +// session isn't connected OR the boot error is auth-shaped (connected-but-expired +// — see isRemoteReauthError). Only re-establishing the remote session fixes it; +// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to +// a remote oauth backend), so a lapsed cloud session is the same failure. +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean { + return ( + isRemoteConfig(config) && + config!.remoteAuthMode === 'oauth' && + (!config!.remoteOauthConnected || isRemoteReauthError(error)) + ) } // Derive the password flag + display label from the probed providers. A