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 <tuancanhnguyen706@gmail.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Ugo Enyioha <ugo.enyioha@outlook.com>
Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
This commit is contained in:
brooklyn!
2026-09-11 05:07:28 -07:00
parent 8429a54bac
commit 8d092c2f71
6 changed files with 426 additions and 102 deletions

View File

@@ -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<unknown>
}
}
// 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<void>(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<ReturnType<typeof launchDesktop>>['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<void>(resolve => server.close(() => resolve()))
sandbox.cleanup()
}
})
}

View File

@@ -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<string, string | undefined> = {}) {
return Object.assign(new EventEmitter(), { statusCode, headers })
}
function wire(res: ReturnType<typeof makeResponse>) {
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<!doctype html><html></html>', 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))
}
}
}
})
})

View File

@@ -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 <T>(work: Promise<T>): Promise<T> => {
const withEnumerationDeadline = async <T>(work: Promise<T>, perSourceTimeoutMs: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | 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(

View File

@@ -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<string, string | string[] | undefined>
}
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)}`))
}
})
}

View File

@@ -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<string, string>) => {
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))
}
}
}
}
})
})

View File

@@ -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<string, string>) => Promise<string>
}
// 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<string, string>,
deps: RemoteOauthTicketDeps
): Promise<string> {
// 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.'
)
}
}