mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +00:00
* feat(desktop): per-profile remote gateway hosts Profile switching silently failed whenever the desktop was connected to a remote backend: the rail routed non-active profiles to a local pool backend, but spawnPoolBackend hard-threw "Profiles are unavailable when connected to a remote Hermes backend", and the renderer swallowed the error into an infinite reconnect backoff while still marking the profile active. Remote was also a single app-global setting, so there was no way to give a profile its own host. Add per-profile remote hosts so each profile can point at its own backend: - connection.json gains a validated `profiles` map; profileRemoteOverride() (pure, unit-tested) selects an explicit per-profile remote. - resolveRemoteBackend(profile) precedence: per-profile override → env override → global remote → local spawn. spawnPoolBackend now connects to a profile's remote (no local child) instead of throwing; startHermes resolves the primary profile's remote. - coerce/sanitize connection config are scope-aware (global vs named profile) and preserve each other's entries; IPC get/save/apply/test thread an optional profile. Per-profile apply drops only that profile's pool backend. - Settings → Gateway adds an "Applies to" scope selector reusing the existing URL/token/OAuth/test UX per profile. Tests: connection-config pure suite (+6) and desktop platform suite pass; tsc/eslint/vitest clean. * refactor(desktop): DRY per-profile remote helpers Share connectionScopeKey + normAuthMode from connection-config.cjs (drop the main.cjs copy), collapse the scope/auth ternaries, route the env remote through buildRemoteConnection, and fold the duplicated remote-block validation into buildRemoteBlock. No behavior change; pure suite + live E2E still green.
100 lines
3.5 KiB
TypeScript
100 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import type { DesktopConnectionConfig } from '@/global'
|
|
|
|
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
|
|
|
|
function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
|
|
return {
|
|
envOverride: false,
|
|
mode: 'remote',
|
|
profile: null,
|
|
remoteAuthMode: 'oauth',
|
|
remoteOauthConnected: false,
|
|
remoteTokenPreview: null,
|
|
remoteTokenSet: false,
|
|
remoteUrl: 'https://box:9119',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('isRemoteReauthFailure', () => {
|
|
it('true for a remote, gated, disconnected gateway with a URL', () => {
|
|
expect(isRemoteReauthFailure(config())).toBe(true)
|
|
})
|
|
|
|
it('false when the oauth session is still connected', () => {
|
|
expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
|
|
})
|
|
|
|
it('false for a local gateway', () => {
|
|
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
|
|
})
|
|
|
|
it('false for a token (non-gated) remote gateway', () => {
|
|
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
|
|
})
|
|
|
|
it('false when there is no remote URL to sign in against', () => {
|
|
expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false)
|
|
})
|
|
|
|
it('false for null/undefined config', () => {
|
|
expect(isRemoteReauthFailure(null)).toBe(false)
|
|
expect(isRemoteReauthFailure(undefined)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('deriveProviderShape', () => {
|
|
it('generic copy when there are no providers', () => {
|
|
expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
|
|
expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
|
|
})
|
|
|
|
it('password shape when the sole provider supports password', () => {
|
|
expect(
|
|
deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
|
|
).toEqual({ isPassword: true, providerLabel: 'Username & Password' })
|
|
})
|
|
|
|
it('OAuth shape when the provider is a redirect IDP', () => {
|
|
expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({
|
|
isPassword: false,
|
|
providerLabel: 'Nous Research'
|
|
})
|
|
})
|
|
|
|
it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => {
|
|
const shape = deriveProviderShape([
|
|
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
|
|
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
|
|
])
|
|
|
|
expect(shape.isPassword).toBe(false)
|
|
expect(shape.providerLabel).toBe('Username & Password / Nous Research')
|
|
})
|
|
|
|
it('falls back to name when displayName is empty', () => {
|
|
expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe(
|
|
'basic'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('signInLabel', () => {
|
|
it('password gateway gets the plain "Sign in to remote gateway" copy', () => {
|
|
expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe(
|
|
'Sign in to remote gateway'
|
|
)
|
|
})
|
|
|
|
it('OAuth gateway names the provider', () => {
|
|
expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe(
|
|
'Sign in with Nous Research'
|
|
)
|
|
})
|
|
|
|
it('null reauth falls back to the generic provider phrase', () => {
|
|
expect(signInLabel(null)).toBe('Sign in with your identity provider')
|
|
})
|
|
})
|