Files

542 lines
18 KiB
TypeScript

import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
isAuthRejectionError,
isGatedMissingHealthError,
isMissingHealthEndpointError,
isNousCloudAgentUrl,
isReauthRequiredError,
isServerSideHttpError,
makeNousCloudBackendDownError,
makeUnsignedOauthError,
waitForHermesReady
} from './backend-health'
const GATE_401 = '401: {"error":"unauthenticated","detail":"Unauthorized","reason":"no_cookie","login_url":"/login"}'
test('uses lightweight /api/health for current backends', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000/', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
return { ok: true }
},
fetchJson: async url => {
calls.push(['token', url])
throw new Error('status should not be called')
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
})
test('falls back to /api/status only for old backends without /api/health', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (url, token) => {
calls.push(['token', url, token ?? ''])
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://127.0.0.1:9000/api/health'],
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
])
})
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('Timed out connecting to Hermes backend after 15000ms')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/Timed out connecting/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
const timeouts: (number | undefined)[] = []
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async (_url, options) => {
timeouts.push(options?.timeoutMs)
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (_url, _token, options) => {
timeouts.push(options?.timeoutMs)
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
})
test('aborts as superseded when the bootstrap signal fires', async () => {
const controller = new AbortController()
controller.abort()
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
signal: controller.signal,
fetchPublicJson: async () => {
throw new Error('should not probe after abort')
},
fetchJson: async () => {
throw new Error('should not probe after abort')
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => error.kind === 'superseded'
)
})
test('recognizes missing-route shapes only', () => {
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
assert.equal(
isMissingHealthEndpointError(
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
),
true
)
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
})
// --- Gated backends that predate /api/health (release 0.19.0 and earlier) ---
//
// The dashboard auth gate runs ahead of the SPA catch-all, so on a backend
// without the route an ANONYMOUS probe is rejected as unauthenticated rather
// than 404 — verified against a simulated 0.19.0 backend:
// credential-free: /api/health -> 401 no_cookie, /api/status -> 200
// credentialed: /api/health -> 404, /api/sessions -> 200
test('anonymous gate-shaped 401 falls back to /api/status (backend predates /api/health)', async () => {
const calls: string[][] = []
await waitForHermesReady('http://192.168.1.132:9119', {
token: null,
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error(GATE_401)
},
fetchJson: async (url, token) => {
calls.push(['token', url, token == null ? 'null' : token])
return { version: '0.19.0', auth_required: true }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://192.168.1.132:9119/api/health'],
['token', 'http://192.168.1.132:9119/api/status', 'null']
])
})
test('a credentialed 401 fails fast for reauth instead of reporting a dead session ready', async () => {
// The regression a blanket 401->fallback introduces: /api/status is public,
// so an expired session would answer 200 and boot would report "ready",
// deferring the no_cookie to the first real API call.
const calls: string[][] = []
await assert.rejects(
waitForHermesReady('https://gateway.example', {
token: 'session-token',
fetchPublicJson: async () => {
throw new Error('public probe must not be used when credentialed')
},
fetchJson: async url => {
calls.push(['status', url])
return { version: '0.19.0' }
},
probeHealth: async url => {
calls.push(['probe', url])
throw new Error(GATE_401)
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => {
assert.equal(isReauthRequiredError(error), true)
assert.equal(error.needsOauthLogin, true)
assert.match(error.message, /remote gateway session has expired/i)
return true
}
)
// Fail fast: never reached the public /api/status leg.
assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']])
})
test('unsigned OAuth is a terminal reauth failure; needsOauthLogin alone is not', () => {
// The unsigned-in throw must set isReauthRequired so startHermes latches.
// needsOauthLogin alone (ticket 401/403) stays a Sign-in hint, not a latch —
// a lapsed AT cookie can still rotate from a live RT on the next mint.
const unsigned = makeUnsignedOauthError() as any
assert.equal(unsigned.needsOauthLogin, true)
assert.equal(unsigned.isReauthRequired, true)
assert.equal(isReauthRequiredError(unsigned), true)
assert.match(unsigned.message, /not signed in/i)
assert.equal(isReauthRequiredError({ needsOauthLogin: true }), false)
assert.equal(isReauthRequiredError(new Error('Could not reach the remote Hermes gateway')), false)
})
test('a credentialed 403 is also a terminal reauth failure', async () => {
await assert.rejects(
waitForHermesReady('https://gateway.example', {
fetchPublicJson: async () => ({}),
fetchJson: async () => ({}),
probeHealth: async () => {
throw new Error('403: {"detail":"Forbidden"}')
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => isReauthRequiredError(error)
)
})
test('a credentialed probe still uses the 404 fallback for a genuinely missing route', async () => {
// With credentials the gate lets the request through to the SPA catch-all,
// so an old backend answers a real 404 — that must still fall back, not be
// mistaken for a rejected session.
const calls: string[][] = []
await waitForHermesReady('https://gateway.example', {
token: 'session-token',
fetchPublicJson: async () => {
throw new Error('public probe must not be used when credentialed')
},
fetchJson: async url => {
calls.push(['status', url])
return { version: '0.19.0' }
},
probeHealth: async url => {
calls.push(['probe', url])
throw new Error('404: {"detail":"Not Found"}')
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['probe', 'https://gateway.example/api/health'],
['status', 'https://gateway.example/api/status']
])
})
test('a non-gate 401 keeps polling rather than skipping a misconfigured health route', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('401: {"detail":"Unauthorized"}')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/401: \{"detail":"Unauthorized"\}/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('credentialed 5xx and 429 keep polling — only 401/403 are terminal', async () => {
for (const transient of ['500: boom', '429: {"detail":"Too Many Requests"}']) {
let attempts = 0
let currentTime = 0
await assert.rejects(
waitForHermesReady('https://gateway.example', {
fetchPublicJson: async () => ({}),
fetchJson: async () => ({}),
probeHealth: async () => {
attempts += 1
throw new Error(transient)
},
probeIsCredentialed: true,
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => isReauthRequiredError(error) === false
)
assert.ok(attempts > 1, `${transient} should have retried, got ${attempts} attempt(s)`)
}
})
test('error-shape predicates', () => {
assert.equal(isGatedMissingHealthError(new Error(GATE_401)), true)
assert.equal(isGatedMissingHealthError(new Error('401: {"detail":"Unauthorized"}')), false)
assert.equal(isGatedMissingHealthError(new Error('404: {"detail":"Not Found"}')), false)
assert.equal(isAuthRejectionError(new Error(GATE_401)), true)
assert.equal(isAuthRejectionError(new Error('403: {"detail":"Forbidden"}')), true)
assert.equal(isAuthRejectionError(new Error('404: {"detail":"Not Found"}')), false)
assert.equal(isAuthRejectionError(new Error('429: slow down')), false)
assert.equal(isAuthRejectionError(new Error('500: boom')), false)
// A gated 401 must NOT be conflated with a missing route by the 404 predicate.
assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false)
})
test('isServerSideHttpError detects 502/503/504', () => {
// 503 — server-side fault
const result503 = isServerSideHttpError(new Error('503: Service Unavailable'))
assert.ok(result503, 'should detect 503')
assert.equal(result503?.statusCode, 503)
assert.equal(result503?.detail, '503: Service Unavailable')
// 502
const result502 = isServerSideHttpError(new Error('502: Bad Gateway'))
assert.ok(result502, 'should detect 502')
assert.equal(result502?.statusCode, 502)
// 504
const result504 = isServerSideHttpError(new Error('504: Gateway Timeout'))
assert.ok(result504, 'should detect 504')
assert.equal(result504?.statusCode, 504)
// 500 is NOT a server-side HTTP error per our definition (keeps polling)
const result500 = isServerSideHttpError(new Error('500: Internal Server Error'))
assert.equal(result500, null)
// 401/403/404/429 are not server-side faults
assert.equal(isServerSideHttpError(new Error('401: Unauthorized')), null)
assert.equal(isServerSideHttpError(new Error('403: Forbidden')), null)
assert.equal(isServerSideHttpError(new Error('404: Not Found')), null)
assert.equal(isServerSideHttpError(new Error('429: Too Many Requests')), null)
// Non-HTTP errors (timeouts, network failures) don't match the pattern
assert.equal(isServerSideHttpError(new Error('connect ECONNREFUSED')), null)
assert.equal(isServerSideHttpError(null), null)
assert.equal(isServerSideHttpError('503: something'), null) // not an Error
})
test('isNousCloudAgentUrl detects cloud agent hosts', () => {
// Positive cases
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com'), true)
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com/api/health'), true)
assert.equal(isNousCloudAgentUrl('http://test.agents.nousresearch.com'), true)
// Negative cases
assert.equal(isNousCloudAgentUrl('http://127.0.0.1:9000'), false)
assert.equal(isNousCloudAgentUrl('https://gateway.example.com'), false)
assert.equal(isNousCloudAgentUrl('https://nousresearch.com'), false)
assert.equal(isNousCloudAgentUrl('not-a-url'), false)
})
test('waitForHermesReady surfaces actionable error for cloud agent 503', async () => {
let attempts = 0
const currentTime = { value: 0 }
try {
await waitForHermesReady('https://ares-3009.agents.nousresearch.com', {
fetchPublicJson: async () => {
attempts++
// Always return 503
throw new Error('503: Service Unavailable')
},
fetchJson: async () => {
throw new Error('503: Service Unavailable')
},
sleep: async () => {},
// Advance the mock clock per poll — a frozen now() never crosses the
// deadline and the readiness loop spins forever (hung the whole vitest
// electron project for 20m in CI).
now: () => {
currentTime.value += 20
return currentTime.value
},
timeoutMs: 100,
pollMs: 1
})
assert.fail('should have thrown')
} catch (error: any) {
assert.ok(error.message.includes('Nous Cloud agent'), `unexpected message: ${error.message}`)
assert.ok(error.message.includes('503'), `should mention status code: ${error.message}`)
assert.ok(error.message.includes('portal.nousresearch.com'), `should mention portal: ${error.message}`)
assert.ok(error.message.includes('discord.gg/NousResearch'), `should mention Discord: ${error.message}`)
assert.equal(error.isCloudBackendDown, true)
assert.equal(error.statusCode, 503)
assert.ok(attempts > 1, 'should have retried before failing')
}
})
test('waitForHermesReady does not cloud-wrap non-cloud 503 errors', async () => {
const currentTime = { value: 0 }
try {
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async () => {
throw new Error('503: Service Unavailable')
},
fetchJson: async () => {
throw new Error('503: Service Unavailable')
},
sleep: async () => {},
// Same advancing clock as above — frozen now() = infinite loop.
now: () => {
currentTime.value += 20
return currentTime.value
},
timeoutMs: 100,
pollMs: 1
})
assert.fail('should have thrown')
} catch (error: any) {
// Non-cloud URLs get the generic message
assert.ok(error.message.includes('did not become ready'), `unexpected message: ${error.message}`)
assert.equal(error.isCloudBackendDown, undefined)
}
})
test('isServerSideHttpError detects structured statusCode even when the message is opaque', () => {
const err = new Error('upstream unavailable') as any
err.statusCode = 503
const result = isServerSideHttpError(err)
assert.ok(result)
assert.equal(result?.statusCode, 503)
assert.equal(result?.detail, 'upstream unavailable')
const err502 = new Error('bad gateway') as any
err502.statusCode = 502
assert.equal(isServerSideHttpError(err502)?.statusCode, 502)
const err504 = new Error('gateway timeout') as any
err504.statusCode = 504
assert.equal(isServerSideHttpError(err504)?.statusCode, 504)
})
test('isServerSideHttpError rejects non-Error inputs even with a 503-shaped value', () => {
// The structured path requires an actual Error (the fetch layer attaches
// statusCode to an Error instance); a bare string/null/number must not be
// misclassified by the legacy prefix fallback.
assert.equal(isServerSideHttpError('503: something'), null)
assert.equal(isServerSideHttpError({ statusCode: 503 }), null)
assert.equal(isServerSideHttpError(null), null)
assert.equal(isServerSideHttpError(503), null)
})
test('isServerSideHttpError structured path excludes 500/401/403/404/429 even when statusCode is attached', () => {
for (const code of [500, 401, 403, 404, 429]) {
const err = new Error(`HTTP ${code}`) as any
err.statusCode = code
assert.equal(isServerSideHttpError(err), null, `should reject statusCode ${code}`)
}
})
test('makeNousCloudBackendDownError produces the Cloud shape and preserves cause', () => {
const err = new Error('upstream unavailable') as any
err.statusCode = 503
const result = makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err)
assert.ok(result)
assert.equal((result as any).isCloudBackendDown, true)
assert.equal((result as any).statusCode, 503)
assert.equal((result as any).cause, err)
assert.ok(result?.message.includes('Nous Cloud agent ares-3009.agents.nousresearch.com is down'))
})
test('makeNousCloudBackendDownError returns null for a Cloud 401 (routes to reauth)', () => {
const err = new Error('Unauthorized') as any
err.statusCode = 401
assert.equal(makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err), null)
})
test('makeNousCloudBackendDownError returns null for a non-Cloud 503 (generic remote failure)', () => {
const err = new Error('Service Unavailable') as any
err.statusCode = 503
assert.equal(makeNousCloudBackendDownError('https://gateway.example.com', err), null)
assert.equal(makeNousCloudBackendDownError('http://127.0.0.1:9000', err), null)
})
test('makeNousCloudBackendDownError preserves legacy string-prefix compatibility', () => {
const result = makeNousCloudBackendDownError(
'https://ares-3009.agents.nousresearch.com',
new Error('503: Service Unavailable')
)
assert.ok(result)
assert.equal((result as any).isCloudBackendDown, true)
assert.equal((result as any).statusCode, 503)
})