mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
The desktop remote-gateway settings now auto-detect whether a gateway
authenticates with OAuth or a static session token and present the
matching UI + connection mechanism.
Detection: an unauthenticated GET {base}/api/status reads auth_required
(true => OAuth, false => session token); /api/auth/providers supplies the
provider label. The settings UI debounce-probes the entered URL and shows
either a 'Sign in with <provider>' button or the session-token box.
OAuth connection mechanism:
- REST is authed by the HttpOnly session cookie held in a persistent
Electron session partition (persist:hermes-remote-oauth); main-process
REST routes through electron net bound to that partition so the cookie
attaches automatically.
- Login opens a BrowserWindow on {base}/login in that partition and
resolves once the hermes_session_at cookie lands.
- WebSocket upgrades use a single-use ?ticket= minted at
POST /api/auth/ws-ticket (the gateway rejects ?token= in gated mode);
getGatewayWsUrl() re-mints before every (re)connect since tickets are
single-use and short-lived.
- Missing cookie / 401 surfaces needsOauthLogin to prompt re-sign-in
(Nous Portal contract v1 issues no refresh token).
Local and token modes are unchanged.
Pure helpers (URL normalize, ws-url token/ticket builders, auth-mode
classify/resolve, cookie detector) are extracted to a standalone
connection-config.cjs (no electron import) and unit-tested with
node --test (26 tests), matching the backend-probes.cjs pattern.
118 lines
3.9 KiB
JavaScript
118 lines
3.9 KiB
JavaScript
/**
|
|
* connection-config.cjs
|
|
*
|
|
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
|
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
|
* auth-mode classification, and the auth-mode coercion rules.
|
|
*
|
|
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
|
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
|
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
|
*
|
|
* Background on the two auth models a remote gateway can use:
|
|
* - 'token': legacy static dashboard session token. REST uses an
|
|
* `X-Hermes-Session-Token` header; WS uses `?token=`.
|
|
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
|
|
* by an HttpOnly session cookie; WS upgrades require a single-use
|
|
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
|
|
* this via the public `/api/status` field `auth_required: true`.
|
|
*/
|
|
|
|
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
|
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
|
// → __Secure-, loopback HTTP → bare). Mirrors
|
|
// hermes_cli/dashboard_auth/cookies.py.
|
|
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
|
|
|
function normalizeRemoteBaseUrl(rawUrl) {
|
|
const value = String(rawUrl || '').trim()
|
|
|
|
if (!value) {
|
|
throw new Error('Remote gateway URL is required.')
|
|
}
|
|
|
|
let parsed
|
|
try {
|
|
parsed = new URL(value)
|
|
} catch (error) {
|
|
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
|
}
|
|
|
|
parsed.hash = ''
|
|
parsed.search = ''
|
|
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return parsed.toString().replace(/\/+$/, '')
|
|
}
|
|
|
|
function buildGatewayWsUrl(baseUrl, token) {
|
|
const parsed = new URL(baseUrl)
|
|
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
|
const prefix = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
|
}
|
|
|
|
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|
const parsed = new URL(baseUrl)
|
|
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
|
const prefix = parsed.pathname.replace(/\/+$/, '')
|
|
|
|
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
|
}
|
|
|
|
function tokenPreview(value) {
|
|
const raw = String(value || '')
|
|
|
|
if (!raw) {
|
|
return null
|
|
}
|
|
|
|
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
|
}
|
|
|
|
/**
|
|
* Classify a gateway's auth mode from its public /api/status body.
|
|
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
|
|
* Returns 'oauth' | 'token'.
|
|
*/
|
|
function authModeFromStatus(statusBody) {
|
|
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective auth mode for a coerce/save operation.
|
|
* Explicit input wins; otherwise inherit the saved value; default 'token'.
|
|
* Returns 'oauth' | 'token'.
|
|
*/
|
|
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
|
if (inputAuthMode === 'oauth') return 'oauth'
|
|
if (inputAuthMode === 'token') return 'token'
|
|
if (existingAuthMode === 'oauth') return 'oauth'
|
|
return 'token'
|
|
}
|
|
|
|
/**
|
|
* True if any cookie in `cookies` is a hermes session access-token cookie
|
|
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
|
* Electron's session.cookies.get returns).
|
|
*/
|
|
function cookiesHaveSession(cookies) {
|
|
if (!Array.isArray(cookies)) return false
|
|
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
|
}
|
|
|
|
module.exports = {
|
|
AT_COOKIE_VARIANTS,
|
|
authModeFromStatus,
|
|
buildGatewayWsUrl,
|
|
buildGatewayWsUrlWithTicket,
|
|
cookiesHaveSession,
|
|
normalizeRemoteBaseUrl,
|
|
resolveAuthMode,
|
|
tokenPreview
|
|
}
|