feat(desktop): per-profile remote gateway hosts (#39778)

* feat(desktop): per-profile remote gateway hosts

Profile switching silently failed whenever the desktop was connected to a
remote backend: the rail routed non-active profiles to a local pool backend,
but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a
remote Hermes backend", and the renderer swallowed the error into an infinite
reconnect backoff while still marking the profile active. Remote was also a
single app-global setting, so there was no way to give a profile its own host.

Add per-profile remote hosts so each profile can point at its own backend:

- connection.json gains a validated `profiles` map; profileRemoteOverride()
  (pure, unit-tested) selects an explicit per-profile remote.
- resolveRemoteBackend(profile) precedence: per-profile override → env override
  → global remote → local spawn. spawnPoolBackend now connects to a profile's
  remote (no local child) instead of throwing; startHermes resolves the primary
  profile's remote.
- coerce/sanitize connection config are scope-aware (global vs named profile)
  and preserve each other's entries; IPC get/save/apply/test thread an optional
  profile. Per-profile apply drops only that profile's pool backend.
- Settings → Gateway adds an "Applies to" scope selector reusing the existing
  URL/token/OAuth/test UX per profile.

Tests: connection-config pure suite (+6) and desktop platform suite pass;
tsc/eslint/vitest clean.

* refactor(desktop): DRY per-profile remote helpers

Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the
main.cjs copy), collapse the scope/auth ternaries, route the env remote through
buildRemoteConnection, and fold the duplicated remote-block validation into
buildRemoteBlock. No behavior change; pure suite + live E2E still green.
This commit is contained in:
brooklyn! 2026-06-05 07:14:18 -05:00 committed by GitHub
parent db204ae203
commit 1a3e608524
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 378 additions and 103 deletions

View file

