diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 282e3a1916559..9adecb9d5bb3c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5896,7 +5896,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL) const savedMode = key ? scoped?.mode : config.mode const ssh = savedMode === 'ssh' ? normalizeSshConfig(block) : null - const savedSsh = savedMode === 'local' && key ? savedProfileSsh(config, key) : null + const savedSsh = savedMode === 'local' + ? key ? savedProfileSsh(config, key) : normalizeSshConfig(block) + : null const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') @@ -6388,7 +6390,9 @@ async function resolveRemoteBackend(profile) { if (override) { const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token) - return buildRemoteConnection(override.url, override.authMode, token, 'profile') + return buildRemoteConnection( + override.url, override.authMode, token, 'profile', undefined, config.profiles?.[connectionScopeKey(profile)]?.mode === 'cloud' ? 'cloud' : 'url' + ) } // 2. Env override (global, token-auth only). @@ -6422,7 +6426,9 @@ async function resolveRemoteBackend(profile) { const authMode = normAuthMode(config.remote?.authMode) const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token) - return buildRemoteConnection(config.remote?.url, authMode, token, 'settings') + return buildRemoteConnection( + config.remote?.url, authMode, token, 'settings', undefined, config.mode === 'cloud' ? 'cloud' : 'url' + ) } // A remote profile's sessions live on its remote host's state.db, not on a local diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 6c1ebc5bf6424..88f0aa4907387 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -40,6 +40,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { 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), + sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'), + sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host), 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), diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index f7743203e5dc6..14dd680762f8c 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,11 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' -import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' +import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw, Terminal } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -15,8 +16,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' +import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' -type Mode = 'local' | 'remote' | 'cloud' +type Mode = 'local' | 'remote' | 'cloud' | 'ssh' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' // Hermes Cloud discovery lifecycle for the cloud-mode panel. @@ -31,8 +33,15 @@ interface GatewaySettingsState { remoteTokenSet: boolean remoteUrl: string cloudOrg: string + sshHost: string + sshUser: string + sshPort: number | null + sshKeyPath: string + sshRemoteHermesPath: string } +const SSH_HOST_CUSTOM = '__custom__' + const EMPTY_STATE: GatewaySettingsState = { envOverride: false, mode: 'local', @@ -41,7 +50,12 @@ const EMPTY_STATE: GatewaySettingsState = { remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: '', - cloudOrg: '' + cloudOrg: '', + sshHost: '', + sshUser: '', + sshPort: null, + sshKeyPath: '', + sshRemoteHermesPath: '' } function ModeCard({ @@ -124,6 +138,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const [state, setState] = useState(EMPTY_STATE) const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(null) + const [sshHostSuggestions, setSshHostSuggestions] = useState([]) + const [sshCustomHost, setSshCustomHost] = useState(false) + const sshResolveSeq = useRef(0) + const sshTestSeq = useRef(0) + const saveSeq = useRef(0) + const signingSeq = useRef(0) + const cloudConnectSeq = useRef(0) + const contextSeq = useRef(0) // --- Hermes Cloud (cloud mode) state --- // One portal session powers discovery + the silent per-agent cascade. These @@ -328,6 +350,30 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // per-profile scopes are the named, non-default profiles. const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles]) + useEffect(() => { + setSshCustomHost(Boolean(state.sshHost && !sshHostSuggestions.includes(state.sshHost))) + }, [state.sshHost, sshHostSuggestions]) + + useEffect(() => { + if (state.mode !== 'ssh' || !window.hermesDesktop?.sshConfigHosts) return + let cancelled = false + void window.hermesDesktop.sshConfigHosts().then(result => { + if (!cancelled) setSshHostSuggestions(result.hosts) + }).catch(() => { + if (!cancelled) setSshHostSuggestions([]) + }) + return () => void (cancelled = true) + }, [state.mode]) + + useEffect(() => { + contextSeq.current += 1 + sshTestSeq.current += 1 + saveSeq.current += 1 + signingSeq.current += 1 + cloudConnectSeq.current += 1 + setLastTest(null) + }, [scope, state.mode, state.sshHost, state.sshUser, state.sshPort, state.sshKeyPath, state.sshRemoteHermesPath]) + const oauthConnected = state.remoteOauthConnected const canUseRemote = useMemo(() => { @@ -347,10 +393,16 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { profile: scope ?? undefined, remoteAuthMode: authMode, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + remoteUrl: trimmedUrl, + sshHost: state.sshHost.trim(), + sshUser: state.sshUser.trim() || undefined, + sshPort: state.sshPort, + sshKeyPath: state.sshKeyPath.trim() || undefined, + sshRemoteHermesPath: state.sshRemoteHermesPath.trim() }) const save = async (apply: boolean) => { + const seq = ++saveSeq.current if (state.mode === 'remote' && !canUseRemote) { notify({ kind: 'warning', @@ -367,6 +419,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const next = apply ? await window.hermesDesktop.applyConnectionConfig(payload()) : await window.hermesDesktop.saveConnectionConfig(payload()) + if (seq !== saveSeq.current) return setState(next) setRemoteToken('') @@ -376,9 +429,28 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { message: apply ? g.restartingMessage : g.savedMessage }) } catch (err) { - notifyError(err, apply ? g.applyFailed : g.saveFailed) + if (seq !== saveSeq.current) return + const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : '' + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired + } + if (state.mode === 'ssh' && sshError) { + notify({ + kind: 'error', + title: apply ? g.applyFailed : g.saveFailed, + message: (errors as Record)[sshError] || g.sshErrUnknown + }) + } else { + notifyError(err, apply ? g.applyFailed : g.saveFailed) + } } finally { - setSaving(false) + if (seq === saveSeq.current) setSaving(false) } } @@ -386,6 +458,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // 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 () => { + const seq = ++signingSeq.current if (!trimmedUrl) { notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst }) @@ -403,10 +476,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteAuthMode: 'oauth', remoteUrl: trimmedUrl }) + if (seq !== signingSeq.current) return setState(saved) const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl) + if (seq !== signingSeq.current) return if (result.connected) { const refreshed = await window.hermesDesktop.getConnectionConfig(scope) @@ -420,24 +495,26 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { }) } } catch (err) { - notifyError(err, g.signInFailed) + if (seq === signingSeq.current) notifyError(err, g.signInFailed) } finally { - setSigningIn(false) + if (seq === signingSeq.current) setSigningIn(false) } } const signOut = async () => { + const seq = ++signingSeq.current setSigningIn(true) try { await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined) const refreshed = await window.hermesDesktop.getConnectionConfig(scope) + if (seq !== signingSeq.current) return setState(refreshed) notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) notifyError(err, g.signOutFailed) } finally { - setSigningIn(false) + if (seq === signingSeq.current) setSigningIn(false) } } @@ -449,6 +526,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // needsOrgSelection we surface the org list and show a picker instead. const discoverCloud = async (org?: string) => { const desktop = window.hermesDesktop + const seq = contextSeq.current if (!desktop?.cloud) { return @@ -458,6 +536,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.discover(org) + if (seq !== contextSeq.current) return if ('needsOrgSelection' in result && result.needsOrgSelection) { // Multi-org user with no org chosen yet: show the picker. Don't clear a @@ -486,6 +565,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('done') } catch (err) { + if (seq !== contextSeq.current) return setCloudAgents([]) setCloudDiscover('error') @@ -570,6 +650,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const cloudSignIn = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -579,20 +660,22 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.login() + if (seq !== signingSeq.current) return setCloudSignedIn(result.signedIn) if (result.signedIn) { await discoverCloud() } } catch (err) { - notifyError(err, g.cloudSignInFailed) + if (seq === signingSeq.current) notifyError(err, g.cloudSignInFailed) } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) setCloudSigningIn(false) } } const cloudSignOut = async () => { const desktop = window.hermesDesktop + const seq = ++signingSeq.current if (!desktop?.cloud) { return @@ -602,6 +685,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { await desktop.cloud.logout() + if (seq !== signingSeq.current) return setCloudSignedIn(false) setCloudAgents([]) setCloudOrgs([]) @@ -609,9 +693,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setCloudDiscover('idle') notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) } catch (err) { - notifyError(err, g.signOutFailed) + if (seq === signingSeq.current) notifyError(err, g.signOutFailed) } finally { - setCloudSigningIn(false) + if (seq === signingSeq.current) setCloudSigningIn(false) } } @@ -619,6 +703,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { // prompt — the shared portal session auto-approves), then persist a cloud-mode // connection pointed at its dashboardUrl and apply it (soft-reconnects in place). const connectCloudAgent = async (agent: DesktopCloudAgent) => { + const seq = contextSeq.current if (!agent.dashboardUrl) { return } @@ -633,6 +718,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { try { const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + if (seq !== contextSeq.current) return if (!result.connected) { notify({ @@ -655,21 +741,81 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteUrl: agent.dashboardUrl, cloudOrg: cloudOrgRef.current ?? undefined }) + if (seq !== contextSeq.current) return setState(next) notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) } catch (err) { + if (seq !== contextSeq.current) return if (err && typeof err === 'object' && 'needsCloudLogin' in err) { setCloudSignedIn(false) } notifyError(err, g.cloudConnectFailed) } finally { - setCloudConnectingId(null) + if (seq === contextSeq.current) setCloudConnectingId(null) + } + } + + const resolveSshHost = async (host: string) => { + if (!host || !window.hermesDesktop?.sshResolveHost) return + const seq = ++sshResolveSeq.current + try { + const resolved = await window.hermesDesktop.sshResolveHost(host) + if (seq !== sshResolveSeq.current) return + setState(current => enrichSelectedSshHost(current, host, resolved)) + } catch { + return + } + } + + const selectHost = (value: string) => { + if (value === SSH_HOST_CUSTOM) { + setSshCustomHost(true) + setState(current => selectSshHost(current, '')) + return + } + setSshCustomHost(false) + setState(current => selectSshHost(current, value)) + void resolveSshHost(value) + } + + const testSsh = async () => { + const seq = ++sshTestSeq.current + if (!state.sshHost.trim()) { + notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost }) + return + } + setTesting(true) + setLastTest(null) + try { + const result = await window.hermesDesktop.testConnectionConfig(payload()) + if (seq !== sshTestSeq.current) return + if (!result.reachable) { + const errors = { + 'auth-failed': g.sshErrAuth, + 'hermes-not-found': g.sshErrNotInstalled, + 'host-key-changed': g.sshErrHostKey, + timeout: g.sshErrTimeout, + unreachable: g.sshErrUnreachable, + 'unsupported-platform': g.sshErrPlatform, + 'update-required': g.sshErrUpdateRequired, + unknown: g.sshErrUnknown + } + throw new Error(errors[result.sshError || 'unknown'] || result.error || g.sshErrUnknown) + } + const message = g.sshReachable(result.host || state.sshHost, result.remotePlatform || '?') + setLastTest(message) + notify({ kind: 'success', title: g.reachableTitle, message }) + } catch (err) { + if (seq === sshTestSeq.current) notifyError(err, g.testFailed) + } finally { + if (seq === sshTestSeq.current) setTesting(false) } } const testRemote = async () => { + const seq = ++sshTestSeq.current if (!canUseRemote) { notify({ kind: 'warning', @@ -691,14 +837,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, remoteUrl: trimmedUrl }) + if (seq !== sshTestSeq.current) return - const message = g.connectedTo(result.baseUrl, result.version ?? undefined) + const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined) setLastTest(message) notify({ kind: 'success', title: g.reachableTitle, message }) } catch (err) { - notifyError(err, g.testFailed) + if (seq === sshTestSeq.current) notifyError(err, g.testFailed) } finally { - setTesting(false) + if (seq === sshTestSeq.current) setTesting(false) } } @@ -761,7 +908,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
{g.modeTitle}
-
+
setState(current => ({ ...current, mode: 'remote' }))} title={g.remoteTitle} /> + setState(current => ({ ...current, mode: 'ssh' }))} + title={g.sshTitle} + />
@@ -1024,6 +1180,36 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { ) : null} + {state.mode === 'ssh' && !state.envOverride ? ( +
+ {sshHostSuggestions.length > 0 && !sshCustomHost ? ( + + + + {sshHostSuggestions.map(host => {host})} + {g.sshHostCustom} + + + } + description={g.sshHostPickDesc} + title={g.sshHostPickTitle} + /> + ) : ( + void resolveSshHost(state.sshHost)} onChange={event => setState(current => selectSshHost(current, event.target.value))} value={state.sshHost} />} + description={g.sshHostDesc} + title={g.sshHostTitle} + /> + )} + setState(current => ({ ...current, sshUser: event.target.value }))} placeholder={g.sshUserPlaceholder} value={state.sshUser} />} description={g.sshUserDesc} title={g.sshUserTitle} /> + setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))} placeholder="22" value={state.sshPort ?? ''} />} description={g.sshPortDesc} title={g.sshPortTitle} /> + setState(current => ({ ...current, sshKeyPath: event.target.value }))} value={state.sshKeyPath} />} description={g.sshKeyDesc} title={g.sshKeyTitle} /> + setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} placeholder={g.sshHermesPathPlaceholder} value={state.sshRemoteHermesPath} />} description={g.sshHermesPathDesc} title={g.sshHermesPathTitle} /> +
+ ) : null} + {lastTest ?
{lastTest}
: null} {/* Test/Save apply to local + remote. Cloud connects via the agent picker @@ -1042,6 +1228,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {testing ? : null} {g.testRemote} + ) : state.mode === 'ssh' ? ( + ) : null} {embedded ? null : (