Skip to content
Merged
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
8 changes: 5 additions & 3 deletions apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:

- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
not "fall back to the cached URL." Only long-lived token/local auth may reuse
a cached URL as a lower rung.
fresh WebSocket ticket on every dial and never falls back to the cached URL.
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
reauthentication; timeout, network, malformed-response, and server failures
remain connectivity errors. Only long-lived token/local auth may reuse a
cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
Expand Down
69 changes: 67 additions & 2 deletions apps/desktop/electron/connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
gatewayTicketFailure,
gatewayWsUrlIpcResult,
isGatewayAuthRejection,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
Expand Down Expand Up @@ -431,12 +434,14 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})

test('resolveTestWsUrl (oauth, mint FAILS) throws β€” must NOT skip WS validation', async () => {
test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => {
const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 })

await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw new Error('401 ticket mint failed')
throw cause
}
}),
(err: any) => {
Expand All @@ -452,6 +457,66 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws β€” must NOT skip WS validatio
)
})

test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => {
const cause = new Error('socket timed out')

await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw cause
}
}),
(err: any) => {
assert.match(err.message, /could not mint a WebSocket ticket/i)
assert.equal(err.needsOauthLogin, undefined)
assert.equal(err.cause, cause)

return true
}
)
})

test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => {
assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true)
assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true)
assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true)
assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false)
assert.equal(isGatewayAuthRejection(new Error('network timeout')), false)

const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any
assert.equal(serverFailure.message, 'retry connection')
assert.equal(serverFailure.needsOauthLogin, undefined)
})

test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => {
assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), {
ok: true,
wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh'
})

for (const statusCode of [401, 403]) {
const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode })

assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
error: `${statusCode}: rejected`,
needsOauthLogin: true,
ok: false
})
}

for (const error of [
Object.assign(new Error('500: unavailable'), { statusCode: 500 }),
new Error('Timed out connecting to Hermes backend after 8000ms'),
Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
]) {
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
error: error.message,
ok: false
})
}
})

test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
await assert.rejects(
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
Expand Down
62 changes: 49 additions & 13 deletions apps/desktop/electron/connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}

/** True only when a gateway explicitly rejected the current OAuth session. */
function isGatewayAuthRejection(error) {
if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) {
return true
}

const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN)

return statusCode === 401 || statusCode === 403
}

function gatewayTicketFailure(error, authMessage, transportMessage) {
const needsOauthLogin = isGatewayAuthRejection(error)
const err = new Error(needsOauthLogin ? authMessage : transportMessage)

if (needsOauthLogin) {
;(err as any).needsOauthLogin = true
}

err.cause = error

return err
}

/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */
async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
try {
return { ok: true as const, wsUrl: await resolveWsUrl() }
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}),
ok: false as const
}
}
}

/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
Expand All @@ -102,12 +139,10 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint ok β†’ ws(s)://…/api/ws?ticket=…
* - oauth, mint fails β†’ THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch β€”
* HTTP /api/status passes, the test reports "reachable", then the renderer
* can't authenticate /api/ws and boot dies with "Could not connect".
* The oauth-mint-failure throw is the important case: swallowing it here would
* re-introduce the exact false-positive this test exists to catch. An explicit
* 401/403 asks for sign-in; transport and server failures remain connectivity
* errors so a temporary outage is not mislabeled as an expired session.
*
* @param {string} baseUrl
* @param {'token'|'oauth'} authMode
Expand All @@ -128,14 +163,12 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
try {
ticket = await mintTicket(baseUrl)
} catch (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.'
throw gatewayTicketFailure(
error,
'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' +
'Open Settings β†’ Gateway and sign in again.',
'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.'
)

;(err as any).needsOauthLogin = true
err.cause = error
throw err
}

return buildGatewayWsUrlWithTicket(baseUrl, ticket)
Expand Down Expand Up @@ -337,6 +370,9 @@ export {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
gatewayTicketFailure,
gatewayWsUrlIpcResult,
isGatewayAuthRejection,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
Expand Down
64 changes: 27 additions & 37 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
gatewayTicketFailure,
gatewayWsUrlIpcResult,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
Expand Down Expand Up @@ -108,6 +110,7 @@ import { ensureMainWindow } from './main-window-lifecycle'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
import {
buildSessionWindowUrl,
chatWindowWebPreferences,
Expand Down Expand Up @@ -933,6 +936,8 @@ function registerMediaProtocol() {

let mainWindow = null
const backendConnectionState = createBackendConnectionState<ReturnType<typeof spawn>, any>()
const remoteLiveness = new RemoteLivenessTracker()
const remoteRevalidation = new RemoteRevalidationCoordinator()
// True while connection-config:apply soft-rehomes the primary β€” suppresses the
// backend-exit toast so an intentional kill doesn't look like a crash.
let softRehomeInProgress = false
Expand Down Expand Up @@ -6323,13 +6328,11 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) {
try {
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
'Your remote gateway session has expired. ' + 'Open Settings β†’ Gateway and click "Sign in" again.'
) as any

err.needsOauthLogin = true
err.cause = error
throw err
throw gatewayTicketFailure(
error,
'Your remote gateway session has expired. Open Settings β†’ Gateway and click "Sign in" again.',
'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.'
)
}

return {
Expand Down Expand Up @@ -6611,6 +6614,7 @@ function stopBackendChild(child) {
// switch / crash recovery), which still resets boot progress + reloads.
function resetHermesConnection({ soft = false } = {}) {
backendStartFailure = null
remoteLiveness.clear()
const hermesProcess = backendConnectionState.invalidate()
stopBackendChild(hermesProcess)

Expand Down Expand Up @@ -7779,42 +7783,28 @@ ipcMain.handle('hermes:connection:revalidate', async () => {
return { ok: true, rebuilt: false }
}

let conn = null

try {
conn = await connectionPromise
} catch {
// The cached boot already rejected (its own catch clears the promise);
// nothing to revalidate β€” the next getConnection() builds fresh.
return { ok: true, rebuilt: false }
}

if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
return { ok: true, rebuilt: false }
}

const base = conn.baseUrl.replace(/\/+$/, '')

try {
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })

return { ok: true, rebuilt: false }
} catch {
// Unreachable remote: drop the stale cache so the renderer's next reconnect
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
// clears the connection promise for a remote (no child to SIGTERM).
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
resetHermesConnection()

return { ok: true, rebuilt: true }
}
// Main and every session pop-out have their own renderer reconnect loop but
// share this primary connection. Coalesce simultaneous requests so one outage
// produces one failure observation rather than exhausting the whole streak.
return remoteRevalidation.run(connectionPromise, () =>
revalidateRemoteConnection({
connectionPromise,
currentConnectionPromise: () => backendConnectionState.getPromise(),
log: rememberLog,
probe: fetchPublicJson,
resetConnection: resetHermesConnection,
tracker: remoteLiveness
})
)
})
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
touchPoolBackend(profile)

return { ok: true }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => {
return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile))
})
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
if (typeof sessionId !== 'string' || !sessionId.trim()) {
return { ok: false, error: 'invalid-session-id' }
Expand Down
Loading
Loading