From 195d4557fc1cd8b92534cda05f01e517d122360b Mon Sep 17 00:00:00 2001 From: yoniebans Date: Wed, 15 Jul 2026 16:39:12 +0200 Subject: [PATCH] feat(desktop): add SSH to Gateway settings and recovery Expose typed SSH discovery IPC, compose SSH alongside Local, Cloud, and Remote URL modes, preserve embedded recovery and soft switching, add stable host selection, status identity, first-contact trust disclosure, and four-locale copy. --- apps/desktop/electron/main.ts | 12 +- apps/desktop/electron/preload.ts | 2 + .../src/app/settings/gateway-settings.tsx | 229 ++++++++++++++++-- .../app/settings/ssh-host-selection.test.ts | 44 ++++ .../src/app/settings/ssh-host-selection.ts | 37 +++ .../app/shell/hooks/use-statusbar-items.tsx | 18 +- .../src/components/boot-failure-overlay.tsx | 7 +- .../components/boot-failure-reauth.test.ts | 18 +- .../src/components/boot-failure-reauth.ts | 27 +++ apps/desktop/src/global.d.ts | 49 +++- apps/desktop/src/i18n/en.ts | 44 +++- apps/desktop/src/i18n/ja.ts | 44 +++- apps/desktop/src/i18n/types.ts | 38 +++ apps/desktop/src/i18n/zh-hant.ts | 44 +++- apps/desktop/src/i18n/zh.ts | 44 +++- 15 files changed, 622 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/app/settings/ssh-host-selection.test.ts create mode 100644 apps/desktop/src/app/settings/ssh-host-selection.ts 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 : (