fix(desktop): reuse saved Cloud gateways from Settings

Separate saved Cloud instances from the live window source, use the existing
registry activation path instead of repeating sign-in/apply, and persist the
chosen instance name while retaining registry identity and custom labels.

Naming metadata adapted from IAvecilla's contribution in PR #103224.

Co-authored-by: IAvecilla <ignacio.avecilla@lambdaclass.com>
This commit is contained in:
Teknium
2026-09-08 04:06:08 -07:00
parent 77a5457343
commit 6909604f97
10 changed files with 325 additions and 30 deletions
@@ -48,6 +48,70 @@ function emptyRegistry(): ConnectionRegistry {
return normalizeRegistry(null)
}
test('Cloud apply upgrades only host labels without changing connection identity', () => {
const url = 'https://agent.example.com'
const name = 'Research cloud'
const first = reconcileAppliedGlobalConnection(emptyRegistry(), {
mode: 'cloud',
remote: { url, authMode: 'oauth' }
})
const named = reconcileAppliedGlobalConnection(first, {
mode: 'cloud',
remote: { url, authMode: 'oauth', name }
})
assert.equal(named.primary, first.primary)
assert.equal(named.connections.find(c => c.id === named.primary)?.label, name)
const restored = normalizeRegistry(JSON.parse(JSON.stringify(named)))
assert.equal(restored.connections.find(c => c.id === named.primary)?.name, name)
const custom = upsertConnection(restored, {
...restored.connections.find(c => c.id === named.primary)!,
label: 'My device'
})
const reapplied = reconcileAppliedGlobalConnection(custom, {
mode: 'cloud',
remote: { url, authMode: 'oauth', name: 'New portal name' }
})
assert.equal(reapplied.primary, first.primary)
assert.equal(reapplied.connections.find(c => c.id === first.primary)?.label, 'My device')
const other = reconcileAppliedGlobalConnection(reapplied, {
mode: 'cloud',
remote: { url: 'https://other.example.com', authMode: 'oauth' }
})
assert.notEqual(other.primary, first.primary)
assert.equal(other.connections.find(c => c.id === other.primary)?.name, undefined)
})
test('Cloud name survives partial edits but never inherits across gateway URLs', () => {
const registry = reconcileAppliedGlobalConnection(emptyRegistry(), {
mode: 'cloud',
remote: { url: 'https://agent.example.com', authMode: 'oauth', name: 'Research cloud' }
})
const existing = registry.connections.find(c => c.id === registry.primary)!
const renamed = normalizeConnectionInput(
mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine' }, existing),
registry
)
assert.equal(renamed.name, 'Research cloud')
const retargeted = normalizeConnectionInput(
mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine', url: 'https://other.example.com' }, existing),
registry
)
assert.equal(retargeted.name, undefined)
})
// --- labels, slugs, handles ---
test('labelKey is case-insensitive and trimmed', () => {
+42 -7
View File
@@ -64,6 +64,8 @@ export interface RegistryConnection {
headers?: Record<string, unknown>
/** cloud: portal org slug/id the instance was discovered under. */
org?: string
/** Cloud instance name, separate from the user-editable label. */
name?: string
/** ssh fields (normalizeSshConfig shapes). */
host?: string
user?: string
@@ -822,6 +824,8 @@ export interface ConnectionInput {
token?: unknown
headers?: Record<string, unknown>
org?: string
/** Cloud instance name, separate from the user-editable label. */
name?: string
host?: string
user?: string
port?: number | string
@@ -958,6 +962,12 @@ export function normalizeConnectionInput(input: ConnectionInput, registry: Conne
}
}
const name = String(input.name || '').trim()
if (kind === 'cloud' && name) {
entry.name = name
}
const org = String(input.org || '').trim()
if (kind === 'cloud' && org) {
@@ -995,6 +1005,14 @@ export function mergeConnectionInput(input: ConnectionInput, existing?: null | R
inherit('url')
inherit('authMode')
inherit('org')
if (
input.kind === 'cloud' &&
(input.url === undefined || normalizeRemoteBaseUrl(input.url) === normalizeRemoteBaseUrl(existing.url))
) {
inherit('name')
}
inherit('host')
inherit('keyPath')
inherit('remoteHermesPath')
@@ -1184,6 +1202,12 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry {
clean.headers = storedHeaders
}
const name = String(entry.name || '').trim()
if (kind === 'cloud' && name) {
clean.name = name
}
const org = String(entry.org || '').trim()
if (kind === 'cloud' && org) {
@@ -1295,6 +1319,12 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
entry.headers = v1Headers
}
const name = String(block.name || '').trim()
if (kind === 'cloud' && name) {
entry.name = name
}
const org = String(block.org || '').trim()
if (kind === 'cloud' && org) {
@@ -1442,7 +1472,7 @@ export function setLastUsedConnection(registry: ConnectionRegistry, id: string):
*
* Remote-shaped entries are matched by normalized URL across remote/cloud so
* changing provenance never duplicates a gateway. Existing identity and
* user-chosen label win; a new entry derives both from the host. Switching to
* user-chosen label win; a Cloud name upgrades only the default host label. Switching to
* local keeps registered remotes available while moving primary/last-used
* back to This device.
*/
@@ -1479,12 +1509,16 @@ export function reconcileAppliedGlobalConnection(
const kind: ConnectionKind = mode === 'cloud' ? 'cloud' : 'remote'
const hostLabel = hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway')
const name = kind === 'cloud' ? String(block.name ?? existing?.name ?? '').trim() : ''
const label =
existing?.label ||
uniqueLabel(
hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'),
registry.connections.map(connection => connection.label)
)
existing && (!name || existing.label !== hostLabel)
? existing.label
: uniqueLabel(
name || hostLabel,
registry.connections.filter(connection => connection.id !== existing?.id).map(connection => connection.label)
)
const entry = normalizeConnectionInput(
{
@@ -1495,7 +1529,8 @@ export function reconcileAppliedGlobalConnection(
authMode: block.authMode,
token: block.token,
headers: block.headers,
org: block.org
org: block.org,
name
},
registry
)
+30 -4
View File
@@ -9304,6 +9304,7 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
token?: object
headers?: object
org?: string
name?: string
savedSsh?: object
} = {
mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local'
@@ -9338,6 +9339,12 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
// Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can
// reopen into the same org for a per-profile cloud connection.
if (cleaned.mode === 'cloud') {
const cloudName = String(entry.name || '').trim()
if (cloudName) {
cleaned.name = cloudName
}
const org = String(entry.org || '').trim()
if (org) {
@@ -9865,12 +9872,12 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered
// under — persisted so Settings can reopen into the same org; omitted from the
// block when empty so plain remote connections stay unchanged.
function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: object) {
function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: object, name?: string) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
const block: { url: string; authMode: string; token: object; headers?: object; org?: string } = {
const block: { url: string; authMode: string; token: object; headers?: object; org?: string; name?: string } = {
url: normalizeRemoteBaseUrl(remoteUrl),
authMode,
token
@@ -9882,6 +9889,12 @@ function buildRemoteBlock(remoteUrl, authMode, token, org?: string, headers?: ob
block.headers = remoteHeaders
}
const nameValue = typeof name === 'string' ? name.trim() : ''
if (nameValue) {
block.name = nameValue
}
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
@@ -9919,6 +9932,19 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
// inherit the saved org. A plain 'remote' connection never carries an org
// (switching cloud→remote drops it), so it stays unset unless mode is cloud.
const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : ''
// A saved name belongs to this exact gateway, not another instance in the same org.
const cloudName =
mode === 'cloud'
? String(
input.cloudName ??
(existingBlock.url && normalizeRemoteBaseUrl(remoteUrl) === normalizeRemoteBaseUrl(existingBlock.url)
? existingBlock.name
: '') ??
''
).trim()
: ''
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const remoteHeaders =
@@ -9962,7 +9988,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
if (remoteLike) {
profiles[key] = {
mode,
...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders)
...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders, cloudName)
}
} else {
const localEntry = localProfileEntry(rawExistingBlock)
@@ -9982,7 +10008,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo
}
const nextRemote = remoteLike
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders)
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg, remoteHeaders, cloudName)
: existingMode === 'ssh'
? rawExistingBlock
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
@@ -1,9 +1,27 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Collect the component graph before the behavioral test deadline starts.
import { GatewaySettings } from './gateway-settings'
const { registry, activeId, selectConnection } = vi.hoisted(() => ({
registry: { value: null as any },
activeId: { value: 'saved-b' },
selectConnection: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@nanostores/react', () => ({ useStore: (store: any) => store.value }))
vi.mock('@/store/connections', () => ({
$connectionsRegistry: registry,
$activeConnectionId: activeId,
refreshConnectionsRegistry: vi.fn().mockResolvedValue(null),
selectConnection,
setConnectionsRegistry: vi.fn()
}))
vi.mock('./connections-registry', async importOriginal => ({
...(await importOriginal<any>()),
ConnectionsRegistrySection: () => null
}))
const getConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
@@ -39,6 +57,69 @@ afterEach(() => {
})
describe('GatewaySettings', () => {
it('keeps saved Cloud instances usable without discovery and marks the live source, not the default', async () => {
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud', remoteUrl: 'https://a.example' })
registry.value = {
connections: [
{ id: 'saved-a', kind: 'cloud', label: 'Research', url: 'https://a.example', authMode: 'oauth' },
{ id: 'saved-b', kind: 'cloud', label: 'Writing', url: 'https://b.example', authMode: 'oauth' }
]
}
const agentSignIn = vi.fn()
const applyConnectionConfig = vi.fn()
Object.assign(window.hermesDesktop, {
applyConnectionConfig,
cloud: {
status: vi.fn().mockResolvedValue({ signedIn: false }),
agentSignIn
}
})
render(<GatewaySettings embedded />)
const research = await screen.findByText('Research')
const row = research.closest('[data-slot]') ?? research.parentElement!.parentElement!
fireEvent.click(within(row as HTMLElement).getByRole('button', { name: 'Use gateway' }))
await waitFor(() => expect(selectConnection).toHaveBeenCalledWith('saved-a'))
expect(screen.getByText('Active in this window')).toBeTruthy()
expect(agentSignIn).not.toHaveBeenCalled()
expect(applyConnectionConfig).not.toHaveBeenCalled()
registry.value = null
})
it('authenticates and saves only the chosen discovered instance with its friendly name', async () => {
registry.value = null
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud' })
const agentSignIn = vi.fn().mockResolvedValue({ connected: true })
const applyConnectionConfig = vi.fn().mockResolvedValue({ ...localConnection, mode: 'cloud' })
Object.assign(window.hermesDesktop, {
applyConnectionConfig,
cloud: {
status: vi.fn().mockResolvedValue({ signedIn: true }),
agentSignIn,
discover: vi.fn().mockResolvedValue({
agents: [
{ id: 'new-a', name: 'Research Bot', dashboardUrl: 'https://new-a.example' },
{ id: 'new-b', name: 'Writing Bot', dashboardUrl: 'https://new-b.example' }
],
org: { id: 'org-a' }
})
}
})
render(<GatewaySettings embedded />)
const buttons = await screen.findAllByRole('button', { name: 'Connect', exact: true })
expect(agentSignIn).not.toHaveBeenCalled()
expect(applyConnectionConfig).not.toHaveBeenCalled()
fireEvent.click(buttons[0])
await waitFor(() =>
expect(applyConnectionConfig).toHaveBeenCalledWith({
mode: 'cloud',
remoteAuthMode: 'oauth',
remoteUrl: 'https://new-a.example',
cloudOrg: 'org-a',
cloudName: 'Research Bot'
})
)
expect(agentSignIn).toHaveBeenCalledExactlyOnceWith('https://new-a.example')
expect(applyConnectionConfig).toHaveBeenCalledTimes(1)
})
it('loads the machine-level connection config (no profile scoping)', async () => {
render(<GatewaySettings />)
expect(await screen.findByText('Local gateway')).toBeTruthy()
@@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
@@ -24,6 +25,12 @@ import {
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import {
$activeConnectionId,
$connectionsRegistry,
refreshConnectionsRegistry,
selectConnection
} from '@/store/connections'
import { notify, notifyError, readableError } from '@/store/notifications'
import { ConnectionsRegistrySection } from './connections-registry'
@@ -169,7 +176,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
const signingSeq = useRef(0)
const cloudConnectSeq = useRef(0)
const contextSeq = useRef(0)
const [connectedCloudUrl, setConnectedCloudUrl] = useState('')
const registry = useStore($connectionsRegistry)
const activeConnectionId = useStore($activeConnectionId)
const savedCloudConnections = registry?.connections.filter(connection => connection.kind === 'cloud') ?? []
useEffect(() => {
void refreshConnectionsRegistry().catch(err => notifyError(err, g.failedLoad))
}, [g.failedLoad])
// Opt-in OS-keychain encryption for stored gateway secrets. Read lazily via
// IPC (never touches the keychain); flipping it re-encodes stored secrets
@@ -215,7 +228,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
const normalized = normalizeGatewaySettingsState(config)
setState(normalized)
setConnectedCloudUrl(savedCloudConnectionUrl(normalized))
}
// When set, the plain-text opt-in dialog is open; `apply` remembers whether
@@ -294,19 +306,29 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// prefers a fresh probe result over the saved value.
const trimmedUrl = coerceRemoteUrlScheme(state.remoteUrl)
// The dashboardUrl of the currently-connected cloud instance (the saved
// cloud connection's remoteUrl), normalized for comparison against each
// discovered agent's dashboardUrl so we can highlight the active one and hide
// its Connect button. Empty unless the saved connection is a cloud one.
// The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl
// (which lowercases the host through URL.toString()), but a discovered agent's
// dashboardUrl arrives raw from NAS — so normalize both sides the same way
// (trim, drop trailing slash, lowercase) or a host-casing difference would
// silently break the connected-highlight.
const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase()
const savedAgent = (agent: DesktopCloudAgent) =>
registry?.connections.find(
connection =>
(connection.kind === 'cloud' || connection.kind === 'remote') &&
connection.url &&
agent.dashboardUrl &&
savedCloudConnectionUrl({ mode: 'cloud', remoteUrl: connection.url }) ===
savedCloudConnectionUrl({ mode: 'cloud', remoteUrl: agent.dashboardUrl })
)
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl)
const isConnectedAgent = (agent: DesktopCloudAgent) => savedAgent(agent)?.id === activeConnectionId
const activateSavedCloud = async (id: string) => {
setCloudConnectingId(id)
try {
await selectConnection(id)
} catch (err) {
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
@@ -887,6 +909,16 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setCloudConnectingId(agent.id)
try {
// Saved sources keep their identity, credentials and default gateway.
// The activation path reuses healthy sockets and validates auth on a new dial.
const saved = savedAgent(agent)
if (saved) {
await selectConnection(saved.id)
return
}
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
if (seq !== contextSeq.current) {
@@ -911,7 +943,8 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
mode: 'cloud',
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrgRef.current ?? undefined
cloudOrg: cloudOrgRef.current ?? undefined,
cloudName: agent.name
})
if (seq !== contextSeq.current) {
@@ -919,6 +952,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
acceptSavedConfig(next)
await refreshConnectionsRegistry()
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (seq !== contextSeq.current) {
@@ -1147,6 +1181,40 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
connection. Replaces the URL/token form while in cloud mode. */}
{state.mode === 'cloud' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
{savedCloudConnections.length > 0 ? (
<div className="mb-4 grid gap-1">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudSavedTitle}
</div>
<p className="mb-2 text-xs text-muted-foreground">{g.cloudSavedDesc}</p>
{savedCloudConnections.map(connection => (
<div data-slot="saved-cloud-gateway" key={connection.id}>
<ListRow
action={
activeConnectionId === connection.id ? (
<Pill tone="primary">
<Check className="size-3" />
{g.cloudActive}
</Pill>
) : (
<Button
disabled={cloudConnectingId !== null}
onClick={() => void activateSavedCloud(connection.id)}
size="sm"
variant="outline"
>
{cloudConnectingId === connection.id ? <Loader2 className="animate-spin" /> : null}
{g.cloudUseSaved}
</Button>
)
}
description={connection.url}
title={connection.label}
/>
</div>
))}
</div>
) : null}
<ListRow
action={
cloudSignedIn ? (
@@ -1256,7 +1324,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
connected ? (
<Pill tone="primary">
<Check className="mr-1 inline size-3" />
{g.cloudConnectedPill}
{g.cloudActive}
</Pill>
) : (
<Button
@@ -1268,13 +1336,15 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
{agent.dashboardUrl
? cloudConnectingId === agent.id
? g.cloudConnecting
: g.cloudConnect
: savedAgent(agent)
? g.cloudUseSaved
: g.cloudConnect
: g.cloudAgentProvisioning}
</Button>
)
}
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
title={agent.name}
title={savedAgent(agent)?.label || agent.name}
/>
</div>
)
+1
View File
@@ -862,6 +862,7 @@ 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
cloudName?: string
sshHost?: string
sshUser?: string
sshPort?: number | null
+5
View File
@@ -922,6 +922,11 @@ export const en: Translations = {
},
cloudRefresh: 'Refresh',
cloudConnect: 'Connect',
cloudSavedTitle: 'Saved Cloud gateways',
cloudSavedDesc:
'Use a saved gateway without changing your default. Sign in below to add instances. Manage names and sign-in in the saved connections list.',
cloudUseSaved: 'Use gateway',
cloudActive: 'Active in this window',
cloudConnecting: 'Connecting…',
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
cloudConnectFailed: 'Could not connect to that agent',
+5
View File
@@ -1135,6 +1135,11 @@ export const ru = defineLocale({
},
cloudRefresh: 'Обновить',
cloudConnect: 'Подключиться',
cloudSavedTitle: 'Сохранённые облачные шлюзы',
cloudSavedDesc:
'Используйте сохранённый шлюз без изменения шлюза по умолчанию. Войдите ниже, чтобы добавить экземпляры. Имена и вход — в списке сохранённых подключений.',
cloudUseSaved: 'Использовать шлюз',
cloudActive: 'Активен в этом окне',
cloudConnecting: 'Подключение…',
cloudDiscoverFailed: 'Не удалось загрузить агентов Hermes Cloud',
cloudConnectFailed: 'Не удалось подключиться к этому агенту',
+4
View File
@@ -789,6 +789,10 @@ export interface Translations {
cloudNoAgents: { before: string; linkText: string; after: string }
cloudRefresh: string
cloudConnect: string
cloudSavedTitle: string
cloudSavedDesc: string
cloudUseSaved: string
cloudActive: string
cloudConnecting: string
cloudDiscoverFailed: string
cloudConnectFailed: string
+4
View File
@@ -1117,6 +1117,10 @@ export const zh: Translations = {
},
cloudRefresh: '刷新',
cloudConnect: '连接',
cloudSavedTitle: '已保存的云网关',
cloudSavedDesc: '使用已保存的网关,不更改默认网关。在下方登录以添加实例。在已保存的连接列表中管理名称和登录。',
cloudUseSaved: '使用网关',
cloudActive: '当前窗口正在使用',
cloudConnecting: '正在连接…',
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
cloudConnectFailed: '无法连接到该智能体',