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.
This commit is contained in:
yoniebans 2026-07-15 16:39:12 +02:00
parent f003d888e1
commit 195d4557fc
15 changed files with 622 additions and 35 deletions

View File

@ -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

View File

@ -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),

View File

@ -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<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
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<string, string>)[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 } = {
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.modeTitle}
</div>
<div className="grid auto-rows-fr grid-cols-1 gap-2 min-[42rem]:grid-cols-3">
<div className="grid auto-rows-fr grid-cols-1 gap-2 sm:grid-cols-2 min-[72rem]:grid-cols-4">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@ -787,6 +934,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
<ModeCard
active={state.mode === 'ssh'}
description={g.sshDesc}
disabled={state.envOverride}
hint={g.sshTrustHint}
icon={Terminal}
onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}
title={g.sshTitle}
/>
</div>
</div>
@ -1024,6 +1180,36 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
</div>
) : null}
{state.mode === 'ssh' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
{sshHostSuggestions.length > 0 && !sshCustomHost ? (
<ListRow
action={
<Select value={sshHostSuggestions.includes(state.sshHost) ? state.sshHost : SSH_HOST_CUSTOM} onValueChange={selectHost}>
<SelectTrigger className={cn('h-8', CONTROL_TEXT)}><SelectValue placeholder={g.sshHostPick} /></SelectTrigger>
<SelectContent>
{sshHostSuggestions.map(host => <SelectItem key={host} value={host}>{host}</SelectItem>)}
<SelectItem value={SSH_HOST_CUSTOM}>{g.sshHostCustom}</SelectItem>
</SelectContent>
</Select>
}
description={g.sshHostPickDesc}
title={g.sshHostPickTitle}
/>
) : (
<ListRow
action={<Input className={cn('h-8', CONTROL_TEXT)} onBlur={() => void resolveSshHost(state.sshHost)} onChange={event => setState(current => selectSshHost(current, event.target.value))} value={state.sshHost} />}
description={g.sshHostDesc}
title={g.sshHostTitle}
/>
)}
<ListRow action={<Input className={cn('h-8', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))} placeholder={g.sshUserPlaceholder} value={state.sshUser} />} description={g.sshUserDesc} title={g.sshUserTitle} />
<ListRow action={<Input className={cn('h-8', CONTROL_TEXT)} inputMode="numeric" onChange={event => setState(current => ({ ...current, sshPort: event.target.value ? Number(event.target.value) : null }))} placeholder="22" value={state.sshPort ?? ''} />} description={g.sshPortDesc} title={g.sshPortTitle} />
<ListRow action={<Input className={cn('h-8 font-mono', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))} value={state.sshKeyPath} />} description={g.sshKeyDesc} title={g.sshKeyTitle} />
<ListRow action={<Input className={cn('h-8 font-mono', CONTROL_TEXT)} onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))} placeholder={g.sshHermesPathPlaceholder} value={state.sshRemoteHermesPath} />} description={g.sshHermesPathDesc} title={g.sshHermesPathTitle} />
</div>
) : null}
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : 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 ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : state.mode === 'ssh' ? (
<Button className="mr-auto" disabled={testing || !state.sshHost.trim()} onClick={() => void testSsh()} size="sm" variant="text">
{testing ? <Loader2 className="animate-spin" /> : null}
{g.sshTestConnection}
</Button>
) : null}
{embedded ? null : (
<Button

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
const state = {
mode: 'ssh',
sshHost: 'linux-box',
sshUser: 'operator',
sshPort: 2222,
sshKeyPath: '/keys/linux',
sshRemoteHermesPath: '/opt/hermes'
}
describe('selectSshHost', () => {
it('clears host-specific fields when the selected host changes', () => {
expect(selectSshHost(state, 'mac-box')).toEqual({
mode: 'ssh',
sshHost: 'mac-box',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
})
})
it('preserves state when reselecting the same host', () => {
expect(selectSshHost(state, state.sshHost)).toBe(state)
})
it('enriches only the host that produced the ssh config result', () => {
const selected = selectSshHost(state, 'mac-box')
expect(enrichSelectedSshHost(selected, 'mac-box', {
identityFile: '~/.ssh/id_ed25519',
port: 22,
user: 'hermes'
})).toMatchObject({
sshHost: 'mac-box',
sshUser: 'hermes',
sshPort: null,
sshKeyPath: '~/.ssh/id_ed25519'
})
expect(enrichSelectedSshHost(state, 'mac-box', { user: 'wrong' })).toBe(state)
})
})

View File

@ -0,0 +1,37 @@
type SshHostState = {
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
type ResolvedSshHost = {
identityFile?: string | null
port?: number | null
user?: string | null
}
function selectSshHost<T extends SshHostState>(state: T, host: string): T {
if (host === state.sshHost) return state
return {
...state,
sshHost: host,
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
}
}
function enrichSelectedSshHost<T extends SshHostState>(state: T, host: string, resolved: ResolvedSshHost): T {
if (state.sshHost !== host) return state
return {
...state,
sshUser: state.sshUser || resolved.user || '',
sshPort: state.sshPort ?? (resolved.port === 22 ? null : resolved.port ?? null),
sshKeyPath: state.sshKeyPath || resolved.identityFile || ''
}
}
export { enrichSelectedSshHost, selectSshHost }

View File

@ -37,7 +37,7 @@ import {
} from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
function workspaceLabel(cwd: string): string {
@ -245,8 +245,23 @@ export function useStatusbarItems({
copy
])
const connectionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote' || !connection.remoteHost) return null
const ssh = connection.remoteKind === 'ssh'
const cloud = connection.remoteKind === 'cloud'
return {
className: cn('px-2 -ml-1 font-medium', ssh ? 'bg-primary text-primary-foreground' : 'bg-accent text-accent-foreground'),
icon: <Terminal className="size-3" />,
id: 'connection',
label: ssh ? copy.connectionSsh(connection.remoteHost) : cloud ? copy.connectionCloud(connection.remoteHost) : copy.connectionRemote(connection.remoteHost),
title: ssh ? copy.connectionSshTooltip(connection.remoteHost) : cloud ? copy.connectionCloudTooltip(connection.remoteHost) : copy.connectionRemoteTooltip(connection.remoteHost),
to: `${SETTINGS_ROUTE}?tab=gateway`
}
}, [connection?.mode, connection?.remoteHost, connection?.remoteKind, copy])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
...(connectionItem ? [connectionItem] : []),
{
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
@ -339,6 +354,7 @@ export function useStatusbarItems({
[
agentsOpen,
commandCenterOpen,
connectionItem,
copy,
currentCwd,
fileMenu.copyPath,

View File

@ -13,7 +13,7 @@ import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
import type { RemoteReauth } from './boot-failure-reauth'
import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
import { deriveProviderShape, isRemoteConfig, isRemoteReauthFailure, signInLabel, sshFailureMessage } from './boot-failure-reauth'
// The recovery "Gateway settings" view embeds the real Settings → Gateway panel
// (identical URL/auth/test/save controls — no parallel form to drift). Lazy so
@ -44,6 +44,7 @@ export function BootFailureOverlay() {
const [logs, setLogs] = useState<string[]>([])
const [showLogs, setShowLogs] = useState(false)
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
const [connectionConfig, setConnectionConfig] = useState<DesktopConnectionConfig | null>(null)
// A remote/cloud backend that failed to boot is fixable from gateway settings,
// so the escape hatch earns emphasis (local failures keep it as a quiet ghost).
const [remoteFailure, setRemoteFailure] = useState(false)
@ -75,6 +76,7 @@ export function BootFailureOverlay() {
useEffect(() => {
if (!visible) {
setRemoteReauth(null)
setConnectionConfig(null)
setRemoteFailure(false)
setView('recovery')
@ -102,6 +104,7 @@ export function BootFailureOverlay() {
return
}
setConnectionConfig(config)
setRemoteFailure(isRemoteConfig(config))
if (!isRemoteReauthFailure(config, boot.error)) {
@ -301,7 +304,7 @@ export function BootFailureOverlay() {
<div className="grid gap-4 p-5 pt-0">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
{boot.error}
{sshFailureMessage(connectionConfig, boot.error, t.settings.gateway)}
</div>
<div className="grid gap-2">

View File

@ -7,7 +7,8 @@ import {
isRemoteConfig,
isRemoteReauthError,
isRemoteReauthFailure,
signInLabel
signInLabel,
sshFailureMessage
} from './boot-failure-reauth'
function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
@ -21,6 +22,11 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
cloudOrg: '',
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
...overrides
}
}
@ -104,6 +110,16 @@ describe('isRemoteReauthError', () => {
})
})
describe('sshFailureMessage', () => {
it('localizes SSH failures without changing non-SSH errors', () => {
const copy = { sshErrAuth: 'localized auth', sshErrUnknown: 'localized unknown' }
const ssh = config({ mode: 'ssh', sshHost: 'box', remoteUrl: '' })
expect(sshFailureMessage(ssh, 'SSH authentication failed', copy)).toBe('localized auth')
expect(sshFailureMessage(ssh, 'unexpected failure', copy)).toBe('localized unknown')
expect(sshFailureMessage(config(), 'raw remote error', copy)).toBe('raw remote error')
})
})
describe('deriveProviderShape', () => {
it('generic copy when there are no providers', () => {
expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' })

View File

@ -58,6 +58,33 @@ export function isRemoteReauthError(error: string | null | undefined): boolean {
// — see isRemoteReauthError). Only re-establishing the remote session fixes it;
// the local Retry/Repair buttons can't. 'cloud' counts as remote (it resolves to
// a remote oauth backend), so a lapsed cloud session is the same failure.
export function sshFailureMessage(
config: DesktopConnectionConfig | null | undefined,
error: string | null | undefined,
copy: {
sshErrAuth?: string
sshErrHostKey?: string
sshErrNotInstalled?: string
sshErrPlatform?: string
sshErrTimeout?: string
sshErrUpdateRequired?: string
sshErrUnreachable?: string
sshErrUnknown?: string
}
): string {
const raw = String(error || '')
if (config?.mode !== 'ssh') return raw
const text = raw.toLowerCase()
if (text.includes('host key')) return copy.sshErrHostKey || raw
if (text.includes('auth')) return copy.sshErrAuth || raw
if (text.includes('not installed') || text.includes('not found')) return copy.sshErrNotInstalled || raw
if (text.includes('unsupported')) return copy.sshErrPlatform || raw
if (text.includes('timed out') || text.includes('timeout')) return copy.sshErrTimeout || raw
if (text.includes('update')) return copy.sshErrUpdateRequired || raw
if (text.includes('unreachable') || text.includes('could not reach')) return copy.sshErrUnreachable || raw
return copy.sshErrUnknown || raw
}
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined, error?: string | null): boolean {
return (
isRemoteConfig(config) &&

View File

@ -52,6 +52,8 @@ declare global {
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
sshConfigHosts: () => Promise<DesktopSshHostsResult>
sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
@ -423,7 +425,7 @@ export interface DesktopConnectionConfig {
// remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into
// the cloud picker. Resolution treats cloud exactly as remote
// (cloud-auto-discovery Q3/Q6).
mode: 'local' | 'remote' | 'cloud'
mode: 'local' | 'remote' | 'cloud' | 'ssh'
// 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
@ -436,10 +438,15 @@ export interface DesktopConnectionConfig {
// connected instance was discovered under, so Settings → Gateway can reopen
// into that org. Empty string for remote/local.
cloudOrg: string
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote' | 'cloud'
mode: 'local' | 'remote' | 'cloud' | 'ssh'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
@ -449,12 +456,44 @@ export interface DesktopConnectionConfigInput {
// For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to
// persist so Settings can reopen into it. Ignored for remote/local modes.
cloudOrg?: string
sshHost?: string
sshUser?: string
sshPort?: number | null
sshKeyPath?: string
sshRemoteHermesPath?: string
}
export interface DesktopConnectionTestResult {
baseUrl: string
ok: boolean
version: string | null
baseUrl?: string
ok?: boolean
version?: string | null
reachable?: boolean
sshError?:
| 'auth-failed'
| 'hermes-not-found'
| 'host-key-changed'
| 'timeout'
| 'unreachable'
| 'unsupported-platform'
| 'update-required'
| 'unknown'
| null
error?: string | null
host?: string
remoteHermesPath?: string
remoteHermesVersion?: string
remotePlatform?: string
}
export interface DesktopSshResolveResult {
hostname: string | null
identityFile: string | null
port: number | null
user: string | null
}
export interface DesktopSshHostsResult {
hosts: string[]
}
export interface DesktopAuthProvider {

View File

@ -619,7 +619,43 @@ export const en: Translations = {
signOutFailed: 'Sign-out failed',
testFailed: 'Remote gateway test failed',
applyFailed: 'Could not apply gateway settings',
saveFailed: 'Could not save gateway settings'
saveFailed: 'Could not save gateway settings',
sshTitle: 'Connect via SSH',
sshDesc:
'Hermes is launched on the remote over SSH and tunneled to this app — nothing to start or expose yourself. Requires working key-based SSH access to the host.',
sshTrustHint: 'The first presented host key is trusted and pinned; later changes fail closed.',
sshHostTitle: 'Host',
sshHostDesc: 'user@host, or a Host alias from ~/.ssh/config.',
sshHostPick: 'Select a host…',
sshHostPickTitle: 'Host',
sshHostPickDesc: 'A Host alias from ~/.ssh/config, or Custom to type one.',
sshHostCustom: 'Custom (enter manually)…',
sshUserTitle: 'User',
sshUserDesc: 'Blank = ~/.ssh/config or your current user.',
sshUserPlaceholder: 'from ~/.ssh/config',
sshPortTitle: 'Port',
sshPortDesc: 'Blank = 22 or the ~/.ssh/config port.',
sshKeyTitle: 'Identity file',
sshKeyDesc: 'Private key path. Blank = ssh-agent or ~/.ssh/config.',
sshHermesPathTitle: 'Hermes path (optional)',
sshHermesPathDesc: 'Full path to the remote hermes binary. Blank = auto-detect.',
sshHermesPathPlaceholder: 'auto-detect',
sshTestConnection: 'Test SSH',
sshConnect: 'Connect',
sshButtonsHint: 'Save applies on the next launch. Connect reconnects now.',
sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`,
sshIncompleteHost: 'Enter an SSH host before connecting.',
sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.',
sshErrAuth:
'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.',
sshErrHostKey:
'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',
sshErrNotInstalled:
'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.',
sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux and macOS remote hosts only.',
sshErrTimeout: 'SSH connection timed out. The host may be unreachable or asleep.',
sshErrUpdateRequired: 'Update Hermes on the remote host before connecting with Desktop SSH.',
sshErrUnknown: 'SSH connection failed.'
},
keys: {
loading: 'Loading API keys and credentials...',
@ -2092,6 +2128,12 @@ export const en: Translations = {
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `Backend v${version}`,
clientLabel: version => `client v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `Remote: ${host}`,
connectionCloud: host => `Cloud: ${host}`,
connectionCloudTooltip: host => `Connected to Hermes Cloud at ${host} · click to manage`,
connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`,
connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`,
backendLabel: version => `backend v${version}`,
commit: sha => `commit ${sha}`,
branch: branch => `branch ${branch}`,

View File

@ -704,7 +704,43 @@ export const ja = defineLocale({
signOutFailed: 'サインアウトに失敗しました',
testFailed: 'リモートゲートウェイのテストに失敗しました',
applyFailed: 'ゲートウェイ設定を適用できませんでした',
saveFailed: 'ゲートウェイ設定を保存できませんでした'
saveFailed: 'ゲートウェイ設定を保存できませんでした',
sshTitle: 'SSH で接続',
sshDesc:
'Hermes は SSH 経由でリモート上に起動され、このアプリにトンネルされます。リモート側で何かを起動・公開する必要はありません。ホストへの鍵ベースの SSH アクセスが前提です。',
sshTrustHint: '初回に提示されたホスト鍵を信頼して固定し、以後の変更は拒否します。',
sshHostTitle: 'ホスト',
sshHostDesc: 'user@host、または ~/.ssh/config の Host エイリアス。',
sshHostPick: 'ホストを選択…',
sshHostPickTitle: 'ホスト',
sshHostPickDesc: '~/.ssh/config の Host エイリアス、または「カスタム」で手入力。',
sshHostCustom: 'カスタム(手入力)…',
sshUserTitle: 'ユーザー',
sshUserDesc: '空欄 = ~/.ssh/config または現在のユーザー。',
sshUserPlaceholder: '~/.ssh/config から',
sshPortTitle: 'ポート',
sshPortDesc: '空欄 = 22 または ~/.ssh/config のポート。',
sshKeyTitle: '鍵ファイル',
sshKeyDesc: '秘密鍵のパス。空欄 = ssh-agent または ~/.ssh/config。',
sshHermesPathTitle: 'Hermes パス(任意)',
sshHermesPathDesc: 'リモートの hermes バイナリへのフルパス。空欄 = 自動検出。',
sshHermesPathPlaceholder: '自動検出',
sshTestConnection: 'SSH をテスト',
sshConnect: '接続',
sshButtonsHint: '「保存」は次回起動時に適用され、「接続」は今すぐ再接続します。',
sshReachable: (host, platform) => `接続可能: ${host}${platform})— Hermes を検出`,
sshIncompleteHost: '接続する前に SSH ホストを入力してください。',
sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。',
sshErrAuth:
'SSH 認証に失敗しました。鍵を ssh-agent に読み込むssh-addか、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。',
sshErrHostKey:
'前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',
sshErrNotInstalled:
'リモートホストに Hermes がインストールされていません。リモートでインストールするcurl -fsSL https://hermes-agent.nousresearch.com/install.sh | shか、Hermes パスを設定してください。',
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux と macOS のリモートホストのみ対応しています。',
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
sshErrUpdateRequired: 'Desktop SSH で接続する前に、リモートホストの Hermes を更新してください。',
sshErrUnknown: 'SSH 接続に失敗しました。'
},
keys: {
loading: 'API キーと認証情報を読み込み中...',
@ -2033,6 +2069,12 @@ export const ja = defineLocale({
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `バックエンド v${version}`,
clientLabel: version => `クライアント v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `リモート: ${host}`,
connectionCloud: host => `クラウド: ${host}`,
connectionCloudTooltip: host => `Hermes Cloud ${host} に接続中 · クリックして管理`,
connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`,
connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`,
backendLabel: version => `バックエンド v${version}`,
commit: sha => `コミット ${sha}`,
branch: branch => `ブランチ ${branch}`,

View File

@ -525,6 +525,38 @@ export interface Translations {
testFailed: string
applyFailed: string
saveFailed: string
sshTitle: string
sshDesc: string
sshTrustHint: string
sshHostTitle: string
sshHostDesc: string
sshHostPick: string
sshHostPickTitle: string
sshHostPickDesc: string
sshHostCustom: string
sshUserTitle: string
sshUserDesc: string
sshUserPlaceholder: string
sshPortTitle: string
sshPortDesc: string
sshKeyTitle: string
sshKeyDesc: string
sshHermesPathTitle: string
sshHermesPathDesc: string
sshHermesPathPlaceholder: string
sshTestConnection: string
sshConnect: string
sshButtonsHint: string
sshReachable: (host: string, platform: string) => string
sshIncompleteHost: string
sshErrUnreachable: string
sshErrAuth: string
sshErrHostKey: string
sshErrNotInstalled: string
sshErrPlatform: string
sshErrTimeout: string
sshErrUpdateRequired: string
sshErrUnknown: string
}
keys: {
loading: string
@ -1723,6 +1755,12 @@ export interface Translations {
desktopVersion: (version: string) => string
backendVersion: (version: string) => string
clientLabel: (version: string) => string
connectionSsh: (host: string) => string
connectionRemote: (host: string) => string
connectionCloud: (host: string) => string
connectionCloudTooltip: (host: string) => string
connectionSshTooltip: (host: string) => string
connectionRemoteTooltip: (host: string) => string
backendLabel: (version: string) => string
commit: (sha: string) => string
branch: (branch: string) => string

View File

@ -684,7 +684,43 @@ export const zhHant = defineLocale({
signOutFailed: '登出失敗',
testFailed: '遠端閘道測試失敗',
applyFailed: '無法套用閘道設定',
saveFailed: '無法儲存閘道設定'
saveFailed: '無法儲存閘道設定',
sshTitle: '透過 SSH 連線',
sshDesc:
'Hermes 會透過 SSH 在遠端啟動並以通道連線到本應用程式——無需自行啟動或公開任何服務。前提:已具備到該主機的金鑰 SSH 存取。',
sshTrustHint: '首次提供的主機金鑰會被信任並固定;後續變更將被拒絕。',
sshHostTitle: '主機',
sshHostDesc: 'user@host或 ~/.ssh/config 中的 Host 別名。',
sshHostPick: '選擇主機…',
sshHostPickTitle: '主機',
sshHostPickDesc: '~/.ssh/config 中的 Host 別名,或選擇「自訂」手動輸入。',
sshHostCustom: '自訂(手動輸入)…',
sshUserTitle: '使用者',
sshUserDesc: '留空 = ~/.ssh/config 或目前使用者。',
sshUserPlaceholder: '來自 ~/.ssh/config',
sshPortTitle: '連接埠',
sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的連接埠。',
sshKeyTitle: '金鑰檔案',
sshKeyDesc: '私密金鑰路徑。留空 = ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路徑(選用)',
sshHermesPathDesc: '遠端 hermes 執行檔的完整路徑。留空 = 自動偵測。',
sshHermesPathPlaceholder: '自動偵測',
sshTestConnection: '測試 SSH',
sshConnect: '連線',
sshButtonsHint: '「儲存」會在下次啟動時生效,「連線」則立即重新連線。',
sshReachable: (host, platform) => `可連線:${host}${platform})——已找到 Hermes`,
sshIncompleteHost: '連線前請輸入 SSH 主機。',
sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',
sshErrAuth:
'SSH 驗證失敗。請將金鑰載入 ssh-agentssh-add或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',
sshErrHostKey:
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
sshErrNotInstalled:
'遠端主機上未安裝 Hermes。請在遠端安裝curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh或設定 Hermes 路徑。',
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式僅支援 Linux 和 macOS 遠端主機。',
sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',
sshErrUpdateRequired: '使用 Desktop SSH 連線前,請更新遠端主機上的 Hermes。',
sshErrUnknown: 'SSH 連線失敗。'
},
keys: {
loading: '正在載入 API 金鑰和憑證...',
@ -1969,6 +2005,12 @@ export const zhHant = defineLocale({
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `後端 v${version}`,
clientLabel: version => `用戶端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `遠端: ${host}`,
connectionCloud: host => `雲端: ${host}`,
connectionCloudTooltip: host => `已連線到 Hermes Cloud ${host} · 點擊管理`,
connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`,
connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`,
backendLabel: version => `後端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,

View File

@ -807,7 +807,43 @@ export const zh: Translations = {
signOutFailed: '退出登录失败',
testFailed: '远程网关测试失败',
applyFailed: '无法应用网关设置',
saveFailed: '无法保存网关设置'
saveFailed: '无法保存网关设置',
sshTitle: '通过 SSH 连接',
sshDesc:
'Hermes 会通过 SSH 在远程启动并以隧道连接到本应用——无需自行启动或暴露任何服务。前提:已具备到该主机的密钥 SSH 访问。',
sshTrustHint: '首次提供的主机密钥会被信任并固定;后续变更将被拒绝。',
sshHostTitle: '主机',
sshHostDesc: 'user@host或 ~/.ssh/config 中的 Host 别名。',
sshHostPick: '选择主机…',
sshHostPickTitle: '主机',
sshHostPickDesc: '~/.ssh/config 中的 Host 别名,或选择"自定义"手动输入。',
sshHostCustom: '自定义(手动输入)…',
sshUserTitle: '用户',
sshUserDesc: '留空 = ~/.ssh/config 或当前用户。',
sshUserPlaceholder: '来自 ~/.ssh/config',
sshPortTitle: '端口',
sshPortDesc: '留空 = 22 或 ~/.ssh/config 中的端口。',
sshKeyTitle: '密钥文件',
sshKeyDesc: '私钥路径。留空 = ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路径(可选)',
sshHermesPathDesc: '远程 hermes 可执行文件的完整路径。留空 = 自动检测。',
sshHermesPathPlaceholder: '自动检测',
sshTestConnection: '测试 SSH',
sshConnect: '连接',
sshButtonsHint: '“保存”将在下次启动时生效,“连接”则立即重新连接。',
sshReachable: (host, platform) => `可连接:${host}${platform})——已找到 Hermes`,
sshIncompleteHost: '连接前请输入 SSH 主机。',
sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',
sshErrAuth:
'SSH 认证失败。请将密钥加载到 ssh-agentssh-add或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',
sshErrHostKey:
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
sshErrNotInstalled:
'远程主机上未安装 Hermes。请在远程安装curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh或设置 Hermes 路径。',
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式仅支持 Linux 和 macOS 远程主机。',
sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',
sshErrUpdateRequired: '使用 Desktop SSH 连接前,请更新远程主机上的 Hermes。',
sshErrUnknown: 'SSH 连接失败。'
},
keys: {
loading: '正在加载 API 密钥和凭据...',
@ -2256,6 +2292,12 @@ export const zh: Translations = {
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `后端 v${version}`,
clientLabel: version => `客户端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `远程: ${host}`,
connectionCloud: host => `云端: ${host}`,
connectionCloudTooltip: host => `已连接到 Hermes Cloud ${host} · 点击管理`,
connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`,
connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`,
backendLabel: version => `后端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,