From 8d092c2f71c624966ac06d7e5aea820675cbb151 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 11 Sep 2026 05:07:28 -0700 Subject: [PATCH] fix(desktop): validate remote OAuth through ticket minting Handle truncated OAuth responses and keep confirmed auth recovery stable. Preserve the current attempt guard before latching failures; the older pre-guard latch proposal is not carried forward. Co-authored-by: xxxigm Co-authored-by: FalconOrtiz Co-authored-by: Ugo Enyioha Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com> --- .../desktop/e2e/remote-oauth-recovery.spec.ts | 111 ++++++++++++++++ .../electron/fetch-json-oauth-session.test.ts | 113 +++++++++++++++++ apps/desktop/electron/main.ts | 118 +++--------------- .../electron/oauth-session-response.ts | 82 ++++++++++++ .../electron/remote-oauth-ticket.test.ts | 68 ++++++++++ apps/desktop/electron/remote-oauth-ticket.ts | 36 ++++++ 6 files changed, 426 insertions(+), 102 deletions(-) create mode 100644 apps/desktop/e2e/remote-oauth-recovery.spec.ts create mode 100644 apps/desktop/electron/fetch-json-oauth-session.test.ts create mode 100644 apps/desktop/electron/oauth-session-response.ts create mode 100644 apps/desktop/electron/remote-oauth-ticket.test.ts create mode 100644 apps/desktop/electron/remote-oauth-ticket.ts diff --git a/apps/desktop/e2e/remote-oauth-recovery.spec.ts b/apps/desktop/e2e/remote-oauth-recovery.spec.ts new file mode 100644 index 0000000000..3ae505dc8c --- /dev/null +++ b/apps/desktop/e2e/remote-oauth-recovery.spec.ts @@ -0,0 +1,111 @@ +import * as fs from 'node:fs' +import * as http from 'node:http' +import type { AddressInfo } from 'node:net' +import * as path from 'node:path' + +import { buildAppEnv, createSandbox, launchDesktop } from './fixtures' +import { allowErrorBanners, expect, test } from './test' + +type DesktopWindow = Window & { + hermesDesktop: { + getBootProgress: () => Promise<{ running: boolean; retryable?: boolean; statusCode?: number; error?: string }> + getConnection: () => Promise + } +} + +// Real Electron/preload/renderer against a loopback gateway whose session was +// lost during restart. No real credentials or installed user state are used. +for (const status of [401, 403]) { + test(`unsigned gateway ${status} leaves recovery settings usable`, async () => { + allowErrorBanners() + const sandbox = createSandbox(`oauth-recovery-${status}`) + let mints = 0 + let signedIn = false + const server = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + const pathname = new URL(req.url ?? '/', 'http://localhost').pathname + res.setHeader('Content-Type', 'application/json') + if (pathname === '/api/status') { + res.end(JSON.stringify({ auth_required: true, auth_flows: [], version: 'test' })) + } else if (pathname === '/api/auth/providers') { + res.end(JSON.stringify({ providers: [{ name: 'portal', supports_password: false }] })) + } else if (pathname === '/login') { + signedIn = true + res.setHeader('Set-Cookie', 'hermes_session_at=fixture-session; Path=/; HttpOnly; SameSite=Lax') + res.end('{}') + } else if (signedIn && pathname === '/api/auth/ws-ticket') { + mints += 1 + res.end(JSON.stringify({ ticket: `fixture-ticket-${mints}` })) + } else if (signedIn && pathname === '/api/health') { + res.end(JSON.stringify({ ok: true, status: 'ok' })) + } else { + if (pathname === '/api/auth/ws-ticket') { + mints += 1 + if (mints === 1) { + // A NAS restart can truncate a response after headers. Exercise + // Electron's real IncomingMessage error, then recover on retry. + res.setHeader('Content-Length', '1024') + res.write('{"partial":') + setTimeout(() => res.destroy(), 20) + return + } + } + res.statusCode = status + res.end(JSON.stringify({ error: 'unauthenticated', reason: 'no_cookie' })) + } + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + fs.writeFileSync(path.join(sandbox.userDataDir, 'connection.json'), JSON.stringify({ + mode: 'remote', remote: { url, authMode: 'oauth' }, profiles: {} + })) + let app: Awaited>['app'] | undefined + try { + const launched = await launchDesktop(buildAppEnv(sandbox, { + HERMES_DESKTOP_DEV_SERVER: '' + })) + app = launched.app + const page = launched.page + await expect(page.getByRole('button', { name: /gateway settings/i })).toBeVisible({ timeout: 60_000 }) + const snapshot = await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getBootProgress()) + expect(snapshot).toMatchObject({ running: false, retryable: false, statusCode: status }) + expect(snapshot.error).toMatch(/not signed in/) + expect(mints).toBeGreaterThan(0) + const settledMints = mints + await page.getByRole('button', { name: /gateway settings/i }).click() + const back = page.getByRole('button', { name: /^back$/i }) + await expect(back).toBeVisible() + const gatewayUrl = page.getByPlaceholder('https://gateway.example.com/hermes') + await expect(gatewayUrl).toHaveValue(url) + // Concurrent IPC readers must reuse the terminal failure, not republish + // startup progress and unmount the settings form. + await page.evaluate(async () => { + await Promise.allSettled(Array.from({ length: 20 }, () => (window as unknown as DesktopWindow).hermesDesktop.getConnection())) + }) + await expect(back).toBeVisible() + await expect(gatewayUrl).toHaveValue(url) + expect(mints).toBe(settledMints) + await page.screenshot({ path: test.info().outputPath(`gateway-settings-${status}.png`) }) + await back.click() + const signIn = page.getByRole('button', { name: /sign in/i }) + await expect(signIn).toBeEnabled() + await signIn.click() + await expect.poll(() => signedIn).toBe(true) + await expect.poll(async () => { + try { + return await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getConnection()) + } catch { + return null + } + }).toMatchObject({ mode: 'remote', baseUrl: url }) + expect(mints).toBeGreaterThan(settledMints) + } finally { + await app?.close() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + sandbox.cleanup() + } + }) +} diff --git a/apps/desktop/electron/fetch-json-oauth-session.test.ts b/apps/desktop/electron/fetch-json-oauth-session.test.ts new file mode 100644 index 0000000000..83c17ba018 --- /dev/null +++ b/apps/desktop/electron/fetch-json-oauth-session.test.ts @@ -0,0 +1,113 @@ +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' + +import { describe, expect, it, vi } from 'vitest' + +import { httpStatusError, readStatusCode } from './api-transport' +import { wireOauthSessionResponse } from './oauth-session-response' + +function makeResponse(statusCode = 200, headers: Record = {}) { + return Object.assign(new EventEmitter(), { statusCode, headers }) +} + +function wire(res: ReturnType) { + const resolve = vi.fn() + const reject = vi.fn() + const clearTimer = vi.fn() + let timedOut = false + + wireOauthSessionResponse(res, { + url: 'https://gw.example.com/api', + isTimedOut: () => timedOut, + clearTimer, + resolve, + reject, + }) + + return { + resolve, + reject, + clearTimer, + timeOut: () => { + timedOut = true + }, + } +} + +describe('OAuth session response', () => { + it('settles once when a response body fails after headers', () => { + const res = makeResponse() + const state = wire(res) + const error = new Error('net::ERR_CONTENT_LENGTH_MISMATCH') + + res.emit('data', Buffer.from('{"partial":')) + expect(() => res.emit('error', error)).not.toThrow() + + // Neither another loader failure nor end may settle a failed body again. + expect(() => res.emit('error', new Error('late failure'))).not.toThrow() + res.emit('end') + + expect(state.reject).toHaveBeenCalledExactlyOnceWith(error) + expect(state.resolve).not.toHaveBeenCalled() + expect(state.clearTimer).toHaveBeenCalledTimes(1) + expect(() => res.emit('data', null)).not.toThrow() + + const timedOutRes = makeResponse() + const timedOutState = wire(timedOutRes) + timedOutRes.emit('data', Buffer.from('{"partial":')) + timedOutState.timeOut() + expect(() => timedOutRes.emit('error', error)).not.toThrow() + timedOutRes.emit('end') + expect(() => timedOutRes.emit('data', null)).not.toThrow() + expect(timedOutState.reject).not.toHaveBeenCalled() + expect(timedOutState.resolve).not.toHaveBeenCalled() + expect(timedOutState.clearTimer).not.toHaveBeenCalled() + }) + + it('preserves JSON and HTTP error contracts when end wins settlement', () => { + const cases = [ + { status: 200, body: '{"ok":"✓"}', headers: {}, value: { ok: '✓' } }, + { status: 204, body: '', headers: {}, value: null }, + { status: 401, body: '{"error":"session_expired"}', headers: {} }, + { status: 403, body: '', headers: {} }, + { status: 503, body: 'unavailable', headers: {} }, + { status: 0, body: 'missing status', headers: {} }, + { status: 200, body: ' \n', headers: {}, error: /got HTML/ }, + { status: 200, body: '{}', headers: { 'Content-Type': 'text/html' }, error: /got HTML/ }, + { status: 200, body: '{"partial":', headers: {}, error: /Invalid JSON/ }, + ] + + for (const entry of cases) { + const res = makeResponse(entry.status, entry.headers) + const state = wire(res) + // Splitting every byte also covers UTF-8 characters split across chunks. + for (const byte of Buffer.from(entry.body)) { + res.emit('data', Buffer.from([byte])) + } + res.emit('end') + + // Late terminal events must not overwrite either success or rejection. + expect(() => res.emit('error', new Error('late loader failure'))).not.toThrow() + res.emit('end') + expect(() => res.emit('data', null)).not.toThrow() + expect(state.clearTimer).toHaveBeenCalledTimes(1) + + if ('value' in entry) { + expect(state.resolve).toHaveBeenCalledExactlyOnceWith(entry.value) + expect(state.reject).not.toHaveBeenCalled() + } else { + expect(state.resolve).not.toHaveBeenCalled() + expect(state.reject).toHaveBeenCalledTimes(1) + const error = state.reject.mock.calls[0][0] + if (entry.error) { + expect(error.message).toMatch(entry.error) + expect(readStatusCode(error)).toBeNaN() + } else { + const expected = httpStatusError(entry.status, entry.body) + expect(error.message).toBe(expected.message) + expect(readStatusCode(error)).toBe(readStatusCode(expected)) + } + } + } + }) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 241f420de5..24a3b2b690 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -55,12 +55,7 @@ import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { BackendDialClaims } from './backend-dial-claim' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' -import { - isReauthRequiredError, - makeNousCloudBackendDownError, - makeUnsignedOauthError, - waitForHermesReady -} from './backend-health' +import { isReauthRequiredError, waitForHermesReady } from './backend-health' import { backendCommandMatches, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership' import { canImportHermesCli, @@ -109,7 +104,6 @@ import { cookiesHavePrivyAccessToken, cookiesHavePrivySession, cookiesHaveSession, - gatewayTicketFailure, gatewayWsUrlIpcResult, hostLabelFromBaseUrl, localProfileEntry, @@ -263,9 +257,7 @@ import { import { registerMcpOauthCallbackIpc } from './mcp-oauth-callback-ipc' import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' import { - oauthGuardMayHardFail, oauthSessionIsLive, - oauthTicketFailureAuthMessage, resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, @@ -284,6 +276,7 @@ import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } fr import { registerNativeNotifications } from './notification-ipc' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' +import { wireOauthSessionResponse } from './oauth-session-response' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { @@ -350,6 +343,7 @@ import { revalidateRemoteConnection, revalidateSuspectPooledRemoteBackends } from './remote-liveness' +import { resolveRemoteOauthTicket, rosterSourceEnumerationTimeoutMs } from './remote-oauth-ticket' import { applyRemoteRequestHeaders, createRegistryGatewayWsUrlHandler, @@ -7857,43 +7851,12 @@ function fetchJsonViaOauthSession(url, options: any = {}) { }, timeoutMs) request.on('response', res => { - const chunks = [] - res.on('data', chunk => chunks.push(Buffer.from(chunk))) - res.on('end', () => { - if (timedOut) { - return - } - - clearTimeout(timer) - const text = Buffer.concat(chunks).toString('utf8') - const statusCode = res.statusCode || 500 - - if (statusCode >= 400) { - reject(httpStatusError(statusCode, text)) - - return - } - - if (!text) { - resolve(null) - - return - } - - const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) - const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') - - if (looksHtml || contentType.includes('text/html')) { - reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) - - return - } - - try { - resolve(JSON.parse(text)) - } catch { - reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`)) - } + wireOauthSessionResponse(res, { + url, + isTimedOut: () => timedOut, + clearTimer: () => clearTimeout(timer), + resolve, + reject }) }) request.on('error', error => { @@ -10154,58 +10117,10 @@ async function buildRemoteConnection( const host = remoteHost || hostLabelFromBaseUrl(baseUrl) if (authMode === 'oauth') { - // OAuth gateway: auth comes from EITHER a native bearer token (cookieless - // RFC 8252 flow) OR the session cookies in the OAuth partition. Liveness is - // NOT "is the access-token cookie present?" — Portal issues a 24h rotating - // refresh token (hermes #37247), and the gateway middleware transparently - // rotates a fresh ~15-min access token from it on the next authenticated - // request. So a session with an expired AT cookie but a live RT cookie is - // still perfectly connectable. We early-out only when NEITHER a native - // token NOR any cookie is present, then mint a ws-ticket (which itself - // prefers the native bearer) as the authoritative liveness check. - // - // The native-token check is essential: the native login stores bearer - // tokens (no cookie is ever set), so gating solely on hasLiveOauthSession - // here would reject a freshly-completed native sign-in and loop the UI back - // into "not signed in" even though mintGatewayWsTicket would succeed with - // the stored bearer. - if ( - !oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl)) && - oauthGuardMayHardFail(await gatewayAuthProviders(baseUrl, remoteHeaders)) - ) { - throw makeUnsignedOauthError() - } - - // Snapshot BEFORE the mint: a confirmed native rejection drops the dead - // token set on its way out (mintGatewayWsTicket's forced rotation), and - // the failure copy must still say "session expired" for a session that - // did exist — "not signed in" is for a jar that never held one. - const hadNativeSession = hasNativeSession(baseUrl) - - let ticket - - try { - ticket = await mintGatewayWsTicket(baseUrl, remoteHeaders) - } catch (error) { - // For a Nous-managed Cloud agent, a 502/503/504 from the WS-ticket mint - // means the backend server itself is down — the actionable Cloud-down - // error. This boundary runs BEFORE the readiness loop, so without this - // the ticket wrapper below would swallow the server-fault classification - // and the renderer would never see isCloudBackendDown. Preserve the - // existing 401/403 reauth and generic transport behavior for everything - // else (#85335). - const cloudError = makeNousCloudBackendDownError(baseUrl, error) - - if (cloudError !== null) { - throw cloudError - } - - throw gatewayTicketFailure( - error, - oauthTicketFailureAuthMessage(hadNativeSession), - 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' - ) - } + const ticket = await resolveRemoteOauthTicket(baseUrl, remoteHeaders, { + hasNativeSession, + mintGatewayWsTicket + }) const wsUrl = buildGatewayWsUrlWithTicket(baseUrl, ticket) @@ -15811,9 +15726,7 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe // the renderer painted stale rows for the entire outage (and the roster IPC // hung >30s in live repro). Bound each source's enumeration; a timeout is // reported like any other unreachable source and retried on the next poll. - const perSourceTimeoutMs = 10_000 - - const withEnumerationDeadline = async (work: Promise): Promise => { + const withEnumerationDeadline = async (work: Promise, perSourceTimeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -15877,7 +15790,8 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe backendDialClaims.run(backendScopeKey(connection.id, null), () => ensureRegistryBackend(connection.id, null) ) - ) + ), + rosterSourceEnumerationTimeoutMs(connection) ) const { body, installId } = await fetchRosterSourceData( diff --git a/apps/desktop/electron/oauth-session-response.ts b/apps/desktop/electron/oauth-session-response.ts new file mode 100644 index 0000000000..94b28d4d8f --- /dev/null +++ b/apps/desktop/electron/oauth-session-response.ts @@ -0,0 +1,82 @@ +import { Buffer } from 'node:buffer' + +import { httpStatusError } from './api-transport' + +// Response-stream extraction based on Bartok9's #99135 (original issue #72530). +export interface OauthResponseLike { + on(event: 'data', cb: (chunk: Buffer) => void): void + on(event: 'error', cb: (error: Error) => void): void + on(event: 'end', cb: () => void): void + statusCode?: number + headers: Record +} + +export interface WireOauthResponseOptions { + url: string + isTimedOut: () => boolean + clearTimer: () => void + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +export function wireOauthSessionResponse(res: OauthResponseLike, opts: WireOauthResponseOptions): void { + const { url, isTimedOut, clearTimer, resolve, reject } = opts + const chunks: Buffer[] = [] + let settled = false + + res.on('data', chunk => { + if (!settled && !isTimedOut()) { + chunks.push(Buffer.from(chunk)) + } + }) + // Electron emits post-header loader failures on the response, not the request. + // Keep the listener after settlement: a late error must not become uncaught. + res.on('error', error => { + if (settled || isTimedOut()) { + return + } + + settled = true + chunks.length = 0 + clearTimer() + reject(error) + }) + res.on('end', () => { + if (settled || isTimedOut()) { + return + } + + settled = true + clearTimer() + const text = Buffer.concat(chunks).toString('utf8') + chunks.length = 0 + const statusCode = res.statusCode || 500 + + if (statusCode >= 400) { + reject(httpStatusError(statusCode, text)) + + return + } + + if (!text) { + resolve(null) + + return + } + + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) + const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') + + if (looksHtml || contentType.includes('text/html')) { + reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) + + return + } + + try { + resolve(JSON.parse(text)) + } catch { + reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`)) + } + }) +} diff --git a/apps/desktop/electron/remote-oauth-ticket.test.ts b/apps/desktop/electron/remote-oauth-ticket.test.ts new file mode 100644 index 0000000000..a7eafafed4 --- /dev/null +++ b/apps/desktop/electron/remote-oauth-ticket.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { isReauthRequiredError } from './backend-health' +import { oauthTicketFailureAuthMessage } from './native-auth-decisions' +import { resolveRemoteOauthTicket, rosterSourceEnumerationTimeoutMs } from './remote-oauth-ticket' + +describe('resolveRemoteOauthTicket', () => { + it('reserves a larger bounded roster dial budget only for OAuth remote sources', () => { + const defaultBudget = rosterSourceEnumerationTimeoutMs({ kind: 'local' }) + for (const kind of ['remote', 'cloud']) { + expect(rosterSourceEnumerationTimeoutMs({ kind, authMode: 'oauth' })).toBeGreaterThan(defaultBudget) + expect(rosterSourceEnumerationTimeoutMs({ kind, authMode: 'token' })).toBe(defaultBudget) + } + expect(rosterSourceEnumerationTimeoutMs({ kind: 'ssh', authMode: 'oauth' })).toBe(defaultBudget) + expect(rosterSourceEnumerationTimeoutMs({ kind: 'remote', authMode: 'oauth' })).toBeLessThanOrEqual(30_000) + expect(defaultBudget).toBeGreaterThan(0) + }) + + it('mints a fresh ticket on every dial even without a preflight native session', async () => { + let mints = 0 + const deps = { + hasNativeSession: () => false, + mintGatewayWsTicket: async (baseUrl: string, headers: Record) => { + expect(baseUrl).toBe('https://gateway.example.com') + expect(headers).toEqual({ 'X-Access': 'proxy-token' }) + return `ticket-${++mints}` + } + } + + const first = await resolveRemoteOauthTicket('https://gateway.example.com', { 'X-Access': 'proxy-token' }, deps) + const second = await resolveRemoteOauthTicket('https://gateway.example.com', { 'X-Access': 'proxy-token' }, deps) + expect(first).not.toBe(second) + expect(mints).toBe(2) + }) + + it('classifies only confirmed auth rejection as reauth and retains the pre-mint session copy', async () => { + for (const baseUrl of ['https://gateway.example.com', 'https://lab.agents.nousresearch.com']) { + for (const hadNativeSession of [false, true]) { + for (const statusCode of [401, 403, 500, 502, 503, 504, undefined]) { + let nativeSession = hadNativeSession + const cause = Object.assign(new Error('ticket request failed'), { statusCode }) + const error = await resolveRemoteOauthTicket(baseUrl, {}, { + hasNativeSession: () => nativeSession, + mintGatewayWsTicket: async () => { + nativeSession = false + throw cause + } + }).catch((failure: Error & { isCloudBackendDown?: boolean; statusCode?: number }) => failure) + + expect(error).toBeInstanceOf(Error) + if (!(error instanceof Error)) { + throw new Error('Expected ticket rejection') + } + expect(error.cause).toBe(cause) + expect(error.statusCode).toBe(statusCode) + const authRejected = statusCode === 401 || statusCode === 403 + expect(isReauthRequiredError(error)).toBe(authRejected) + expect(error.isCloudBackendDown === true).toBe( + baseUrl.includes('.agents.nousresearch.com') && [502, 503, 504].includes(statusCode ?? 0) + ) + if (authRejected) { + expect(error.message).toBe(oauthTicketFailureAuthMessage(hadNativeSession)) + } + } + } + } + }) +}) diff --git a/apps/desktop/electron/remote-oauth-ticket.ts b/apps/desktop/electron/remote-oauth-ticket.ts new file mode 100644 index 0000000000..f609a51d57 --- /dev/null +++ b/apps/desktop/electron/remote-oauth-ticket.ts @@ -0,0 +1,36 @@ +import { makeNousCloudBackendDownError } from './backend-health' +import { gatewayTicketFailure } from './connection-config' +import { oauthTicketFailureAuthMessage } from './native-auth-decisions' + +interface RemoteOauthTicketDeps { + hasNativeSession: (baseUrl: string) => boolean + mintGatewayWsTicket: (baseUrl: string, headers: Record) => Promise +} + +// Roster dials use this same mint path before readiness; the ordinary 10s +// deadline can discard a healthy OAuth source while its cold session warms. +export function rosterSourceEnumerationTimeoutMs(connection: { kind?: string; authMode?: string }): number { + return (connection.kind === 'remote' || connection.kind === 'cloud') && connection.authMode === 'oauth' + ? 30_000 + : 10_000 +} + +export async function resolveRemoteOauthTicket( + baseUrl: string, + headers: Record, + deps: RemoteOauthTicketDeps +): Promise { + // The mint is authoritative: a cold cookie partition may not yet report a + // session. Snapshot native state only for copy; a rejected mint can erase it. + const hadNativeSession = deps.hasNativeSession(baseUrl) + + try { + return await deps.mintGatewayWsTicket(baseUrl, headers) + } catch (error) { + throw makeNousCloudBackendDownError(baseUrl, error) ?? gatewayTicketFailure( + error, + oauthTicketFailureAuthMessage(hadNativeSession), + 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' + ) + } +}