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:
111
apps/desktop/e2e/remote-oauth-recovery.spec.ts
Normal file
111
apps/desktop/e2e/remote-oauth-recovery.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
113
apps/desktop/electron/fetch-json-oauth-session.test.ts
Normal file
113
apps/desktop/electron/fetch-json-oauth-session.test.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
82
apps/desktop/electron/oauth-session-response.ts
Normal file
82
apps/desktop/electron/oauth-session-response.ts
Normal 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)}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
68
apps/desktop/electron/remote-oauth-ticket.test.ts
Normal file
68
apps/desktop/electron/remote-oauth-ticket.test.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
36
apps/desktop/electron/remote-oauth-ticket.ts
Normal file
36
apps/desktop/electron/remote-oauth-ticket.ts
Normal 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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user