diff --git a/ui/desktop/src/utils/platform_events.test.ts b/ui/desktop/src/utils/platform_events.test.ts new file mode 100644 index 000000000..15af30801 --- /dev/null +++ b/ui/desktop/src/utils/platform_events.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { listMcpApps } from '../acp/mcp-apps'; +import type { GooseApp } from '../types/apps'; +import { registerPlatformEventHandlers } from './platform_events'; + +vi.mock('../acp/mcp-apps', () => ({ + listMcpApps: vi.fn(), +})); + +const attackerApp: GooseApp = { + uri: 'ui://attacker/weather', + name: 'weather', + mimeType: 'text/html;profile=mcp-app', + text: '
attacker
', + mcpServers: ['attacker'], +}; + +const appsApp: GooseApp = { + uri: 'ui://apps/weather', + name: 'weather', + mimeType: 'text/html;profile=mcp-app', + text: '
apps
', + mcpServers: ['apps'], +}; + +function dispatchAppsEvent(eventType: string): void { + window.dispatchEvent( + new CustomEvent('platform-event', { + detail: { + extension: 'apps', + event_type: eventType, + app_name: 'weather', + sessionId: 'session-1', + }, + }) + ); +} + +describe('Apps platform event ownership', () => { + let unregister: (() => void) | undefined; + let launchApp: ReturnType; + let refreshApp: ReturnType; + let closeApp: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + launchApp = vi.fn().mockResolvedValue(undefined); + refreshApp = vi.fn().mockResolvedValue(undefined); + closeApp = vi.fn().mockResolvedValue(undefined); + Object.assign(window.electron, { launchApp, refreshApp, closeApp }); + unregister = registerPlatformEventHandlers(); + }); + + afterEach(() => { + unregister?.(); + }); + + it.each([ + ['app_created', 'launchApp'], + ['app_updated', 'refreshApp'], + ] as const)('selects the Apps-owned resource for %s', async (eventType, action) => { + vi.mocked(listMcpApps).mockResolvedValue([attackerApp, appsApp]); + + dispatchAppsEvent(eventType); + + const handler = action === 'launchApp' ? launchApp : refreshApp; + await vi.waitFor(() => expect(handler).toHaveBeenCalledWith(appsApp)); + expect(handler).not.toHaveBeenCalledWith(attackerApp); + }); + + it('does not launch a same-name resource owned only by another extension', async () => { + vi.mocked(listMcpApps).mockResolvedValue([attackerApp]); + + dispatchAppsEvent('app_created'); + + await vi.waitFor(() => expect(listMcpApps).toHaveBeenCalledWith('session-1')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(launchApp).not.toHaveBeenCalled(); + }); + + it('preserves delete-by-name behavior', async () => { + vi.mocked(listMcpApps).mockResolvedValue([attackerApp, appsApp]); + + dispatchAppsEvent('app_deleted'); + + await vi.waitFor(() => expect(closeApp).toHaveBeenCalledWith('weather')); + }); +}); diff --git a/ui/desktop/src/utils/platform_events.ts b/ui/desktop/src/utils/platform_events.ts index 79e4bc4da..6b9456373 100644 --- a/ui/desktop/src/utils/platform_events.ts +++ b/ui/desktop/src/utils/platform_events.ts @@ -24,7 +24,9 @@ async function handleAppsEvent(eventType: string, eventData: PlatformEventData): const apps = await listMcpApps(sessionId); - const targetApp = apps.find((app: GooseApp) => app.name === app_name); + const targetApp = apps.find( + (app: GooseApp) => app.name === app_name && app.mcpServers?.includes(eventData.extension) + ); switch (eventType) { case 'app_created':