mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
* feat(desktop): per-profile remote gateway hosts Profile switching silently failed whenever the desktop was connected to a remote backend: the rail routed non-active profiles to a local pool backend, but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a remote Hermes backend", and the renderer swallowed the error into an infinite reconnect backoff while still marking the profile active. Remote was also a single app-global setting, so there was no way to give a profile its own host. Add per-profile remote hosts so each profile can point at its own backend: - connection.json gains a validated `profiles` map; profileRemoteOverride() (pure, unit-tested) selects an explicit per-profile remote. - resolveRemoteBackend(profile) precedence: per-profile override → env override → global remote → local spawn. spawnPoolBackend now connects to a profile's remote (no local child) instead of throwing; startHermes resolves the primary profile's remote. - coerce/sanitize connection config are scope-aware (global vs named profile) and preserve each other's entries; IPC get/save/apply/test thread an optional profile. Per-profile apply drops only that profile's pool backend. - Settings → Gateway adds an "Applies to" scope selector reusing the existing URL/token/OAuth/test UX per profile. Tests: connection-config pure suite (+6) and desktop platform suite pass; tsc/eslint/vitest clean. * refactor(desktop): DRY per-profile remote helpers Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the main.cjs copy), collapse the scope/auth ternaries, route the env remote through buildRemoteConnection, and fold the duplicated remote-block validation into buildRemoteBlock. No behavior change; pure suite + live E2E still green.
254 lines
9.4 KiB
JavaScript
254 lines
9.4 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 session cookies 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.
|
|
//
|
|
// Two cookies are in play (see that module):
|
|
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
|
|
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
|
|
// instant the AT expires.
|
|
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
|
|
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
|
|
// has lapsed but the RT cookie is still present, the gateway middleware
|
|
// transparently rotates a fresh AT on the next authenticated request
|
|
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
|
|
// AT cookie. A liveness check that looked only at the AT cookie would
|
|
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
|
|
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
|
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
|
|
|
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)}`
|
|
}
|
|
|
|
/**
|
|
* Build the WS URL the renderer would connect with, so the connection test can
|
|
* exercise the same transport the app actually uses.
|
|
*
|
|
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
|
* so this stays electron-free and unit-testable; main.cjs passes the real
|
|
* `mintGatewayWsTicket`.
|
|
*
|
|
* Return semantics:
|
|
* - token mode + token → ws(s)://…/api/ws?token=…
|
|
* - token mode, no token → null (genuine skip; nothing to authenticate with)
|
|
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
|
* - oauth, mint fails → THROWS (NOT a skip)
|
|
*
|
|
* The oauth-mint-failure throw is the important case: the real boot path
|
|
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
|
* "session expired" auth error and refuses to connect. Swallowing it here
|
|
* would re-introduce the exact false-positive this test exists to catch —
|
|
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
|
* can't authenticate /api/ws and boot dies with "Could not connect".
|
|
*
|
|
* @param {string} baseUrl
|
|
* @param {'token'|'oauth'} authMode
|
|
* @param {string|null} token
|
|
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
|
if (authMode === 'oauth') {
|
|
const mintTicket = deps.mintTicket
|
|
if (typeof mintTicket !== 'function') {
|
|
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
|
}
|
|
let ticket
|
|
try {
|
|
ticket = await mintTicket(baseUrl)
|
|
} catch (error) {
|
|
const err = new Error(
|
|
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
|
'(it may have expired). Open Settings → Gateway and sign in again.'
|
|
)
|
|
err.needsOauthLogin = true
|
|
err.cause = error
|
|
throw err
|
|
}
|
|
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
|
}
|
|
if (!token) {
|
|
return null
|
|
}
|
|
return buildGatewayWsUrl(baseUrl, token)
|
|
}
|
|
|
|
// Normalize a profile name to a connection scope key, or null for the global
|
|
// (default) connection. Shared by the resolver and the IPC layer.
|
|
function connectionScopeKey(profile) {
|
|
return String(profile ?? '').trim() || null
|
|
}
|
|
|
|
// Coerce a remote auth mode to one of the two supported values ('token' default).
|
|
function normAuthMode(mode) {
|
|
return mode === 'oauth' ? 'oauth' : 'token'
|
|
}
|
|
|
|
/**
|
|
* Select a profile's explicit remote override from a connection config, or null
|
|
* when it has none (so the caller falls back to env → global remote → local).
|
|
*
|
|
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
|
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
|
* is the raw stored secret; main.cjs decrypts it. Returns
|
|
* `{ url, authMode, token } | null`.
|
|
*/
|
|
function profileRemoteOverride(config, profile) {
|
|
const key = connectionScopeKey(profile)
|
|
const entry = key ? config?.profiles?.[key] : null
|
|
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
|
return null
|
|
}
|
|
|
|
const url = String(entry.url || '').trim()
|
|
if (!url) {
|
|
return null
|
|
}
|
|
|
|
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
|
}
|
|
|
|
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).
|
|
*
|
|
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
|
|
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
|
|
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
|
|
* check. `cookiesHaveSession` remains exported for callers that specifically
|
|
* need to know whether an unexpired access token is present right now.
|
|
*/
|
|
function cookiesHaveSession(cookies) {
|
|
if (!Array.isArray(cookies)) return false
|
|
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
|
}
|
|
|
|
/**
|
|
* True if the cookie jar holds a credential that can yield an authenticated
|
|
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
|
|
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
|
|
* transparently rotates a fresh AT from the RT on the next authenticated
|
|
* request. Gating connectivity on the AT alone would force a full IDP
|
|
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
|
|
*
|
|
* This answers "should we even attempt to connect / show as signed in?", not
|
|
* "is the access token unexpired?". The authoritative liveness check is still
|
|
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
|
|
* the RT is also dead/revoked).
|
|
*/
|
|
function cookiesHaveLiveSession(cookies) {
|
|
if (!Array.isArray(cookies)) return false
|
|
return cookies.some(
|
|
c =>
|
|
c &&
|
|
c.value &&
|
|
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
AT_COOKIE_VARIANTS,
|
|
RT_COOKIE_VARIANTS,
|
|
authModeFromStatus,
|
|
buildGatewayWsUrl,
|
|
buildGatewayWsUrlWithTicket,
|
|
connectionScopeKey,
|
|
cookiesHaveSession,
|
|
cookiesHaveLiveSession,
|
|
normAuthMode,
|
|
normalizeRemoteBaseUrl,
|
|
profileRemoteOverride,
|
|
resolveAuthMode,
|
|
resolveTestWsUrl,
|
|
tokenPreview
|
|
}
|