@ -131,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
return buildGatewayWsUrl(baseUrl, token)
}
// Normalize a profile name to a connection scope key, or null for the global
// (default) connection. Shared by the resolver and the IPC layer.
function connectionScopeKey(profile) {
return String(profile ?? '').trim() || null
}
// Coerce a remote auth mode to one of the two supported values ('token' default).
function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env global remote local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
function tokenPreview(value) {
const raw = String(value || '')
@ -207,9 +242,12 @@ module.exports = {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview

View file

@ -19,14 +19,77 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
test('connectionScopeKey trims to a name or null for the global scope', () => {
assert.equal(connectionScopeKey(' coder '), 'coder')
assert.equal(connectionScopeKey(''), null)
assert.equal(connectionScopeKey(null), null)
assert.equal(connectionScopeKey(undefined), null)
})
test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('oauth'), 'oauth')
assert.equal(normAuthMode('token'), 'token')
assert.equal(normAuthMode(undefined), 'token')
assert.equal(normAuthMode('weird'), 'token')
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, ''), null)
assert.equal(profileRemoteOverride(config, null), null)
assert.equal(profileRemoteOverride(config, undefined), null)
})
test('profileRemoteOverride returns null when the profile has no entry', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
assert.equal(profileRemoteOverride(config, 'writer'), null)
})
test('profileRemoteOverride ignores local or url-less profile entries', () => {
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
})
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
const config = {
profiles: {
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
token: { value: 'sek' }
})
})
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
assert.equal(profileRemoteOverride(null, 'coder'), null)
})
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {

View file

@ -32,9 +32,12 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@ -3481,6 +3484,38 @@ function decryptDesktopSecret(secret) {
return value
}
// Validate + normalize the per-profile remote overrides map read from disk.
// Drops malformed names/entries and keeps only the recognized fields so a
// hand-edited or stale connection.json can't inject junk into resolution.
function sanitizeConnectionProfiles(raw) {
if (!raw || typeof raw !== 'object') {
return {}
}
const out = {}
for (const [name, entry] of Object.entries(raw)) {
if (!entry || typeof entry !== 'object') {
continue
}
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
cleaned.url = url
}
cleaned.authMode = normAuthMode(entry.authMode)
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
out[name] = cleaned
}
return out
}
function readDesktopConnectionConfig() {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
@ -3496,7 +3531,7 @@ function readDesktopConnectionConfig() {
return connectionConfigCache
}
let config = { mode: 'local', remote: {} }
let config = { mode: 'local', remote: {}, profiles: {} }
try {
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
@ -3510,7 +3545,11 @@ function readDesktopConnectionConfig() {
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
// profileRemoteOverride() can resolve them; normalized lazily on save.
profiles: sanitizeConnectionProfiles(parsed.profiles)
}
}
} catch {
@ -3562,10 +3601,19 @@ function writeActiveDesktopProfile(name) {
return value || null
}
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 || '')
// Sanitize a connection config into the renderer-facing shape. With no
// `profile` this describes the global/default connection (the existing
// behavior); with a `profile` it describes that profile's per-profile remote
// override (or an empty "local/inherit" view when the profile has none).
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
const key = connectionScopeKey(profile)
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@ -3581,82 +3629,75 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
}
return {
mode: config.mode === 'remote' ? 'remote' : 'local',
mode,
// Echo the scope back so the UI knows which profile (if any) this reflects.
profile: key,
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.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)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
const nextToken = incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingBlock.token
if (mode === 'remote') {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
// 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.')
if (key) {
// Per-profile scope: a remote entry pins this profile to its own backend; a
// local entry clears the override so the profile inherits the default.
const profiles = { ...(existing.profiles || {}) }
if (mode === 'remote') {
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
} else {
delete profiles[key]
}
} else if (remoteUrl) {
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
return { mode, remote: nextRemote }
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
async function resolveRemoteBackend() {
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
return {
baseUrl,
mode: 'remote',
source: 'env',
authMode: 'token',
token: rawEnvToken,
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
}
}
const config = readDesktopConnectionConfig()
if (config.mode !== 'remote') {
return null
}
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookies in the OAuth
@ -3664,16 +3705,9 @@ async function resolveRemoteBackend() {
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable.
//
// We therefore:
// 1. cheap early-out ONLY when the jar holds NEITHER an AT nor an RT
// cookie — a genuinely signed-out user — to avoid a pointless network
// round-trip and give a clear "sign in" message.
// 2. otherwise probe liveness by actually minting a ws-ticket. That POST
// carries the cookie jar (incl. the RT cookie); the gateway refreshes
// the AT server-side and returns a ticket. A real 401 here means the
// RT is also dead/revoked → genuine re-login needed.
// AT cookie but a live RT cookie is still perfectly connectable. We
// early-out only when neither cookie is present, then mint a ws-ticket as
// the authoritative liveness check.
if (!(await hasLiveOauthSession(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
@ -3685,10 +3719,6 @@ async function resolveRemoteBackend() {
let ticket
try {
// This mint is the authoritative liveness check. If only the RT cookie
// is alive, the gateway rotates a fresh AT cookie back onto the partition
// via Set-Cookie (Electron's persistent session absorbs it), so the very
// next request is already re-authed — no user-visible re-login.
ticket = await mintGatewayWsTicket(baseUrl)
} catch (error) {
const err = new Error(
@ -3702,7 +3732,7 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'oauth',
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
@ -3710,8 +3740,6 @@ async function resolveRemoteBackend() {
}
}
const token = decryptDesktopSecret(config.remote?.token)
if (!token) {
throw new Error(
'Remote Hermes gateway is selected, but no session token is saved. ' +
@ -3722,13 +3750,54 @@ async function resolveRemoteBackend() {
return {
baseUrl,
mode: 'remote',
source: 'settings',
source,
authMode: 'token',
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
// 3. global remote (connection.json `mode: 'remote'`)
// A null/empty profile resolves the env/global remote, so legacy callers and
// the connection test (which pass no profile) are unchanged.
async function resolveRemoteBackend(profile) {
const config = readDesktopConnectionConfig()
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
}
// 2. Env override (global, token-auth only).
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode !== 'remote') {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
}
async function probeRemoteAuthMode(rawUrl) {
// Determine how a remote gateway expects callers to authenticate, WITHOUT
// sending any credentials. ``/api/status`` is public on every Hermes
@ -3796,6 +3865,11 @@ async function probeRemoteAuthMode(rawUrl) {
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/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;
@ -3803,17 +3877,17 @@ async function testDesktopConnectionConfig(input = {}) {
let baseUrl
let token = null
let authMode = 'token'
if (config.mode === 'remote') {
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
if (wantRemote && block?.url) {
baseUrl = normalizeRemoteBaseUrl(block.url)
authMode = normAuthMode(block.authMode)
if (authMode !== 'oauth') {
token = decryptDesktopSecret(config.remote.token)
token = decryptDesktopSecret(block.token)
}
} else {
const remote = (await resolveRemoteBackend()) || (await startHermes())
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
baseUrl = remote.baseUrl
token = remote.token
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
authMode = normAuthMode(remote.authMode)
}
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
@ -3985,10 +4059,21 @@ function startPoolIdleReaper() {
// local-spawn portion of startHermes() but without the boot-progress UI,
// bootstrap, or remote handling (those belong to the primary backend only).
async function spawnPoolBackend(profile, entry) {
// Remote deployments are single-tenant; profiles only apply to local backends.
const remote = await resolveRemoteBackend()
// A profile may point at its OWN remote backend (connection.json
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
// In either case there is no local child to spawn — we just verify the
// remote is reachable and hand back its connection descriptor. The pool
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
// tolerate.
const remote = await resolveRemoteBackend(profile)
if (remote) {
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
await waitForHermes(remote.baseUrl, remote.token)
return {
...remote,
profile,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
const port = await pickPort()
@ -4089,7 +4174,9 @@ async function startHermes() {
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
const remote = await resolveRemoteBackend()
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
@ -4426,7 +4513,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
})
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:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
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) => {
@ -4450,16 +4539,27 @@ ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config)
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
await teardownPrimaryBackendAndWait()
const key = connectionScopeKey(payload?.profile)
mainWindow?.reload()
return sanitizeDesktopConnectionConfig(config)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
// re-resolves against the new remote/local target.
stopPoolBackend(key)
} else {
// Global connection, or the primary profile's connection: re-home the
// window backend by tearing it down and reloading the renderer.
await teardownPrimaryBackendAndWait()
mainWindow?.reload()
}
return sanitizeDesktopConnectionConfig(config, payload?.profile)
})
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))

View file

@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
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),

View file

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@ -6,6 +7,7 @@ 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'
import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
@ -74,6 +76,23 @@ function ModeCard({
)
}
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
return (
<button
className={cn(
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
active
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
{label}
</button>
)
}
export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -83,6 +102,16 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
const [scope, setScope] = useState<null | string>(null)
const profiles = useStore($profiles)
useEffect(() => {
void refreshActiveProfile()
}, [])
// 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).
@ -100,8 +129,14 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}
setLoading(true)
// Clear scope-local entry state so a token from one scope can't leak into
// the next when switching profiles.
setRemoteToken('')
setLastTest(null)
desktop
.getConnectionConfig()
.getConnectionConfig(scope)
.then(config => {
if (cancelled) {
return
@ -117,7 +152,7 @@ export function GatewaySettings() {
})
return () => void (cancelled = true)
}, [])
}, [scope])
// 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
@ -223,6 +258,10 @@ export function GatewaySettings() {
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
// The 'default' profile uses the global ("All profiles") connection, so the
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@ -239,6 +278,7 @@ export function GatewaySettings() {
const payload = () => ({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@ -296,6 +336,7 @@ export function GatewaySettings() {
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
@ -305,7 +346,7 @@ export function GatewaySettings() {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
@ -327,7 +368,7 @@ export function GatewaySettings() {
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
@ -357,6 +398,7 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
profile: scope ?? undefined,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
@ -395,10 +437,35 @@ export function GatewaySettings() {
</div>
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
its own remote host.
</p>
</div>
{namedProfiles.length > 0 ? (
<div className="mb-5 grid gap-2">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
Applies to
</div>
<div className="flex flex-wrap gap-1.5">
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
{namedProfiles.map(profile => (
<ScopeChip
active={scope === profile.name}
key={profile.name}
label={profile.name}
onSelect={() => setScope(profile.name)}
/>
))}
</div>
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{scope === null
? 'Default connection for every profile that has no override of its own.'
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
</p>
</div>
) : null}
{state.envOverride ? (
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />

View file

@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
return {
envOverride: false,
mode: 'remote',
profile: null,
remoteAuthMode: 'oauth',
remoteOauthConnected: false,
remoteTokenPreview: null,

View file

@ -12,7 +12,7 @@ declare global {
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string