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>
This commit is contained in:
Brooklyn Nicholson 2026-07-12 04:08:14 -04:00
parent c1c74d7518
commit f3af066b85
3 changed files with 57 additions and 12 deletions

View file

@ -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

View file

@ -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> = {}): 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' })

View file

@ -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