Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/desktop/electron/connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
routeProfileForApiRequest,
tokenPreview
} from './connection-config'

Expand Down Expand Up @@ -141,6 +142,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', '', {
Expand All @@ -158,6 +170,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', () => {
Expand Down
29 changes: 23 additions & 6 deletions apps/desktop/electron/connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,31 @@ 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 = {}) {
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
}

Expand Down Expand Up @@ -307,5 +323,6 @@ export {
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
routeProfileForApiRequest,
tokenPreview
}
22 changes: 19 additions & 3 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
routeProfileForApiRequest,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
Expand Down Expand Up @@ -7413,17 +7414,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}`
Expand Down
24 changes: 22 additions & 2 deletions apps/desktop/electron/profile-delete-respawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
)
})
14 changes: 7 additions & 7 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 } : {}),
Expand Down