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:
parent
f003d888e1
commit
195d4557fc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-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 金鑰和憑證...',
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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-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 密钥和凭据...',
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue