542 lines
18 KiB
TypeScript
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)
|
|
})
|