diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index e071e3595ad3..1e248dd1183c 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -4,9 +4,9 @@ import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-route import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks'; import { type SharedSessionDetails } from './sharedSessions'; import { ErrorUI } from './components/ErrorBoundary'; -import { ConfirmationModal } from './components/ui/ConfirmationModal'; +import { ExtensionInstallModal } from './components/modals/ExtensionInstallModal'; +import { useExtensionInstallModal } from './hooks/useExtensionInstallModal'; import { ToastContainer } from 'react-toastify'; -import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; import AnnouncementModal from './components/AnnouncementModal'; import { generateSessionId } from './sessions'; @@ -28,7 +28,6 @@ import { DraftProvider } from './contexts/DraftContext'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; -import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; import { type SessionDetails } from './sessions'; @@ -397,11 +396,6 @@ const ExtensionsRoute = () => { export default function App() { const [fatalError, setFatalError] = useState(null); - const [modalVisible, setModalVisible] = useState(false); - const [pendingLink, setPendingLink] = useState(null); - const [modalMessage, setModalMessage] = useState(''); - const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); - const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); const [isLoadingSession, setIsLoadingSession] = useState(false); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); @@ -418,6 +412,8 @@ export default function App() { const { getExtensions, addExtension, read } = useConfig(); const initAttemptedRef = useRef(false); + const { modalState, modalConfig, dismissModal, confirmInstall } = + useExtensionInstallModal(addExtension); // Create a setView function for useChat hook - we'll use window.history instead of navigate const setView = (view: View, viewOptions: ViewOptions = {}) => { @@ -471,18 +467,6 @@ export default function App() { const { chat, setChat } = useChat({ setIsLoadingSession, setView, setPairChat }); - function extractCommand(link: string): string { - const url = new URL(link); - const cmd = url.searchParams.get('cmd') || 'Unknown Command'; - const args = url.searchParams.getAll('arg').map(decodeURIComponent); - return `${cmd} ${args.join(' ')}`.trim(); - } - - function extractRemoteUrl(link: string): string | null { - const url = new URL(link); - return url.searchParams.get('url'); - } - useEffect(() => { if (initAttemptedRef.current) { console.log('Initialization already attempted, skipping...'); @@ -751,84 +735,6 @@ export default function App() { return () => window.electron.off('set-view', handleSetView); }, []); - const config = window.electron.getConfig(); - const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING !== true; - - useEffect(() => { - console.log('Setting up extension handler'); - const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { - const link = args[0] as string; - try { - console.log(`Received add-extension event with link: ${link}`); - const command = extractCommand(link); - const remoteUrl = extractRemoteUrl(link); - const extName = extractExtensionName(link); - window.electron.logInfo(`Adding extension from deep link ${link}`); - setPendingLink(link); - let warningMessage = ''; - let label = 'OK'; - let title = 'Confirm Extension Installation'; - let isBlocked = false; - let useDetailedMessage = false; - if (remoteUrl) { - useDetailedMessage = true; - } else { - try { - const allowedCommands = await window.electron.getAllowedExtensions(); - if (allowedCommands && allowedCommands.length > 0) { - const isCommandAllowed = allowedCommands.some((allowedCmd: string) => - command.startsWith(allowedCmd) - ); - if (!isCommandAllowed) { - useDetailedMessage = true; - title = '⛔️ Untrusted Extension ⛔️'; - if (STRICT_ALLOWLIST) { - isBlocked = true; - label = 'Extension Blocked'; - warningMessage = - '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + - 'Installation is blocked by your administrator. ' + - 'Please contact your administrator if you need this extension.'; - } else { - label = 'Override and install'; - warningMessage = - '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + - 'Installing extensions from untrusted sources may pose security risks. ' + - 'Please contact an admin if you are unsure or want to allow this extension.'; - } - } - } - } catch (error) { - console.error('Error checking allowlist:', error); - } - } - if (useDetailedMessage) { - const detailedMessage = remoteUrl - ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` - : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; - setModalMessage(`${detailedMessage}${warningMessage}`); - } else { - const messageDetails = `Command: ${command}`; - setModalMessage( - `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` - ); - } - setExtensionConfirmLabel(label); - setExtensionConfirmTitle(title); - if (isBlocked) { - setPendingLink(null); - } - setModalVisible(true); - } catch (error) { - console.error('Error handling add-extension event:', error); - } - }; - window.electron.on('add-extension', handleAddExtension); - return () => { - window.electron.off('add-extension', handleAddExtension); - }; - }, [STRICT_ALLOWLIST]); - useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; @@ -842,41 +748,15 @@ export default function App() { }; }, []); - const handleConfirm = async () => { - if (pendingLink) { - console.log(`Confirming installation of extension from: ${pendingLink}`); - setModalVisible(false); - try { - await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { - console.log('Extension deep link handler called with view:', view, 'options:', options); - switch (view) { - case 'settings': - window.location.hash = '#/extensions'; - // Store the config for the extensions route - window.history.replaceState(options, '', '#/extensions'); - break; - default: - window.location.hash = `#/${view}`; - } - }); - console.log('Extension installation successful'); - } catch (error) { - console.error('Failed to add extension:', error); - } finally { - setPendingLink(null); - } + const handleExtensionConfirm = async () => { + const result = await confirmInstall(); + if (result.success) { + console.log('Extension installation completed successfully'); } else { - console.log('Extension installation blocked by allowlist restrictions'); - setModalVisible(false); + console.error('Extension installation failed:', result.error); } }; - const handleCancel = () => { - console.log('Cancelled extension installation.'); - setModalVisible(false); - setPendingLink(null); - }; - if (fatalError) { return ; } @@ -908,16 +788,14 @@ export default function App() { closeOnClick pauseOnHover /> - {modalVisible && ( - - )} +
diff --git a/ui/desktop/src/components/modals/ExtensionInstallModal.tsx b/ui/desktop/src/components/modals/ExtensionInstallModal.tsx new file mode 100644 index 000000000000..86f6321923a1 --- /dev/null +++ b/ui/desktop/src/components/modals/ExtensionInstallModal.tsx @@ -0,0 +1,88 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { ModalType, ExtensionModalConfig } from '../../types/extension'; + +interface ExtensionInstallModalProps { + isOpen: boolean; + modalType: ModalType; + config: ExtensionModalConfig | null; + onConfirm: () => void; + onCancel: () => void; + isSubmitting?: boolean; +} + +export function ExtensionInstallModal({ + isOpen, + modalType, + config, + onConfirm, + onCancel, + isSubmitting = false, +}: ExtensionInstallModalProps) { + if (!config) return null; + + const getConfirmButtonVariant = () => { + switch (modalType) { + case 'blocked': + return 'outline'; + case 'untrusted': + return 'destructive'; + case 'trusted': + default: + return 'default'; + } + }; + + const getTitleClassName = () => { + switch (modalType) { + case 'blocked': + return 'text-red-600 dark:text-red-400'; + case 'untrusted': + return 'text-yellow-600 dark:text-yellow-400'; + case 'trusted': + default: + return ''; + } + }; + + return ( + !open && onCancel()}> + + + {config.title} + + {config.message} + + + + + {config.showSingleButton ? ( + + ) : ( + <> + + + + )} + + + + ); +} diff --git a/ui/desktop/src/hooks/useExtensionInstallModal.test.ts b/ui/desktop/src/hooks/useExtensionInstallModal.test.ts new file mode 100644 index 000000000000..884da36b2347 --- /dev/null +++ b/ui/desktop/src/hooks/useExtensionInstallModal.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useExtensionInstallModal } from './useExtensionInstallModal'; +import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink'; + +const mockElectron = { + getConfig: vi.fn(), + getAllowedExtensions: vi.fn(), + logInfo: vi.fn(), + processExtensionLink: vi.fn(), + on: vi.fn(), + off: vi.fn(), +}; + +vi.mock('../components/settings/extensions/utils', () => ({ + extractExtensionName: vi.fn((link: string) => { + const url = new URL(link); + return url.searchParams.get('name') || 'Unknown Extension'; + }), +})); + +vi.mock('../components/settings/extensions/deeplink', () => ({ + addExtensionFromDeepLink: vi.fn(), +})); + +beforeEach(() => { + Object.defineProperty(globalThis, 'window', { + value: { + electron: mockElectron, + }, + writable: true, + }); + + mockElectron.getConfig.mockReturnValue({ + GOOSE_ALLOWLIST_WARNING: false, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useExtensionInstallModal', () => { + const mockAddExtension = vi.fn(); + + describe('Initial State', () => { + it('should initialize with correct default state', () => { + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + expect(result.current.modalState).toEqual({ + isOpen: false, + modalType: 'trusted', + extensionInfo: null, + isPending: false, + error: null, + }); + expect(result.current.modalConfig).toBeNull(); + }); + }); + + describe('Extension Request Handling', () => { + it('should handle trusted extension (default behaviour, no allowlist)', async () => { + mockElectron.getAllowedExtensions.mockResolvedValue([]); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest( + 'goose://extension?cmd=npx&arg=test-extension&name=TestExt' + ); + }); + + expect(result.current.modalState.isOpen).toBe(true); + expect(result.current.modalState.modalType).toBe('trusted'); + expect(result.current.modalState.extensionInfo?.name).toBe('TestExt'); + expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation'); + }); + + it('should handle trusted extension (from allowlist)', async () => { + mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest( + 'goose://extension?cmd=npx&arg=test-extension&name=AllowedExt' + ); + }); + + expect(result.current.modalState.modalType).toBe('trusted'); + expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation'); + }); + + it('should handle warning mode', async () => { + mockElectron.getConfig.mockReturnValue({ + GOOSE_ALLOWLIST_WARNING: true, + }); + + mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest( + 'goose://extension?cmd=npx&arg=untrusted-extension&name=UntrustedExt' + ); + }); + + expect(result.current.modalState.modalType).toBe('untrusted'); + expect(result.current.modalConfig?.title).toBe('Install Untrusted Extension?'); + expect(result.current.modalConfig?.confirmLabel).toBe('Install Anyway'); + expect(result.current.modalConfig?.showSingleButton).toBe(false); + }); + + it('should handle blocked extension', async () => { + mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest( + 'goose://extension?cmd=npx&arg=blocked-extension&name=BlockedExt' + ); + }); + + expect(result.current.modalState.modalType).toBe('blocked'); + expect(result.current.modalConfig?.title).toBe('Extension Installation Blocked'); + expect(result.current.modalConfig?.confirmLabel).toBe('OK'); + expect(result.current.modalConfig?.showSingleButton).toBe(true); + expect(result.current.modalConfig?.isBlocked).toBe(true); + }); + }); + + describe('Modal Actions', () => { + it('should dismiss modal correctly', async () => { + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test'); + }); + + expect(result.current.modalState.isOpen).toBe(true); + + act(() => { + result.current.dismissModal(); + }); + + expect(result.current.modalState.isOpen).toBe(false); + expect(result.current.modalState.extensionInfo).toBeNull(); + }); + + it('should handle successful extension installation', async () => { + vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined); + mockElectron.getAllowedExtensions.mockResolvedValue([]); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test'); + }); + + let installResult; + await act(async () => { + installResult = await result.current.confirmInstall(); + }); + + expect(installResult).toEqual({ success: true }); + expect(addExtensionFromDeepLink).toHaveBeenCalledWith( + 'goose://extension?cmd=npx&arg=test&name=Test', + mockAddExtension, + expect.any(Function) + ); + expect(result.current.modalState.isOpen).toBe(false); + }); + + it('should handle failed extension installation', async () => { + const error = new Error('Installation failed'); + vi.mocked(addExtensionFromDeepLink).mockRejectedValue(error); + mockElectron.getAllowedExtensions.mockResolvedValue([]); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test'); + }); + + let installResult; + await act(async () => { + installResult = await result.current.confirmInstall(); + }); + + expect(installResult).toEqual({ + success: false, + error: 'Installation failed', + }); + expect(result.current.modalState.error).toBe('Installation failed'); + }); + + it('should not install blocked extensions', async () => { + mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']); + + const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension)); + + await act(async () => { + await result.current.handleExtensionRequest( + 'goose://extension?cmd=npx&arg=blocked&name=Blocked' + ); + }); + + expect(result.current.modalState.modalType).toBe('blocked'); + + let installResult; + await act(async () => { + installResult = await result.current.confirmInstall(); + }); + + expect(installResult).toEqual({ + success: false, + error: 'No pending extension to install', + }); + expect(addExtensionFromDeepLink).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/desktop/src/hooks/useExtensionInstallModal.ts b/ui/desktop/src/hooks/useExtensionInstallModal.ts new file mode 100644 index 000000000000..fafb0bb2af32 --- /dev/null +++ b/ui/desktop/src/hooks/useExtensionInstallModal.ts @@ -0,0 +1,223 @@ +import { useState, useCallback, useEffect } from 'react'; +import { IpcRendererEvent } from 'electron'; +import { extractExtensionName } from '../components/settings/extensions/utils'; +import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink'; +import type { ExtensionConfig } from '../api/types.gen'; +import { + ExtensionModalState, + ExtensionInfo, + ModalType, + ExtensionModalConfig, + ExtensionInstallResult, +} from '../types/extension'; + +function extractCommand(link: string): string { + const url = new URL(link); + const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const args = url.searchParams.getAll('arg').map(decodeURIComponent); + return `${cmd} ${args.join(' ')}`.trim(); +} + +function extractRemoteUrl(link: string): string | null { + const url = new URL(link); + return url.searchParams.get('url'); +} + +export const useExtensionInstallModal = ( + addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise +) => { + const [modalState, setModalState] = useState({ + isOpen: false, + modalType: 'trusted', + extensionInfo: null, + isPending: false, + error: null, + }); + + const [pendingLink, setPendingLink] = useState(null); + + const determineModalType = async ( + command: string, + _remoteUrl: string | null + ): Promise => { + try { + const config = window.electron.getConfig(); + const ALLOWLIST_WARNING_MODE = config.GOOSE_ALLOWLIST_WARNING === true; + + // If warning mode is enabled, always show warning but allow installation + if (ALLOWLIST_WARNING_MODE) { + return 'untrusted'; + } + + const allowedCommands = await window.electron.getAllowedExtensions(); + + // If no allowlist configured + if (!allowedCommands || allowedCommands.length === 0) { + return 'trusted'; + } + + const isCommandAllowed = allowedCommands.some((allowedCmd: string) => + command.startsWith(allowedCmd) + ); + + return isCommandAllowed ? 'trusted' : 'blocked'; + } catch (error) { + console.error('Error checking allowlist:', error); + return 'trusted'; + } + }; + + const generateModalConfig = ( + modalType: ModalType, + extensionInfo: ExtensionInfo + ): ExtensionModalConfig => { + const { name, command, remoteUrl } = extensionInfo; + + switch (modalType) { + case 'blocked': + return { + title: 'Extension Installation Blocked', + message: `\n\nThis extension command is not in the allowed list and its installation is blocked.\n\nExtension: ${name}\nCommand: ${command || remoteUrl}\n\nContact your administrator to request approval for this extension.`, + confirmLabel: 'OK', + cancelLabel: '', + showSingleButton: true, + isBlocked: true, + }; + + case 'untrusted': { + const securityMessage = `\n\nThis extension command is not in the allowed list and will be able to access your conversations and provide additional functionality.\n\nInstalling extensions from untrusted sources may pose security risks.`; + + return { + title: 'Install Untrusted Extension?', + message: `${securityMessage}\n\nExtension: ${name}\n${remoteUrl ? `URL: ${remoteUrl}` : `Command: ${command}`}\n\nContact your administrator if you are unsure about this.`, + confirmLabel: 'Install Anyway', + cancelLabel: 'Cancel', + showSingleButton: false, + isBlocked: false, + }; + } + + case 'trusted': + default: + return { + title: 'Confirm Extension Installation', + message: `Are you sure you want to install the ${name} extension?\n\nCommand: ${command || remoteUrl}`, + confirmLabel: 'Yes', + cancelLabel: 'No', + showSingleButton: false, + isBlocked: false, + }; + } + }; + + const handleExtensionRequest = useCallback(async (link: string): Promise => { + try { + console.log(`Processing extension request: ${link}`); + + const command = extractCommand(link); + const remoteUrl = extractRemoteUrl(link); + const extName = extractExtensionName(link); + + const extensionInfo: ExtensionInfo = { + name: extName, + command: command, + remoteUrl: remoteUrl || undefined, + link: link, + }; + + const modalType = await determineModalType(command, remoteUrl); + + setModalState({ + isOpen: true, + modalType, + extensionInfo, + isPending: false, + error: null, + }); + + setPendingLink(modalType === 'blocked' ? null : link); + + window.electron.logInfo(`Extension modal opened: ${modalType} for ${extName}`); + } catch (error) { + console.error('Error processing extension request:', error); + setModalState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } + }, []); + + const dismissModal = useCallback(() => { + setModalState({ + isOpen: false, + modalType: 'trusted', + extensionInfo: null, + isPending: false, + error: null, + }); + setPendingLink(null); + }, []); + + const confirmInstall = useCallback(async (): Promise => { + if (!pendingLink) { + return { success: false, error: 'No pending extension to install' }; + } + + setModalState((prev) => ({ ...prev, isPending: true })); + + try { + console.log(`Confirming installation of extension from: ${pendingLink}`); + + dismissModal(); + + if (addExtension) { + await addExtensionFromDeepLink(pendingLink, addExtension, () => { + console.log('Extension installation completed, navigating to extensions'); + }); + } else { + throw new Error('addExtension function not provided to hook'); + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Installation failed'; + console.error('Extension installation failed:', error); + + setModalState((prev) => ({ + ...prev, + error: errorMessage, + isPending: false, + })); + + return { success: false, error: errorMessage }; + } + }, [pendingLink, dismissModal, addExtension]); + + const getModalConfig = (): ExtensionModalConfig | null => { + if (!modalState.extensionInfo) return null; + return generateModalConfig(modalState.modalType, modalState.extensionInfo); + }; + + useEffect(() => { + console.log('Setting up extension install modal handler'); + + const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { + const link = args[0] as string; + await handleExtensionRequest(link); + }; + + window.electron.on('add-extension', handleAddExtension); + + return () => { + window.electron.off('add-extension', handleAddExtension); + }; + }, [handleExtensionRequest]); + + return { + modalState, + modalConfig: getModalConfig(), + handleExtensionRequest, + dismissModal, + confirmInstall, + }; +}; diff --git a/ui/desktop/src/types/extension.ts b/ui/desktop/src/types/extension.ts new file mode 100644 index 000000000000..01a2f2658b3a --- /dev/null +++ b/ui/desktop/src/types/extension.ts @@ -0,0 +1,30 @@ +export type ModalType = 'blocked' | 'untrusted' | 'trusted'; + +export interface ExtensionInfo { + name: string; + command?: string; + remoteUrl?: string; + link: string; +} + +export interface ExtensionModalState { + isOpen: boolean; + modalType: ModalType; + extensionInfo: ExtensionInfo | null; + isPending: boolean; + error: string | null; +} + +export interface ExtensionModalConfig { + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + showSingleButton: boolean; + isBlocked: boolean; +} + +export interface ExtensionInstallResult { + success: boolean; + error?: string; +}