diff --git a/apps/desktop/electron/eva-managed.cjs b/apps/desktop/electron/eva-managed.cjs index 4d88a91b6fd15..38d5acc6e4b15 100644 --- a/apps/desktop/electron/eva-managed.cjs +++ b/apps/desktop/electron/eva-managed.cjs @@ -714,6 +714,26 @@ 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,63}$/.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, @@ -737,5 +757,7 @@ module.exports = { parseEvaDesktopAuthCallback, pollEvaDeviceCode, publicEvaEnrollmentStatus, + resolveEvaManagedDesktopProfile, + resolveEvaManagedDesktopProfileFromSources, revokeEvaDesktopSession } diff --git a/apps/desktop/electron/eva-managed.test.cjs b/apps/desktop/electron/eva-managed.test.cjs index 09f74a4738ceb..68529fcabc5e9 100644 --- a/apps/desktop/electron/eva-managed.test.cjs +++ b/apps/desktop/electron/eva-managed.test.cjs @@ -21,7 +21,9 @@ const { normalizeHermesEnrollment, parseEvaDesktopAuthCallback, pollEvaDeviceCode, - publicEvaEnrollmentStatus + publicEvaEnrollmentStatus, + resolveEvaManagedDesktopProfile, + resolveEvaManagedDesktopProfileFromSources } = require('./eva-managed.cjs') const FUTURE = '2099-07-19T12:00:00.000Z' @@ -695,3 +697,63 @@ 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') + 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 } + ]) { + 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 741ff5866efce..99545a00175e9 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, + resolveEvaManagedDesktopProfileFromSources } = require('./eva-managed.cjs') const { createEvaMediaGrantCodec } = require('./eva-media-grant.cjs') const { createEvaManagedRuntime } = require('./eva-runtime.cjs') @@ -10225,16 +10226,27 @@ 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 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 451ca0689dd2b..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 @@ -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' @@ -79,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) @@ -125,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) }) @@ -170,6 +182,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 +233,130 @@ 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('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('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 () => { + throw new Error('assigned profile unavailable') + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { ...desktop, eva: {} } + $activeGatewayProfile.set('previous-profile') + const gateways: unknown[] = [] + + 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).toHaveLength(0) + 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 () => { @@ -356,6 +493,57 @@ 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([]) + + 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) + 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 b3da7250b6cf1..505ac1411e247 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 { @@ -285,11 +286,26 @@ 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 { + } 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 + } + + sourceProfile = 'default' $activeGatewayProfile.set('default') + setPrimaryGateway(gateway, 'default') } } @@ -331,6 +347,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) @@ -339,9 +364,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), @@ -405,6 +427,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())) @@ -438,8 +461,6 @@ export function useGatewayBoot({ } }) - const sourceProfile = normalizeProfileKey($activeGatewayProfile.get()) - const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile }) ) @@ -528,6 +549,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'), @@ -546,13 +577,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 d7f2416abe7c5..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 @@ -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.mock.calls.some(([method]) => method === 'slash.exec')).toBe(false) + 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/app/session/hooks/use-prompt-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts index bcc6ffc7f03e6..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 @@ -202,6 +202,44 @@ 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.' + ) + }) + + 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.') + }) + + 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', () => { 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..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 @@ -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 }] } * @@ -245,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 @@ -262,6 +264,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 (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() + : '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 : '' diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 08b98886ad2b2..a03cebb7c544a 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -150,6 +150,33 @@ 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') + 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 }) + 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..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 @@ -324,8 +332,30 @@ 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 } + }, + 300_000, + false + ), + argumentMode: 'text' + }, { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } ]