desktop: OAuth-aware remote gateway connection

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.
This commit is contained in:
Ben
2026-06-03 11:21:44 +10:00
committed by Teknium
parent 9cbc37e25b
commit 9d07927a23
10 changed files with 1107 additions and 241 deletions

View File

@ -0,0 +1,118 @@
/**
* 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
}

View File

@ -0,0 +1,180 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(
buildGatewayWsUrl('https://gw.example.com', 'tok123'),
'wss://gw.example.com/api/ws?token=tok123'
)
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(
buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'),
'ws://127.0.0.1:9119/api/ws?token=abc'
)
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(
buildGatewayWsUrl('https://host/hermes', 't'),
'wss://host/hermes/api/ws?token=t'
)
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(
buildGatewayWsUrl('https://host', 'a/b c+d'),
'wss://host/api/ws?token=a%2Fb%20c%2Bd'
)
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(
buildGatewayWsUrlWithTicket('https://host', 'a+b/c'),
'wss://host/api/ws?ticket=a%2Bb%2Fc'
)
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, [
'__Host-hermes_session_at',
'__Secure-hermes_session_at',
'hermes_session_at'
])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})

View File

@ -27,6 +27,15 @@ const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
@ -466,10 +475,6 @@ let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
// existing-install adopt branch (3b) so repair re-drives the installer instead
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
let connectionConfigCache = null
let connectionConfigCacheMtime = null
const hermesLog = []
@ -1571,12 +1576,8 @@ function readJson(filePath) {
// Marker schema (version 1):
// {
// schemaVersion: 1,
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
// // may be null for adopted installs
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
// pinnedBranch: "<branch name>" | null,
// adopted: <bool>, // true when we adopted a pre-existing
// // install rather than bootstrapping it;
// // treated as authoritative even sans commit
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
@ -1584,25 +1585,11 @@ function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
// ever having written the bootstrap marker -- so we must be able to recognise
// "already installed" off the filesystem alone, not just the marker.
function isActiveRuntimeUsable() {
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function isBootstrapComplete() {
const marker = readBootstrapMarker()
if (!marker || typeof marker !== 'object') return false
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
// Adopted markers (an existing install we detected and took ownership of,
// possibly without a resolvable commit) are still authoritative -- they
// attest a runnable install we deliberately decided to forward to.
if (marker.adopted !== true) return false
}
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
// We DELIBERATELY do NOT verify that the checkout is currently at the
// pinned commit -- users update via the in-app update path or `hermes
// update`, which moves HEAD legitimately. The marker just attests "we
@ -1610,22 +1597,7 @@ function isBootstrapComplete() {
// a runnable venv: an interrupted or split-home install can leave the marker
// + checkout without a venv, and trusting that spawns a dead backend
// ("gateway offline") instead of re-running bootstrap to repair it.
return isActiveRuntimeUsable()
}
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
// provenance a freshly-bootstrapped one would. null when git is unavailable or
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
function readActiveHeadCommit() {
try {
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
} catch {
return null
}
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function writeBootstrapMarker(payload) {
@ -1634,7 +1606,6 @@ function writeBootstrapMarker(payload) {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
pinnedBranch: payload.pinnedBranch || null,
adopted: Boolean(payload.adopted),
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
@ -1792,24 +1763,6 @@ function resolveHermesBackend(dashboardArgs) {
return createActiveBackend(dashboardArgs)
}
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
// written only by OUR bootstrap, so a runtime from `install.sh
// --include-desktop` (or a DMG launch over a prior CLI install) is
// runnable yet markerless -- without this we'd fall to step 6 and re-run
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
// canonical location (unlike a random `hermes` on PATH), so adopt it:
// stamp the marker once and forward straight to the app. Repair skips
// this so a broken-but-present venv still gets rebuilt.
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
try {
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
} catch (err) {
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
}
return createActiveBackend(dashboardArgs)
}
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
// a previous tool-only setup, or pip-installed system-wide. Use it but
// do NOT write a bootstrap marker; the user did this themselves and we
@ -2000,9 +1953,6 @@ async function ensureRuntime(backend) {
}
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
// below SHOULD land on the fresh marker fast-path rather than skip it.
forceBootstrapRepair = false
// Re-resolve now that the install exists. The new resolution lands in
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
return ensureRuntime(resolveHermesBackend(backend.args))
@ -2149,6 +2099,80 @@ function fetchJson(url, token, options = {}) {
})
}
function fetchPublicJson(url, options = {}) {
// Credential-free JSON GET/POST for public gateway endpoints
// (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends
// NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before
// any credentials exist, and any time we must not leak a token to an
// endpoint that doesn't need one.
return new Promise((resolve, reject) => {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const req = client.request(
parsed,
{
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(body ? { 'Content-Length': String(body.length) } : {})
}
},
res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
return
}
if (!text) {
resolve(null)
return
}
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
const contentType = String(res.headers['content-type'] || '')
if (looksHtml || contentType.includes('text/html')) {
reject(
new Error(
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
'The endpoint is likely missing on the Hermes backend.'
)
)
return
}
try {
resolve(JSON.parse(text))
} catch {
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
}
})
}
)
req.on('error', reject)
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
if (body) req.write(body)
req.end()
})
}
function mimeTypeForPath(filePath) {
const ext = path.extname(filePath || '').toLowerCase()
@ -2212,6 +2236,7 @@ const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
])
let linkTitleSession = null
let oauthSession = null
let renderTitleInFlight = 0
const renderTitleQueue = []
@ -3077,47 +3102,269 @@ function installMediaPermissions() {
})
}
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
// ---------------------------------------------------------------------------
// OAuth remote-gateway auth.
//
// Hosted Hermes gateways gate the dashboard behind an OAuth provider (e.g.
// Nous Research) instead of a static session token. The auth model is
// fundamentally different from the token path:
//
// * REST is authed by HttpOnly session cookies (``hermes_session_at``),
// established by a browser redirect round-trip (/login → IDP →
// /auth/callback sets cookies). We cannot read the HttpOnly cookie value
// in JS — instead we let an Electron BrowserWindow complete the round
// trip into a PERSISTENT session partition, and thereafter route our REST
// through Electron's ``net`` bound to that same partition so the cookie
// jar attaches the cookie automatically.
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
// path is unconditionally rejected by gated gateways.
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
// a ~15-min TTL. On 401 we must re-run the login round trip.
// ---------------------------------------------------------------------------
if (!value) {
throw new Error('Remote gateway URL is required.')
}
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
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 getOauthSession() {
if (oauthSession || !app.isReady()) return oauthSession
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
return oauthSession
}
function buildGatewayWsUrl(baseUrl, token) {
// Bare + prefixed variants of the access-token cookie live in
// connection-config.cjs (cookiesHaveSession). See that module for details.
async function hasOauthSessionCookie(baseUrl) {
const sess = getOauthSession()
if (!sess) return false
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)}`
try {
// Query by URL so the cookie jar applies Domain/Path/Secure scoping for us.
const cookies = await sess.cookies.get({ url: baseUrl })
return cookiesHaveSession(cookies)
} catch {
// Fall back to a host match if the URL query path errors.
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHaveSession(cookies)
} catch {
return false
}
}
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
async function clearOauthSession(baseUrl) {
const sess = getOauthSession()
if (!sess) return
try {
const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {})
await Promise.all(
cookies.map(c => {
const scheme = c.secure ? 'https' : 'http'
const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}`
return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined)
})
)
} catch {
// Best effort — a stale cookie self-expires anyway.
}
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
// Open the gateway's /login page in a visible window using the OAuth session
// partition, and resolve once the access-token cookie appears (login done) or
// reject if the user closes the window first. The window navigates through the
// IDP and back to /auth/callback, which sets the session cookies on the
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
function openOauthLoginWindow(baseUrl) {
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start an OAuth login.'))
return
}
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let settled = false
let win = null
let pollTimer = null
const finish = (err) => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
// window already torn down
}
if (err) reject(err)
else resolve({ baseUrl, ok: true })
}
const checkCookie = async () => {
if (settled) return
if (await hasOauthSessionCookie(baseUrl)) finish(null)
}
try {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes gateway',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
session: sess,
webSecurity: true
}
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
// Re-check the cookie jar on every successful navigation (the callback
// redirect is the moment cookies get set) plus a low-frequency poll as a
// belt-and-braces fallback for IDPs that finish via in-page JS.
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
win.on('closed', () => {
if (!settled) finish(new Error('Login window closed before authentication completed.'))
})
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
// login, which is a valid authenticated page that sets the cookies. We
// only care that the cookie jar is populated.
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
win.loadURL(loginUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
})
}
// JSON request routed through the OAuth session partition so the HttpOnly
// session cookie is attached automatically by Electron's net stack. Used for
// authed REST against a gated gateway, including minting WS tickets.
function fetchJsonViaOauthSession(url, options = {}) {
return new Promise((resolve, reject) => {
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const request = electronNet.request({
method: options.method || 'GET',
url,
session: sess,
useSessionCookies: true,
redirect: 'follow'
})
request.setHeader('Content-Type', 'application/json')
if (body) request.setHeader('Content-Length', String(body.length))
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
try {
request.abort()
} catch {
// already finished
}
reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
}, 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) {
const err = new Error(`${statusCode}: ${text || ''}`)
err.statusCode = statusCode
reject(err)
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)}`))
}
})
})
request.on('error', error => {
if (timedOut) return
clearTimeout(timer)
reject(error)
})
if (body) request.write(body)
request.end()
})
}
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
// Throws (with statusCode 401) if the session cookie is missing/expired —
// callers treat that as "needs re-login".
async function mintGatewayWsTicket(baseUrl) {
const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
method: 'POST',
timeoutMs: 8_000
})
const ticket = body?.ticket
if (!ticket || typeof ticket !== 'string') {
throw new Error('Gateway did not return a WS ticket.')
}
return ticket
}
// Build a fresh WS URL for the *current* connection. Critical for reconnects:
// OAuth WS tickets are single-use with a ~30s TTL, so the ticket baked into
// the cached connection's wsUrl is stale on the second connect. The renderer
// calls this immediately before every gateway.connect() so each WS upgrade
// carries a freshly-minted ticket. For local/token connections this just
// reuses the static token (no minting needed).
async function freshGatewayWsUrl() {
const connection = await startHermes()
if (connection.authMode === 'oauth') {
const ticket = await mintGatewayWsTicket(connection.baseUrl)
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
}
// Local/token: the cached wsUrl already carries the (long-lived) token.
return connection.wsUrl
}
function encryptDesktopSecret(value) {
@ -3168,9 +3415,14 @@ function readDesktopConnectionConfig() {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
// authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket)
// or 'token' (legacy static session token). Default to 'token' for
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
remote
}
}
} catch {
@ -3190,12 +3442,25 @@ function writeDesktopConnectionConfig(config) {
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
}
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
const remoteToken = decryptDesktopSecret(config.remote?.token)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
const remoteUrl = String(config.remote?.url || '')
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
try {
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
} catch {
remoteOauthConnected = false
}
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
remoteUrl: String(config.remote?.url || ''),
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
@ -3206,10 +3471,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
const persistToken = options.persistToken !== false
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
authMode,
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
@ -3220,7 +3488,10 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
if (!decryptDesktopSecret(nextRemote.token)) {
// OAuth gateways authenticate via the session cookie established by the
// login window, NOT a static token — so no token is required here. The
// cookie presence is verified at connect time (resolveRemoteBackend).
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
throw new Error('Remote gateway session token is required.')
}
} else if (remoteUrl) {
@ -3230,7 +3501,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
return { mode, remote: nextRemote }
}
function resolveRemoteBackend() {
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
@ -3248,6 +3519,7 @@ function resolveRemoteBackend() {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
@ -3259,6 +3531,47 @@ function resolveRemoteBackend() {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
// Verify the cookie is present, then mint a single-use WS ticket (the
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
// user needs to (re-)log in via Settings → Gateway.
if (!(await hasOauthSessionCookie(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
)
err.needsOauthLogin = true
throw err
}
let ticket
try {
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
'Your remote gateway session has expired. ' +
'Open Settings → Gateway and click "Sign in" again.'
)
err.needsOauthLogin = true
err.cause = error
throw err
}
return {
baseUrl,
mode: 'remote',
source: 'settings',
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
@ -3268,31 +3581,98 @@ function resolveRemoteBackend() {
)
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
return {
baseUrl,
mode: 'remote',
source: 'settings',
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
// gateway (it backs the portal liveness probe) and reports:
// auth_required: true → OAuth gate is engaged (cookie + ws-ticket auth)
// auth_required: false → loopback/--insecure: legacy session-token auth
// ``/api/auth/providers`` (also public, only meaningful when gated) gives
// the human-facing provider name(s) for the login button label.
//
// The settings UI calls this as the user types a URL so it can render an
// OAuth login button vs a session-token entry box. Network/parse failures
// surface as ``reachable: false`` rather than throwing, so a half-typed or
// unreachable URL degrades to "can't tell yet" instead of a hard error.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
let status
try {
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
} catch (error) {
return {
baseUrl,
reachable: false,
authMode: 'unknown',
providers: [],
version: null,
error: error instanceof Error ? error.message : String(error)
}
}
const authRequired = authModeFromStatus(status) === 'oauth'
let providers = []
if (authRequired) {
// Best-effort: a gated gateway exposes the registered OAuth providers so
// the button can read "Log in with Nous Research" instead of a generic
// label. A failure here doesn't change the auth mode, so swallow it.
try {
const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
if (Array.isArray(body?.providers)) {
providers = body.providers
.filter(p => p && typeof p === 'object')
.map(p => ({ name: String(p.name || ''), displayName: String(p.display_name || p.name || '') }))
.filter(p => p.name)
}
} catch {
// Provider listing is optional metadata; the auth mode is already known.
}
}
return {
baseUrl,
reachable: true,
authMode: authRequired ? 'oauth' : 'token',
providers,
version: status?.version || null,
error: null
}
}
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote =
config.mode === 'remote'
? {
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
token: decryptDesktopSecret(config.remote.token)
}
: resolveRemoteBackend() || (await startHermes())
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
// for local we fall back to the resolved/started backend.
let baseUrl
let token = null
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
if ((config.remote.authMode || 'token') !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
return {
ok: true,
baseUrl: remote.baseUrl,
baseUrl,
version: status?.version || null
}
}
@ -3335,7 +3715,7 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = resolveRemoteBackend()
const remote = await resolveRemoteBackend()
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@ -3350,6 +3730,7 @@ async function startHermes() {
baseUrl: remote.baseUrl,
mode: 'remote',
source: remote.source,
authMode: remote.authMode || 'token',
token: remote.token,
wsUrl: remote.wsUrl,
logs: hermesLog.slice(-80),
@ -3454,6 +3835,7 @@ async function startHermes() {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
@ -3605,13 +3987,13 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl())
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
bootstrapFailure = null
forceBootstrapRepair = false
connectionPromise = null
bootstrapState = {
active: false,
@ -3639,9 +4021,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
// Force the next resolution past both the marker fast-path and the adopt
// branch so the installer actually re-runs (the whole point of repair).
forceBootstrapRepair = true
resetHermesConnection()
return { ok: true }
})
@ -3661,6 +4040,21 @@ ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
// land in the OAuth partition. The caller (settings UI) typically saves the
// remote config with authMode='oauth' first, then calls this. We normalize
// the URL defensively so a login can be driven from a raw URL too.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
await openOauthLoginWindow(baseUrl)
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
})
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
@ -3691,7 +4085,19 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
const url = `${connection.baseUrl}${request.path}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
if (connection.authMode === 'oauth') {
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,
timeoutMs
})
}
return fetchJson(url, connection.token, {
method: request?.method,
body: request?.body,
timeoutMs
@ -4157,99 +4563,7 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ---------------------------------------------------------------------------
// macOS first-launch placement: move into /Applications and pin to the Dock
// ---------------------------------------------------------------------------
//
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
// no Dock tile, and "which icon do I click?" confusion. On first packaged
// launch we relocate into /Applications (Electron relaunches from there) and,
// once we're that canonical copy, pin to the Dock. Both macOS-only,
// packaged-only, best-effort, run at most once.
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
// is underway (caller must stop init). No-op in dev, off macOS, or already in
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
// it. `exists` -> stale copy; replace it so there's exactly one current app.
function maybeRelocateToApplications() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
try {
if (app.isInApplicationsFolder()) return false
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
return moved
} catch (err) {
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
return false
}
}
const DOCK_PINNED_MARKER = 'dock-pinned.json'
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
// Guarded by a userData marker + membership check so we never duplicate the tile.
function maybePinToDock() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
if (fileExists(marker)) return
let bundle
try {
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
bundle = runningAppBundle()
} catch {
return
}
if (!bundle) return
// The Dock stores tiles as file-reference URLs (type 15), e.g.
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
const done = (note = {}) => {
try {
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
} catch {
// best-effort; we re-check next launch (membership guard dedupes)
}
}
try {
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (apps.includes(url)) return done({ alreadyPresent: true })
} catch {
// persistent-apps may not exist yet; -array-add creates it
}
const tile =
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
'</dict></dict></dict>'
try {
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
// Flush the write through cfprefsd before restarting the Dock, otherwise the
// Dock reloads stale prefs and our tile is lost in the race.
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
done()
rememberLog(`[install] pinned to Dock: ${url}`)
} catch (err) {
rememberLog(`[install] Dock pin skipped: ${err.message}`)
}
}
app.whenReady().then(() => {
// macOS: relocate into /Applications before anything else so setup + state
// land in the final location; on success this relaunches, so bail here.
if (maybeRelocateToApplications()) return
maybePinToDock()
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {

View File

@ -2,11 +2,15 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),

View File

@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@ -230,7 +230,11 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
await gateway.connect(conn.wsUrl)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl can already be stale; getGatewayWsUrl() re-mints it.
const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
await gateway.connect(wsUrl)
if (cancelled) {
return

View File

@ -45,7 +45,10 @@ export function useGatewayRequest() {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
// Re-mint the WS URL before reconnecting — OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is stale here.
const wsUrl = (await desktop.getGatewayWsUrl?.().catch(() => null)) || conn.wsUrl
await existing.connect(wsUrl)
return existing
} catch {

View File

@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
mode: Mode
remoteAuthMode: AuthMode
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@ -22,6 +27,8 @@ interface GatewaySettingsState {
const EMPTY_STATE: GatewaySettingsState = {
envOverride: false,
mode: 'local',
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
@ -71,10 +78,18 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const probeSeq = useRef(0)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@ -104,15 +119,95 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}, [])
const canUseRemote = useMemo(
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
[remoteToken, state.remoteTokenSet, state.remoteUrl]
)
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const seq = ++probeSeq.current
setProbeStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [state.mode, trimmedUrl])
// Effective auth mode: a reachable probe wins; otherwise fall back to the
// saved config's mode so a re-open of settings doesn't flicker.
const authMode: AuthMode = useMemo(() => {
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
return probe.authMode
}
return state.remoteAuthMode
}, [probe, probeStatus, state.remoteAuthMode])
const providerLabel = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
if (providers.length === 1) {
return providers[0].displayName || providers[0].name
}
if (providers.length > 1) {
return providers.map(p => p.displayName || p.name).join(' / ')
}
return 'your identity provider'
}, [probe])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
if (!trimmedUrl) {
return false
}
if (authMode === 'oauth') {
return oauthConnected
}
return Boolean(remoteToken.trim()) || state.remoteTokenSet
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
const payload = () => ({
mode: state.mode,
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const save = async (apply: boolean) => {
@ -120,7 +215,10 @@ export function GatewaySettings() {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before switching to remote.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before switching to remote.'
: 'Enter a remote URL and session token before switching to remote.'
})
return
@ -147,12 +245,73 @@ export function GatewaySettings() {
}
}
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
// the URL the login window needs), then open the gateway login window and
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
if (!trimmedUrl) {
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
return
}
setSigningIn(true)
try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
setState(saved)
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
}
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setSigningIn(false)
}
}
const signOut = async () => {
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
notifyError(err, 'Sign-out failed')
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message: 'Enter a remote URL and session token before testing.'
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before testing.'
: 'Enter a remote URL and session token before testing.'
})
return
@ -164,8 +323,9 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
@ -229,7 +389,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes backend using its session token."
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth; self-hosted ones may use a session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
@ -251,23 +411,71 @@ export function GatewaySettings() {
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
title="Remote URL"
/>
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
Checking how this gateway authenticates
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
Could not reach this gateway yet. Check the URL the auth method will appear once it responds.
</div>
) : null}
{/* OAuth gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> Signed in
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
Sign out
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
Sign in with {providerLabel}
</Button>
)
}
description={
oauthConnected
? 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
}
title="Authentication"
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authMode === 'token' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
) : null}
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}

View File

@ -4,11 +4,15 @@ declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getGatewayWsUrl: () => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@ -141,6 +145,7 @@ export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@ -163,6 +168,8 @@ export interface HermesWindowState {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@ -170,6 +177,7 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
}
@ -180,6 +188,31 @@ export interface DesktopConnectionTestResult {
version: string | null
}
export interface DesktopAuthProvider {
name: string
displayName: string
}
export interface DesktopConnectionProbeResult {
baseUrl: string
reachable: boolean
authMode: 'oauth' | 'token' | 'unknown'
providers: DesktopAuthProvider[]
version: string | null
error: string | null
}
export interface DesktopOauthLoginResult {
ok: boolean
baseUrl: string
connected: boolean
}
export interface DesktopOauthLogoutResult {
ok: boolean
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean

View File

@ -49,6 +49,7 @@ import {
IconLoader2 as Loader2,
IconLoader2 as Loader2Icon,
IconLock as Lock,
IconLogin as LogIn,
IconMessageCircle as MessageCircle,
IconMessage2 as MessageSquareText,
IconMicrophone as Mic,
@ -148,6 +149,7 @@ export {
Loader2,
Loader2Icon,
Lock,
LogIn,
MessageCircle,
MessageSquareText,
Mic,