fix(desktop): route plugin profiles through registry
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Vendored
+5
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user