diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 7b61b3bf4f..5bce688fc2 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -455,10 +455,13 @@ test('apiRequestRegistryConnectionId extracts a genuinely non-local connection i assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1') }) -test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => { +test('apiRequestRegistryConnectionId preserves an explicit local registry route', () => { + assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), 'local') +}) + +test('apiRequestRegistryConnectionId resolves null for unscoped legacy routes', () => { assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null) assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null) - assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null) assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null) assert.equal(apiRequestRegistryConnectionId(null), null) assert.equal(apiRequestRegistryConnectionId(undefined), null) diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 88c3ae2e00..74b5aace58 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -750,15 +750,16 @@ function pathWithProfileScope(path, profile) { /** * Registry connection a REST request is explicitly pinned to, or null for the - * legacy profile-routed path. `''`/`'local'` mean the local pool — callers - * only detour through the registry for a genuinely non-local connection, so - * single-source users keep the byte-identical v1 route. + * legacy profile-routed path. An explicit `local` id must stay registry-scoped: + * when the v1 route is remote, only the registry resolver can force the request + * back to this device. Single-source users omit the id and keep the + * byte-identical v1 route. */ function apiRequestRegistryConnectionId(request): null | string { const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : '' const id = String(raw ?? '').trim() - if (!id || id === 'local') { + if (!id) { return null } diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index a1f2bbb7a0..c97e0bb44a 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -28,6 +28,7 @@ import { REGISTRY_VERSION, rememberSshEnumeration, removeConnection, + resolvedConnectionId, resolveRegistryLocalRoute, setPrimaryConnection, shouldDeferLocalEnumeration, @@ -54,6 +55,49 @@ test('labelSlug kebab-cases and never returns empty for non-empty input', () => assert.equal(labelSlug('!!!'), 'connection') }) +test('resolvedConnectionId identifies local and migrated remote descriptors', () => { + const registry = migrateV1ToRegistry({ + mode: 'local', + profiles: { + personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' }, + work: { mode: 'ssh', host: 'work-host', user: 'root' } + } + }) + + const personal = registry.connections.find(connection => connection.kind === 'remote') + const work = registry.connections.find(connection => connection.kind === 'ssh') + + assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'https://personal.example:9443', + mode: 'remote', + remoteKind: 'url' + }), + personal?.id + ) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'http://127.0.0.1:49152', + mode: 'remote', + remoteHost: 'root@work-host', + remoteKind: 'ssh' + }), + work?.id + ) +}) + +test('resolvedConnectionId does not guess an unregistered remote', () => { + assert.equal( + resolvedConnectionId(emptyRegistry(), { + baseUrl: 'https://unknown.example', + mode: 'remote', + remoteKind: 'url' + }), + null + ) +}) + test('agentHandle bare when unique, @name-device shape when duplicated', () => { assert.equal(agentHandle('research', 'Homelab', false), 'research') assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab') diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index c9dcc09b2c..9c497e997f 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -178,6 +178,79 @@ export interface RegistryLocalRoute { poolKey: string } +export interface ResolvedConnectionDescriptor { + baseUrl?: string + mode?: 'local' | 'remote' + remoteHost?: string + remoteKind?: 'cloud' | 'ssh' | 'url' +} + +/** + * Recover registry identity for a descriptor resolved through the legacy v1 + * profile path. Registry-scoped routes already carry `connectionId`; this + * bridge keeps migrated per-profile remotes truthful until v1 is retired. + */ +export function resolvedConnectionId( + registry: ConnectionRegistry, + descriptor: ResolvedConnectionDescriptor +): null | string { + if (descriptor.mode === 'local') { + return registry.connections.find(connection => connection.kind === 'local')?.id ?? null + } + + if (descriptor.mode !== 'remote') { + return null + } + + if (descriptor.remoteKind === 'ssh') { + const remoteHost = String(descriptor.remoteHost || '') + .trim() + .toLowerCase() + + if (!remoteHost) { + return null + } + + return ( + registry.connections.find(connection => { + if (connection.kind !== 'ssh') { + return false + } + + const host = String(connection.host || '') + .trim() + .toLowerCase() + + const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host + + return target === remoteHost + })?.id ?? null + ) + } + + let baseUrl = '' + + try { + baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl) + } catch { + return null + } + + return ( + registry.connections.find(connection => { + if (connection.kind !== 'cloud' && connection.kind !== 'remote') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === baseUrl + } catch { + return false + } + })?.id ?? null + ) +} + /** * How the registry's 'local' entry resolves a backend for `profile`. * diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index eb5e9f3169..6a2f6dc780 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -106,6 +106,7 @@ import { normalizeRegistry, rememberSshEnumeration, removeConnection, + resolvedConnectionId, resolveRegistryLocalRoute, setPrimaryConnection, shouldDeferLocalEnumeration, @@ -11941,7 +11942,12 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:connection', async (_event, profile) => { + const connection = await ensureBackend(profile) + const connectionId = resolvedConnectionId(readDesktopConnectionsRegistry(), connection) + + return connectionId ? { ...connection, connectionId } : connection +}) // Registry-scoped variant: resolve a backend for (connectionId, profile). // connectionId '' / 'local' / the registry primary all behave sensibly; the // local kind delegates to ensureBackend when the v1 route is local, and @@ -11949,8 +11955,11 @@ ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(pro // registry 'local' entry always means this machine). ipcMain.handle('hermes:connection:for', async (_event, payload) => { const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any) + const registry = readDesktopConnectionsRegistry() + const id = String(connectionId || '').trim() || registry.primary + const connection = await ensureRegistryBackend(id, profile) - return ensureRegistryBackend(connectionId, profile) + return { ...connection, connectionId: id, registryScoped: true } }) // Reconnect-after-wake recovery. A REMOTE primary backend has no child process, // so the 'exit'/'error' handlers that would clear a dead connection promise never @@ -13485,7 +13494,8 @@ async function handleHermesApiRequest(request) { // profile's. Resolve the backend through the registry (same pool the job // list and WS traffic use) instead of the legacy profile route; a shared // remote/cloud host serves every profile via ?profile=, so scope the path. - // '' / 'local' fall through to the byte-identical v1 route below (#87882). + // An absent/empty id falls through to the byte-identical v1 route below. + // Explicit `local` stays registry-pinned so it cannot inherit a v1 remote. const registryConnectionId = apiRequestRegistryConnectionId(request) if (registryConnectionId) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 2a5136c744..bc53598dd0 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -661,6 +661,10 @@ export interface HermesConnection { // secondaries carry it directly; legacy primary remotes preserve it from // their selected stored route before dialing. connectionId?: string + // True only when getConnectionFor explicitly resolved a v2 registry route. + // An inferred connectionId identifies the visible source but its v1 profile + // name may still be a client-side routing alias rather than a backend profile. + registryScoped?: boolean // True only when `profile` is a request scope on the shared primary backend. // A pooled backend also carries `profile`, so presence alone cannot identify // the shared-primary routing case. @@ -1076,8 +1080,8 @@ export interface HermesApiRequest { // Route this REST call to a specific REGISTERED gateway connection (v2 // registry). Data owned by a remote gateway — cron jobs and their run // sessions — lives in that host's state.db, so requests for it must resolve - // through the owning connection, not the local profile pool. Omit / '' / - // 'local' keep the legacy profile-routed path. + // through the owning connection, not the local profile pool. Omit / '' to + // keep the legacy profile-routed path; explicit 'local' forces this device. connectionId?: string | null } diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 43c6fa052c..528891697d 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -24,6 +24,7 @@ import { listSidebarSessions, pluginSocket, resetSidebarBatchCapability, + setApiRequestConnection, setApiRequestProfile, speakText, transcribeAudio, @@ -52,6 +53,7 @@ describe('Hermes REST helpers', () => { }) afterEach(() => { + setApiRequestConnection(null) setApiRequestProfile(null) vi.restoreAllMocks() Reflect.deleteProperty(window, 'hermesDesktop') @@ -101,6 +103,40 @@ describe('Hermes REST helpers', () => { ) }) + it('routes session, profile, and model reads through the active registry source', async () => { + api.mockImplementation(async ({ path }: { path: string }) => + path.startsWith('/api/profiles/sessions/sidebar') + ? { recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } } + : emptySessionsResponse + ) + setApiRequestConnection('personal') + + await listSessions() + await listAllProfileSessions() + await listSidebarSessions({ + recentsProfile: 'default', + recentsLimit: 20, + recentsExclude: [], + cronLimit: 20, + messagingLimit: 20, + messagingExclude: [] + }) + await getProfiles() + await getGlobalModelInfo() + + for (const call of api.mock.calls) { + expect(call[0]).toEqual(expect.objectContaining({ connectionId: 'personal' })) + } + }) + + it('keeps an explicit This device source on REST requests', async () => { + setApiRequestConnection('local') + + await getProfiles() + + expect(api).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'local', path: '/api/profiles' })) + }) + it('defaults missing sidebar slices to empty session arrays', async () => { api.mockResolvedValue({}) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 296e33c20f..aab8cb1ab2 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1,6 +1,6 @@ import { JsonRpcGatewayClient } from '@hermes/shared' -import type { HermesConnection } from '@/global' +import type { HermesApiRequest, HermesConnection } from '@/global' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { recordTranscriptTail } from '@/store/transcript-tail' import type { @@ -288,8 +288,9 @@ export function setApiRequestConnection(connectionId: null | string): void { // Registry connection scope for a REST request. A registered remote gateway // owns its own state.db — cron jobs and their run sessions live THERE — so // requests for gateway-owned data must carry the connection id for the main -// process to route them to that host (hermes:api's registry branch). Null / -// 'local' resolves to no tag, keeping single-source users byte-identical. +// process to route them to that host (hermes:api's registry branch). Null +// resolves to no tag, keeping single-source users byte-identical; explicit +// 'local' must remain tagged when the legacy primary points elsewhere. function connectionScoped(): { connectionId?: string } { return _apiConnectionId ? { connectionId: _apiConnectionId } : {} } @@ -341,6 +342,12 @@ export function profileScopeKey(scope?: ProfileScope): string { return (scope ?? '').trim() || 'default' } +/** Send a REST request to the renderer's active registry source. Request-level + * routing may override the active source for an explicitly-owned resource. */ +export function hermesApi(request: HermesApiRequest): Promise { + return window.hermesDesktop.api({ ...connectionScoped(), ...request }) +} + /** Registry connection id that connection-scoped WS calls should target * (null → the local pool). Read-only twin of setApiRequestConnection. */ export function getApiRequestConnection(): null | string { @@ -400,7 +407,7 @@ export async function pluginRest(pluginId: string, path: string, opts: Plugin const suffix = pluginPathSuffix('pluginRest', path) - return window.hermesDesktop.api({ + return hermesApi({ path: `/api/plugins/${pluginId}${suffix}`, method: opts.method, body: opts.body, @@ -496,7 +503,7 @@ export async function listSessions( archived: 'exclude' | 'include' | 'only' = 'exclude', order: 'created' | 'recent' = 'recent' ): Promise { - const result = await window.hermesDesktop.api({ + const result = await hermesApi({ path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + `&archived=${archived}&order=${order}`, @@ -537,7 +544,7 @@ export async function listAllProfileSessions( ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` : '' - const result = await window.hermesDesktop.api({ + const result = await hermesApi({ path: `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`, @@ -670,7 +677,7 @@ async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise; scanned: string[] }> { - return window.hermesDesktop.api<{ + return hermesApi<{ pull_requests: Record scanned: string[] }>({ @@ -703,7 +710,7 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise< let result: SidebarSessionsResponse try { - result = await window.hermesDesktop.api({ + result = await hermesApi({ path: `/api/profiles/sessions/sidebar?${params.toString()}`, timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS }) @@ -729,7 +736,7 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise< // Mutations take the owning `profile` so Electron can route them to the correct // remote backend or local profile scope. Omit for the current/default profile. export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', @@ -742,7 +749,7 @@ export function setSessionArchived(id: string, archived: boolean, profile?: stri // pinned chat. Best-effort: the sidebar stays localStorage-driven for its own // display; this only feeds the backend policy. export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', @@ -755,7 +762,7 @@ export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: st // routing as the other session mutations: a remote session's row lives only // on its remote host, so the owning profile must travel with the request. export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', @@ -764,7 +771,7 @@ export function setSessionUnreadRemote(id: string, unread: boolean, profile?: st } export function searchSessions(query: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: `/api/sessions/search?q=${encodeURIComponent(query)}` }) } @@ -776,7 +783,7 @@ export function searchSessions(query: string): Promise { export function getSession(id: string, profile?: string | null): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' - return window.hermesDesktop.api({ + return hermesApi({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}${suffix}` }) @@ -815,7 +822,7 @@ export function getSessionMessages( const suffix = query.size ? `?${query.toString()}` : '' - return window.hermesDesktop.api({ + return hermesApi({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) @@ -918,7 +925,7 @@ export async function getAllSessionMessages( } export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' @@ -930,7 +937,7 @@ export function renameSession( title: string, profile?: string | null ): Promise<{ ok: boolean; title: string }> { - return window.hermesDesktop.api<{ ok: boolean; title: string }>({ + return hermesApi<{ ok: boolean; title: string }>({ ...(profile ? { profile } : {}), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', @@ -939,7 +946,7 @@ export function renameSession( } export function getGlobalModelInfo(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/model/info', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS @@ -947,7 +954,7 @@ export function getGlobalModelInfo(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/status' }) @@ -984,14 +991,14 @@ export function getLogs(params: { const suffix = query.toString() - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: suffix ? `/api/logs?${suffix}` : '/api/logs' }) } export function getHermesConfig(profile?: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/config', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS @@ -1006,7 +1013,7 @@ export function getHermesConfigRecord(profile?: ProfileScope): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/config/defaults', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS @@ -1014,14 +1021,14 @@ export function getHermesConfigDefaults(): Promise { } export function getHermesConfigSchema(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/config/schema' }) } export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(profile), path: '/api/config', method: 'PUT', @@ -1031,7 +1038,7 @@ export function saveHermesConfig(config: HermesConfigRecord, profile?: null | st // surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` }) @@ -1042,7 +1049,7 @@ export function saveMemoryProviderConfig( values: Record, profile?: null | string ): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(profile), path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, method: 'PUT', @@ -1051,7 +1058,7 @@ export function saveMemoryProviderConfig( } export function getEnvVars(profile?: null | string): Promise> { - return window.hermesDesktop.api>({ + return hermesApi>({ ...profileScoped(profile), path: '/api/env' }) @@ -1071,7 +1078,7 @@ export function validateProviderCredential( value: string, apiKey?: string ): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { - return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ + return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ ...profileScoped(), path: '/api/providers/validate', method: 'POST', @@ -1080,14 +1087,14 @@ export function validateProviderCredential( } export function getCustomEndpoints(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/providers/custom-endpoints' }) } export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/providers/custom-endpoints', method: 'POST', @@ -1096,7 +1103,7 @@ export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/providers/custom-endpoints/validate', method: 'POST', body: endpoint @@ -1104,7 +1111,7 @@ export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise< } export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { - return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ + return hermesApi<{ ok: boolean; provider: string; model: string }>({ ...profileScoped(), path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, method: 'POST' @@ -1112,7 +1119,7 @@ export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provi } export function deleteCustomEndpoint(id: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, method: 'DELETE' @@ -1138,14 +1145,14 @@ export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key } export function listOAuthProviders(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/providers/oauth' }) } export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { - return window.hermesDesktop.api<{ ok: boolean; provider: string }>({ + return hermesApi<{ ok: boolean; provider: string }>({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, method: 'DELETE' @@ -1162,7 +1169,7 @@ export function startOAuthLogin(providerId: string, profile?: ProfileScope): Pro } export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, method: 'POST', @@ -1182,7 +1189,7 @@ export function pollOAuthSession( } export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(), path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, method: 'DELETE' @@ -1195,7 +1202,7 @@ export function startMemoryProviderOAuth( provider: string, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, method: 'POST' @@ -1206,7 +1213,7 @@ export function getMemoryProviderOAuthStatus( provider: string, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` }) @@ -1232,7 +1239,7 @@ export function getSkillContent( } export function getStarmapGraph(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), // Backend REST contract — stays /api/learning even though the UI feature is // now "star map". Renaming this would break against an un-upgraded backend. @@ -1355,7 +1362,7 @@ export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise /** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server * "already in progress" slot so a retry doesn't 409. */ export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> { - return window.hermesDesktop.api<{ ok: boolean; status: string }>({ + return hermesApi<{ ok: boolean; status: string }>({ ...profileScoped(profile), path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`, method: 'DELETE' @@ -1463,14 +1470,14 @@ export function runToolsetPostSetup( } export function getTerminalBackends(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/tools/terminal/backends' }) } export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { - return window.hermesDesktop.api<{ ok: boolean; backend: string }>({ + return hermesApi<{ ok: boolean; backend: string }>({ ...profileScoped(), path: '/api/tools/terminal/backend', method: 'PUT', @@ -1479,14 +1486,14 @@ export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; b } export function getComputerUseStatus(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/tools/computer-use/status' }) } export function grantComputerUsePermissions(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/tools/computer-use/permissions/grant', method: 'POST' @@ -1494,7 +1501,7 @@ export function grantComputerUsePermissions(): Promise { } export function getMessagingPlatforms(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/messaging/platforms' }) @@ -1505,7 +1512,7 @@ export function updateMessagingPlatform( body: MessagingPlatformUpdate, profile?: null | string ): Promise<{ ok: boolean; platform: string }> { - return window.hermesDesktop.api<{ ok: boolean; platform: string }>({ + return hermesApi<{ ok: boolean; platform: string }>({ ...profileScoped(profile), path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, method: 'PUT', @@ -1517,7 +1524,7 @@ export function testMessagingPlatform( platformId: string, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, method: 'POST' @@ -1532,7 +1539,7 @@ export function testMessagingPlatform( // a row they can already see. export function getPairing(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/pairing' }) @@ -1543,7 +1550,7 @@ export function approvePairing( requestId: string, profile?: null | string ): Promise<{ ok: boolean; user: PairingUser }> { - return window.hermesDesktop.api<{ ok: boolean; user: PairingUser }>({ + return hermesApi<{ ok: boolean; user: PairingUser }>({ ...profileScoped(profile), path: '/api/pairing/approve', method: 'POST', @@ -1554,7 +1561,7 @@ export function approvePairing( } export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(profile), path: '/api/pairing/revoke', method: 'POST', @@ -1568,14 +1575,14 @@ export function revokePairing(platform: string, userId: string, profile?: null | // best-effort restarts the gateway; subscription changes hot-reload. export function getWebhooks(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/webhooks' }) } export function enableWebhooks(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/webhooks/enable', method: 'POST' @@ -1583,7 +1590,7 @@ export function enableWebhooks(): Promise { } export function createWebhook(body: WebhookCreatePayload): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/webhooks', method: 'POST', @@ -1592,7 +1599,7 @@ export function createWebhook(body: WebhookCreatePayload): Promise { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(), path: `/api/webhooks/${encodeURIComponent(name)}`, method: 'DELETE' @@ -1603,7 +1610,7 @@ export function setWebhookEnabled( name: string, enabled: boolean ): Promise<{ enabled: boolean; name: string; ok: boolean }> { - return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({ + return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({ ...profileScoped(), path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, method: 'PUT', @@ -1619,7 +1626,7 @@ export function setWebhookEnabled( export function getCronJobs(profile?: string): Promise { const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs${suffix}`, @@ -1628,7 +1635,7 @@ export function getCronJobs(profile?: string): Promise { } export function getCronJob(jobId: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}` @@ -1636,7 +1643,7 @@ export function getCronJob(jobId: string): Promise { } export async function getCronJobRuns(jobId: string, limit = 20): Promise { - const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({ + const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` @@ -1649,7 +1656,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise { - const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({ + const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({ ...profileScoped(), ...connectionScoped(), path: '/api/cron/delivery-targets' @@ -1659,7 +1666,7 @@ export async function getCronDeliveryTargets(): Promise { } export function createCronJob(body: CronJobCreatePayload): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: '/api/cron/jobs', @@ -1669,7 +1676,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise { } export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, @@ -1679,7 +1686,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, @@ -1688,7 +1695,7 @@ export function pauseCronJob(jobId: string): Promise { } export function resumeCronJob(jobId: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, @@ -1697,7 +1704,7 @@ export function resumeCronJob(jobId: string): Promise { } export function triggerCronJob(jobId: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, @@ -1707,7 +1714,7 @@ export function triggerCronJob(jobId: string): Promise { } export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, @@ -1727,7 +1734,7 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { // routing. instantiate creates a real per-profile job, so it names the target // profile explicitly via ?profile=. This mirrors the dashboard's api.ts. export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { - return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({ + return hermesApi<{ blueprints: AutomationBlueprint[] }>({ ...profileScoped(), ...connectionScoped(), path: '/api/cron/blueprints', @@ -1739,7 +1746,7 @@ export function instantiateAutomationBlueprint( body: { blueprint: string; values: Record }, profile: string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), ...connectionScoped(), path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, @@ -1749,14 +1756,14 @@ export function instantiateAutomationBlueprint( } export function getProfiles(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/profiles', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS }) } export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { - return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ + return hermesApi<{ name: string; ok: boolean; path: string }>({ path: '/api/profiles', method: 'POST', body @@ -1764,7 +1771,7 @@ export function createProfile(body: ProfileCreatePayload): Promise<{ name: strin } export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> { - return window.hermesDesktop.api<{ name: string; ok: boolean; path: string }>({ + return hermesApi<{ name: string; ok: boolean; path: string }>({ path: `/api/profiles/${encodeURIComponent(name)}`, method: 'PATCH', body: { new_name: newName } @@ -1772,20 +1779,20 @@ export function renameProfile(name: string, newName: string): Promise<{ name: st } export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { - return window.hermesDesktop.api<{ ok: boolean; path: string }>({ + return hermesApi<{ ok: boolean; path: string }>({ path: `/api/profiles/${encodeURIComponent(name)}`, method: 'DELETE' }) } export function getProfileSoul(name: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: `/api/profiles/${encodeURIComponent(name)}/soul` }) } export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ path: `/api/profiles/${encodeURIComponent(name)}/soul`, method: 'PUT', body: { content } @@ -1793,7 +1800,7 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok: } export function getProfileSetupCommand(name: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: `/api/profiles/${encodeURIComponent(name)}/setup-command` }) } @@ -1805,7 +1812,7 @@ export function exportProfileArchive( name: string, opts: { extraFiles?: Record; output?: string } = {} ): Promise<{ archive: string; ok: boolean }> { - return window.hermesDesktop.api<{ archive: string; ok: boolean }>({ + return hermesApi<{ archive: string; ok: boolean }>({ path: `/api/profiles/${encodeURIComponent(name)}/export`, method: 'POST', body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, @@ -1820,7 +1827,7 @@ export function importProfileArchive( archive: string, name?: string ): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { - return window.hermesDesktop.api<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ + return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ path: '/api/profiles/import', method: 'POST', body: { archive, name: name || null }, @@ -1857,7 +1864,7 @@ export function getGlobalModelOptions( params.set('explicit_only', '1') } - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS @@ -1878,7 +1885,7 @@ export function getRecommendedDefaultModel( provider: string, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` }) @@ -1888,7 +1895,7 @@ export function setGlobalModel( provider: string, model: string ): Promise<{ ok: boolean; provider: string; model: string }> { - return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ + return hermesApi<{ ok: boolean; provider: string; model: string }>({ ...profileScoped(), path: '/api/model/set', method: 'POST', @@ -1901,14 +1908,14 @@ export function setGlobalModel( } export function getAuxiliaryModels(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/model/auxiliary' }) } export function getMoaModels(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/model/moa' }) @@ -1918,7 +1925,7 @@ export function saveMoaModels( body: MoaConfigResponse, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/model/moa', method: 'PUT', @@ -1930,7 +1937,7 @@ export function setModelAssignment( body: ModelAssignmentRequest, profile?: null | string ): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/model/set', method: 'POST', @@ -1939,7 +1946,7 @@ export function setModelAssignment( } export function restartGateway(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/gateway/restart', method: 'POST' @@ -1947,7 +1954,7 @@ export function restartGateway(): Promise { } export function updateHermes(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/hermes/update', method: 'POST' @@ -1958,7 +1965,7 @@ export function updateHermes(): Promise { * authoritative source for the backend's behind-count + "what's changed", * distinct from the Electron client clone's git state. */ export function checkHermesUpdate(force = false): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: `/api/hermes/update/check${force ? '?force=true' : ''}` }) @@ -1972,7 +1979,7 @@ export function getActionStatus(name: string, lines = 200, profile?: ProfileScop } export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/audio/transcribe', method: 'POST', ...profileScoped(), @@ -1988,7 +1995,7 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/audio/speak', method: 'POST', @@ -2001,7 +2008,7 @@ export function speakText(text: string): Promise { } export function getElevenLabsVoices(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/audio/elevenlabs/voices', ...profileScoped(profile) }) @@ -2016,7 +2023,7 @@ export function getElevenLabsVoices(profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: '/api/skills/hub/sources', timeoutMs: HUB_REQUEST_TIMEOUT_MS @@ -2031,7 +2038,7 @@ export function searchSkillsHub( ): Promise { const params = new URLSearchParams({ q: query, source, limit: String(limit) }) - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/skills/hub/search?${params.toString()}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS @@ -2039,7 +2046,7 @@ export function searchSkillsHub( } export function previewSkillHub(identifier: string, profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS @@ -2047,7 +2054,7 @@ export function previewSkillHub(identifier: string, profile?: null | string): Pr } export function scanSkillHub(identifier: string, profile?: null | string): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(profile), path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, timeoutMs: HUB_REQUEST_TIMEOUT_MS @@ -2088,7 +2095,7 @@ export function updateSkillsFromHub(profile?: ProfileScope): Promise { - return window.hermesDesktop.api<{ servers: McpServerSummary[] }>({ + return hermesApi<{ servers: McpServerSummary[] }>({ ...profileScoped(), path: '/api/mcp/servers' }) @@ -2104,7 +2111,7 @@ export function addMcpServer(body: { env?: Record auth?: string }): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/mcp/servers', method: 'POST', @@ -2115,7 +2122,7 @@ export function addMcpServer(body: { /** Remove one server from `mcp_servers` (the inline setup card's rollback * when a directory install is cancelled after the config write). */ export function removeMcpServer(name: string): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(), path: `/api/mcp/servers/${encodeURIComponent(name)}`, method: 'DELETE' @@ -2123,7 +2130,7 @@ export function removeMcpServer(name: string): Promise<{ ok: boolean }> { } export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { - return window.hermesDesktop.api<{ ok: boolean }>({ + return hermesApi<{ ok: boolean }>({ ...profileScoped(), path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, method: 'PUT', @@ -2142,7 +2149,7 @@ export function getMcpCatalog(profile?: ProfileScope): Promise { - return window.hermesDesktop.api<{ available: boolean; authenticated: boolean }>({ + return hermesApi<{ available: boolean; authenticated: boolean }>({ ...profileScoped(), path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` }) @@ -2167,14 +2174,14 @@ export function installMcpCatalogEntry( // --------------------------------------------------------------------------- export function getMemoryStatus(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/memory' }) } export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { - return window.hermesDesktop.api<{ ok: boolean; deleted: string[] }>({ + return hermesApi<{ ok: boolean; deleted: string[] }>({ ...profileScoped(), path: '/api/memory/reset', method: 'POST', @@ -2183,14 +2190,14 @@ export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: bo } export function getCuratorStatus(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/curator' }) } export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { - return window.hermesDesktop.api<{ ok: boolean; paused: boolean }>({ + return hermesApi<{ ok: boolean; paused: boolean }>({ ...profileScoped(), path: '/api/curator/paused', method: 'PUT', @@ -2199,7 +2206,7 @@ export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused } export function runCurator(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ ...profileScoped(), path: '/api/curator/run', method: 'POST', @@ -2215,15 +2222,15 @@ export function runCurator(): Promise { // --------------------------------------------------------------------------- export function runDoctor(): Promise { - return window.hermesDesktop.api({ path: '/api/ops/doctor', method: 'POST', body: {} }) + return hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) } export function runSecurityAudit(): Promise { - return window.hermesDesktop.api({ path: '/api/ops/security-audit', method: 'POST', body: {} }) + return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) } export function runBackup(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/ops/backup', method: 'POST', body: {} @@ -2231,7 +2238,7 @@ export function runBackup(): Promise { } export function runDebugShare(): Promise { - return window.hermesDesktop.api({ + return hermesApi({ path: '/api/ops/debug-share', method: 'POST', body: {}, diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 6e23a7b07f..5e73d84c87 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -1,7 +1,7 @@ import { atom, batch, computed } from 'nanostores' import type { HermesConnection } from '@/global' -import { getProfiles, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes' +import { getProfiles, hermesApi, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes' import { invalidateProfileScopedQueries } from '@/lib/query-client' import { arraysEqual, @@ -141,7 +141,7 @@ export async function refreshActiveProfile(): Promise { const epoch = profileListEpoch try { - const res = await window.hermesDesktop.api({ + const res = await hermesApi({ path: '/api/profiles/active', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS })