From 4f8dda95d0e22409e680f21205b194f7200de1c7 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 5 Jul 2026 11:50:16 +0000 Subject: [PATCH] fix(desktop): only treat real 401/403 as OAuth session expiry Previously, any error during ws-ticket minting (including timeouts and 5xx responses) was turned into a 'session expired, sign in again' error. This caused false-positive 'remote gateway session has expired' messages when the gateway was reachable but slow or temporarily unhealthy. - Add isGatewayAuthFailure / isOauthSessionAuthFailure helpers that only match statusCode 401/403 or equivalent message patterns. - Non-auth failures now propagate unchanged instead of being wrapped as GatewayReauthRequiredError. - Updated tests to cover both the auth-failure path and the transport- failure path. This directly addresses the misclassification seen in Desktop boot logs where 'Cached remote Hermes backend failed liveness probe' was followed by a spurious reauth prompt. Fixes the root cause of many false 'sign in again' prompts on remote OAuth gateways. --- apps/desktop/electron/connection-config.cjs | 10 +++++++ .../electron/connection-config.test.cjs | 30 ++++++++++++++++++- apps/desktop/electron/main.cjs | 9 ++++++ apps/desktop/src/lib/gateway-ws-url.test.ts | 8 ++++- apps/shared/src/websocket-url.ts | 18 +++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 12f7859640d8c..074b0c32d0763 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -37,6 +37,12 @@ const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'] const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'] +function isOauthSessionAuthFailure(error) { + if (!error || typeof error !== 'object') return false + if (error.statusCode === 401 || error.statusCode === 403) return true + return /(^|\b)(401|403)\b/.test(String(error.message || '')) +} + function normalizeRemoteBaseUrl(rawUrl) { const value = String(rawUrl || '').trim() @@ -115,6 +121,9 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) { try { ticket = await mintTicket(baseUrl) } catch (error) { + if (!isOauthSessionAuthFailure(error)) { + throw error + } const err = new Error( 'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' + '(it may have expired). Open Settings → Gateway and sign in again.' @@ -279,5 +288,6 @@ module.exports = { profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, + isOauthSessionAuthFailure, tokenPreview } diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 1c7330e78d092..694d0b076e787 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -28,6 +28,7 @@ const { profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, + isOauthSessionAuthFailure, tokenPreview } = require('./connection-config.cjs') @@ -373,7 +374,9 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio () => resolveTestWsUrl('https://gw.example.com', 'oauth', null, { mintTicket: async () => { - throw new Error('401 ticket mint failed') + const err = new Error('401 ticket mint failed') + err.statusCode = 401 + throw err } }), err => { @@ -388,6 +391,31 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio ) }) +test('resolveTestWsUrl (oauth, transport failure) preserves the original error', async () => { + const timeout = new Error('Timed out connecting to Hermes backend after 8000ms') + await assert.rejects( + () => + resolveTestWsUrl('https://gw.example.com', 'oauth', null, { + mintTicket: async () => { + throw timeout + } + }), + err => { + assert.equal(err, timeout) + assert.equal(err.needsOauthLogin, undefined) + return true + } + ) +}) + +test('isOauthSessionAuthFailure only flags auth failures', () => { + assert.equal(isOauthSessionAuthFailure({ statusCode: 401 }), true) + assert.equal(isOauthSessionAuthFailure({ statusCode: 403 }), true) + assert.equal(isOauthSessionAuthFailure(new Error('401 expired')), true) + assert.equal(isOauthSessionAuthFailure(new Error('Timed out connecting to Hermes backend')), false) + assert.equal(isOauthSessionAuthFailure({ statusCode: 500, message: '500 upstream error' }), false) +}) + test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => { await assert.rejects( () => resolveTestWsUrl('https://gw.example.com', 'oauth', null), diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bd6b867fac0d9..217e6dcb12345 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4597,6 +4597,12 @@ function fetchJsonViaOauthSession(url, options = {}) { }) } +function isOauthSessionAuthFailure(error) { + if (!error || typeof error !== 'object') return false + if (error.statusCode === 401 || error.statusCode === 403) return true + return /(^|\b)(401|403)\b/.test(String(error.message || '')) +} + // Mint a single-use WS ticket for a gated gateway. Returns the ticket string. // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". @@ -4899,6 +4905,9 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { + if (!isOauthSessionAuthFailure(error)) { + throw error + } const err = new Error( 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' ) diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts index e8b09765923f0..360f640c84966 100644 --- a/apps/desktop/src/lib/gateway-ws-url.test.ts +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -20,13 +20,19 @@ describe('resolveGatewayWsUrl', () => { }) it('preserves the underlying mint failure as the cause', async () => { - const cause = new Error('401 cookie expired') + const cause = Object.assign(new Error('401 cookie expired'), { statusCode: 401 }) 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('passes through transport failures instead of misclassifying them as reauth', async () => { + const cause = new Error('Timed out connecting to Hermes backend after 8000ms') + const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause) + }) + it('throws a reauth error when the preload cannot mint (no method)', async () => { await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError) }) diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts index 78562b55df03d..5bf424dee5c11 100644 --- a/apps/shared/src/websocket-url.ts +++ b/apps/shared/src/websocket-url.ts @@ -31,6 +31,20 @@ export function isGatewayReauthRequired(error: unknown): error is GatewayReauthR ) } +function isGatewayAuthFailure(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const statusCode = (error as { statusCode?: unknown }).statusCode + if (statusCode === 401 || statusCode === 403) { + return true + } + + const message = String((error as { message?: unknown }).message || '') + return /(^|\b)(401|403)\b/.test(message) +} + export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: GatewayWsConnection): Promise { const mint = deps.getGatewayWsUrl const profile = conn.profile ?? null @@ -45,6 +59,10 @@ export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: G try { return await mint(profile) } catch (error) { + if (!isGatewayAuthFailure(error)) { + throw error + } + throw new GatewayReauthRequiredError( 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', { cause: error }