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:
@@ -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" />
|
||||
|
||||
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
return {
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
profile: null,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
|
||||
Vendored
+7
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user