From 17271a8a6bb3aeadabb42dfae34932d24ca3fe9e Mon Sep 17 00:00:00 2001 From: addel Date: Sun, 16 Aug 2026 14:00:45 +1000 Subject: [PATCH] fix(desktop): route plugin profiles through registry --- apps/desktop/electron/main.ts | 60 +++++++-- .../electron/plugin-profile-routes.test.ts | 85 ++++++++++++- .../desktop/electron/plugin-profile-routes.ts | 115 ++++++++++++++++++ apps/desktop/src/global.d.ts | 7 +- apps/desktop/src/sdk/index.ts | 46 ++++++- .../src/store/gateway-profile-request.test.ts | 66 ++++++++++ apps/desktop/src/store/gateway.ts | 54 +++++++- .../developer-guide/desktop-plugin-sdk.md | 28 +++-- 8 files changed, 426 insertions(+), 35 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 510275bda3..2fc06ce473 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) }) }) diff --git a/apps/desktop/electron/plugin-profile-routes.test.ts b/apps/desktop/electron/plugin-profile-routes.test.ts index 88fd021543..3fee32f824 100644 --- a/apps/desktop/electron/plugin-profile-routes.test.ts +++ b/apps/desktop/electron/plugin-profile-routes.test.ts @@ -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 { 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([]) + }) +}) diff --git a/apps/desktop/electron/plugin-profile-routes.ts b/apps/desktop/electron/plugin-profile-routes.ts index a622926099..8b7e45a30b 100644 --- a/apps/desktop/electron/plugin-profile-routes.ts +++ b/apps/desktop/electron/plugin-profile-routes.ts @@ -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 globalConfig: ProfileRouteConfig @@ -33,6 +51,36 @@ interface BuildOpaqueProfileRoutesOptions { resolveSsh: (config: ProfileRouteConfig) => Promise } +/** 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() + 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() +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 507f371066..80a4ae236c 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -31,8 +31,9 @@ declare global { }) => Promise // Union agent roster across every registered connection. getAgentRoster?: () => Promise - // 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 // 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 diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1973438502..671e5706b3 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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(readViewport()) +async function requestPluginProfile( + route: PluginProfileRoute | string, + method: string, + params: Record +): Promise { + if (typeof route !== 'string') { + return requestGatewayForAgent(route.connectionId, route.profile, method, params) + } + + const getAgentRoster = window.hermesDesktop?.getAgentRoster + + if (!getAgentRoster) { + return requestGatewayForProfile(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(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 (profile: string, method: string, params: Record = {}): Promise => - requestGatewayForProfile(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 ( + route: PluginProfileRoute | string, + method: string, + params: Record = {} + ): Promise => requestPluginProfile(route, method, params), /** Gateway JSON-RPC — sessions, config, skills, cron, kanban, everything * the app itself uses. Lazy: resolves the LIVE socket per call. */ diff --git a/apps/desktop/src/store/gateway-profile-request.test.ts b/apps/desktop/src/store/gateway-profile-request.test.ts index d9c2eabe5d..61f0db42cd 100644 --- a/apps/desktop/src/store/gateway-profile-request.test.ts +++ b/apps/desktop/src/store/gateway-profile-request.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 0fa9349dfc..6d4ad28a21 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -220,15 +220,17 @@ async function openSecondary(entry: Secondary): Promise { ? 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( } } +/** + * 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( + connectionId: null | string, + profile: string, + method: string, + params: Record = {} +): Promise { + const key = normKey(profile) + const scope = backendScopeKey(connectionId, key) + + if (scope === key) { + return requestGatewayForProfile(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(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 diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index 542160f626..1b0c3729a0 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -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(profile, method, params?) // routed RPC; no foreground swap +host.requestProfile(route, method, params?) // registry-routed RPC; no foreground swap +host.requestProfile(profile, method, params?) // legacy v1/local overload host.request(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