From 9b0c35976703b8657eaebb0f3c76721599d0b88b Mon Sep 17 00:00:00 2001 From: embwl0x Date: Fri, 10 Jul 2026 05:53:03 -0400 Subject: [PATCH] fix(desktop): avoid local profile REST backend spawns --- .../electron/connection-config.test.ts | 46 +++++++++++++++++++ apps/desktop/electron/connection-config.ts | 32 ++++++++++--- apps/desktop/electron/main.ts | 22 +++++++-- .../electron/profile-delete-respawn.test.ts | 24 +++++++++- apps/desktop/src/hermes.ts | 14 +++--- 5 files changed, 120 insertions(+), 18 deletions(-) diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 518d49805df8..0dd2a3627c6e 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -30,6 +30,7 @@ import { resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + routeProfileForApiRequest, tokenPreview } from './connection-config' @@ -171,6 +172,17 @@ test('pathWithGlobalRemoteProfile skips local and per-profile remote override pa ) }) +test('pathWithGlobalRemoteProfile appends local-primary profile scope', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/config', 'iris', { + globalRemote: false, + localPrimary: true, + profileRemoteOverride: false + }), + '/api/config?profile=iris' + ) +}) + test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => { assert.equal( pathWithGlobalRemoteProfile('/api/model/info', '', { @@ -188,6 +200,40 @@ test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => { ) }) +// --- routeProfileForApiRequest --- + +test('routeProfileForApiRequest keeps local profile REST on the primary backend', () => { + assert.equal( + routeProfileForApiRequest('iris', { + globalRemote: false, + profileRemoteOverride: false + }), + null + ) +}) + +test('routeProfileForApiRequest routes remote profile scopes through profile descriptors', () => { + assert.equal( + routeProfileForApiRequest('iris', { + globalRemote: false, + profileRemoteOverride: true + }), + 'iris' + ) + assert.equal( + routeProfileForApiRequest('iris', { + globalRemote: true, + profileRemoteOverride: false + }), + 'iris' + ) +}) + +test('routeProfileForApiRequest ignores empty profile scopes', () => { + assert.equal(routeProfileForApiRequest('', { globalRemote: true }), null) + assert.equal(routeProfileForApiRequest(null, { profileRemoteOverride: true }), null) +}) + // --- normalizeRemoteBaseUrl --- test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 569f9cc07261..a9d7ae597da7 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -197,15 +197,34 @@ function profileRemoteOverride(config, profile) { } /** - * In global-remote mode one backend serves every Desktop profile, so REST calls - * that are scoped by renderer-side `request.profile` must carry that scope as a - * query parameter. Local pooled backends and per-profile remote overrides do not - * need this: they already run against a backend scoped to the target profile. + * Decide whether a profile-scoped REST request must resolve a profile backend. + * Per-profile remotes are real separate hosts, and app-global remote mode may + * need a cached remote descriptor per active profile. Local profiles should NOT + * spawn a pooled backend for ordinary REST: the primary dashboard can serve + * local profiles through the `?profile=` query param. */ -function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) { +function routeProfileForApiRequest( + profile, + opts: { globalRemote?: boolean; profileRemoteOverride?: boolean } = {}, +) { const scopedProfile = connectionScopeKey(profile) + if (!scopedProfile) { + return null + } + + return opts.globalRemote || opts.profileRemoteOverride ? scopedProfile : null +} - if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) { +/** + * Add renderer-side `request.profile` to a REST path when the selected backend + * is not already scoped to that profile. This covers app-global remote mode and + * local profiles served through the primary dashboard. Per-profile remote + * overrides are already profile-scoped, so sending `?profile=` there would ask + * the remote host for a profile name it may not have. + */ +function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) { + const scopedProfile = connectionScopeKey(profile) + if (!scopedProfile || opts.profileRemoteOverride || (!opts.globalRemote && !opts.localPrimary)) { return path } @@ -346,5 +365,6 @@ export { resolveAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, + routeProfileForApiRequest, tokenPreview } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ba50d6d7cc1b..1eb0b16ad619 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -49,6 +49,7 @@ import { normAuthMode, pathWithGlobalRemoteProfile, profileRemoteOverride, + routeProfileForApiRequest, resolveAuthMode, resolveTestWsUrl, tokenPreview @@ -7913,17 +7914,32 @@ ipcMain.handle('hermes:api', async (_event, request) => { const tornDownProfile = await prepareProfileDeleteRequest(request) const profile = request?.profile + const useGlobalRemote = globalRemoteActive() + const useProfileRemote = profileHasRemoteOverride(profile) // After tearing down a backend for profile deletion, route to the primary // backend instead of spawning a fresh pool backend. A freshly spawned // backend calls ensure_hermes_home() which recreates the profile directory, // defeating the deletion and leaving a zombie process. - const routeProfile = tornDownProfile ? null : profile + // + // For ordinary local-profile REST calls, also stay on the primary dashboard: + // the backend serves local profiles through ?profile=. Spawning an isolated + // per-profile dashboard here made Hermes One launch multiple backend servers + // during startup as soon as the renderer refreshed profile-scoped settings. + // Per-profile remotes and app-global remote mode still resolve through the + // profile route so they hit the correct host/descriptor. + const routeProfile = tornDownProfile + ? null + : routeProfileForApiRequest(profile, { + globalRemote: useGlobalRemote, + profileRemoteOverride: useProfileRemote + }) const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { - globalRemote: globalRemoteActive(), - profileRemoteOverride: profileHasRemoteOverride(profile) + globalRemote: useGlobalRemote, + localPrimary: !routeProfile && !tornDownProfile, + profileRemoteOverride: useProfileRemote }) const url = `${connection.baseUrl}${requestPath}` diff --git a/apps/desktop/electron/profile-delete-respawn.test.ts b/apps/desktop/electron/profile-delete-respawn.test.ts index 2fbddfc5a596..09b6286673f2 100644 --- a/apps/desktop/electron/profile-delete-respawn.test.ts +++ b/apps/desktop/electron/profile-delete-respawn.test.ts @@ -49,8 +49,8 @@ test('hermes:api handler routes profile-delete requests to the primary backend', // torn-down profile, routing to the primary (null) instead. assert.match( source, - /const routeProfile = tornDownProfile \? null : profile/, - 'handler should route to primary backend when a profile was just torn down' + /const routeProfile = tornDownProfile\s+\?\s+null\s+:\s+routeProfileForApiRequest\(profile,/, + 'handler should route to primary backend when a profile was just torn down, else use REST profile routing' ) // ensureBackend must be called with the conditional route profile. @@ -60,3 +60,23 @@ test('hermes:api handler routes profile-delete requests to the primary backend', 'handler should pass routeProfile (not raw profile) to ensureBackend' ) }) + +test('hermes:api handler does not spawn local profile backends for REST requests', () => { + const source = readElectronFile('main.ts') + + assert.match( + source, + /routeProfileForApiRequest\(profile,/, + 'handler should use the REST-specific profile routing helper' + ) + assert.match( + source, + /const connection = await ensureBackend\(routeProfile\)/, + 'handler should resolve the computed route profile, not request.profile directly' + ) + assert.match( + source, + /localPrimary: !routeProfile && !tornDownProfile/, + 'local profile REST should stay on the primary backend and carry ?profile= instead' + ) +}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a902e4b53e1c..f4b5f727ceaf 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -182,9 +182,10 @@ export class HermesGateway extends JsonRpcGatewayClient { // Profile that profile-scoped REST settings (config/env/skills/tools/model/…) // should target. Mirrors $activeGatewayProfile, pushed in from the store via // setApiRequestProfile so this module needs no store import (avoids a cycle). -// Electron main consumes request.profile to pick which backend *process* serves -// the call; each pooled backend already has its own HERMES_HOME, so no backend -// change is needed. Null → primary, so single-profile users are unaffected. +// Electron main consumes request.profile as request scope: local profiles stay +// on the primary dashboard via ?profile=, while remote profile overrides still +// route to the owning remote backend. Null → primary, so single-profile users +// are unaffected. let _apiProfile: null | string = null export function setApiRequestProfile(profile: null | string): void { @@ -256,10 +257,9 @@ export async function listAllProfileSessions( } } -// Mutations take the owning `profile` so Electron routes them to that profile's -// backend (remote pool or local primary) via request.profile — matching the -// read path. A remote session's row lives only on its remote host, so a mutation -// that hit the local primary would no-op or 404. Omit for the current/default. +// Mutations take the owning `profile` so Electron can scope the request: +// remote-owned sessions route to their remote backend, while local profiles are +// addressed through the primary dashboard with ?profile=. Omit for the current/default. export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...(profile ? { profile } : {}),