Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/desktop/electron/connection-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.'
Expand Down Expand Up @@ -279,5 +288,6 @@ module.exports = {
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
isOauthSessionAuthFailure,
tokenPreview
}
30 changes: 29 additions & 1 deletion apps/desktop/electron/connection-config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const {
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
isOauthSessionAuthFailure,
tokenPreview
} = require('./connection-config.cjs')

Expand Down Expand Up @@ -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 => {
Expand All @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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.'
)
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/lib/gateway-ws-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
18 changes: 18 additions & 0 deletions apps/shared/src/websocket-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const mint = deps.getGatewayWsUrl
const profile = conn.profile ?? null
Expand All @@ -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 }
Expand Down