From 9d07927a23eea91b3ebc9e7b442f138433aabb6d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 3 Jun 2026 11:21:44 +1000 Subject: [PATCH] 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 ' 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. --- apps/desktop/electron/connection-config.cjs | 118 +++ .../electron/connection-config.test.cjs | 180 +++++ apps/desktop/electron/main.cjs | 730 +++++++++++++----- apps/desktop/electron/preload.cjs | 4 + apps/desktop/package.json | 2 +- .../src/app/gateway/hooks/use-gateway-boot.ts | 6 +- .../app/gateway/hooks/use-gateway-request.ts | 5 +- .../src/app/settings/gateway-settings.tsx | 268 ++++++- apps/desktop/src/global.d.ts | 33 + apps/desktop/src/lib/icons.ts | 2 + 10 files changed, 1107 insertions(+), 241 deletions(-) create mode 100644 apps/desktop/electron/connection-config.cjs create mode 100644 apps/desktop/electron/connection-config.test.cjs diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs new file mode 100644 index 000000000..0704cd6a2 --- /dev/null +++ b/apps/desktop/electron/connection-config.cjs @@ -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 +} diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs new file mode 100644 index 000000000..754e9f700 --- /dev/null +++ b/apps/desktop/electron/connection-config.test.cjs @@ -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') +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c89b263c5..48a5e1cbd 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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: "" | null, -// adopted: , // true when we adopted a pre-existing -// // install rather than bootstrapping it; -// // treated as authoritative even sans commit // completedAt: "", // desktopVersion: "" // 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 = - 'tile-datafile-data' + - `_CFURLString${url}_CFURLStringType15` + - '' - 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 { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 03fe08ef4..65fc591e8 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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'), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 986289325..88323c9e3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 815ce205b..b953b0abd 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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 diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 1968bc672..4ef96a4dc 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -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 { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index a4b831489..46447a338 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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(EMPTY_STATE) const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(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('idle') + const [probe, setProbe] = useState(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() { /> 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" /> - 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' ? ( +
+ + Checking how this gateway authenticates… +
+ ) : null} + + {state.mode === 'remote' && probeStatus === 'error' ? ( +
+ + Could not reach this gateway yet. Check the URL — the auth method will appear once it responds. +
+ ) : null} + + {/* OAuth gateways: present a sign-in button + connection status. */} + {state.mode === 'remote' && authMode === 'oauth' ? ( + + + Signed in + + + + ) : ( + + ) + } + 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' ? ( + 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} {lastTest ?
{lastTest}
: null} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 3f8a4dace..f9527c877 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -4,11 +4,15 @@ declare global { interface Window { hermesDesktop: { getConnection: () => Promise + getGatewayWsUrl: () => Promise getBootProgress: () => Promise getConnectionConfig: () => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise + probeConnectionConfig: (remoteUrl: string) => Promise + oauthLoginConnectionConfig: (remoteUrl: string) => Promise + oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise api: (request: HermesApiRequest) => Promise notify: (payload: HermesNotification) => Promise requestMicrophoneAccess: () => Promise @@ -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 diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 0622f2558..fe2d41693 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -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,