diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index d92428af69..438e914615 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -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', () => { diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index a6a22c3d90..72f52cf5b0 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -64,6 +64,8 @@ export interface RegistryConnection { headers?: Record /** 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 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 ) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dc669cb15c..56342dfb5c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -9304,6 +9304,7 @@ function sanitizeConnectionProfiles(raw: Record) { 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) { // 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 } diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx index 7a2faf1d35..a42a6ed77f 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx @@ -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()), + 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() + 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() + 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() expect(await screen.findByText('Local gateway')).toBeTruthy() diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 4de24c278c..874e5e0b4c 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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 ? (
+ {savedCloudConnections.length > 0 ? ( +
+
+ {g.cloudSavedTitle} +
+

{g.cloudSavedDesc}

+ {savedCloudConnections.map(connection => ( +
+ + + {g.cloudActive} + + ) : ( + + ) + } + description={connection.url} + title={connection.label} + /> +
+ ))} +
+ ) : null} - {g.cloudConnectedPill} + {g.cloudActive} ) : (
) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index bd02af438c..641d7bfb6e 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 24e2ebf0d8..4fe8bcb34f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 554b148f21..8e7afb5de4 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -1135,6 +1135,11 @@ export const ru = defineLocale({ }, cloudRefresh: 'Обновить', cloudConnect: 'Подключиться', + cloudSavedTitle: 'Сохранённые облачные шлюзы', + cloudSavedDesc: + 'Используйте сохранённый шлюз без изменения шлюза по умолчанию. Войдите ниже, чтобы добавить экземпляры. Имена и вход — в списке сохранённых подключений.', + cloudUseSaved: 'Использовать шлюз', + cloudActive: 'Активен в этом окне', cloudConnecting: 'Подключение…', cloudDiscoverFailed: 'Не удалось загрузить агентов Hermes Cloud', cloudConnectFailed: 'Не удалось подключиться к этому агенту', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 2f3e9a5159..ae869d3435 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 84283ff043..2fc5561b55 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1117,6 +1117,10 @@ export const zh: Translations = { }, cloudRefresh: '刷新', cloudConnect: '连接', + cloudSavedTitle: '已保存的云网关', + cloudSavedDesc: '使用已保存的网关,不更改默认网关。在下方登录以添加实例。在已保存的连接列表中管理名称和登录。', + cloudUseSaved: '使用网关', + cloudActive: '当前窗口正在使用', cloudConnecting: '正在连接…', cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体', cloudConnectFailed: '无法连接到该智能体',