fix(desktop): route plugin profiles through registry

This commit is contained in:
addel
2026-08-16 14:00:45 +10:00
committed by Teknium
parent 496946ba8c
commit 17271a8a6b
8 changed files with 426 additions and 35 deletions
+50 -10
View File
@@ -215,7 +215,14 @@ import {
parentWatchdogEnv
} from './parent-process-identity'
import { selectPoolEvictions } from './pool-eviction'
import { buildOpaqueProfileRoutes, type EffectiveSshRoute, type ProfileRouteConfig } from './plugin-profile-routes'
import {
buildOpaqueProfileRoutes,
buildRegistryProfileRoutes,
type EffectiveSshRoute,
localRouteFallbackProfiles,
type ProfileRouteConfig,
registryGatewayWsUrl
} from './plugin-profile-routes'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
@@ -11983,7 +11990,7 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:plugin-profile-routes', async (_event, rawProfileNames) => {
const profileNames = Array.isArray(rawProfileNames)
const fallbackProfileNames = Array.isArray(rawProfileNames)
? rawProfileNames
.filter(name => typeof name === 'string')
.map(name => name.trim())
@@ -11991,17 +11998,48 @@ ipcMain.handle('hermes:plugin-profile-routes', async (_event, rawProfileNames) =
.slice(0, 256)
: []
const registry = readDesktopConnectionsRegistry()
const enumerations = await enumerateRegistryAgentSources(registry)
let agents = buildAgentRoster(enumerations)
// A local enumeration can fail while remote/cloud sources succeed. Preserve
// cached v1 profile names as explicitly-local rows so those valid routes do
// not disappear and duplicate names remain source-qualified.
const localSource = registry.connections.find(source => source.kind === 'local')
const localEnumeration = localSource
? enumerations.find(({ connection }) => connection.id === localSource.id)
: undefined
const localFallbackProfiles = localSource
? localRouteFallbackProfiles(agents, localSource.id, fallbackProfileNames, Boolean(localEnumeration?.error))
: []
if (localSource && localFallbackProfiles.length > 0) {
agents = [
...agents,
...localFallbackProfiles.map(profile => ({
connectionId: localSource.id,
connectionKind: localSource.kind,
connectionLabel: localSource.label,
handle: profile,
profile
}))
]
}
const config = readDesktopConnectionConfig()
const globalConfig = (await sanitizeDesktopConnectionConfig(config, null)) as ProfileRouteConfig
const localProfiles = agents.filter(agent => agent.connectionId === 'local').map(agent => agent.profile)
return buildOpaqueProfileRoutes({
const legacyRoutes = await buildOpaqueProfileRoutes({
getProfileConfig: async profile => (await sanitizeDesktopConnectionConfig(config, profile)) as ProfileRouteConfig,
globalConfig,
installationId: desktopInstallationId,
primaryProfile: primaryProfileKey(),
profileNames,
profileNames: localProfiles,
resolveSsh: effectiveSshRouteForPlugin
})
return buildRegistryProfileRoutes({ agents, legacyRoutes, sources: registry.connections })
})
ipcMain.handle('hermes:ssh-config:hosts', async () => ({ hosts: collectSshConfigHosts() }))
ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => {
@@ -12158,10 +12196,8 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => {
// are SKIPPED (connect-on-demand — dialing every ssh box just to list agents
// would spawn tunnels the user never asked for); once dialed, their pooled
// descriptor serves the enumeration like any remote.
ipcMain.handle('hermes:agents:roster', async () => {
const registry = readDesktopConnectionsRegistry()
const enumerations = await Promise.all(
async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRegistry()) {
return Promise.all(
registry.connections.map(async connection => {
try {
if (
@@ -12190,6 +12226,10 @@ ipcMain.handle('hermes:agents:roster', async () => {
}
})
)
}
ipcMain.handle('hermes:agents:roster', async () => {
const enumerations = await enumerateRegistryAgentSources()
return {
agents: buildAgentRoster(enumerations),
@@ -12214,10 +12254,10 @@ ipcMain.handle('hermes:gateway:ws-url-for', async (_event, payload) => {
if (connection.authMode === 'oauth') {
const ticket = await mintGatewayWsTicket(connection.baseUrl)
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
return registryGatewayWsUrl(connection, buildGatewayWsUrlWithTicket(connection.baseUrl, ticket))
}
return connection.wsUrl
return registryGatewayWsUrl(connection, connection.wsUrl)
})
})
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { buildOpaqueProfileRoutes, type ProfileRouteConfig } from './plugin-profile-routes'
import {
buildOpaqueProfileRoutes,
buildRegistryProfileRoutes,
localRouteFallbackProfiles,
type ProfileRouteConfig,
registryGatewayWsUrl
} from './plugin-profile-routes'
function config(overrides: Partial<ProfileRouteConfig> = {}): ProfileRouteConfig {
return {
@@ -165,3 +171,80 @@ describe('buildOpaqueProfileRoutes', () => {
expect(JSON.stringify(routes.map(({ connectionId, mode }) => ({ connectionId, mode })))).not.toContain('org-a')
})
})
describe('buildRegistryProfileRoutes', () => {
it('keeps duplicate profile names distinct by registry connection without exposing source details', () => {
const routes = buildRegistryProfileRoutes({
agents: [
{ connectionId: 'local', profile: 'research' },
{ connectionId: 'homelab', profile: 'research' }
],
legacyRoutes: [
{ connectionId: 'legacy-hash', mode: 'local', profile: 'research', targetProfile: 'research' }
],
sources: [
{ id: 'local', kind: 'local', label: 'This device' },
{
authMode: 'token',
host: 'private.lan',
id: 'homelab',
kind: 'ssh',
keyPath: '/secret/id_ed25519',
label: 'Homelab',
remoteProfile: 'remote-research',
token: 'encrypted-secret'
}
]
})
expect(routes).toEqual([
{ connectionId: 'local', mode: 'local', profile: 'research', targetProfile: 'research' },
{ connectionId: 'homelab', mode: 'remote', profile: 'research', targetProfile: 'remote-research' }
])
expect(JSON.stringify(routes)).not.toContain('private.lan')
expect(JSON.stringify(routes)).not.toContain('id_ed25519')
expect(JSON.stringify(routes)).not.toContain('encrypted-secret')
expect(new Set(routes.map(route => `${route.connectionId}/${route.profile}`))).toHaveLength(2)
})
it('preserves legacy v1 targetProfile correction for routes reached through local', () => {
const routes = buildRegistryProfileRoutes({
agents: [{ connectionId: 'local', profile: 'barry' }],
legacyRoutes: [
{ connectionId: 'legacy-hash', mode: 'remote', profile: 'barry', targetProfile: 'default' }
],
sources: [{ id: 'local', kind: 'local', label: 'This device' }]
})
expect(routes).toEqual([
{ connectionId: 'local', mode: 'remote', profile: 'barry', targetProfile: 'default' }
])
})
it('scopes registry-shared remote websocket URLs to the requested profile', () => {
expect(
registryGatewayWsUrl(
{ profile: 'research', sharedRemote: true },
'wss://gateway.example/api/ws?token=secret'
)
).toBe('wss://gateway.example/api/ws?token=secret&profile=research')
expect(
registryGatewayWsUrl({ profile: 'research' }, 'ws://127.0.0.1:5151/api/ws?token=local')
).toBe('ws://127.0.0.1:5151/api/ws?token=local')
})
})
describe('localRouteFallbackProfiles', () => {
it('restores failed local profiles when another source returned agents', () => {
const agents = [{ connectionId: 'cloud-prod', profile: 'default' }]
expect(localRouteFallbackProfiles(agents, 'local', ['default', 'venture'], true)).toEqual([
'default',
'venture'
])
})
it('does not synthesize local routes after a successful local enumeration', () => {
expect(localRouteFallbackProfiles([], 'local', ['default'], false)).toEqual([])
})
})
@@ -24,6 +24,24 @@ export interface OpaqueProfileRoute {
targetProfile: string
}
interface RegistryProfileRouteAgent {
connectionId: string
profile: string
}
interface RegistryProfileRouteSource {
[field: string]: unknown
id: string
kind: 'cloud' | 'local' | 'remote' | 'ssh'
remoteProfile?: string
}
interface BuildRegistryProfileRoutesOptions {
agents: RegistryProfileRouteAgent[]
legacyRoutes?: OpaqueProfileRoute[]
sources: RegistryProfileRouteSource[]
}
interface BuildOpaqueProfileRoutesOptions {
getProfileConfig: (profile: string) => ProfileRouteConfig | Promise<ProfileRouteConfig>
globalConfig: ProfileRouteConfig
@@ -33,6 +51,36 @@ interface BuildOpaqueProfileRoutesOptions {
resolveSsh: (config: ProfileRouteConfig) => Promise<EffectiveSshRoute>
}
/** Return cached local profile names only when the local roster read failed. */
export function localRouteFallbackProfiles(
agents: RegistryProfileRouteAgent[],
localConnectionId: string,
profileNames: string[],
localEnumerationFailed: boolean
): string[] {
if (!localEnumerationFailed) {
return []
}
const existing = new Set(
agents
.filter(agent => agent.connectionId === localConnectionId)
.map(agent => normalizeProfile(agent.profile))
)
const fallback: string[] = []
for (const raw of profileNames) {
const profile = normalizeProfile(raw)
if (!existing.has(profile)) {
existing.add(profile)
fallback.push(profile)
}
}
return fallback
}
function normalizeProfile(name: null | string | undefined): string {
return String(name ?? '').trim() || 'default'
}
@@ -159,3 +207,70 @@ export async function buildOpaqueProfileRoutes({
})
)
}
/**
* Project the union registry roster into the narrow plugin descriptor. Registry
* ids and profile names are routing identities; endpoint/auth/source fields are
* deliberately discarded here. The local source keeps the v1 resolver's mode
* and targetProfile semantics because getConnectionFor(local, profile) delegates
* to that compatibility path.
*/
export function buildRegistryProfileRoutes({
agents,
legacyRoutes = [],
sources
}: BuildRegistryProfileRoutesOptions): OpaqueProfileRoute[] {
const sourceById = new Map(sources.map(source => [source.id, source]))
const legacyByProfile = new Map(legacyRoutes.map(route => [route.profile, route]))
const seen = new Set<string>()
const routes: OpaqueProfileRoute[] = []
for (const agent of agents) {
const profile = normalizeProfile(agent.profile)
const source = sourceById.get(agent.connectionId)
const key = `${agent.connectionId}\0${profile}`
if (!source || seen.has(key)) {
continue
}
seen.add(key)
if (source.kind === 'local') {
const legacy = legacyByProfile.get(profile)
routes.push({
connectionId: source.id,
mode: legacy?.mode ?? 'local',
profile,
targetProfile: legacy?.targetProfile ?? profile
})
continue
}
routes.push({
connectionId: source.id,
mode: 'remote',
profile,
targetProfile: source.kind === 'ssh' && source.remoteProfile ? normalizeProfile(source.remoteProfile) : profile
})
}
return routes
}
/** Add the backend profile scope only for registry remote/cloud descriptors. */
export function registryGatewayWsUrl(
connection: { profile?: null | string; sharedRemote?: boolean },
wsUrl: string
): string {
if (!connection.sharedRemote) {
return wsUrl
}
const url = new URL(wsUrl)
url.searchParams.set('profile', normalizeProfile(connection.profile))
return url.toString()
}
+5 -2
View File
@@ -31,8 +31,9 @@ declare global {
}) => Promise<GatewayWsUrlResult>
// Union agent roster across every registered connection.
getAgentRoster?: () => Promise<DesktopAgentRoster>
// Credential-free, installation-keyed route identities for Desktop
// plugins. Endpoint and auth material never crosses the IPC boundary.
// Credential-free routes across the union connection registry. The
// optional profile list is used only by the single-local v1 fallback;
// endpoint and auth material never crosses the IPC boundary.
getProfileRoutes: (profiles: string[]) => Promise<DesktopPluginProfileRoute[]>
// Reconnect-after-wake recovery: liveness-probe the cached PRIMARY backend
// and drop it if a remote one has gone unreachable, so the next
@@ -582,6 +583,8 @@ export interface DesktopUpdateProgress {
}
export interface DesktopPluginProfileRoute {
// Registry source identity. Pair with profile; profile names are not unique
// across sources.
connectionId: string
mode: 'local' | 'remote'
profile: string
+40 -6
View File
@@ -30,6 +30,7 @@ import {
$gateway,
openGatewayForAgent,
openGatewayForProfile,
requestGatewayForAgent,
requestGatewayForProfile
} from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
@@ -119,6 +120,34 @@ const $busyBySession = computed($sessionStates, states => {
const $viewport = atom<ViewportRect>(readViewport())
async function requestPluginProfile<T>(
route: PluginProfileRoute | string,
method: string,
params: Record<string, unknown>
): Promise<T> {
if (typeof route !== 'string') {
return requestGatewayForAgent<T>(route.connectionId, route.profile, method, params)
}
const getAgentRoster = window.hermesDesktop?.getAgentRoster
if (!getAgentRoster) {
return requestGatewayForProfile<T>(route, method, params)
}
const roster = await getAgentRoster()
const profile = route.trim() || 'default'
const matches = roster.agents.filter(agent => agent.profile === profile)
if (matches.length === 1 && matches[0].connectionId === 'local') {
return requestGatewayForProfile<T>(profile, method, params)
}
throw new Error(
`Profile "${profile}" requires a route descriptor from host.profileRoutes(); profile-only routing is limited to legacy/local profiles.`
)
}
if (typeof window !== 'undefined') {
const refresh = () => $viewport.set(readViewport())
window.addEventListener('resize', refresh)
@@ -335,8 +364,8 @@ export const host = {
/** One-shot system status snapshot (platforms, versions, …). */
status: async () => getStatus(),
/** Credential-free Desktop profile routes for connection-aware plugin UI.
* Profiles sharing one execution gateway receive the same opaque id. */
/** Credential-free routes across every current registry source. Identity is
* the (connectionId, profile) pair; endpoint/auth details stay in Electron. */
profileRoutes: async () => {
const desktop = window.hermesDesktop
const getProfileRoutes = desktop?.getProfileRoutes
@@ -357,10 +386,15 @@ export const host = {
return getProfileRoutes(profiles.map(profile => profile.name))
},
/** Gateway JSON-RPC through a named Desktop profile without foregrounding
* that profile or changing the active chat/gateway. */
requestProfile: async <T>(profile: string, method: string, params: Record<string, unknown> = {}): Promise<T> =>
requestGatewayForProfile<T>(profile, method, params),
/** Gateway JSON-RPC through a credential-free route descriptor without
* foregrounding it. Passing a bare profile is the v1/local compatibility
* overload; registry callers must pass the descriptor so duplicate names
* remain unambiguous. */
requestProfile: async <T>(
route: PluginProfileRoute | string,
method: string,
params: Record<string, unknown> = {}
): Promise<T> => requestPluginProfile<T>(route, method, params),
/** Gateway JSON-RPC — sessions, config, skills, cron, kanban, everything
* the app itself uses. Lazy: resolves the LIVE socket per call. */
@@ -50,6 +50,7 @@ const {
configureGatewayRegistry,
ensureGatewayForProfile,
pruneSecondaryGateways,
requestGatewayForAgent,
requestGatewayForProfile,
setPrimaryGateway
} = await import('./gateway')
@@ -180,3 +181,68 @@ describe('requestGatewayForProfile', () => {
expect(secondaryGateways[0].close).toHaveBeenCalledOnce()
})
})
describe('requestGatewayForAgent', () => {
it('leases separate registry sockets for duplicate profile names without changing the active gateway', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 'legacy-token' }))
const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: connectionId === 'source-a' ? 5151 : 5252,
profile,
token: `${connectionId}-token`
}))
const getGatewayWsUrlFor = vi.fn(async ({ connectionId, profile }) => ({
ok: true as const,
wsUrl: `ws://${connectionId}/${profile}`
}))
setPrimaryGateway(primary as never, 'default')
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
getConnectionFor,
getGatewayWsUrlFor,
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
const [fromA, fromB] = await Promise.all([
requestGatewayForAgent('source-a', 'research', 'session.list', { limit: 1 }),
requestGatewayForAgent('source-b', 'research', 'session.list', { limit: 2 })
])
expect(fromA).toEqual({ method: 'session.list', params: { limit: 1 } })
expect(fromB).toEqual({ method: 'session.list', params: { limit: 2 } })
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' })
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' })
expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'research' })
expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'research' })
expect(getConnection).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(2)
expect($gateway.get()).toBe(primary)
})
it('falls back to the legacy profile path for the local registry connection', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({ port: 5151, profile, token: 'legacy-token' }))
const getConnectionFor = vi.fn()
setPrimaryGateway(primary as never, 'default')
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection,
getConnectionFor,
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')
await expect(requestGatewayForAgent('local', 'worker', 'profiles.list')).resolves.toEqual({
method: 'profiles.list',
params: {}
})
expect(getConnection).toHaveBeenCalledWith('worker')
expect(getConnectionFor).not.toHaveBeenCalled()
expect($gateway.get()).toBe(primary)
})
})
+50 -4
View File
@@ -220,15 +220,17 @@ async function openSecondary(entry: Secondary): Promise<void> {
? await desktop.getConnectionFor({ connectionId: entry.connectionId, profile: entry.profile })
: await desktop.getConnection(entry.profile)
const wsUrl = await resolveGatewayWsUrl(
const wsDeps =
entry.connectionId && desktop.getGatewayWsUrlFor
? {
getGatewayWsUrl: () =>
desktop.getGatewayWsUrlFor!({ connectionId: entry.connectionId, profile: entry.profile })
}
: desktop,
conn
)
: entry.connectionId
? {}
: desktop
const wsUrl = await resolveGatewayWsUrl(wsDeps, conn)
await entry.gateway.connect(wsUrl)
@@ -453,6 +455,50 @@ export async function requestGatewayForProfile<T>(
}
}
/**
* Send a gateway RPC through one registry source without activating it. The
* composite (connectionId, profile) pool key prevents same-named agents on two
* sources from sharing a socket. Local/empty ids deliberately retain the v1
* profile resolver, including shared-primary request scoping.
*/
export async function requestGatewayForAgent<T>(
connectionId: null | string,
profile: string,
method: string,
params: Record<string, unknown> = {}
): Promise<T> {
const key = normKey(profile)
const scope = backendScopeKey(connectionId, key)
if (scope === key) {
return requestGatewayForProfile<T>(key, method, params)
}
if (!window.hermesDesktop?.getConnectionFor) {
throw new Error('This Desktop build cannot dial registry connections. Update Hermes Desktop.')
}
const entry = g.secondaries.get(scope) ?? createSecondary(key, connectionId)
// Existing dev-HMR entries predate request leases.
if (!Number.isFinite(entry.activeRequests)) {
entry.activeRequests = 0
}
entry.wantOpen = true
entry.activeRequests += 1
try {
if (!isOpen(entry.gateway)) {
await openSecondary(entry)
}
return await entry.gateway.request<T>(method, params)
} finally {
entry.activeRequests = Math.max(0, entry.activeRequests - 1)
}
}
// Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm
// (store/profile). Runs the same spawn + connect chain as a real switch, so by
// click time ensureGatewayForProfile finds an open socket and just activates
@@ -412,22 +412,26 @@ host.logs(...) // tail an app log file
host.status() // one-shot system status snapshot
host.restartGateway() // restart the backend gateway
host.profileRoutes() // [{ profile, targetProfile, connectionId, mode }]
host.requestProfile<T>(profile, method, params?) // routed RPC; no foreground swap
host.requestProfile<T>(route, method, params?) // registry-routed RPC; no foreground swap
host.requestProfile<T>(profile, method, params?) // legacy v1/local overload
host.request<T>(method, params?) // active-gateway JSON-RPC — the real power
```
`host.request` is the same JSON-RPC the app itself uses (sessions, config, skills,
cron, kanban, …). `host.requestProfile` routes that RPC through a named Desktop
profile's local/SSH/URL/cloud backend without changing the active chat or gateway.
Use `host.profileRoutes()` for connection-aware UI: `connectionId` is stable,
installation-scoped, and derived inside Electron with installation-keyed hashing;
profiles sharing one execution gateway receive the same id, while endpoint or
credential fields never cross the plugin IPC boundary. Key persisted identity by
`connectionId + targetProfile`, not by profile name alone. `profile` is the Desktop
route to pass to `host.requestProfile()` or `host.openSession()`; `targetProfile` is
the backend Hermes profile served by that route. They differ only when a Desktop
route explicitly maps to another backend profile (for example an SSH
`remoteProfile` override); profile names are bot identity, not connection secrets.
cron, kanban, …). `host.requestProfile` accepts a descriptor from
`host.profileRoutes()` and routes that RPC through its exact registry source and
profile without changing the active chat or gateway. The profile-only overload is
retained for unambiguous legacy v1/local callers; registry-aware plugins should pass
the descriptor so two sources exposing the same profile name cannot collide.
`host.profileRoutes()` inventories every reachable source in the current connection
registry. `connectionId` is the registry routing identity; pair it with `profile`
for keys and persistence. Endpoint, token, SSH host/key, and other raw connection
fields never cross the plugin IPC boundary. `profile` is the source-local route used
for requests; `targetProfile` is the backend Hermes profile served by that route.
They differ when a route explicitly maps to another backend profile (for example an
SSH `remoteProfile` override or a legacy per-profile URL alias). This distinction
preserves backend identity without exposing connection secrets.
Profile-shaped plugins get first-class methods too:
`profiles.list` (each profile + its most recent conversation as