From 36c1d8fd5f69ca2bed3d99905ac62a082ef93169 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 15:03:14 +0700 Subject: [PATCH 1/9] fix(desktop): restore managed profile boot and MCP reload --- apps/desktop/electron/eva-managed.cjs | 9 +++++ apps/desktop/electron/eva-managed.test.cjs | 13 ++++++- apps/desktop/electron/main.ts | 13 ++++--- .../gateway/hooks/use-gateway-boot.test.tsx | 35 +++++++++++++++++++ .../src/app/gateway/hooks/use-gateway-boot.ts | 6 +++- .../src/lib/desktop-slash-commands.test.ts | 24 +++++++++++++ .../desktop/src/lib/desktop-slash-commands.ts | 18 ++++++++-- 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/apps/desktop/electron/eva-managed.cjs b/apps/desktop/electron/eva-managed.cjs index 4d88a91b6fd15..5da784a0ddc21 100644 --- a/apps/desktop/electron/eva-managed.cjs +++ b/apps/desktop/electron/eva-managed.cjs @@ -714,6 +714,14 @@ function publicEvaEnrollmentStatus(state, now = Date.now()) { } } +function resolveEvaManagedDesktopProfile(response) { + const current = String(response?.current || '').trim() + if (current === 'default' || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(current)) { + throw new EvaBrokerError('evaOS Agent could not verify its assigned profile.', 502, 'invalid-profile-scope') + } + return current +} + module.exports = { EVA_MANAGED_POLICY, EvaBrokerError, @@ -737,5 +745,6 @@ module.exports = { parseEvaDesktopAuthCallback, pollEvaDeviceCode, publicEvaEnrollmentStatus, + resolveEvaManagedDesktopProfile, revokeEvaDesktopSession } diff --git a/apps/desktop/electron/eva-managed.test.cjs b/apps/desktop/electron/eva-managed.test.cjs index 09f74a4738ceb..c738814b4bd6d 100644 --- a/apps/desktop/electron/eva-managed.test.cjs +++ b/apps/desktop/electron/eva-managed.test.cjs @@ -21,7 +21,8 @@ const { normalizeHermesEnrollment, parseEvaDesktopAuthCallback, pollEvaDeviceCode, - publicEvaEnrollmentStatus + publicEvaEnrollmentStatus, + resolveEvaManagedDesktopProfile } = require('./eva-managed.cjs') const FUTURE = '2099-07-19T12:00:00.000Z' @@ -695,3 +696,13 @@ test('renderer-facing enrollment status never exposes tokens or backend URLs', ( assert.equal(status.agentId, 'jane') assert.doesNotMatch(serialized, /desktop-secret|runtime-secret|secret-endpoint/) }) + +test('managed desktop profile uses only the backend-authoritative current process identity', () => { + assert.equal(resolveEvaManagedDesktopProfile({ active: 'asuka-eva02', current: 'asuka-eva02' }), 'asuka-eva02') + for (const response of [null, {}, { current: 'default' }, { current: '../main' }, { current: 'ASUKA' }]) { + assert.throws( + () => resolveEvaManagedDesktopProfile(response), + error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope' + ) + } +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 741ff5866efce..6b7c64b35ed47 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -95,7 +95,8 @@ const { assertEvaManagedLocalMutationAllowed, assertEvaManagedLocalTerminalAllowed, buildEvaAccountRendererResetScript, - EVA_MANAGED_POLICY + EVA_MANAGED_POLICY, + resolveEvaManagedDesktopProfile } = require('./eva-managed.cjs') const { createEvaMediaGrantCodec } = require('./eva-media-grant.cjs') const { createEvaManagedRuntime } = require('./eva-runtime.cjs') @@ -10225,9 +10226,13 @@ ipcMain.handle('hermes:eva:sign-in', async () => evaManagedRuntime.signIn()) ipcMain.handle('hermes:eva:sign-out', async () => evaManagedRuntime.signOut()) ipcMain.handle('hermes:eva:refresh', async () => evaManagedRuntime.refresh()) -ipcMain.handle('hermes:profile:get', async () => ({ - profile: EVA_MANAGED_BUILD ? 'default' : readActiveDesktopProfile() -})) +ipcMain.handle('hermes:profile:get', async () => { + if (!EVA_MANAGED_BUILD) { + return { profile: readActiveDesktopProfile() } + } + const response = await evaManagedRuntime.requestApi({ path: '/api/profiles/active', method: 'GET' }) + return { profile: resolveEvaManagedDesktopProfile(response) } +}) ipcMain.handle('hermes:profile:set', async (_event, name) => { if (EVA_MANAGED_BUILD) { if (!name || name === 'default') { diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 451ca0689dd2b..430b5cf6b1889 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -3,6 +3,7 @@ import { MemoryRouter, useLocation, useNavigate } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $desktopBoot, applyDesktopBootProgress } from '@/store/boot' +import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, $sessionsLoading } from '@/store/session' import { takeGatewaySurvivor } from './gateway-hmr-survivor' @@ -170,6 +171,7 @@ beforeEach(() => { navigateRoute = null ;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket ;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop() + $activeGatewayProfile.set('default') $gatewayState.set('idle') $desktopBoot.set({ error: null, @@ -220,6 +222,39 @@ async function advanceBackoff() { } describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => { + it('adopts the backend-authoritative managed profile before session refresh', async () => { + const desktop = fakeDesktop() + desktop.profile.get = vi.fn(async () => ({ profile: 'asuka-eva02' })) + const refreshSessions = vi.fn(async () => undefined) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + + render( + + + + ) + await flushAsync() + + expect($activeGatewayProfile.get()).toBe('asuka-eva02') + expect(refreshSessions).toHaveBeenCalled() + expect($desktopBoot.get().error).toBeNull() + }) + + it('fails closed when a managed boot cannot verify its assigned profile', async () => { + const desktop = fakeDesktop() + desktop.profile.get = vi.fn(async () => { + throw new Error('assigned profile unavailable') + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + + renderHarness() + await flushAsync() + + expect($activeGatewayProfile.get()).toBe('default') + expect($desktopBoot.get().error).toBe('assigned profile unavailable') + expect($desktopBoot.get().visible).toBe(true) + }) + it('redirects managed sign-in-required boot to Gateway settings without a generic boot failure', async () => { const desktop = fakeDesktop() desktop.getConnection = vi.fn(async () => { diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index b3da7250b6cf1..57b149179932f 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -6,6 +6,7 @@ import { SETTINGS_ROUTE } from '@/app/routes' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' +import { isManagedEvaosAgent } from '@/i18n/managed-brand' import { desktopDefaultCwd } from '@/lib/desktop-fs' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { @@ -288,7 +289,10 @@ export function useGatewayBoot({ $activeGatewayProfile.set(profileKey) setPrimaryGateway(gateway, profileKey) void ensureGatewayForProfile(profileKey) - } catch { + } catch (error) { + if (isManagedEvaosAgent()) { + throw error + } $activeGatewayProfile.set('default') } } diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 08b98886ad2b2..5434c291645c0 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -150,6 +150,30 @@ describe('desktop slash command curation', () => { } }) + it('routes /reload-mcp through its confirmed current-session RPC', () => { + const command = resolveDesktopCommand('/reload-mcp') + const alias = resolveDesktopCommand('/reload_mcp') + expect(command?.surface.kind).toBe('rpc') + expect(alias?.name).toBe('/reload-mcp') + expect(isDesktopSlashSuggestion('/reload-mcp')).toBe(true) + expect(isDesktopSlashSuggestion('/reload_mcp')).toBe(false) + expect(desktopSlashCommandArgumentMode('/reload-mcp')).toBe('text') + + if (command?.surface.kind !== 'rpc') { + return + } + expect(command.surface.rpc).toBe('reload.mcp') + const context = { command: '/reload-mcp', name: 'reload-mcp', sessionId: 's-1' } + expect(command.surface.buildParams({ ...context, arg: '' })).toEqual({ session_id: 's-1' }) + expect(command.surface.buildParams({ ...context, arg: 'now' })).toEqual({ session_id: 's-1', confirm: true }) + expect(command.surface.buildParams({ ...context, arg: 'always' })).toEqual({ + session_id: 's-1', + confirm: true, + always: true + }) + expect(command.surface.buildParams({ ...context, arg: 'now please' })).toEqual({ session_id: 's-1' }) + }) + it('keeps commands with richer CLI semantics on the slash worker', () => { for (const name of ['/agents', '/steer', '/stop', '/usage']) { expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index ca37b9c637056..d2ca631635568 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -324,8 +324,22 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ { name: '/usage', description: 'Show token usage for this session', surface: exec() }, { name: '/version', description: 'Show evaOS Agent version', surface: exec() }, - // No desktop surface, but carry an alias (underscore spelling variants). - { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') }, + { + name: '/reload-mcp', + description: 'Reload MCP servers and refresh tools for this session', + aliases: ['/reload_mcp'], + surface: rpc('reload.mcp', ctx => { + const choice = ctx.arg.trim().toLowerCase() + if (choice === 'always') { + return { session_id: ctx.sessionId, confirm: true, always: true } + } + if (['now', 'approve', 'once', 'yes'].includes(choice)) { + return { session_id: ctx.sessionId, confirm: true } + } + return { session_id: ctx.sessionId } + }), + argumentMode: 'text' + }, { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } ] From a763edb8a97163f1df085dcfcacd60b2027047a9 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 17:20:26 +0700 Subject: [PATCH 2/9] fix(desktop): harden managed profile boot and MCP reload --- apps/desktop/electron/eva-managed.cjs | 15 +++++- apps/desktop/electron/eva-managed.test.cjs | 51 ++++++++++++++++++- apps/desktop/electron/main.ts | 13 +++-- apps/desktop/release-notes.md | 3 ++ .../gateway/hooks/use-gateway-boot.test.tsx | 51 +++++++++++++++++-- .../src/app/gateway/hooks/use-gateway-boot.ts | 13 ++++- .../hooks/use-prompt-actions/index.test.tsx | 28 ++++++++++ .../session/hooks/use-prompt-actions/slash.ts | 2 +- .../src/lib/desktop-slash-commands.test.ts | 3 ++ .../desktop/src/lib/desktop-slash-commands.ts | 40 ++++++++++----- 10 files changed, 194 insertions(+), 25 deletions(-) diff --git a/apps/desktop/electron/eva-managed.cjs b/apps/desktop/electron/eva-managed.cjs index 5da784a0ddc21..6f977065af7f8 100644 --- a/apps/desktop/electron/eva-managed.cjs +++ b/apps/desktop/electron/eva-managed.cjs @@ -715,13 +715,25 @@ function publicEvaEnrollmentStatus(state, now = Date.now()) { } function resolveEvaManagedDesktopProfile(response) { - const current = String(response?.current || '').trim() + const current = typeof response?.current === 'string' ? response.current.trim() : '' if (current === 'default' || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(current)) { throw new EvaBrokerError('evaOS Agent could not verify its assigned profile.', 502, 'invalid-profile-scope') } return current } +async function resolveEvaManagedDesktopProfileFromSources(readActiveProfile, readEnrollmentStatus) { + try { + return resolveEvaManagedDesktopProfile(await readActiveProfile()) + } catch (error) { + if (Number(error?.statusCode) !== 404) { + throw error + } + + return resolveEvaManagedDesktopProfile({ current: readEnrollmentStatus()?.agentId }) + } +} + module.exports = { EVA_MANAGED_POLICY, EvaBrokerError, @@ -746,5 +758,6 @@ module.exports = { pollEvaDeviceCode, publicEvaEnrollmentStatus, resolveEvaManagedDesktopProfile, + resolveEvaManagedDesktopProfileFromSources, revokeEvaDesktopSession } diff --git a/apps/desktop/electron/eva-managed.test.cjs b/apps/desktop/electron/eva-managed.test.cjs index c738814b4bd6d..24e9454c253da 100644 --- a/apps/desktop/electron/eva-managed.test.cjs +++ b/apps/desktop/electron/eva-managed.test.cjs @@ -22,7 +22,8 @@ const { parseEvaDesktopAuthCallback, pollEvaDeviceCode, publicEvaEnrollmentStatus, - resolveEvaManagedDesktopProfile + resolveEvaManagedDesktopProfile, + resolveEvaManagedDesktopProfileFromSources } = require('./eva-managed.cjs') const FUTURE = '2099-07-19T12:00:00.000Z' @@ -699,10 +700,56 @@ test('renderer-facing enrollment status never exposes tokens or backend URLs', ( test('managed desktop profile uses only the backend-authoritative current process identity', () => { assert.equal(resolveEvaManagedDesktopProfile({ active: 'asuka-eva02', current: 'asuka-eva02' }), 'asuka-eva02') - for (const response of [null, {}, { current: 'default' }, { current: '../main' }, { current: 'ASUKA' }]) { + for (const response of [ + null, + {}, + { current: 'default' }, + { current: '../main' }, + { current: 'ASUKA' }, + { current: true }, + { current: 123 } + ]) { assert.throws( () => resolveEvaManagedDesktopProfile(response), error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope' ) } }) + +test('managed desktop profile falls back to enrolled identity only when the active endpoint is absent', async () => { + const missing = Object.assign(new Error('404: missing'), { statusCode: 404 }) + assert.equal( + await resolveEvaManagedDesktopProfileFromSources( + async () => { + throw missing + }, + () => ({ agentId: 'asuka-eva02' }) + ), + 'asuka-eva02' + ) + + for (const error of [ + Object.assign(new Error('unauthorized'), { statusCode: 401 }), + Object.assign(new Error('forbidden'), { statusCode: 403 }), + Object.assign(new Error('unavailable'), { statusCode: 503 }), + new Error('transport failed') + ]) { + await assert.rejects( + () => + resolveEvaManagedDesktopProfileFromSources( + async () => Promise.reject(error), + () => ({ agentId: 'asuka-eva02' }) + ), + candidate => candidate === error + ) + } + + await assert.rejects( + () => resolveEvaManagedDesktopProfileFromSources(async () => ({ current: true }), () => ({ agentId: 'asuka-eva02' })), + error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope' + ) + await assert.rejects( + () => resolveEvaManagedDesktopProfileFromSources(async () => Promise.reject(missing), () => ({ agentId: 'default' })), + error => error instanceof EvaBrokerError && error.code === 'invalid-profile-scope' + ) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 6b7c64b35ed47..99545a00175e9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -96,7 +96,7 @@ const { assertEvaManagedLocalTerminalAllowed, buildEvaAccountRendererResetScript, EVA_MANAGED_POLICY, - resolveEvaManagedDesktopProfile + resolveEvaManagedDesktopProfileFromSources } = require('./eva-managed.cjs') const { createEvaMediaGrantCodec } = require('./eva-media-grant.cjs') const { createEvaManagedRuntime } = require('./eva-runtime.cjs') @@ -10230,16 +10230,23 @@ ipcMain.handle('hermes:profile:get', async () => { if (!EVA_MANAGED_BUILD) { return { profile: readActiveDesktopProfile() } } - const response = await evaManagedRuntime.requestApi({ path: '/api/profiles/active', method: 'GET' }) - return { profile: resolveEvaManagedDesktopProfile(response) } + + const profile = await resolveEvaManagedDesktopProfileFromSources( + () => evaManagedRuntime.requestApi({ path: '/api/profiles/active', method: 'GET' }), + () => evaManagedRuntime.status() + ) + + return { profile } }) ipcMain.handle('hermes:profile:set', async (_event, name) => { if (EVA_MANAGED_BUILD) { if (!name || name === 'default') { return { profile: 'default' } } + throw new Error('evaOS Agent uses the agent assigned by Electric Sheep; Desktop profiles cannot change it.') } + const next = writeActiveDesktopProfile(name) // Switching profiles is a backend re-home: relaunch the dashboard under the diff --git a/apps/desktop/release-notes.md b/apps/desktop/release-notes.md index 3b48ff432b7b4..35473f64054aa 100644 --- a/apps/desktop/release-notes.md +++ b/apps/desktop/release-notes.md @@ -1,5 +1,8 @@ # evaOS Agent 2026.7.20-es.12 +- Restores the assigned managed profile after every app restart so sessions load and new chats stay on the authorized agent. +- Loads managed MCP configuration before server discovery, avoiding a manual reload on fresh sessions. +- Restores `/reload-mcp` in the desktop command palette with the existing confirmation-preserving backend action. - Adds short-lived, profile-authoritative authentication for Pipedream's native MCP without placing developer or provider credentials on customer VMs. - Uses root-configured customer, Hermes agent, and app identity for that token refresh and no longer reads a per-app provider-grant file. - Runs tools annotated exactly `readOnlyHint: true` directly and routes every write-capable or unannotated MCP call through Hermes' existing approval mode before any connection or RPC. diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 430b5cf6b1889..e0b166d44e4a4 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -80,6 +80,10 @@ class FakeWebSocket { this.emit('close', {}) } + message(frame: unknown) { + this.emit('message', { data: JSON.stringify(frame) }) + } + private emit(type: string, ev: unknown) { for (const fn of this.listeners[type] ?? []) { fn(ev) @@ -126,13 +130,20 @@ function fakeDesktop() { function Harness({ beforeConnectionSwitch = () => undefined, + handleGatewayEvent = () => undefined, + onGatewayReady = () => undefined, refreshSessions -}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise } = {}) { +}: { + beforeConnectionSwitch?: () => void + handleGatewayEvent?: (event: { profile?: string }) => void + onGatewayReady?: (gateway: unknown) => void + refreshSessions?: () => Promise +} = {}) { useGatewayBoot({ beforeConnectionSwitch, - handleGatewayEvent: () => undefined, + handleGatewayEvent, onConnectionReady: () => undefined, - onGatewayReady: () => undefined, + onGatewayReady, refreshHermesConfig: async () => undefined, refreshSessions: refreshSessions ?? (async () => undefined) }) @@ -226,6 +237,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => const desktop = fakeDesktop() desktop.profile.get = vi.fn(async () => ({ profile: 'asuka-eva02' })) const refreshSessions = vi.fn(async () => undefined) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } render( @@ -240,19 +252,50 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($desktopBoot.get().error).toBeNull() }) + it('tags primary gateway events with the profile adopted during managed boot', async () => { + const desktop = fakeDesktop() + desktop.profile.get = vi.fn(async () => ({ profile: 'asuka-eva02' })) + const events: Array<{ profile?: string }> = [] + + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + + render( + + events.push(event)} /> + + ) + await flushAsync() + + FakeWebSocket.instances[0]?.message({ + jsonrpc: '2.0', + method: 'event', + params: { type: 'session.updated', session_id: 's-1' } + }) + + expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' })) + }) + it('fails closed when a managed boot cannot verify its assigned profile', async () => { const desktop = fakeDesktop() desktop.profile.get = vi.fn(async () => { throw new Error('assigned profile unavailable') }) ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + $activeGatewayProfile.set('previous-profile') + const gateways: unknown[] = [] - renderHarness() + render( + + gateways.push(gateway)} /> + + ) await flushAsync() expect($activeGatewayProfile.get()).toBe('default') expect($desktopBoot.get().error).toBe('assigned profile unavailable') expect($desktopBoot.get().visible).toBe(true) + expect(FakeWebSocket.instances[0]?.readyState).toBe(FakeWebSocket.CLOSED) + expect(gateways.at(-1)).toBeNull() }) it('redirects managed sign-in-required boot to Gateway settings without a generic boot failure', async () => { diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 57b149179932f..cac727b00bc7d 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -286,13 +286,23 @@ export function useGatewayBoot({ try { const pref = await desktop.profile?.get?.() const profileKey = (pref?.profile ?? '').trim() || 'default' + sourceProfile = profileKey $activeGatewayProfile.set(profileKey) setPrimaryGateway(gateway, profileKey) void ensureGatewayForProfile(profileKey) } catch (error) { if (isManagedEvaosAgent()) { + sourceProfile = 'default' + $activeGatewayProfile.set('default') + closeSecondaryGateways() + gateway.close() + publish(null) + callbacksRef.current.onGatewayReady(null) + setPrimaryGateway(null) + $gateway.set(null) throw error } + $activeGatewayProfile.set('default') } } @@ -409,6 +419,7 @@ export function useGatewayBoot({ } const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway() + let sourceProfile = normalizeProfileKey(survivor?.profile ?? $activeGatewayProfile.get()) callbacksRef.current.onGatewayReady(gateway) setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get())) @@ -442,8 +453,6 @@ export function useGatewayBoot({ } }) - const sourceProfile = normalizeProfileKey($activeGatewayProfile.get()) - const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }) ) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index d7f2416abe7c5..fa3a9fd2ea865 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -996,6 +996,34 @@ describe('usePromptActions exec fallback error reporting', () => { expect(renderedSeedTexts(seeds).some(text => text.includes('session status from slash worker'))).toBe(true) }) + it('does not bypass reload.mcp confirmation when the dedicated RPC is unavailable', async () => { + const seeds: Record[] = [] + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'reload.mcp') { + throw new Error('method not found: reload.mcp') + } + + throw new Error(`unexpected method: ${method}`) + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/reload-mcp now') + + expect(requestGateway).toHaveBeenCalledWith('reload.mcp', expect.objectContaining({ confirm: true }), 300_000) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything(), expect.anything()) + expect(renderedSeedTexts(seeds).some(text => text.includes('method not found: reload.mcp'))).toBe(true) + }) + it('still reports a real command.dispatch failure for skill/quick commands', async () => { const seeds: Record[] = [] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 765de6130f75f..c67dfcaa69f92 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -463,7 +463,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { // managed runtime exposes the dedicated RPC surface. Desktop and its // gateway update independently; older gateways still support the // slash-worker route. - if (isMissingRpcMethod(err)) { + if (surface.fallbackToExec !== false && isMissingRpcMethod(err)) { await runExec(ctx) return diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 5434c291645c0..a03cebb7c544a 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -162,7 +162,10 @@ describe('desktop slash command curation', () => { if (command?.surface.kind !== 'rpc') { return } + expect(command.surface.rpc).toBe('reload.mcp') + expect(command.surface.timeoutMs).toBe(300_000) + expect(command.surface.fallbackToExec).toBe(false) const context = { command: '/reload-mcp', name: 'reload-mcp', sessionId: 's-1' } expect(command.surface.buildParams({ ...context, arg: '' })).toEqual({ session_id: 's-1' }) expect(command.surface.buildParams({ ...context, arg: 'now' })).toEqual({ session_id: 's-1', confirm: true }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index d2ca631635568..6698cbca9ae83 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -94,6 +94,7 @@ export type DesktopCommandSurface = kind: 'rpc' rpc: string timeoutMs?: number + fallbackToExec?: boolean buildParams: (ctx: SlashCommandBuildCtx) => Record } | { kind: 'exec' } @@ -155,8 +156,15 @@ const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => const rpc = ( rpcName: string, buildParams: (ctx: SlashCommandBuildCtx) => Record, - timeoutMs?: number -): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams }) + timeoutMs?: number, + fallbackToExec = true +): DesktopCommandSurface => ({ + kind: 'rpc', + rpc: rpcName, + timeoutMs, + ...(fallbackToExec ? {} : { fallbackToExec: false }), + buildParams +}) /** * THE source of truth for desktop slash commands. Everything below — execution @@ -328,16 +336,24 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ name: '/reload-mcp', description: 'Reload MCP servers and refresh tools for this session', aliases: ['/reload_mcp'], - surface: rpc('reload.mcp', ctx => { - const choice = ctx.arg.trim().toLowerCase() - if (choice === 'always') { - return { session_id: ctx.sessionId, confirm: true, always: true } - } - if (['now', 'approve', 'once', 'yes'].includes(choice)) { - return { session_id: ctx.sessionId, confirm: true } - } - return { session_id: ctx.sessionId } - }), + surface: rpc( + 'reload.mcp', + ctx => { + const choice = ctx.arg.trim().toLowerCase() + + if (choice === 'always') { + return { session_id: ctx.sessionId, confirm: true, always: true } + } + + if (['now', 'approve', 'once', 'yes'].includes(choice)) { + return { session_id: ctx.sessionId, confirm: true } + } + + return { session_id: ctx.sessionId } + }, + 300_000, + false + ), argumentMode: 'text' }, { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } From 38355b97adbb1768ac618f86cfe52adfcbe400e3 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 17:37:57 +0700 Subject: [PATCH 3/9] fix(desktop): adopt managed profile before socket --- .../gateway/hooks/use-gateway-boot.test.tsx | 35 ++++++++++++++++++- .../src/app/gateway/hooks/use-gateway-boot.ts | 17 +++++---- .../hooks/use-prompt-actions/index.test.tsx | 2 +- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index e0b166d44e4a4..344a167ac898d 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -275,6 +275,39 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' })) }) + it('does not connect the managed gateway before the authoritative profile resolves', async () => { + const desktop = fakeDesktop() + let resolveProfile: ((value: { profile: string }) => void) | undefined + const profile = new Promise<{ profile: string }>(resolve => { + resolveProfile = resolve + }) + desktop.profile.get = vi.fn(() => profile) + const events: Array<{ profile?: string }> = [] + + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + + render( + + events.push(event)} /> + + ) + await flushAsync() + + expect(FakeWebSocket.instances).toHaveLength(0) + expect(events).toEqual([]) + + resolveProfile?.({ profile: 'asuka-eva02' }) + await flushAsync() + + FakeWebSocket.instances[0]?.message({ + jsonrpc: '2.0', + method: 'event', + params: { type: 'session.updated', session_id: 's-1' } + }) + + expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' })) + }) + it('fails closed when a managed boot cannot verify its assigned profile', async () => { const desktop = fakeDesktop() desktop.profile.get = vi.fn(async () => { @@ -294,7 +327,7 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($activeGatewayProfile.get()).toBe('default') expect($desktopBoot.get().error).toBe('assigned profile unavailable') expect($desktopBoot.get().visible).toBe(true) - expect(FakeWebSocket.instances[0]?.readyState).toBe(FakeWebSocket.CLOSED) + expect(FakeWebSocket.instances).toHaveLength(0) expect(gateways.at(-1)).toBeNull() }) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index cac727b00bc7d..c9bfd49a0fc21 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -541,6 +541,16 @@ export function useGatewayBoot({ return } + // Resolve the backend-authoritative managed profile before opening the + // socket. Gateway events can arrive as soon as the WebSocket opens; if + // profile adoption happens afterwards, those events can be tagged with + // the stale/default profile and leak state into the wrong session scope. + await adoptPrimaryProfile() + + if (cancelled) { + return + } + setDesktopBootStep({ phase: 'renderer.gateway.connect', message: translateNow('boot.steps.connectingGateway'), @@ -559,13 +569,6 @@ export function useGatewayBoot({ return } - // Profile adoption must land first: refreshSessions scopes its fetch by - // $profileScope ← $activeGatewayProfile. The remaining three fetches - // (cwd seed, config, sessions) are independent REST calls — running - // them serially added their sum to time-to-populated-sidebar when only - // the max is needed. - await adoptPrimaryProfile() - setDesktopBootStep({ phase: 'renderer.config', message: translateNow('boot.steps.loadingSettings'), diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index fa3a9fd2ea865..0c2e4e6496002 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1020,7 +1020,7 @@ describe('usePromptActions exec fallback error reporting', () => { await handle!.submitText('/reload-mcp now') expect(requestGateway).toHaveBeenCalledWith('reload.mcp', expect.objectContaining({ confirm: true }), 300_000) - expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything(), expect.anything()) + expect(requestGateway.mock.calls.some(([method]) => method === 'slash.exec')).toBe(false) expect(renderedSeedTexts(seeds).some(text => text.includes('method not found: reload.mcp'))).toBe(true) }) From 1d9adb071a90927cacdeab148f486a4467edd4d1 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 17:48:14 +0700 Subject: [PATCH 4/9] fix(desktop): scope applied gateways before reconnect --- .../gateway/hooks/use-gateway-boot.test.tsx | 37 +++++++++++++++++++ .../src/app/gateway/hooks/use-gateway-boot.ts | 12 ++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 344a167ac898d..dace4602e3e19 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -467,6 +467,43 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect($gatewayState.get()).toBe('open') }) + it('does not reconnect an applied managed gateway before its authoritative profile resolves', async () => { + const desktop = fakeDesktop() + const events: Array<{ profile?: string }> = [] + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + + render( + + events.push(event)} /> + + ) + await flushAsync() + expect(FakeWebSocket.instances).toHaveLength(1) + + let resolveProfile: ((value: { profile: string }) => void) | undefined + const profile = new Promise<{ profile: string }>(resolve => { + resolveProfile = resolve + }) + desktop.profile.get = vi.fn(() => profile) + + act(() => connectionApplied?.()) + await flushAsync() + + expect(FakeWebSocket.instances).toHaveLength(1) + expect(events).toEqual([]) + + resolveProfile?.({ profile: 'asuka-eva02' }) + await flushAsync() + + expect(FakeWebSocket.instances).toHaveLength(2) + FakeWebSocket.instances[1]?.message({ + jsonrpc: '2.0', + method: 'event', + params: { type: 'session.updated', session_id: 's-2' } + }) + expect(events).toContainEqual(expect.objectContaining({ profile: 'asuka-eva02' })) + }) + it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => { renderHarness() await flushAsync() diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index c9bfd49a0fc21..7bcc9f3774a62 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -345,6 +345,15 @@ export function useGatewayBoot({ return } + // A connection change can also change the backend-assigned profile. + // Adopt it before opening the replacement socket for the same reason as + // cold boot: events may arrive immediately after the handshake. + await adoptPrimaryProfile() + + if (cancelled) { + return + } + publish(conn) const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) @@ -353,9 +362,6 @@ export function useGatewayBoot({ return } - // Same shape as boot(): profile first (session scope depends on it), - // then the independent fetches concurrently. - await adoptPrimaryProfile() await Promise.all([ seedDefaultCwd(), callbacksRef.current.refreshHermesConfig().catch(() => undefined), From 247848a7e6226c2bd3c4a5537a327ff21a39dbdb Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 18:10:41 +0700 Subject: [PATCH 5/9] fix(desktop): keep gateway event scope atomic --- .../gateway/hooks/use-gateway-boot.test.tsx | 42 ++++++++++++++++++- .../src/app/gateway/hooks/use-gateway-boot.ts | 2 + 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index dace4602e3e19..f30bb98eb469a 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -331,6 +331,32 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect(gateways.at(-1)).toBeNull() }) + it('keeps non-managed event scope aligned when profile lookup falls back to default', async () => { + const desktop = fakeDesktop() + desktop.profile.get = vi.fn(async () => { + throw new Error('profile preference unavailable') + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = desktop + $activeGatewayProfile.set('previous-profile') + const events: Array<{ profile?: string }> = [] + + render( + + events.push(event)} /> + + ) + await flushAsync() + + FakeWebSocket.instances[0]?.message({ + jsonrpc: '2.0', + method: 'event', + params: { type: 'session.updated', session_id: 's-default' } + }) + + expect($activeGatewayProfile.get()).toBe('default') + expect(events).toContainEqual(expect.objectContaining({ profile: 'default' })) + }) + it('redirects managed sign-in-required boot to Gateway settings without a generic boot failure', async () => { const desktop = fakeDesktop() desktop.getConnection = vi.fn(async () => { @@ -492,7 +518,21 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => expect(FakeWebSocket.instances).toHaveLength(1) expect(events).toEqual([]) - resolveProfile?.({ profile: 'asuka-eva02' }) + await act(async () => { + resolveProfile?.({ profile: 'asuka-eva02' }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(FakeWebSocket.instances).toHaveLength(2) + expect(FakeWebSocket.instances[1]?.readyState).toBe(0) + FakeWebSocket.instances[0]?.message({ + jsonrpc: '2.0', + method: 'event', + params: { type: 'session.updated', session_id: 's-old' } + }) + expect(events).toEqual([]) + await flushAsync() expect(FakeWebSocket.instances).toHaveLength(2) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 7bcc9f3774a62..505ac1411e247 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -303,7 +303,9 @@ export function useGatewayBoot({ throw error } + sourceProfile = 'default' $activeGatewayProfile.set('default') + setPrimaryGateway(gateway, 'default') } } From 4acb26885dbcdb81564fdeb6a18ad2a86c2d28b7 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 18:21:43 +0700 Subject: [PATCH 6/9] fix(desktop): render mcp reload status --- .../hooks/use-prompt-actions/utils.test.ts | 20 +++++++++++++++++++ .../session/hooks/use-prompt-actions/utils.ts | 13 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts index bcc6ffc7f03e6..66f9f1d03dfac 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts @@ -202,6 +202,26 @@ describe('renderRpcResult', () => { }) }) + describe('reload.mcp', () => { + it('renders the backend confirmation warning instead of raw JSON', () => { + expect( + renderRpcResult( + { + status: 'confirm_required', + message: 'Reloading invalidates the prompt cache. Run /reload-mcp now to continue.' + }, + 'reload-mcp' + ) + ).toBe('Reloading invalidates the prompt cache. Run /reload-mcp now to continue.') + }) + + it('renders a concise completion message instead of raw JSON', () => { + expect(renderRpcResult({ status: 'reloaded', loaded_rev: 'rev-a' }, 'reload-mcp')).toBe( + 'MCP servers reloaded and tools refreshed for this session.' + ) + }) + }) + describe('process.stop', () => { it('reports the numeric number of stopped processes', () => { expect(renderRpcResult({ killed: 2 }, 'stop')).toBe('Stopped 2 background processes.') diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index cc9ba164ad750..fb737577adf90 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -233,6 +233,7 @@ export function slashStatusText(command: string, output: string): string { * - `session.save`: { file: "" } * - `session.usage`: { calls, input, output, total, credits_lines? } * - `session.steer`: { status: 'queued' | 'rejected', text } + * - `reload.mcp`: { status: 'confirm_required' | 'reloaded', message? } * - `process.stop`: { killed: boolean } * - `agents.list`: { processes: [{ session_id, command, status, uptime }] } * @@ -262,6 +263,18 @@ export function renderRpcResult(response: unknown, name: string): string { return lines.join('\n') } + // reload.mcp — surface the backend's confirmation warning verbatim and a + // concise completion line instead of exposing the raw RPC envelope. + if (name === 'reload-mcp' && (r.status === 'confirm_required' || r.status === 'reloaded')) { + if (r.status === 'confirm_required') { + return typeof r.message === 'string' && r.message.trim() + ? r.message.trim() + : 'Reloading MCP servers requires confirmation. Run /reload-mcp now to continue.' + } + + return 'MCP servers reloaded and tools refreshed for this session.' + } + // session.steer — { status: 'queued' | 'rejected', text } if (r.status === 'queued' || r.status === 'rejected') { const text = typeof r.text === 'string' ? r.text : '' From c47d5347bac09d9fdc885ec4a33b3c50d52c1fe9 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 18:25:22 +0700 Subject: [PATCH 7/9] test(desktop): cover mcp reload alias output --- .../app/session/hooks/use-prompt-actions/utils.test.ts | 9 +++++++++ .../src/app/session/hooks/use-prompt-actions/utils.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts index 66f9f1d03dfac..ca5d01af99e53 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts @@ -220,6 +220,15 @@ describe('renderRpcResult', () => { 'MCP servers reloaded and tools refreshed for this session.' ) }) + + it('renders the supported underscore alias without exposing the raw envelope', () => { + expect(renderRpcResult({ status: 'reloaded', loaded_rev: 'rev-a' }, 'reload_mcp')).toBe( + 'MCP servers reloaded and tools refreshed for this session.' + ) + expect( + renderRpcResult({ status: 'confirm_required', message: 'Confirm MCP reload.' }, 'reload_mcp') + ).toBe('Confirm MCP reload.') + }) }) describe('process.stop', () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index fb737577adf90..4d0bb953e31b2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -265,7 +265,7 @@ export function renderRpcResult(response: unknown, name: string): string { // reload.mcp — surface the backend's confirmation warning verbatim and a // concise completion line instead of exposing the raw RPC envelope. - if (name === 'reload-mcp' && (r.status === 'confirm_required' || r.status === 'reloaded')) { + if ((name === 'reload-mcp' || name === 'reload_mcp') && (r.status === 'confirm_required' || r.status === 'reloaded')) { if (r.status === 'confirm_required') { return typeof r.message === 'string' && r.message.trim() ? r.message.trim() From d81b6f9dfd28e285973eadb204b1e8a53ccc0ce2 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 18:27:43 +0700 Subject: [PATCH 8/9] fix(desktop): canonicalize mcp reload command name --- .../app/session/hooks/use-prompt-actions/utils.test.ts | 9 +++++++++ .../src/app/session/hooks/use-prompt-actions/utils.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts index ca5d01af99e53..8103e9b9e13da 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts @@ -229,6 +229,15 @@ describe('renderRpcResult', () => { renderRpcResult({ status: 'confirm_required', message: 'Confirm MCP reload.' }, 'reload_mcp') ).toBe('Confirm MCP reload.') }) + + it('renders case-insensitive command spellings without exposing the raw envelope', () => { + expect(renderRpcResult({ status: 'reloaded', loaded_rev: 'rev-a' }, 'RELOAD-MCP')).toBe( + 'MCP servers reloaded and tools refreshed for this session.' + ) + expect( + renderRpcResult({ status: 'confirm_required', message: 'Confirm MCP reload.' }, 'RELOAD_MCP') + ).toBe('Confirm MCP reload.') + }) }) describe('process.stop', () => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 4d0bb953e31b2..407a775daecb0 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -246,6 +246,7 @@ export function renderRpcResult(response: unknown, name: string): string { } const r = response as Record + const canonicalName = name.toLowerCase().replaceAll('_', '-') const summary = r.summary as { headline?: string; token_line?: string; note?: string; noop?: boolean } | undefined @@ -265,7 +266,7 @@ export function renderRpcResult(response: unknown, name: string): string { // reload.mcp — surface the backend's confirmation warning verbatim and a // concise completion line instead of exposing the raw RPC envelope. - if ((name === 'reload-mcp' || name === 'reload_mcp') && (r.status === 'confirm_required' || r.status === 'reloaded')) { + if (canonicalName === 'reload-mcp' && (r.status === 'confirm_required' || r.status === 'reloaded')) { if (r.status === 'confirm_required') { return typeof r.message === 'string' && r.message.trim() ? r.message.trim() From 50f1e722f7041c32ea3915215a8f4bd18de78399 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 31 Aug 2026 18:43:22 +0700 Subject: [PATCH 9/9] fix(desktop): align managed profile ids with Hermes --- apps/desktop/electron/eva-managed.cjs | 2 +- apps/desktop/electron/eva-managed.test.cjs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/eva-managed.cjs b/apps/desktop/electron/eva-managed.cjs index 6f977065af7f8..38d5acc6e4b15 100644 --- a/apps/desktop/electron/eva-managed.cjs +++ b/apps/desktop/electron/eva-managed.cjs @@ -716,7 +716,7 @@ function publicEvaEnrollmentStatus(state, now = Date.now()) { function resolveEvaManagedDesktopProfile(response) { const current = typeof response?.current === 'string' ? response.current.trim() : '' - if (current === 'default' || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(current)) { + if (current === 'default' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(current)) { throw new EvaBrokerError('evaOS Agent could not verify its assigned profile.', 502, 'invalid-profile-scope') } return current diff --git a/apps/desktop/electron/eva-managed.test.cjs b/apps/desktop/electron/eva-managed.test.cjs index 24e9454c253da..68529fcabc5e9 100644 --- a/apps/desktop/electron/eva-managed.test.cjs +++ b/apps/desktop/electron/eva-managed.test.cjs @@ -700,12 +700,16 @@ test('renderer-facing enrollment status never exposes tokens or backend URLs', ( test('managed desktop profile uses only the backend-authoritative current process identity', () => { assert.equal(resolveEvaManagedDesktopProfile({ active: 'asuka-eva02', current: 'asuka-eva02' }), 'asuka-eva02') + assert.equal(resolveEvaManagedDesktopProfile({ current: 'worker_alpha' }), 'worker_alpha') + assert.equal(resolveEvaManagedDesktopProfile({ current: 'worker-' }), 'worker-') + assert.equal(resolveEvaManagedDesktopProfile({ current: `a${'_'.repeat(63)}` }), `a${'_'.repeat(63)}`) for (const response of [ null, {}, { current: 'default' }, { current: '../main' }, { current: 'ASUKA' }, + { current: `a${'_'.repeat(64)}` }, { current: true }, { current: 123 } ]) {