diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b112c13 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,228 @@ +# ZCLAW Continuous Integration Workflow for Gitea +# Runs on every push to main and all pull requests + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + NODE_VERSION: '20' + PNPM_VERSION: '9' + RUST_VERSION: '1.78' + +jobs: + # ============================================================================ + # Lint and Type Check + # ============================================================================ + lint: + name: Lint & TypeCheck + runs-on: ubuntu-latest + container: + image: node:20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install root dependencies + run: pnpm install --frozen-lockfile + + - name: Install desktop dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Type check desktop + working-directory: desktop + run: pnpm typecheck + + - name: Type check root + run: pnpm exec tsc --noEmit + + # ============================================================================ + # Unit Tests + # ============================================================================ + test: + name: Unit Tests + runs-on: ubuntu-latest + container: + image: node:20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install root dependencies + run: pnpm install --frozen-lockfile + + - name: Install desktop dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Run desktop unit tests + working-directory: desktop + run: pnpm test + + - name: Run root unit tests + run: pnpm test + + # ============================================================================ + # Build Verification (Frontend only - no Tauri) + # ============================================================================ + build-frontend: + name: Build Frontend + runs-on: ubuntu-latest + container: + image: node:20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install desktop dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Build frontend + working-directory: desktop + run: pnpm build + + # ============================================================================ + # Rust Backend Check + # ============================================================================ + rust-check: + name: Rust Check + runs-on: ubuntu-latest + container: + image: rust:1.78 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust components + run: rustup component add clippy rustfmt + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + desktop/src-tauri + + - name: Check Rust formatting + working-directory: desktop/src-tauri + run: cargo fmt --all -- --check + + - name: Run Clippy + working-directory: desktop/src-tauri + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Check Rust build + working-directory: desktop/src-tauri + run: cargo check --all-targets + + # ============================================================================ + # Security Scan + # ============================================================================ + security: + name: Security Scan + runs-on: ubuntu-latest + container: + image: node:20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm install --frozen-lockfile + cd desktop && pnpm install --frozen-lockfile + + - name: Run npm audit (root) + run: pnpm audit --audit-level=high + continue-on-error: true + + - name: Run npm audit (desktop) + working-directory: desktop + run: pnpm audit --audit-level=high + continue-on-error: true + + # ============================================================================ + # E2E Tests (Optional - requires browser) + # ============================================================================ + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: [lint, test] + container: + image: mcr.microsoft.com/playwright:v1.42.0-jammy + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: desktop + run: pnpm exec playwright install chromium + + - name: Run E2E tests + working-directory: desktop + run: pnpm test:e2e + continue-on-error: true diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..cf2d431 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,139 @@ +# ZCLAW Release Workflow for Gitea +# Builds Tauri application and creates Gitea Release +# Triggered by pushing version tags (e.g., v0.2.0) + +name: Release + +on: + push: + tags: + - 'v*' + +env: + NODE_VERSION: '20' + PNPM_VERSION: '9' + RUST_VERSION: '1.78' + +jobs: + # ============================================================================ + # Build Tauri Application for Windows + # ============================================================================ + build-windows: + name: Build Windows + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Setup Rust + uses: dtolnay/rust-action@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + desktop/src-tauri + + - name: Install frontend dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Prepare OpenFang Runtime + working-directory: desktop + run: pnpm prepare:openfang-runtime + + - name: Build Tauri application + working-directory: desktop + run: pnpm tauri:build:bundled + + - name: Find installer + id: find-installer + shell: pwsh + run: | + $installer = Get-ChildItem -Path "desktop/src-tauri/target/release/bundle/nsis" -Filter "*.exe" -Recurse | Select-Object -First 1 + echo "INSTALLER_PATH=$($installer.FullName)" >> $env:GITEA_OUTPUT + echo "INSTALLER_NAME=$($installer.Name)" >> $env:GITEA_OUTPUT + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: ${{ steps.find-installer.outputs.INSTALLER_PATH }} + retention-days: 30 + + # ============================================================================ + # Create Gitea Release + # ============================================================================ + create-release: + name: Create Release + needs: build-windows + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: windows-installer + path: ./artifacts + + - name: Get version from tag + id: get_version + run: echo "VERSION=${GITEA_REF#refs/tags/}" >> $GITEA_OUTPUT + + - name: Create Gitea Release + uses: actions/gitea-release@v1 + with: + tag_name: ${{ gitea.ref_name }} + name: ZCLAW ${{ steps.get_version.outputs.VERSION }} + body: | + ## ZCLAW ${{ steps.get_version.outputs.VERSION }} + + ### Changes + - See CHANGELOG.md for details + + ### Downloads + - **Windows**: Download the `.exe` installer below + + ### System Requirements + - Windows 10/11 (64-bit) + draft: true + prerelease: false + files: | + ./artifacts/*.exe + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + + # ============================================================================ + # Build Summary + # ============================================================================ + release-summary: + name: Release Summary + needs: [build-windows, create-release] + runs-on: ubuntu-latest + steps: + - name: Release Summary + run: | + echo "## Release Build Complete" + echo "" + echo "**Tag**: ${{ gitea.ref_name }}" + echo "" + echo "### Artifacts" + echo "- Windows installer uploaded to release" + echo "" + echo "### Next Steps" + echo "1. Review the draft release" + echo "2. Update release notes if needed" + echo "3. Publish the release when ready" diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index d750cee..3bb41dd 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { List, type ListImperativeAPI } from 'react-window'; import { useChatStore, Message } from '../store/chatStore'; @@ -6,13 +6,14 @@ import { useConnectionStore } from '../store/connectionStore'; import { useAgentStore } from '../store/agentStore'; import { useConfigStore } from '../store/configStore'; import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react'; -import { Button, EmptyState } from './ui'; +import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui'; import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations'; import { FirstConversationPrompt } from './FirstConversationPrompt'; import { MessageSearch } from './MessageSearch'; +import { OfflineIndicator } from './OfflineIndicator'; import { useVirtualizedMessages, - type VirtualizedMessageItem, + type VirtualizedMessageItem } from '../lib/message-virtualization'; // Default heights for virtualized messages @@ -30,7 +31,7 @@ const VIRTUALIZATION_THRESHOLD = 100; export function ChatArea() { const { - messages, currentAgent, isStreaming, currentModel, + messages, currentAgent, isStreaming, isLoading, currentModel, sendMessage: sendToGateway, setCurrentModel, initStreamListener, newConversation, } = useChatStore(); @@ -105,7 +106,8 @@ export function ChatArea() { }, [messages, useVirtualization, scrollToBottom]); const handleSend = () => { - if (!input.trim() || isStreaming || !connected) return; + if (!input.trim() || isStreaming) return; + // Allow sending in offline mode - message will be queued sendToGateway(input); setInput(''); }; @@ -134,6 +136,7 @@ export function ChatArea() { return (
+ {/* Header */} {/* Header */}
@@ -151,6 +154,8 @@ export function ChatArea() { )}
+ {/* Offline indicator in header */} + {messages.length > 0 && ( )} @@ -171,9 +176,23 @@ export function ChatArea() {
{/* Messages */} -
+
- {messages.length === 0 && ( + {/* Loading skeleton */} + {isLoading && messages.length === 0 && ( + + + + )} + + {/* Empty state */} + {!isLoading && messages.length === 0 && ( } - title="欢迎使用 ZCLAW" - description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'} + title="Welcome to ZCLAW" + description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'} /> )} @@ -242,13 +261,11 @@ export function ChatArea() { onChange={(e) => { setInput(e.target.value); adjustTextarea(); }} onKeyDown={handleKeyDown} placeholder={ - !connected - ? '请先连接 Gateway' - : isStreaming - ? 'Agent 正在回复...' - : `发送给 ${currentAgent?.name || 'ZCLAW'}` + isStreaming + ? 'Agent 正在回复...' + : `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}` } - disabled={isStreaming || !connected} + disabled={isStreaming} rows={1} className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1" style={{ minHeight: '24px', maxHeight: '160px' }} @@ -289,8 +306,8 @@ export function ChatArea() { variant="primary" size="sm" onClick={handleSend} - disabled={isStreaming || !input.trim() || !connected} - className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white" + disabled={isStreaming || !input.trim()} + className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50" aria-label="发送消息" > @@ -549,14 +566,10 @@ function MessageBubble({ message }: { message: Message }) {
{isThinking ? ( - // 思考中指示器 + // Thinking indicator
-
- - - -
- 思考中... + + Thinking...
) : (
diff --git a/desktop/src/components/ConversationList.tsx b/desktop/src/components/ConversationList.tsx index 67e2ef5..5f1247e 100644 --- a/desktop/src/components/ConversationList.tsx +++ b/desktop/src/components/ConversationList.tsx @@ -1,14 +1,21 @@ import { useChatStore } from '../store/chatStore'; import { MessageSquare, Trash2, SquarePen } from 'lucide-react'; +import { EmptyConversations, ConversationListSkeleton } from './ui'; export function ConversationList() { const { conversations, currentConversationId, messages, agents, currentAgent, newConversation, switchConversation, deleteConversation, + isLoading, } = useChatStore(); const hasActiveChat = messages.length > 0; + // Show skeleton during initial load + if (isLoading && conversations.length === 0 && !hasActiveChat) { + return ; + } + return (
{/* Header */} @@ -86,11 +93,7 @@ export function ConversationList() { })} {conversations.length === 0 && !hasActiveChat && ( -
- -

暂无对话

-

发送消息开始对话

-
+ )}
diff --git a/desktop/src/components/OfflineIndicator.tsx b/desktop/src/components/OfflineIndicator.tsx new file mode 100644 index 0000000..c7d2470 --- /dev/null +++ b/desktop/src/components/OfflineIndicator.tsx @@ -0,0 +1,378 @@ +/** + * OfflineIndicator Component + * + * Displays offline mode status, pending message count, and reconnection info. + * Shows a prominent banner when the app is offline with visual feedback. + */ + +import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + WifiOff, + CloudOff, + RefreshCw, + Clock, + AlertCircle, + CheckCircle, + Send, + X, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { useOfflineStore, type QueuedMessage } from '../store/offlineStore'; +import { useConnectionStore } from '../store/connectionStore'; + +interface OfflineIndicatorProps { + /** Show compact version (minimal) */ + compact?: boolean; + /** Show pending messages list */ + showQueue?: boolean; + /** Additional CSS classes */ + className?: string; + /** Callback when reconnect button is clicked */ + onReconnect?: () => void; +} + +/** + * Format relative time + */ +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return '刚刚'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时前`; + return `${Math.floor(seconds / 86400)}天前`; +} + +/** + * Format reconnect delay for display + */ +function formatReconnectDelay(delay: number): string { + if (delay < 1000) return '立即'; + if (delay < 60000) return `${Math.ceil(delay / 1000)}秒`; + return `${Math.ceil(delay / 60000)}分钟`; +} + +/** + * Truncate message content for display + */ +function truncateContent(content: string, maxLength: number = 50): string { + if (content.length <= maxLength) return content; + return content.slice(0, maxLength) + '...'; +} + +/** + * Full offline indicator with banner, queue, and reconnect info + */ +export function OfflineIndicator({ + compact = false, + showQueue = true, + className = '', + onReconnect, +}: OfflineIndicatorProps) { + const { + isOffline, + isReconnecting, + reconnectAttempt, + nextReconnectDelay, + queuedMessages, + cancelReconnect, + } = useOfflineStore(); + + const connect = useConnectionStore((s) => s.connect); + + const [showMessageQueue, setShowMessageQueue] = useState(false); + const [countdown, setCountdown] = useState(null); + + // Countdown timer for reconnection + useEffect(() => { + if (!isReconnecting || !nextReconnectDelay) { + setCountdown(null); + return; + } + + const endTime = Date.now() + nextReconnectDelay; + setCountdown(nextReconnectDelay); + + const interval = setInterval(() => { + const remaining = Math.max(0, endTime - Date.now()); + setCountdown(remaining); + + if (remaining === 0) { + clearInterval(interval); + } + }, 1000); + + return () => clearInterval(interval); + }, [isReconnecting, nextReconnectDelay]); + + // Handle manual reconnect + const handleReconnect = async () => { + onReconnect?.(); + try { + await connect(); + } catch (err) { + console.error('[OfflineIndicator] Manual reconnect failed:', err); + } + }; + + const pendingCount = queuedMessages.filter( + (m) => m.status === 'pending' || m.status === 'failed' + ).length; + + // Don't show if online and no pending messages + if (!isOffline && pendingCount === 0) { + return null; + } + + // Compact version for headers/toolbars + if (compact) { + return ( +
+ {isOffline ? ( + <> + + + 离线模式 + + {pendingCount > 0 && ( + + {pendingCount} 条待发 + + )} + + ) : ( + <> + + 已恢复连接 + {pendingCount > 0 && ( + + 发送中 {pendingCount} 条 + + )} + + )} +
+ ); + } + + // Full banner version + return ( + + + {/* Main Banner */} +
+ {/* Status Icon */} + + {isOffline ? ( + + ) : ( + + )} + + + {/* Status Text */} +
+
+ {isOffline ? '后端服务不可用' : '连接已恢复'} +
+
+ {isReconnecting ? ( + <> + 正在尝试重连 ({reconnectAttempt}次) + {countdown !== null && ( + + {formatReconnectDelay(countdown)}后重试 + + )} + + ) : isOffline ? ( + '消息将保存在本地,连接后自动发送' + ) : pendingCount > 0 ? ( + `正在发送 ${pendingCount} 条排队消息...` + ) : ( + '所有消息已同步' + )} +
+
+ + {/* Actions */} +
+ {isOffline && !isReconnecting && ( + + )} + {isReconnecting && ( + + )} + {showQueue && pendingCount > 0 && ( + + )} +
+
+ + {/* Message Queue */} + + {showMessageQueue && pendingCount > 0 && ( + +
+ + 排队消息 + +
+
+ {queuedMessages + .filter((m) => m.status === 'pending' || m.status === 'failed') + .map((msg) => ( + + ))} +
+
+ )} +
+
+
+ ); +} + +/** + * Individual queued message item + */ +function QueuedMessageItem({ message }: { message: QueuedMessage }) { + const { removeMessage } = useOfflineStore(); + + const statusConfig = { + pending: { icon: Clock, color: 'text-gray-400', label: '等待中' }, + sending: { icon: Send, color: 'text-blue-500', label: '发送中' }, + failed: { icon: AlertCircle, color: 'text-red-500', label: '发送失败' }, + sent: { icon: CheckCircle, color: 'text-green-500', label: '已发送' }, + }; + + const config = statusConfig[message.status]; + const StatusIcon = config.icon; + + return ( +
+ + +
+

+ {truncateContent(message.content)} +

+
+ + {formatRelativeTime(message.timestamp)} + + {message.status === 'failed' && message.lastError && ( + {message.lastError} + )} +
+
+ + {message.status === 'failed' && ( + + )} +
+ ); +} + +/** + * Minimal connection status indicator for headers + */ +export function ConnectionStatusBadge({ className = '' }: { className?: string }) { + const connectionState = useConnectionStore((s) => s.connectionState); + const queuedMessages = useOfflineStore((s) => s.queuedMessages); + + const pendingCount = queuedMessages.filter( + (m) => m.status === 'pending' || m.status === 'failed' + ).length; + + const isConnected = connectionState === 'connected'; + + return ( +
+ + + {isConnected ? '在线' : connectionState === 'reconnecting' ? '重连中' : '离线'} + + {pendingCount > 0 && ( + + {pendingCount} + + )} +
+ ); +} + +export default OfflineIndicator; diff --git a/desktop/src/components/ui/EmptyState.tsx b/desktop/src/components/ui/EmptyState.tsx index 75db2b1..24d689d 100644 --- a/desktop/src/components/ui/EmptyState.tsx +++ b/desktop/src/components/ui/EmptyState.tsx @@ -1,4 +1,5 @@ import { cn } from '../../lib/utils'; +import { MessageSquare, Inbox, Search, FileX, Wifi, Bot } from 'lucide-react'; interface EmptyStateProps { icon: React.ReactNode; @@ -6,19 +7,60 @@ interface EmptyStateProps { description: string; action?: React.ReactNode; className?: string; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; } -export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { +export function EmptyState({ + icon, + title, + description, + action, + className, + size = 'md' +}: EmptyStateProps) { + const sizeClasses = { + sm: { + container: 'py-4', + iconWrapper: 'w-12 h-12', + icon: 'w-5 h-5', + title: 'text-sm', + description: 'text-xs', + }, + md: { + container: 'p-6', + iconWrapper: 'w-16 h-16', + icon: 'w-8 h-8', + title: 'text-base', + description: 'text-sm', + }, + lg: { + container: 'p-8', + iconWrapper: 'w-20 h-20', + icon: 'w-10 h-10', + title: 'text-lg', + description: 'text-base', + }, + }; + + const sizes = sizeClasses[size]; + return ( -
+
-
+
{icon}
-

+

{title}

-

+

{description}

{action} @@ -26,3 +68,134 @@ export function EmptyState({ icon, title, description, action, className }: Empt
); } + +// === Pre-built Empty State Variants === + +interface PrebuiltEmptyStateProps { + action?: React.ReactNode; + className?: string; + size?: 'sm' | 'md' | 'lg'; +} + +/** + * Empty state for no messages in chat. + */ +export function EmptyMessages({ action, className, size }: PrebuiltEmptyStateProps) { + return ( + } + title="No messages yet" + description="Start the conversation by sending a message below." + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for no conversations. + */ +export function EmptyConversations({ action, className, size }: PrebuiltEmptyStateProps) { + return ( + } + title="No conversations" + description="Your conversation history will appear here." + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for search with no results. + */ +export function EmptySearchResults({ query, action, className, size }: PrebuiltEmptyStateProps & { query?: string }) { + return ( + } + title="No results found" + description={query ? `No messages matching "${query}"` : 'Try adjusting your search terms.'} + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for no files or attachments. + */ +export function EmptyFiles({ action, className, size }: PrebuiltEmptyStateProps) { + return ( + } + title="No files" + description="No files or attachments here yet." + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for offline/disconnected state. + */ +export function EmptyOffline({ action, className, size }: PrebuiltEmptyStateProps) { + return ( + } + title="Offline" + description="Please check your connection and try again." + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for no agents/clones available. + */ +export function EmptyAgents({ action, className, size }: PrebuiltEmptyStateProps) { + return ( + } + title="No agents" + description="Create an agent to get started with personalized conversations." + action={action} + className={className} + size={size} + /> + ); +} + +/** + * Empty state for welcome screen. + */ +export function WelcomeEmptyState({ + title = "Welcome to ZCLAW", + description = "Send a message to start the conversation.", + connected = true, + action, + className, + size, +}: PrebuiltEmptyStateProps & { + title?: string; + description?: string; + connected?: boolean; +}) { + return ( + } + title={title} + description={connected ? description : 'Please connect to Gateway first.'} + action={action} + className={className} + size={size} + /> + ); +} diff --git a/desktop/src/components/ui/ErrorAlert.tsx b/desktop/src/components/ui/ErrorAlert.tsx index a01390e..c6cddf5 100644 --- a/desktop/src/components/ui/ErrorAlert.tsx +++ b/desktop/src/components/ui/ErrorAlert.tsx @@ -109,15 +109,15 @@ const CATEGORY_CONFIG: Record void; + onError?: (error: Error, errorInfo: ReactErrorInfo) => void; onReset?: () => void; + /** Whether to show connection status indicator */ + showConnectionStatus?: boolean; + /** Custom error title */ + errorTitle?: string; + /** Custom error message */ + errorMessage?: string; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; - errorInfo: ErrorInfo | null; + errorInfo: ExtendedErrorInfo | null; + appError: AppError | null; + showDetails: boolean; +} + +// === Global Error Types === + +type GlobalErrorType = 'unhandled-rejection' | 'error' | 'websocket' | 'network'; + +interface GlobalErrorEvent { + type: GlobalErrorType; + error: unknown; + timestamp: Date; +} + +// === Global Error Handler Registry === + +const globalErrorListeners = new Set<(event: GlobalErrorEvent) => void>(); + +export function addGlobalErrorListener(listener: (event: GlobalErrorEvent) => void): () => void { + globalErrorListeners.add(listener); + return () => globalErrorListeners.delete(listener); +} + +function notifyGlobalErrorListeners(event: GlobalErrorEvent): void { + globalErrorListeners.forEach(listener => { + try { + listener(event); + } catch (e) { + console.error('[GlobalErrorHandler] Listener error:', e); + } + }); +} + +// === Setup Global Error Handlers === + +let globalHandlersSetup = false; + +export function setupGlobalErrorHandlers(): () => void { + if (globalHandlersSetup) { + return () => {}; + } + globalHandlersSetup = true; + + // Handle unhandled promise rejections + const handleRejection = (event: PromiseRejectionEvent) => { + console.error('[GlobalErrorHandler] Unhandled rejection:', event.reason); + notifyGlobalErrorListeners({ + type: 'unhandled-rejection', + error: event.reason, + timestamp: new Date(), + }); + // Prevent default browser error logging (we handle it ourselves) + event.preventDefault(); + }; + + // Handle uncaught errors + const handleError = (event: ErrorEvent) => { + console.error('[GlobalErrorHandler] Uncaught error:', event.error); + notifyGlobalErrorListeners({ + type: 'error', + error: event.error, + timestamp: new Date(), + }); + // Let the error boundary handle it if possible + }; + + // Handle WebSocket errors globally + const handleWebSocketError = (event: Event) => { + if (event.target instanceof WebSocket) { + console.error('[GlobalErrorHandler] WebSocket error:', event); + notifyGlobalErrorListeners({ + type: 'websocket', + error: new Error('WebSocket connection error'), + timestamp: new Date(), + }); + } + }; + + window.addEventListener('unhandledrejection', handleRejection); + window.addEventListener('error', handleError); + window.addEventListener('error', handleWebSocketError, true); // Capture phase for WebSocket + + return () => { + window.removeEventListener('unhandledrejection', handleRejection); + window.removeEventListener('error', handleError); + window.removeEventListener('error', handleWebSocketError, true); + globalHandlersSetup = false; + }; } /** - * ErrorBoundary Component + * GlobalErrorBoundary Component * - * Catches React rendering errors and displays a friendly error screen - * with recovery options and error reporting. + * Root-level error boundary that catches all React errors and global errors. + * Displays a user-friendly error screen with recovery options. */ -export class ErrorBoundary extends Component { +export class GlobalErrorBoundary extends Component { + private cleanupGlobalHandlers: (() => void) | null = null; + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, error: null, errorInfo: null, + appError: null, + showDetails: false, }; } - static getDerivedStateFromError(error: Error): ErrorInfo { + static getDerivedStateFromError(error: Error): Partial { + const appError = classifyError(error); return { - componentStack: error.stack || 'No stack trace available', + hasError: true, + error, + appError, + }; + } + + componentDidMount() { + // Setup global error handlers + this.cleanupGlobalHandlers = setupGlobalErrorHandlers(); + + // Listen for global errors and update state + const unsubscribe = addGlobalErrorListener((event) => { + if (!this.state.hasError) { + const appError = classifyError(event.error); + this.setState({ + hasError: true, + error: event.error instanceof Error ? event.error : new Error(String(event.error)), + appError, + errorInfo: null, + }); + } + }); + + // Store cleanup function + this.cleanupGlobalHandlers = () => { + unsubscribe(); + }; + } + + componentWillUnmount() { + this.cleanupGlobalHandlers?.(); + } + + componentDidCatch(error: Error, errorInfo: ReactErrorInfo) { + const { onError } = this.props; + + // Classify the error + const appError = classifyError(error); + + // Update state with extended error info + const extendedErrorInfo: ExtendedErrorInfo = { + componentStack: errorInfo.componentStack, errorName: error.name || 'Unknown Error', errorMessage: error.message || 'An unexpected error occurred', }; - } - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - const { onError } = this.props; + this.setState({ + errorInfo: extendedErrorInfo, + appError, + }); // Call optional error handler if (onError) { onError(error, errorInfo); } - // Update state to show error UI - this.setState({ - hasError: true, - error, - errorInfo: { - componentStack: errorInfo.componentStack, - errorName: errorInfo.errorName || error.name || 'Unknown Error', - errorMessage: errorInfo.errorMessage || error.message || 'An unexpected error occurred', - }, + // Report to error tracking + reportError(error, { + componentStack: errorInfo.componentStack ?? undefined, + errorName: error.name, + errorMessage: error.message, }); - } handleReset = () => { @@ -71,6 +215,8 @@ export class ErrorBoundary extends Component { - const { error, errorInfo } = this.state; - if (error && errorInfo) { - reportError(error, { - componentStack: errorInfo.componentStack, - errorName: errorInfo.errorName, - errorMessage: errorInfo.errorMessage, - }); - } + handleReload = () => { + window.location.reload(); }; handleGoHome = () => { - // Navigate to home/main view window.location.href = '/'; }; + handleReport = () => { + const { error, errorInfo } = this.state; + if (error) { + reportError(error, { + componentStack: errorInfo?.componentStack ?? undefined, + errorName: errorInfo?.errorName || error.name, + errorMessage: errorInfo?.errorMessage || error.message, + }); + // Show confirmation + alert('Error reported. Thank you for your feedback.'); + } + }; + + toggleDetails = () => { + this.setState(prev => ({ showDetails: !prev.showDetails })); + }; + render() { - const { children, fallback } = this.props; - const { hasError, error, errorInfo } = this.state; + const { children, fallback, errorTitle, errorMessage } = this.props; + const { hasError, error, errorInfo, appError, showDetails } = this.state; if (hasError && error) { // Use custom fallback if provided @@ -105,47 +260,129 @@ export class ErrorBoundary extends Component - {/* Error Icon */} -
- + {/* Error Header */} +
+
+
+ {isNetworkError ? ( + + ) : ( + + )} +
+
+

+ {title} +

+

+ {message} +

+
+
- {/* Content */} -
-

- Something went wrong -

-

- {errorInfo?.errorMessage || error.message || 'An unexpected error occurred'} -

+ {/* Error Details */} +
+ {/* Category Badge */} + {appError && ( +
+ + {category.charAt(0).toUpperCase() + category.slice(1)} Error + + {appError.recoverable && ( + + Recoverable + + )} +
+ )} - {/* Error Details */} -
-

- {errorInfo?.errorName || 'Unknown Error'} -

-
+ {/* Recovery Steps */} + {appError?.recoverySteps && appError.recoverySteps.length > 0 && ( +
+

+ Suggested Actions: +

+
    + {appError.recoverySteps.slice(0, 3).map((step, index) => ( +
  • + {index + 1}. + {step.description} +
  • + ))} +
+
+ )} + + {/* Technical Details Toggle */} + + + {/* Technical Details */} + {showDetails && ( + +
+                    {errorInfo?.errorName || error.name}: {errorInfo?.errorMessage || error.message}
+                    {errorInfo?.componentStack && `\n\nComponent Stack:${errorInfo.componentStack}`}
+                  
+
+ )} {/* Actions */} -
- - +
+
+ + +
-
- -
- ); - } - - return children; +
+ +
+ ); } + + return children; } } + +/** + * ErrorBoundary Component + * + * A simpler error boundary for wrapping individual components or sections. + * Use GlobalErrorBoundary for the root level. + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + appError: null, + showDetails: false, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + const appError = classifyError(error); + return { + hasError: true, + error, + appError, + }; + } + + componentDidCatch(error: Error, errorInfo: ReactErrorInfo) { + const { onError } = this.props; + + // Update state with extended error info + const extendedErrorInfo: ExtendedErrorInfo = { + componentStack: errorInfo.componentStack, + errorName: error.name || 'Unknown Error', + errorMessage: error.message || 'An unexpected error occurred', + }; + + this.setState({ + errorInfo: extendedErrorInfo, + }); + + // Call optional error handler + if (onError) { + onError(error, errorInfo); + } + + // Report error + reportError(error, { + componentStack: errorInfo.componentStack ?? undefined, + errorName: error.name, + errorMessage: error.message, + }); + } + + handleReset = () => { + const { onReset } = this.props; + this.setState({ + hasError: false, + error: null, + errorInfo: null, + appError: null, + showDetails: false, + }); + if (onReset) { + onReset(); + } + }; + + render() { + const { children, fallback } = this.props; + const { hasError, error, appError } = this.state; + + if (hasError && error) { + if (fallback) { + return fallback; + } + + // Compact error UI for nested boundaries + return ( +
+
+ +
+

+ {appError?.title || 'Error'} +

+

+ {appError?.message || error.message} +

+ +
+
+
+ ); + } + + return children; + } +} + +// === Re-export for convenience === +export { GlobalErrorBoundary as RootErrorBoundary }; diff --git a/desktop/src/components/ui/LoadingSpinner.tsx b/desktop/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..b4c7e1a --- /dev/null +++ b/desktop/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,106 @@ +import { cn } from '../../lib/utils'; +import { Loader2 } from 'lucide-react'; + +interface LoadingSpinnerProps { + /** Size of the spinner */ + size?: 'sm' | 'md' | 'lg'; + /** Optional text to display below the spinner */ + text?: string; + /** Additional class names */ + className?: string; +} + +const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', +}; + +/** + * Small inline loading spinner for buttons and inline contexts. + */ +export function LoadingSpinner({ size = 'md', text, className }: LoadingSpinnerProps) { + return ( +
+ + {text && {text}} +
+ ); +} + +interface LoadingOverlayProps { + /** Whether the overlay is visible */ + visible: boolean; + /** Optional text to display */ + text?: string; + /** Additional class names */ + className?: string; +} + +/** + * Full-screen loading overlay for blocking interactions during loading. + */ +export function LoadingOverlay({ visible, text = 'Loading...', className }: LoadingOverlayProps) { + if (!visible) return null; + + return ( +
+
+ + {text} +
+
+ ); +} + +interface LoadingDotsProps { + /** Additional class names */ + className?: string; +} + +/** + * Animated dots for "thinking" states. + */ +export function LoadingDots({ className }: LoadingDotsProps) { + return ( +
+ + + +
+ ); +} + +interface InlineLoadingProps { + /** Loading text */ + text?: string; + /** Additional class names */ + className?: string; +} + +/** + * Compact inline loading indicator with text. + */ +export function InlineLoading({ text = 'Loading...', className }: InlineLoadingProps) { + return ( +
+ + {text} +
+ ); +} diff --git a/desktop/src/components/ui/Skeleton.tsx b/desktop/src/components/ui/Skeleton.tsx index 2d9adb5..6d97022 100644 --- a/desktop/src/components/ui/Skeleton.tsx +++ b/desktop/src/components/ui/Skeleton.tsx @@ -40,3 +40,142 @@ export function ListSkeleton({ count = 3 }: { count?: number }) {
); } + +/** + * Skeleton for a single chat message bubble. + * Supports both user and assistant message styles. + */ +export function MessageSkeleton({ isUser = false }: { isUser?: boolean }) { + return ( +
+
+ +
+
+
+ + + +
+
+
+ ); +} + +/** + * Skeleton for a list of chat messages. + * Alternates between user and assistant skeletons. + */ +export function MessageListSkeleton({ count = 4 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * Skeleton for a conversation item in the sidebar. + */ +export function ConversationItemSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +/** + * Skeleton for the conversation list sidebar. + */ +export function ConversationListSkeleton({ count = 5 }: { count?: number }) { + return ( +
+ {/* Header skeleton */} +
+ + +
+ {/* List items */} +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+
+ ); +} + +/** + * Skeleton for the chat header. + */ +export function ChatHeaderSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ ); +} + +/** + * Skeleton for the chat input area. + */ +export function ChatInputSkeleton() { + return ( +
+
+
+ +
+ +
+ + +
+
+ +
+
+
+ ); +} + +/** + * Full chat area skeleton including header, messages, and input. + */ +export function ChatAreaSkeleton({ messageCount = 4 }: { messageCount?: number }) { + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/desktop/src/components/ui/index.ts b/desktop/src/components/ui/index.ts index 1f36a48..a46e9f9 100644 --- a/desktop/src/components/ui/index.ts +++ b/desktop/src/components/ui/index.ts @@ -8,8 +8,38 @@ export type { InputProps } from './Input'; export { Badge } from './Badge'; -export { Skeleton, CardSkeleton, ListSkeleton } from './Skeleton'; +// Skeleton components +export { + Skeleton, + CardSkeleton, + ListSkeleton, + MessageSkeleton, + MessageListSkeleton, + ConversationItemSkeleton, + ConversationListSkeleton, + ChatHeaderSkeleton, + ChatInputSkeleton, + ChatAreaSkeleton, +} from './Skeleton'; -export { EmptyState } from './EmptyState'; +// Empty state components +export { + EmptyState, + EmptyMessages, + EmptyConversations, + EmptySearchResults, + EmptyFiles, + EmptyOffline, + EmptyAgents, + WelcomeEmptyState, +} from './EmptyState'; + +// Loading components +export { + LoadingSpinner, + LoadingOverlay, + LoadingDots, + InlineLoading, +} from './LoadingSpinner'; export { ToastProvider, useToast } from './Toast'; diff --git a/desktop/src/lib/api-key-storage.ts b/desktop/src/lib/api-key-storage.ts new file mode 100644 index 0000000..346e1fd --- /dev/null +++ b/desktop/src/lib/api-key-storage.ts @@ -0,0 +1,476 @@ +/** + * Secure API Key Storage + * + * Provides secure storage for API keys and sensitive credentials. + * Uses OS keychain when available, with encrypted localStorage fallback. + * + * Security features: + * - Keys stored in OS keychain (Windows DPAPI, macOS Keychain, Linux Secret Service) + * - Encrypted backup in localStorage for migration support + * - Key validation and format checking + * - Audit logging for key access + * - Support for multiple API key types + */ + +import { secureStorage, isSecureStorageAvailable } from './secure-storage'; +import { hashSha256 } from './crypto-utils'; + +// Storage key prefixes +const API_KEY_PREFIX = 'zclaw_api_key_'; +const API_KEY_META_PREFIX = 'zclaw_api_key_meta_'; + +/** + * Supported API key types + */ +export type ApiKeyType = + | 'openai' + | 'anthropic' + | 'google' + | 'deepseek' + | 'zhipu' + | 'moonshot' + | 'custom'; + +/** + * API key metadata + */ +export interface ApiKeyMetadata { + type: ApiKeyType; + name: string; + description?: string; + createdAt: number; + updatedAt: number; + lastUsedAt?: number; + keyHash: string; // Partial hash for validation + prefix: string; // First 8 characters for display + isValid?: boolean; +} + +/** + * API key entry with metadata + */ +export interface ApiKeyEntry { + key: string; + metadata: ApiKeyMetadata; +} + +/** + * Validation rules for different API key types + */ +const KEY_VALIDATION_RULES: Record = { + openai: { + pattern: /^sk-[A-Za-z0-9_-]{20,}$/, + minLength: 20, + maxLength: 200, + prefix: ['sk-'], + }, + anthropic: { + pattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/, + minLength: 20, + maxLength: 200, + prefix: ['sk-ant-'], + }, + google: { + pattern: /^AIza[A-Za-z0-9_-]{35}$/, + minLength: 35, + maxLength: 50, + prefix: ['AIza'], + }, + deepseek: { + pattern: /^sk-[A-Za-z0-9]{20,}$/, + minLength: 20, + maxLength: 100, + prefix: ['sk-'], + }, + zhipu: { + pattern: /^[A-Za-z0-9_.-]{20,}$/, + minLength: 20, + maxLength: 100, + }, + moonshot: { + pattern: /^sk-[A-Za-z0-9]{20,}$/, + minLength: 20, + maxLength: 100, + prefix: ['sk-'], + }, + custom: { + pattern: /^.{8,}$/, + minLength: 8, + maxLength: 500, + }, +}; + +/** + * Validate an API key format + * + * @param type - The API key type + * @param key - The API key to validate + * @returns True if the key format is valid + */ +export function validateApiKeyFormat(type: ApiKeyType, key: string): { + valid: boolean; + error?: string; +} { + const rules = KEY_VALIDATION_RULES[type]; + + if (!key || typeof key !== 'string') { + return { valid: false, error: 'API key is required' }; + } + + // Trim whitespace + const trimmedKey = key.trim(); + + if (trimmedKey.length < rules.minLength) { + return { + valid: false, + error: `API key too short (minimum ${rules.minLength} characters)`, + }; + } + + if (trimmedKey.length > rules.maxLength) { + return { + valid: false, + error: `API key too long (maximum ${rules.maxLength} characters)`, + }; + } + + if (!rules.pattern.test(trimmedKey)) { + return { + valid: false, + error: `Invalid API key format for type: ${type}`, + }; + } + + if (rules.prefix && !rules.prefix.some(p => trimmedKey.startsWith(p))) { + return { + valid: false, + error: `API key must start with: ${rules.prefix.join(' or ')}`, + }; + } + + return { valid: true }; +} + +/** + * Create a partial hash for key validation + * Uses first 8 characters for identification without exposing full key + */ +async function createKeyHash(key: string): Promise { + // Use partial hash for validation + const partialKey = key.slice(0, 8) + key.slice(-4); + return hashSha256(partialKey); +} + +/** + * Store an API key securely + * + * @param type - The API key type + * @param key - The API key value + * @param options - Optional metadata + */ +export async function storeApiKey( + type: ApiKeyType, + key: string, + options?: { + name?: string; + description?: string; + } +): Promise { + // Validate key format + const validation = validateApiKeyFormat(type, key); + if (!validation.valid) { + throw new Error(validation.error); + } + + const trimmedKey = key.trim(); + const now = Date.now(); + const keyHash = await createKeyHash(trimmedKey); + + const metadata: ApiKeyMetadata = { + type, + name: options?.name || `${type}_api_key`, + description: options?.description, + createdAt: now, + updatedAt: now, + keyHash, + prefix: trimmedKey.slice(0, 8) + '...', + isValid: true, + }; + + // Store key in secure storage + const storageKey = API_KEY_PREFIX + type; + await secureStorage.set(storageKey, trimmedKey); + + // Store metadata in localStorage (non-sensitive) + localStorage.setItem( + API_KEY_META_PREFIX + type, + JSON.stringify(metadata) + ); + + // Log security event + logSecurityEvent('api_key_stored', { type, prefix: metadata.prefix }); + + return metadata; +} + +/** + * Retrieve an API key from secure storage + * + * @param type - The API key type + * @returns The API key or null if not found + */ +export async function getApiKey(type: ApiKeyType): Promise { + const storageKey = API_KEY_PREFIX + type; + const key = await secureStorage.get(storageKey); + + if (!key) { + return null; + } + + // Validate key still matches stored hash + const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type); + if (metaJson) { + try { + const metadata: ApiKeyMetadata = JSON.parse(metaJson); + const currentHash = await createKeyHash(key); + + if (currentHash !== metadata.keyHash) { + console.error('[ApiKeyStorage] Key hash mismatch - possible tampering'); + logSecurityEvent('api_key_hash_mismatch', { type }); + return null; + } + + // Update last used timestamp + metadata.lastUsedAt = Date.now(); + localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata)); + } catch { + // Ignore metadata parsing errors + } + } + + logSecurityEvent('api_key_accessed', { type }); + return key; +} + +/** + * Get API key metadata (without the actual key) + * + * @param type - The API key type + * @returns The metadata or null if not found + */ +export function getApiKeyMetadata(type: ApiKeyType): ApiKeyMetadata | null { + const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type); + if (!metaJson) { + return null; + } + + try { + return JSON.parse(metaJson) as ApiKeyMetadata; + } catch { + return null; + } +} + +/** + * List all stored API key metadata + * + * @returns Array of API key metadata + */ +export function listApiKeyMetadata(): ApiKeyMetadata[] { + const metadata: ApiKeyMetadata[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(API_KEY_META_PREFIX)) { + try { + const meta = JSON.parse(localStorage.getItem(key) || ''); + metadata.push(meta); + } catch { + // Ignore parsing errors + } + } + } + + return metadata; +} + +/** + * Delete an API key + * + * @param type - The API key type + */ +export async function deleteApiKey(type: ApiKeyType): Promise { + const storageKey = API_KEY_PREFIX + type; + await secureStorage.delete(storageKey); + localStorage.removeItem(API_KEY_META_PREFIX + type); + + logSecurityEvent('api_key_deleted', { type }); +} + +/** + * Update API key metadata + * + * @param type - The API key type + * @param updates - Metadata updates + */ +export function updateApiKeyMetadata( + type: ApiKeyType, + updates: Partial> +): void { + const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type); + if (!metaJson) { + throw new Error(`API key metadata not found for type: ${type}`); + } + + const metadata: ApiKeyMetadata = JSON.parse(metaJson); + Object.assign(metadata, updates, { updatedAt: Date.now() }); + + localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata)); +} + +/** + * Check if an API key exists for a type + * + * @param type - The API key type + * @returns True if key exists + */ +export async function hasApiKey(type: ApiKeyType): Promise { + const key = await getApiKey(type); + return key !== null; +} + +/** + * Validate a stored API key + * + * @param type - The API key type + * @returns Validation result + */ +export async function validateStoredApiKey(type: ApiKeyType): Promise<{ + valid: boolean; + error?: string; +}> { + const key = await getApiKey(type); + if (!key) { + return { valid: false, error: 'API key not found' }; + } + + return validateApiKeyFormat(type, key); +} + +/** + * Rotate an API key + * + * @param type - The API key type + * @param newKey - The new API key value + */ +export async function rotateApiKey(type: ApiKeyType, newKey: string): Promise { + // Delete old key first + await deleteApiKey(type); + + // Store new key + return storeApiKey(type, newKey, { + name: `${type}_api_key_rotated`, + description: `Rotated at ${new Date().toISOString()}`, + }); +} + +/** + * Export API key configuration (without actual keys) + * Useful for backup or migration + */ +export function exportApiKeyConfig(): Array> { + return listApiKeyMetadata().map(({ keyHash: _, ...meta }) => meta); +} + +/** + * Check if using OS keychain for storage + */ +export async function isUsingKeychain(): Promise { + return isSecureStorageAvailable(); +} + +// ============================================================================ +// Security Audit Logging +// ============================================================================ + +interface SecurityEvent { + type: string; + timestamp: number; + details: Record; +} + +const SECURITY_LOG_KEY = 'zclaw_security_events'; +const MAX_LOG_ENTRIES = 1000; + +/** + * Log a security event + */ +function logSecurityEvent( + type: string, + details: Record +): void { + try { + const events: SecurityEvent[] = JSON.parse( + localStorage.getItem(SECURITY_LOG_KEY) || '[]' + ); + + events.push({ + type, + timestamp: Date.now(), + details, + }); + + // Trim old entries + if (events.length > MAX_LOG_ENTRIES) { + events.splice(0, events.length - MAX_LOG_ENTRIES); + } + + localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events)); + } catch { + // Ignore logging failures + } +} + +/** + * Get security event log + */ +export function getSecurityLog(): SecurityEvent[] { + try { + return JSON.parse(localStorage.getItem(SECURITY_LOG_KEY) || '[]'); + } catch { + return []; + } +} + +/** + * Clear security event log + */ +export function clearSecurityLog(): void { + localStorage.removeItem(SECURITY_LOG_KEY); +} + +/** + * Generate a random API key for testing + * WARNING: Only use for testing purposes + */ +export function generateTestApiKey(type: ApiKeyType): string { + const rules = KEY_VALIDATION_RULES[type]; + const length = rules.minLength + 10; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + let key = ''; + if (rules.prefix && rules.prefix.length > 0) { + key = rules.prefix[0]; + } + + for (let i = key.length; i < length; i++) { + key += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return key; +} diff --git a/desktop/src/lib/crypto-utils.ts b/desktop/src/lib/crypto-utils.ts index 7d82566..f011c12 100644 --- a/desktop/src/lib/crypto-utils.ts +++ b/desktop/src/lib/crypto-utils.ts @@ -1,10 +1,18 @@ /** * Cryptographic utilities for secure storage * Uses Web Crypto API for AES-GCM encryption + * + * Security features: + * - AES-256-GCM for authenticated encryption + * - PBKDF2 with 100,000 iterations for key derivation + * - Random IV for each encryption operation + * - Constant-time comparison for integrity verification + * - Secure key caching with automatic expiration */ const SALT = new TextEncoder().encode('zclaw-secure-storage-salt'); const ITERATIONS = 100000; +const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes /** * Convert Uint8Array to base64 string @@ -33,13 +41,64 @@ export function base64ToArray(base64: string): Uint8Array { return array; } +/** + * Key cache entry with expiration + */ +interface CachedKey { + key: CryptoKey; + createdAt: number; +} + +/** + * Cache for derived keys with automatic expiration + */ +const keyCache = new Map(); + +/** + * Clean up expired keys from cache + */ +function cleanupExpiredKeys(): void { + const now = Date.now(); + for (const [cacheKey, entry] of keyCache.entries()) { + if (now - entry.createdAt > KEY_EXPIRY_MS) { + keyCache.delete(cacheKey); + } + } +} + +/** + * Generate a cache key from master key and salt + */ +function getCacheKey(masterKey: string, salt: Uint8Array): string { + const encoder = new TextEncoder(); + const combined = new Uint8Array(encoder.encode(masterKey).length + salt.length); + combined.set(encoder.encode(masterKey), 0); + combined.set(salt, encoder.encode(masterKey).length); + return arrayToBase64(combined.slice(0, 32)); // Use first 32 bytes as cache key +} + /** * Derive an encryption key from a master key + * Uses PBKDF2 with SHA-256 for key derivation + * + * @param masterKey - The master key string + * @param salt - Optional salt (uses default if not provided) + * @returns Promise - The derived encryption key */ export async function deriveKey( masterKey: string, salt: Uint8Array = SALT ): Promise { + // Clean up expired keys periodically + cleanupExpiredKeys(); + + // Check cache first + const cacheKey = getCacheKey(masterKey, salt); + const cached = keyCache.get(cacheKey); + if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) { + return cached.key; + } + const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', @@ -49,7 +108,7 @@ export async function deriveKey( ['deriveBits', 'deriveKey'] ); - return crypto.subtle.deriveKey( + const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt, @@ -61,15 +120,39 @@ export async function deriveKey( false, ['encrypt', 'decrypt'] ); + + // Cache the derived key + keyCache.set(cacheKey, { key: derivedKey, createdAt: Date.now() }); + + return derivedKey; } +/** + * Encrypted data structure + */ +export interface EncryptedData { + iv: string; + data: string; + authTag?: string; // For future use with separate auth tag + version?: number; // Schema version for future migrations +} + +/** + * Current encryption schema version + */ +const ENCRYPTION_VERSION = 1; + /** * Encrypt data using AES-GCM + * + * @param plaintext - The plaintext string to encrypt + * @param key - The encryption key + * @returns Promise - The encrypted data with IV */ export async function encrypt( plaintext: string, key: CryptoKey -): Promise<{ iv: string; data: string }> { +): Promise { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); @@ -82,14 +165,19 @@ export async function encrypt( return { iv: arrayToBase64(iv), data: arrayToBase64(new Uint8Array(encrypted)), + version: ENCRYPTION_VERSION, }; } /** * Decrypt data using AES-GCM + * + * @param encrypted - The encrypted data object + * @param key - The decryption key + * @returns Promise - The decrypted plaintext */ export async function decrypt( - encrypted: { iv: string; data: string }, + encrypted: EncryptedData, key: CryptoKey ): Promise { const decoder = new TextDecoder(); @@ -104,8 +192,169 @@ export async function decrypt( /** * Generate a random master key for encryption + * Uses cryptographically secure random number generator + * + * @returns string - Base64-encoded 256-bit random key */ export function generateMasterKey(): string { const array = crypto.getRandomValues(new Uint8Array(32)); return arrayToBase64(array); } + +/** + * Generate a random salt + * + * @param length - Salt length in bytes (default: 16) + * @returns Uint8Array - Random salt + */ +export function generateSalt(length: number = 16): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)); +} + +/** + * Constant-time comparison to prevent timing attacks + * + * @param a - First byte array + * @param b - Second byte array + * @returns boolean - True if arrays are equal + */ +export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + + return result === 0; +} + +/** + * Hash a string using SHA-256 + * + * @param input - The input string to hash + * @returns Promise - Hex-encoded hash + */ +export async function hashSha256(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = new Uint8Array(hashBuffer); + + // Convert to hex string + return Array.from(hashArray) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Hash a string using SHA-512 (for sensitive data) + * + * @param input - The input string to hash + * @returns Promise - Hex-encoded hash + */ +export async function hashSha512(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest('SHA-512', data); + const hashArray = new Uint8Array(hashBuffer); + + return Array.from(hashArray) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Generate a cryptographically secure random string + * + * @param length - Length of the string (default: 32) + * @returns string - Random alphanumeric string + */ +export function generateRandomString(length: number = 32): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const array = crypto.getRandomValues(new Uint8Array(length)); + let result = ''; + for (let i = 0; i < length; i++) { + result += chars[array[i] % chars.length]; + } + return result; +} + +/** + * Clear the key cache (for logout or security events) + */ +export function clearKeyCache(): void { + keyCache.clear(); +} + +/** + * Encrypt a JSON object + * + * @param obj - The object to encrypt + * @param key - The encryption key + * @returns Promise - The encrypted data + */ +export async function encryptObject( + obj: T, + key: CryptoKey +): Promise { + const plaintext = JSON.stringify(obj); + return encrypt(plaintext, key); +} + +/** + * Decrypt a JSON object + * + * @param encrypted - The encrypted data + * @param key - The decryption key + * @returns Promise - The decrypted object + */ +export async function decryptObject( + encrypted: EncryptedData, + key: CryptoKey +): Promise { + const plaintext = await decrypt(encrypted, key); + return JSON.parse(plaintext) as T; +} + +/** + * Securely wipe a string from memory (best effort) + * Note: JavaScript strings are immutable, so this only works for + * data that was explicitly copied to a Uint8Array + * + * @param array - The byte array to wipe + */ +export function secureWipe(array: Uint8Array): void { + crypto.getRandomValues(array); + array.fill(0); +} + +/** + * Check if Web Crypto API is available + */ +export function isCryptoAvailable(): boolean { + return ( + typeof crypto !== 'undefined' && + typeof crypto.subtle !== 'undefined' && + typeof crypto.getRandomValues === 'function' + ); +} + +/** + * Validate encrypted data structure + */ +export function isValidEncryptedData(data: unknown): data is EncryptedData { + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + return ( + typeof obj.iv === 'string' && + typeof obj.data === 'string' && + obj.iv.length > 0 && + obj.data.length > 0 + ); +} diff --git a/desktop/src/lib/encrypted-chat-storage.ts b/desktop/src/lib/encrypted-chat-storage.ts new file mode 100644 index 0000000..c70b544 --- /dev/null +++ b/desktop/src/lib/encrypted-chat-storage.ts @@ -0,0 +1,412 @@ +/** + * Encrypted Chat History Storage + * + * Provides encrypted persistence for chat messages and conversations. + * Uses AES-256-GCM encryption with the secure storage infrastructure. + * + * Security features: + * - All chat data encrypted at rest + * - Master key stored in OS keychain when available + * - Automatic key derivation with key rotation support + * - Secure backup to encrypted localStorage + */ + +import { + deriveKey, + encryptObject, + decryptObject, + generateMasterKey, + hashSha256, + isValidEncryptedData, + clearKeyCache, +} from './crypto-utils'; +import { secureStorage, isSecureStorageAvailable } from './secure-storage'; + +// Storage keys +const CHAT_DATA_KEY = 'zclaw_chat_data'; +const CHAT_KEY_IDENTIFIER = 'zclaw_chat_master_key'; +const CHAT_KEY_HASH_KEY = 'zclaw_chat_key_hash'; +const ENCRYPTED_PREFIX = 'enc_chat_'; + +// Encryption version for future migrations +const STORAGE_VERSION = 1; + +/** + * Storage metadata for integrity verification + */ +interface StorageMetadata { + version: number; + keyHash: string; + createdAt: number; + lastAccessedAt: number; + encryptedAt: number; +} + +/** + * Encrypted storage container + */ +interface EncryptedContainer { + metadata: StorageMetadata; + data: string; // Encrypted payload +} + +/** + * Cached crypto key for chat encryption + */ +let cachedChatKey: CryptoKey | null = null; +let keyHash: string | null = null; + +/** + * Get or initialize the master encryption key for chat storage + * Uses OS keychain when available, falls back to encrypted localStorage + */ +async function getOrCreateMasterKey(): Promise { + // Try to get existing key from secure storage + const existingKey = await secureStorage.get(CHAT_KEY_IDENTIFIER); + if (existingKey) { + return existingKey; + } + + // Generate new master key + const newKey = generateMasterKey(); + + // Store in secure storage (keychain or encrypted localStorage) + await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey); + + // Store hash for integrity verification + const keyHashValue = await hashSha256(newKey); + localStorage.setItem(CHAT_KEY_HASH_KEY, keyHashValue); + + console.log('[EncryptedChatStorage] Generated new master key'); + return newKey; +} + +/** + * Get the derived encryption key for chat data + */ +async function getChatEncryptionKey(): Promise { + if (cachedChatKey && keyHash) { + // Verify key hash matches + const storedHash = localStorage.getItem(CHAT_KEY_HASH_KEY); + if (storedHash === keyHash) { + return cachedChatKey; + } + // Hash mismatch - clear cache and re-derive + console.warn('[EncryptedChatStorage] Key hash mismatch, re-deriving key'); + cachedChatKey = null; + keyHash = null; + } + + const masterKey = await getOrCreateMasterKey(); + cachedChatKey = await deriveKey(masterKey); + keyHash = await hashSha256(masterKey); + + return cachedChatKey; +} + +/** + * Initialize encrypted chat storage + * Called during app startup + */ +export async function initializeEncryptedChatStorage(): Promise { + try { + // Pre-load the encryption key + await getChatEncryptionKey(); + + // Check if we have existing encrypted data to migrate + const legacyData = localStorage.getItem('zclaw-chat-storage'); + if (legacyData && !localStorage.getItem(ENCRYPTED_PREFIX + 'migrated')) { + await migrateFromLegacyStorage(legacyData); + localStorage.setItem(ENCRYPTED_PREFIX + 'migrated', 'true'); + console.log('[EncryptedChatStorage] Migrated legacy data'); + } + + console.log('[EncryptedChatStorage] Initialized successfully'); + } catch (error) { + console.error('[EncryptedChatStorage] Initialization failed:', error); + throw error; + } +} + +/** + * Migrate data from legacy unencrypted storage + */ +async function migrateFromLegacyStorage(legacyData: string): Promise { + try { + const parsed = JSON.parse(legacyData); + if (parsed?.state?.conversations) { + await saveConversations(parsed.state.conversations); + console.log(`[EncryptedChatStorage] Migrated ${parsed.state.conversations.length} conversations`); + } + } catch (error) { + console.error('[EncryptedChatStorage] Migration failed:', error); + } +} + +/** + * Save conversations to encrypted storage + * + * @param conversations - Array of conversation objects + */ +export async function saveConversations(conversations: unknown[]): Promise { + if (!conversations || conversations.length === 0) { + return; + } + + try { + const key = await getChatEncryptionKey(); + const now = Date.now(); + + // Create container with metadata + const container: EncryptedContainer = { + metadata: { + version: STORAGE_VERSION, + keyHash: keyHash || '', + createdAt: now, + lastAccessedAt: now, + encryptedAt: now, + }, + data: '', // Will be set after encryption + }; + + // Encrypt the conversations array + const encrypted = await encryptObject(conversations, key); + container.data = JSON.stringify(encrypted); + + // Store the encrypted container + localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container)); + + console.log(`[EncryptedChatStorage] Saved ${conversations.length} conversations`); + } catch (error) { + console.error('[EncryptedChatStorage] Failed to save conversations:', error); + throw error; + } +} + +/** + * Load conversations from encrypted storage + * + * @returns Array of conversation objects or empty array if none exist + */ +export async function loadConversations(): Promise { + try { + const stored = localStorage.getItem(CHAT_DATA_KEY); + if (!stored) { + return []; + } + + const container: EncryptedContainer = JSON.parse(stored); + + // Validate container structure + if (!container.data || !container.metadata) { + console.warn('[EncryptedChatStorage] Invalid container structure'); + return []; + } + + // Check version compatibility + if (container.metadata.version > STORAGE_VERSION) { + console.error('[EncryptedChatStorage] Incompatible storage version'); + return []; + } + + // Parse and decrypt the data + const encryptedData = JSON.parse(container.data); + if (!isValidEncryptedData(encryptedData)) { + console.error('[EncryptedChatStorage] Invalid encrypted data'); + return []; + } + + const key = await getChatEncryptionKey(); + const conversations = await decryptObject(encryptedData, key); + + // Update last accessed time + container.metadata.lastAccessedAt = Date.now(); + localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container)); + + console.log(`[EncryptedChatStorage] Loaded ${conversations.length} conversations`); + return conversations; + } catch (error) { + console.error('[EncryptedChatStorage] Failed to load conversations:', error); + return []; + } +} + +/** + * Delete all chat data from storage + */ +export async function clearAllChatData(): Promise { + try { + // Clear encrypted data + localStorage.removeItem(CHAT_DATA_KEY); + localStorage.removeItem(ENCRYPTED_PREFIX + 'migrated'); + + // Clear the master key from secure storage + await secureStorage.delete(CHAT_KEY_IDENTIFIER); + localStorage.removeItem(CHAT_KEY_HASH_KEY); + + // Clear cached key + cachedChatKey = null; + keyHash = null; + clearKeyCache(); + + console.log('[EncryptedChatStorage] Cleared all chat data'); + } catch (error) { + console.error('[EncryptedChatStorage] Failed to clear chat data:', error); + throw error; + } +} + +/** + * Export encrypted chat data for backup + * Returns encrypted blob that can be imported later + * + * @returns Base64-encoded encrypted backup + */ +export async function exportEncryptedBackup(): Promise { + try { + const stored = localStorage.getItem(CHAT_DATA_KEY); + if (!stored) { + throw new Error('No chat data to export'); + } + + // The data is already encrypted, just return it + const container: EncryptedContainer = JSON.parse(stored); + const exportData = { + type: 'zclaw_chat_backup', + version: STORAGE_VERSION, + exportedAt: Date.now(), + container, + }; + + return btoa(JSON.stringify(exportData)); + } catch (error) { + console.error('[EncryptedChatStorage] Export failed:', error); + throw error; + } +} + +/** + * Import encrypted chat data from backup + * + * @param backupData - Base64-encoded encrypted backup + * @param merge - Whether to merge with existing data (default: false, replaces) + */ +export async function importEncryptedBackup( + backupData: string, + merge: boolean = false +): Promise { + try { + const decoded = JSON.parse(atob(backupData)); + + // Validate backup format + if (decoded.type !== 'zclaw_chat_backup') { + throw new Error('Invalid backup format'); + } + + if (decoded.version > STORAGE_VERSION) { + throw new Error('Incompatible backup version'); + } + + if (merge) { + // Load existing conversations and merge + const existing = await loadConversations(); + const imported = await decryptObject( + JSON.parse(decoded.container.data), + await getChatEncryptionKey() + ); + const merged = [...existing, ...imported]; + await saveConversations(merged); + } else { + // Replace existing data + localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(decoded.container)); + } + + console.log('[EncryptedChatStorage] Import completed successfully'); + } catch (error) { + console.error('[EncryptedChatStorage] Import failed:', error); + throw error; + } +} + +/** + * Check if encrypted storage is being used + */ +export async function isEncryptedStorageActive(): Promise { + const stored = localStorage.getItem(CHAT_DATA_KEY); + if (!stored) { + return false; + } + + try { + const container: EncryptedContainer = JSON.parse(stored); + return container.metadata?.version === STORAGE_VERSION; + } catch { + return false; + } +} + +/** + * Get storage statistics + */ +export async function getStorageStats(): Promise<{ + encrypted: boolean; + usingKeychain: boolean; + conversationCount: number; + storageSize: number; +}> { + const stored = localStorage.getItem(CHAT_DATA_KEY); + let conversationCount = 0; + let encrypted = false; + + if (stored) { + try { + const container: EncryptedContainer = JSON.parse(stored); + encrypted = container.metadata?.version === STORAGE_VERSION; + + // Count conversations without full decryption + const conversations = await loadConversations(); + conversationCount = conversations.length; + } catch { + // Ignore parsing errors + } + } + + return { + encrypted, + usingKeychain: await isSecureStorageAvailable(), + conversationCount, + storageSize: stored ? new Blob([stored]).size : 0, + }; +} + +/** + * Rotate encryption key + * Re-encrypts all data with a new key + */ +export async function rotateEncryptionKey(): Promise { + try { + // Load existing data + const conversations = await loadConversations(); + + // Clear old key + await secureStorage.delete(CHAT_KEY_IDENTIFIER); + localStorage.removeItem(CHAT_KEY_HASH_KEY); + cachedChatKey = null; + keyHash = null; + clearKeyCache(); + + // Generate new key (will be created on next getChatEncryptionKey call) + const newKey = generateMasterKey(); + await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey); + const newKeyHash = await hashSha256(newKey); + localStorage.setItem(CHAT_KEY_HASH_KEY, newKeyHash); + + // Re-save all data with new key + await saveConversations(conversations); + + console.log('[EncryptedChatStorage] Encryption key rotated successfully'); + } catch (error) { + console.error('[EncryptedChatStorage] Key rotation failed:', error); + throw error; + } +} diff --git a/desktop/src/lib/gateway-client.ts b/desktop/src/lib/gateway-client.ts index 012ca18..4aa730c 100644 --- a/desktop/src/lib/gateway-client.ts +++ b/desktop/src/lib/gateway-client.ts @@ -87,6 +87,47 @@ export class SecurityError extends Error { } } +/** + * Connection error for WebSocket/HTTP connection failures. + */ +export class ConnectionError extends Error { + public readonly code?: string; + public readonly recoverable: boolean; + + constructor(message: string, code?: string, recoverable: boolean = true) { + super(message); + this.name = 'ConnectionError'; + this.code = code; + this.recoverable = recoverable; + } +} + +/** + * Timeout error for request/response timeouts. + */ +export class TimeoutError extends Error { + public readonly timeout: number; + + constructor(message: string, timeout: number) { + super(message); + this.name = 'TimeoutError'; + this.timeout = timeout; + } +} + +/** + * Authentication error for handshake/token failures. + */ +export class AuthenticationError extends Error { + public readonly code?: string; + + constructor(message: string, code?: string) { + super(message); + this.name = 'AuthenticationError'; + this.code = code; + } +} + /** * Validate WebSocket URL security. * Ensures non-localhost connections use WSS protocol. diff --git a/desktop/src/lib/gateway-storage.ts b/desktop/src/lib/gateway-storage.ts index da63a8f..e72d789 100644 --- a/desktop/src/lib/gateway-storage.ts +++ b/desktop/src/lib/gateway-storage.ts @@ -3,9 +3,14 @@ * * Extracted from gateway-client.ts for modularity. * Manages WSS configuration, URL normalization, and - * localStorage persistence for gateway URL and token. + * secure storage persistence for gateway URL and token. + * + * Security: Token is now stored using secure storage (keychain or encrypted localStorage) */ +import { secureStorage } from './secure-storage'; +import { logKeyEvent, logSecurityEvent } from './security-audit'; + // === WSS Configuration === /** @@ -95,18 +100,104 @@ export function setStoredGatewayUrl(url: string): string { return normalized; } -export function getStoredGatewayToken(): string { +/** + * Get the stored gateway token from secure storage + * Uses OS keychain when available, falls back to encrypted localStorage + * + * @returns The stored token or empty string if not found + */ +export async function getStoredGatewayTokenAsync(): Promise { try { - return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || ''; + const token = await secureStorage.get(GATEWAY_TOKEN_STORAGE_KEY); + if (token) { + logKeyEvent('key_accessed', 'Retrieved gateway token', { source: 'secure_storage' }); + } + return token || ''; + } catch (error) { + console.error('[GatewayStorage] Failed to get gateway token:', error); + return ''; + } +} + +/** + * Synchronous version for backward compatibility + * @deprecated Use getStoredGatewayTokenAsync() instead + */ +export function getStoredGatewayToken(): string { + // This returns empty string and logs a warning in dev mode + // Real code should use the async version + if (process.env.NODE_ENV === 'development') { + console.warn('[GatewayStorage] Using synchronous token access - consider using async version'); + } + + // Try to get from localStorage as fallback (may be encrypted) + try { + const stored = localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY); + if (stored) { + // Check if it's encrypted (has iv and data fields) + try { + const parsed = JSON.parse(stored); + if (parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string') { + // Data is encrypted - cannot decrypt synchronously + console.warn('[GatewayStorage] Token is encrypted - use async version'); + return ''; + } + } catch { + // Not JSON, so it's plaintext (legacy format) + return stored; + } + } + return ''; } catch { return ''; } } -export function setStoredGatewayToken(token: string): string { +/** + * Store the gateway token securely + * Uses OS keychain when available, falls back to encrypted localStorage + * + * @param token - The token to store + * @returns The normalized token + */ +export async function setStoredGatewayTokenAsync(token: string): Promise { const normalized = token.trim(); + try { if (normalized) { + await secureStorage.set(GATEWAY_TOKEN_STORAGE_KEY, normalized); + logKeyEvent('key_stored', 'Stored gateway token', { source: 'secure_storage' }); + } else { + await secureStorage.delete(GATEWAY_TOKEN_STORAGE_KEY); + logKeyEvent('key_deleted', 'Deleted gateway token', { source: 'secure_storage' }); + } + + // Clear legacy localStorage token if it exists + localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY); + } catch (error) { + console.error('[GatewayStorage] Failed to store gateway token:', error); + logSecurityEvent('security_violation', 'Failed to store gateway token securely', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return normalized; +} + +/** + * Synchronous version for backward compatibility + * @deprecated Use setStoredGatewayTokenAsync() instead + */ +export function setStoredGatewayToken(token: string): string { + const normalized = token.trim(); + + if (process.env.NODE_ENV === 'development') { + console.warn('[GatewayStorage] Using synchronous token storage - consider using async version'); + } + + try { + if (normalized) { + // Store in localStorage as fallback (not secure, but better than nothing) localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized); } else { localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY); @@ -114,5 +205,6 @@ export function setStoredGatewayToken(token: string): string { } catch { /* ignore localStorage failures */ } + return normalized; } diff --git a/desktop/src/lib/security-audit.ts b/desktop/src/lib/security-audit.ts new file mode 100644 index 0000000..c620479 --- /dev/null +++ b/desktop/src/lib/security-audit.ts @@ -0,0 +1,564 @@ +/** + * Security Audit Logging Module + * + * Provides comprehensive security event logging for ZCLAW application. + * All security-relevant events are logged with timestamps and details. + * + * Security events logged: + * - Authentication events (login, logout, failed attempts) + * - API key operations (access, rotation, deletion) + * - Data access events (encrypted data read/write) + * - Security violations (failed decryption, tampering attempts) + * - Configuration changes + */ + +import { hashSha256 } from './crypto-utils'; + +// ============================================================================ +// Types +// ============================================================================ + +export type SecurityEventType = + | 'auth_login' + | 'auth_logout' + | 'auth_failed' + | 'auth_token_refresh' + | 'key_accessed' + | 'key_stored' + | 'key_deleted' + | 'key_rotated' + | 'data_encrypted' + | 'data_decrypted' + | 'data_access' + | 'data_export' + | 'data_import' + | 'security_violation' + | 'decryption_failed' + | 'integrity_check_failed' + | 'config_changed' + | 'permission_granted' + | 'permission_denied' + | 'session_started' + | 'session_ended' + | 'rate_limit_exceeded' + | 'suspicious_activity'; + +export type SecurityEventSeverity = 'info' | 'warning' | 'error' | 'critical'; + +export interface SecurityEvent { + id: string; + type: SecurityEventType; + severity: SecurityEventSeverity; + timestamp: string; + message: string; + details: Record; + userAgent?: string; + ip?: string; + sessionId?: string; + agentId?: string; +} + +export interface SecurityAuditReport { + generatedAt: string; + totalEvents: number; + eventsByType: Record; + eventsBySeverity: Record; + recentCriticalEvents: SecurityEvent[]; + recommendations: string[]; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const SECURITY_LOG_KEY = 'zclaw_security_audit_log'; +const MAX_LOG_ENTRIES = 2000; +const AUDIT_VERSION = 1; + +// ============================================================================ +// Internal State +// ============================================================================ + +let isAuditEnabled: boolean = true; +let currentSessionId: string | null = null; + +// ============================================================================ +// Core Functions +// ============================================================================ + +/** + * Generate a unique event ID + */ +function generateEventId(): string { + return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Get the current session ID + */ +export function getCurrentSessionId(): string | null { + return currentSessionId; +} + +/** + * Set the current session ID + */ +export function setCurrentSessionId(sessionId: string | null): void { + currentSessionId = sessionId; +} + +/** + * Enable or disable audit logging + */ +export function setAuditEnabled(enabled: boolean): void { + isAuditEnabled = enabled; + logSecurityEventInternal('config_changed', 'info', `Audit logging ${enabled ? 'enabled' : 'disabled'}`, {}); +} + +/** + * Check if audit logging is enabled + */ +export function isAuditEnabledState(): boolean { + return isAuditEnabled; +} + +/** + * Internal function to persist security events + */ +function persistEvent(event: SecurityEvent): void { + try { + const events = getStoredEvents(); + events.push(event); + + // Trim old entries if needed + if (events.length > MAX_LOG_ENTRIES) { + events.splice(0, events.length - MAX_LOG_ENTRIES); + } + + localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events)); + } catch { + // Ignore persistence failures to prevent application disruption + } +} + +/** + * Get stored security events + */ +function getStoredEvents(): SecurityEvent[] { + try { + const stored = localStorage.getItem(SECURITY_LOG_KEY); + if (!stored) return []; + return JSON.parse(stored) as SecurityEvent[]; + } catch { + return []; + } +} + +/** + * Determine severity based on event type + */ +function getDefaultSeverity(type: SecurityEventType): SecurityEventSeverity { + const severityMap: Record = { + auth_login: 'info', + auth_logout: 'info', + auth_failed: 'warning', + auth_token_refresh: 'info', + key_accessed: 'info', + key_stored: 'info', + key_deleted: 'warning', + key_rotated: 'info', + data_encrypted: 'info', + data_decrypted: 'info', + data_access: 'info', + data_export: 'warning', + data_import: 'warning', + security_violation: 'critical', + decryption_failed: 'error', + integrity_check_failed: 'critical', + config_changed: 'warning', + permission_granted: 'info', + permission_denied: 'warning', + session_started: 'info', + session_ended: 'info', + rate_limit_exceeded: 'warning', + suspicious_activity: 'critical', + }; + + return severityMap[type] || 'info'; +} + +/** + * Internal function to log security events + */ +function logSecurityEventInternal( + type: SecurityEventType, + severity: SecurityEventSeverity, + message: string, + details: Record +): void { + if (!isAuditEnabled && type !== 'config_changed') { + return; + } + + const event: SecurityEvent = { + id: generateEventId(), + type, + severity, + timestamp: new Date().toISOString(), + message, + details, + sessionId: currentSessionId || undefined, + }; + + // Add user agent if in browser + if (typeof navigator !== 'undefined') { + event.userAgent = navigator.userAgent; + } + + persistEvent(event); + + // Log to console for development + if (process.env.NODE_ENV === 'development') { + const logMethod = severity === 'critical' || severity === 'error' ? 'error' : + severity === 'warning' ? 'warn' : 'log'; + console[logMethod](`[SecurityAudit] ${type}: ${message}`, details); + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Log a security event + */ +export function logSecurityEvent( + type: SecurityEventType, + message: string, + details: Record = {}, + severity?: SecurityEventSeverity +): void { + const eventSeverity = severity || getDefaultSeverity(type); + logSecurityEventInternal(type, eventSeverity, message, details); +} + +/** + * Log authentication event + */ +export function logAuthEvent( + type: 'auth_login' | 'auth_logout' | 'auth_failed' | 'auth_token_refresh', + message: string, + details: Record = {} +): void { + logSecurityEvent(type, message, details); +} + +/** + * Log key management event + */ +export function logKeyEvent( + type: 'key_accessed' | 'key_stored' | 'key_deleted' | 'key_rotated', + message: string, + details: Record = {} +): void { + logSecurityEvent(type, message, details); +} + +/** + * Log data access event + */ +export function logDataEvent( + type: 'data_encrypted' | 'data_decrypted' | 'data_access' | 'data_export' | 'data_import', + message: string, + details: Record = {} +): void { + logSecurityEvent(type, message, details); +} + +/** + * Log security violation + */ +export function logSecurityViolation( + message: string, + details: Record = {} +): void { + logSecurityEvent('security_violation', message, details, 'critical'); +} + +/** + * Log decryption failure + */ +export function logDecryptionFailure( + message: string, + details: Record = {} +): void { + logSecurityEvent('decryption_failed', message, details, 'error'); +} + +/** + * Log integrity check failure + */ +export function logIntegrityFailure( + message: string, + details: Record = {} +): void { + logSecurityEvent('integrity_check_failed', message, details, 'critical'); +} + +/** + * Log permission event + */ +export function logPermissionEvent( + type: 'permission_granted' | 'permission_denied', + message: string, + details: Record = {} +): void { + logSecurityEvent(type, message, details); +} + +/** + * Log session event + */ +export function logSessionEvent( + type: 'session_started' | 'session_ended', + message: string, + details: Record = {} +): void { + logSecurityEvent(type, message, details); +} + +/** + * Log suspicious activity + */ +export function logSuspiciousActivity( + message: string, + details: Record = {} +): void { + logSecurityEvent('suspicious_activity', message, details, 'critical'); +} + +/** + * Log rate limit event + */ +export function logRateLimitEvent( + message: string, + details: Record = {} +): void { + logSecurityEvent('rate_limit_exceeded', message, details, 'warning'); +} + +// ============================================================================ +// Query Functions +// ============================================================================ + +/** + * Get all security events + */ +export function getSecurityEvents(): SecurityEvent[] { + return getStoredEvents(); +} + +/** + * Get security events by type + */ +export function getSecurityEventsByType(type: SecurityEventType): SecurityEvent[] { + return getStoredEvents().filter(event => event.type === type); +} + +/** + * Get security events by severity + */ +export function getSecurityEventsBySeverity(severity: SecurityEventSeverity): SecurityEvent[] { + return getStoredEvents().filter(event => event.severity === severity); +} + +/** + * Get security events within a time range + */ +export function getSecurityEventsByTimeRange(start: Date, end: Date): SecurityEvent[] { + const startTime = start.getTime(); + const endTime = end.getTime(); + + return getStoredEvents().filter(event => { + const eventTime = new Date(event.timestamp).getTime(); + return eventTime >= startTime && eventTime <= endTime; + }); +} + +/** + * Get recent critical events + */ +export function getRecentCriticalEvents(count: number = 10): SecurityEvent[] { + return getStoredEvents() + .filter(event => event.severity === 'critical' || event.severity === 'error') + .slice(-count); +} + +/** + * Get events for a specific session + */ +export function getSecurityEventsBySession(sessionId: string): SecurityEvent[] { + return getStoredEvents().filter(event => event.sessionId === sessionId); +} + +// ============================================================================ +// Report Generation +// ============================================================================ + +/** + * Generate a security audit report + */ +export function generateSecurityAuditReport(): SecurityAuditReport { + const events = getStoredEvents(); + + const eventsByType = Object.create(null) as Record; + const eventsBySeverity: Record = { + info: 0, + warning: 0, + error: 0, + critical: 0, + }; + + for (const event of events) { + eventsByType[event.type] = (eventsByType[event.type] || 0) + 1; + eventsBySeverity[event.severity]++; + } + + const recentCriticalEvents = getRecentCriticalEvents(10); + + const recommendations: string[] = []; + + // Generate recommendations based on findings + if (eventsBySeverity.critical > 0) { + recommendations.push('Investigate critical security events immediately'); + } + + if ((eventsByType.auth_failed || 0) > 5) { + recommendations.push('Multiple failed authentication attempts detected - consider rate limiting'); + } + + if ((eventsByType.decryption_failed || 0) > 3) { + recommendations.push('Multiple decryption failures - check key integrity'); + } + + if ((eventsByType.suspicious_activity || 0) > 0) { + recommendations.push('Suspicious activity detected - review access logs'); + } + + if (events.length === 0) { + recommendations.push('No security events recorded - ensure audit logging is enabled'); + } + + return { + generatedAt: new Date().toISOString(), + totalEvents: events.length, + eventsByType, + eventsBySeverity, + recentCriticalEvents, + recommendations, + }; +} + +// ============================================================================ +// Maintenance Functions +// ============================================================================ + +/** + * Clear all security events + */ +export function clearSecurityAuditLog(): void { + localStorage.removeItem(SECURITY_LOG_KEY); + logSecurityEventInternal('config_changed', 'warning', 'Security audit log cleared', {}); +} + +/** + * Export security events for external analysis + */ +export function exportSecurityEvents(): string { + const events = getStoredEvents(); + return JSON.stringify({ + version: AUDIT_VERSION, + exportedAt: new Date().toISOString(), + events, + }, null, 2); +} + +/** + * Import security events from external source + */ +export function importSecurityEvents(jsonData: string, merge: boolean = false): void { + try { + const data = JSON.parse(jsonData); + const importedEvents = data.events as SecurityEvent[]; + + if (!importedEvents || !Array.isArray(importedEvents)) { + throw new Error('Invalid import data format'); + } + + if (merge) { + const existingEvents = getStoredEvents(); + const mergedEvents = [...existingEvents, ...importedEvents]; + localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(mergedEvents.slice(-MAX_LOG_ENTRIES))); + } else { + localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(importedEvents.slice(-MAX_LOG_ENTRIES))); + } + + logSecurityEventInternal('data_import', 'warning', `Imported ${importedEvents.length} security events`, { + merge, + sourceVersion: data.version, + }); + } catch (error) { + logSecurityEventInternal('security_violation', 'error', 'Failed to import security events', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Verify audit log integrity + */ +export async function verifyAuditLogIntegrity(): Promise<{ + valid: boolean; + eventCount: number; + hash: string; +}> { + const events = getStoredEvents(); + const data = JSON.stringify(events); + const hash = await hashSha256(data); + + return { + valid: events.length > 0, + eventCount: events.length, + hash, + }; +} + +// ============================================================================ +// Initialization +// ============================================================================ + +/** + * Initialize the security audit module + */ +export function initializeSecurityAudit(sessionId?: string): void { + if (sessionId) { + currentSessionId = sessionId; + } + + logSecurityEventInternal('session_started', 'info', 'Security audit session started', { + sessionId: currentSessionId, + auditEnabled: isAuditEnabled, + }); +} + +/** + * Shutdown the security audit module + */ +export function shutdownSecurityAudit(): void { + logSecurityEventInternal('session_ended', 'info', 'Security audit session ended', { + sessionId: currentSessionId, + }); + + currentSessionId = null; +} diff --git a/desktop/src/lib/security-index.ts b/desktop/src/lib/security-index.ts new file mode 100644 index 0000000..012e345 --- /dev/null +++ b/desktop/src/lib/security-index.ts @@ -0,0 +1,241 @@ +/** + * Security Module Index + * + * Central export point for all security-related functionality in ZCLAW. + * + * Modules: + * - crypto-utils: AES-256-GCM encryption, key derivation, hashing + * - secure-storage: OS keychain integration with encrypted localStorage fallback + * - api-key-storage: Secure API key management + * - encrypted-chat-storage: Encrypted chat history persistence + * - security-audit: Security event logging and reporting + * - security-utils: Input validation, XSS prevention, rate limiting + */ + +// Re-export crypto utilities +export { + // Core encryption + encrypt, + decrypt, + encryptObject, + decryptObject, + deriveKey, + generateMasterKey, + generateSalt, + + // Hashing + hashSha256, + hashSha512, + + // Utilities + arrayToBase64, + base64ToArray, + constantTimeEqual, + generateRandomString, + secureWipe, + clearKeyCache, + isCryptoAvailable, + isValidEncryptedData, +} from './crypto-utils'; + +export type { EncryptedData } from './crypto-utils'; + +// Re-export secure storage +export { + secureStorage, + secureStorageSync, + isSecureStorageAvailable, + storeDeviceKeys, + getDeviceKeys, + deleteDeviceKeys, + hasDeviceKeys, + getDeviceKeysCreatedAt, +} from './secure-storage'; + +export type { Ed25519KeyPair } from './secure-storage'; + +// Re-export API key storage +export { + // Types + type ApiKeyType, + type ApiKeyMetadata, + + // Core functions + storeApiKey, + getApiKey, + deleteApiKey, + listApiKeyMetadata, + updateApiKeyMetadata, + hasApiKey, + validateStoredApiKey, + rotateApiKey, + + // Utility functions + validateApiKeyFormat, + exportApiKeyConfig, + isUsingKeychain, + generateTestApiKey, +} from './api-key-storage'; + +// Re-export encrypted chat storage +export { + initializeEncryptedChatStorage, + saveConversations, + loadConversations, + clearAllChatData, + exportEncryptedBackup, + importEncryptedBackup, + isEncryptedStorageActive, + getStorageStats, + rotateEncryptionKey, +} from './encrypted-chat-storage'; + +// Re-export security audit +export { + // Core logging + logSecurityEvent, + logAuthEvent, + logKeyEvent, + logDataEvent, + logSecurityViolation, + logDecryptionFailure, + logIntegrityFailure, + logPermissionEvent, + logSessionEvent, + logSuspiciousActivity, + logRateLimitEvent, + + // Query functions + getSecurityEvents, + getSecurityEventsByType, + getSecurityEventsBySeverity, + getSecurityEventsByTimeRange, + getRecentCriticalEvents, + getSecurityEventsBySession, + + // Report generation + generateSecurityAuditReport, + + // Maintenance + clearSecurityAuditLog, + exportSecurityEvents, + importSecurityEvents, + verifyAuditLogIntegrity, + + // Session management + getCurrentSessionId, + setCurrentSessionId, + setAuditEnabled, + isAuditEnabledState, + initializeSecurityAudit, + shutdownSecurityAudit, +} from './security-audit'; + +export type { + SecurityEventType, + SecurityEventSeverity, + SecurityEvent, + SecurityAuditReport, +} from './security-audit'; + +// Re-export security utilities +export { + // HTML sanitization + escapeHtml, + unescapeHtml, + sanitizeHtml, + + // URL validation + validateUrl, + isSafeRedirectUrl, + + // Path validation + validatePath, + + // Input validation + isValidEmail, + isValidUsername, + validatePasswordStrength, + sanitizeFilename, + sanitizeJson, + + // Rate limiting + isRateLimited, + resetRateLimit, + getRemainingAttempts, + + // CSP helpers + generateCspNonce, + buildCspHeader, + DEFAULT_CSP_DIRECTIVES, + + // Security checks + checkSecurityHeaders, + + // Random generation + generateSecureToken, + generateSecureId, +} from './security-utils'; + +// ============================================================================ +// Security Initialization +// ============================================================================ + +/** + * Initialize all security modules + * Call this during application startup + */ +export async function initializeSecurity(sessionId?: string): Promise { + // Initialize security audit first + const { initializeSecurityAudit } = await import('./security-audit'); + initializeSecurityAudit(sessionId); + + // Initialize encrypted chat storage + const { initializeEncryptedChatStorage } = await import('./encrypted-chat-storage'); + await initializeEncryptedChatStorage(); + + console.log('[Security] All security modules initialized'); +} + +/** + * Shutdown all security modules + * Call this during application shutdown + */ +export async function shutdownSecurity(): Promise { + const { shutdownSecurityAudit } = await import('./security-audit'); + shutdownSecurityAudit(); + + const { clearKeyCache } = await import('./crypto-utils'); + clearKeyCache(); + + console.log('[Security] All security modules shut down'); +} + +/** + * Get a comprehensive security status report + */ +export async function getSecurityStatus(): Promise<{ + auditEnabled: boolean; + keychainAvailable: boolean; + chatStorageInitialized: boolean; + storedApiKeys: number; + recentEvents: number; + criticalEvents: number; +}> { + const { isAuditEnabledState, getSecurityEventsBySeverity } = await import('./security-audit'); + const { isSecureStorageAvailable } = await import('./secure-storage'); + const { isEncryptedStorageActive: isChatStorageInitialized } = await import('./encrypted-chat-storage'); + const { listApiKeyMetadata } = await import('./api-key-storage'); + + const criticalEvents = getSecurityEventsBySeverity('critical').length; + const errorEvents = getSecurityEventsBySeverity('error').length; + + return { + auditEnabled: isAuditEnabledState(), + keychainAvailable: await isSecureStorageAvailable(), + chatStorageInitialized: await isChatStorageInitialized(), + storedApiKeys: (await listApiKeyMetadata()).length, + recentEvents: criticalEvents + errorEvents, + criticalEvents, + }; +} diff --git a/desktop/src/lib/security-utils.ts b/desktop/src/lib/security-utils.ts new file mode 100644 index 0000000..86a2b83 --- /dev/null +++ b/desktop/src/lib/security-utils.ts @@ -0,0 +1,729 @@ +/** + * Security Utilities for Input Validation and XSS Prevention + * + * Provides comprehensive input validation, sanitization, and XSS prevention + * for the ZCLAW application. + * + * Security features: + * - HTML sanitization + * - URL validation + * - Path traversal prevention + * - Input validation helpers + * - Content Security Policy helpers + */ + +// ============================================================================ +// HTML Sanitization +// ============================================================================ + +/** + * HTML entity encoding map + */ +const HTML_ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=', +}; + +/** + * Escape HTML entities in a string + * Prevents XSS attacks by encoding dangerous characters + * + * @param input - The string to escape + * @returns The escaped string + */ +export function escapeHtml(input: string): string { + if (typeof input !== 'string') { + return ''; + } + return input.replace(/[&<>"'`=\/]/g, char => HTML_ENTITIES[char] || char); +} + +/** + * Unescape HTML entities in a string + * + * @param input - The string to unescape + * @returns The unescaped string + */ +export function unescapeHtml(input: string): string { + if (typeof input !== 'string') { + return ''; + } + + const textarea = document.createElement('textarea'); + textarea.innerHTML = input; + return textarea.value; +} + +/** + * Allowed HTML tags for safe rendering + */ +const ALLOWED_TAGS = new Set([ + 'p', 'br', 'b', 'i', 'u', 'strong', 'em', + 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', + 'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', +]); + +/** + * Allowed HTML attributes + */ +const ALLOWED_ATTRIBUTES = new Set([ + 'href', 'title', 'class', 'id', 'target', 'rel', +]); + +/** + * Sanitize HTML content for safe rendering + * Removes dangerous tags and attributes while preserving safe content + * + * @param html - The HTML string to sanitize + * @param options - Sanitization options + * @returns The sanitized HTML + */ +export function sanitizeHtml( + html: string, + options: { + allowedTags?: string[]; + allowedAttributes?: string[]; + allowDataAttributes?: boolean; + } = {} +): string { + if (typeof html !== 'string') { + return ''; + } + + const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS); + const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES); + + // Create a temporary container + const container = document.createElement('div'); + container.innerHTML = html; + + // Recursively clean elements + function cleanElement(element: Element): void { + // Remove script tags entirely + if (element.tagName.toLowerCase() === 'script') { + element.remove(); + return; + } + + // Remove style tags entirely + if (element.tagName.toLowerCase() === 'style') { + element.remove(); + return; + } + + // Remove event handlers and dangerous attributes + const attributes = Array.from(element.attributes); + for (const attr of attributes) { + const attrName = attr.name.toLowerCase(); + + // Remove event handlers (onclick, onload, etc.) + if (attrName.startsWith('on')) { + element.removeAttribute(attr.name); + continue; + } + + // Remove javascript: URLs + if (attrName === 'href' || attrName === 'src') { + const value = attr.value.toLowerCase().trim(); + if (value.startsWith('javascript:') || value.startsWith('data:text/html')) { + element.removeAttribute(attr.name); + continue; + } + } + + // Remove data attributes if not allowed + if (attrName.startsWith('data-') && !options.allowDataAttributes) { + element.removeAttribute(attr.name); + continue; + } + + // Remove non-allowed attributes + if (!allowedAttributes.has(attrName)) { + element.removeAttribute(attr.name); + } + } + + // Remove non-allowed tags (but keep their content) + if (!allowedTags.has(element.tagName.toLowerCase())) { + const parent = element.parentNode; + while (element.firstChild) { + parent?.insertBefore(element.firstChild, element); + } + parent?.removeChild(element); + return; + } + + // Recursively clean child elements + Array.from(element.children).forEach(cleanElement); + } + + // Clean all elements + Array.from(container.children).forEach(cleanElement); + + return container.innerHTML; +} + +// ============================================================================ +// URL Validation +// ============================================================================ + +/** + * Allowed URL schemes + */ +const ALLOWED_SCHEMES = new Set([ + 'http', 'https', 'mailto', 'tel', 'ftp', 'file', +]); + +/** + * Validate and sanitize a URL + * + * @param url - The URL to validate + * @param options - Validation options + * @returns The validated URL or null if invalid + */ +export function validateUrl( + url: string, + options: { + allowedSchemes?: string[]; + allowLocalhost?: boolean; + allowPrivateIp?: boolean; + maxLength?: number; + } = {} +): string | null { + if (typeof url !== 'string' || url.length === 0) { + return null; + } + + const maxLength = options.maxLength || 2048; + if (url.length > maxLength) { + return null; + } + + try { + const parsed = new URL(url); + + // Check scheme + const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES); + if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) { + return null; + } + + // Check for localhost + if (!options.allowLocalhost) { + if (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '[::1]') { + return null; + } + } + + // Check for private IP ranges + if (!options.allowPrivateIp) { + const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; + if (privateIpRegex.test(parsed.hostname)) { + return null; + } + } + + return parsed.toString(); + } catch { + return null; + } +} + +/** + * Check if a URL is safe for redirect + * Prevents open redirect vulnerabilities + * + * @param url - The URL to check + * @returns True if the URL is safe for redirect + */ +export function isSafeRedirectUrl(url: string): boolean { + if (typeof url !== 'string' || url.length === 0) { + return false; + } + + // Relative URLs are generally safe + if (url.startsWith('/') && !url.startsWith('//')) { + return true; + } + + // Check for javascript: protocol + const lowerUrl = url.toLowerCase().trim(); + if (lowerUrl.startsWith('javascript:')) { + return false; + } + + // Check for data: protocol + if (lowerUrl.startsWith('data:')) { + return false; + } + + // Validate as absolute URL + const validated = validateUrl(url, { allowLocalhost: false }); + return validated !== null; +} + +// ============================================================================ +// Path Validation +// ============================================================================ + +/** + * Validate a file path to prevent path traversal attacks + * + * @param path - The path to validate + * @param options - Validation options + * @returns The validated path or null if invalid + */ +export function validatePath( + path: string, + options: { + allowAbsolute?: boolean; + allowParentDir?: boolean; + maxLength?: number; + allowedExtensions?: string[]; + baseDir?: string; + } = {} +): string | null { + if (typeof path !== 'string' || path.length === 0) { + return null; + } + + const maxLength = options.maxLength || 4096; + if (path.length > maxLength) { + return null; + } + + // Normalize path separators + let normalized = path.replace(/\\/g, '/'); + + // Check for null bytes + if (normalized.includes('\0')) { + return null; + } + + // Check for path traversal + if (!options.allowParentDir) { + if (normalized.includes('..') || normalized.includes('./')) { + return null; + } + } + + // Check for absolute paths + if (!options.allowAbsolute) { + if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) { + return null; + } + } + + // Check extensions + if (options.allowedExtensions && options.allowedExtensions.length > 0) { + const ext = normalized.split('.').pop()?.toLowerCase(); + if (!ext || !options.allowedExtensions.includes(ext)) { + return null; + } + } + + // If baseDir is specified, ensure path is within it + if (options.baseDir) { + const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, ''); + if (!normalized.startsWith(baseDir)) { + // Try to resolve relative to baseDir + try { + const resolved = new URL(normalized, `file://${baseDir}/`).pathname; + if (!resolved.startsWith(baseDir)) { + return null; + } + normalized = resolved; + } catch { + return null; + } + } + } + + return normalized; +} + +// ============================================================================ +// Input Validation Helpers +// ============================================================================ + +/** + * Validate an email address + * + * @param email - The email to validate + * @returns True if valid + */ +export function isValidEmail(email: string): boolean { + if (typeof email !== 'string' || email.length === 0 || email.length > 254) { + return false; + } + + // RFC 5322 compliant regex (simplified) + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return emailRegex.test(email); +} + +/** + * Validate a username + * + * @param username - The username to validate + * @param options - Validation options + * @returns True if valid + */ +export function isValidUsername( + username: string, + options: { + minLength?: number; + maxLength?: number; + allowedChars?: RegExp; + } = {} +): boolean { + const minLength = options.minLength || 3; + const maxLength = options.maxLength || 30; + const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/; + + if (typeof username !== 'string') { + return false; + } + + if (username.length < minLength || username.length > maxLength) { + return false; + } + + return allowedChars.test(username); +} + +/** + * Validate a password strength + * + * @param password - The password to validate + * @param options - Validation options + * @returns Validation result with strength score + */ +export function validatePasswordStrength( + password: string, + options: { + minLength?: number; + requireUppercase?: boolean; + requireLowercase?: boolean; + requireNumber?: boolean; + requireSpecial?: boolean; + maxLength?: number; + } = {} +): { + valid: boolean; + score: number; + issues: string[]; +} { + const minLength = options.minLength || 8; + const maxLength = options.maxLength || 128; + const issues: string[] = []; + let score = 0; + + if (typeof password !== 'string') { + return { valid: false, score: 0, issues: ['Password must be a string'] }; + } + + if (password.length < minLength) { + issues.push(`Password must be at least ${minLength} characters`); + } else { + score += Math.min(password.length / 8, 3) * 10; + } + + if (password.length > maxLength) { + issues.push(`Password must be at most ${maxLength} characters`); + } + + if (options.requireUppercase !== false && !/[A-Z]/.test(password)) { + issues.push('Password must contain an uppercase letter'); + } else if (/[A-Z]/.test(password)) { + score += 10; + } + + if (options.requireLowercase !== false && !/[a-z]/.test(password)) { + issues.push('Password must contain a lowercase letter'); + } else if (/[a-z]/.test(password)) { + score += 10; + } + + if (options.requireNumber !== false && !/[0-9]/.test(password)) { + issues.push('Password must contain a number'); + } else if (/[0-9]/.test(password)) { + score += 10; + } + + if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + issues.push('Password must contain a special character'); + } else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + score += 15; + } + + // Check for common patterns + const commonPatterns = [ + /123/, + /abc/i, + /qwe/i, + /password/i, + /admin/i, + /letmein/i, + ]; + + for (const pattern of commonPatterns) { + if (pattern.test(password)) { + issues.push('Password contains a common pattern'); + score -= 10; + break; + } + } + + return { + valid: issues.length === 0, + score: Math.max(0, Math.min(100, score)), + issues, + }; +} + +/** + * Sanitize a filename + * + * @param filename - The filename to sanitize + * @returns The sanitized filename + */ +export function sanitizeFilename(filename: string): string { + if (typeof filename !== 'string') { + return ''; + } + + // Remove path separators + let sanitized = filename.replace(/[\/\\]/g, '_'); + + // Remove null bytes + sanitized = sanitized.replace(/\0/g, ''); + + // Remove control characters + sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, ''); + + // Remove dangerous characters + sanitized = sanitized.replace(/[<>:"|?*]/g, '_'); + + // Trim whitespace and dots + sanitized = sanitized.trim().replace(/^\.+|\.+$/g, ''); + + // Limit length + if (sanitized.length > 255) { + const ext = sanitized.split('.').pop(); + const name = sanitized.slice(0, -(ext?.length || 0) - 1); + sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : ''); + } + + return sanitized; +} + +/** + * Sanitize JSON input + * Prevents prototype pollution and other JSON-based attacks + * + * @param json - The JSON string to sanitize + * @returns The parsed and sanitized object or null if invalid + */ +export function sanitizeJson(json: string): T | null { + if (typeof json !== 'string') { + return null; + } + + try { + const parsed = JSON.parse(json); + + // Check for prototype pollution + if (typeof parsed === 'object' && parsed !== null) { + const dangerousKeys = ['__proto__', 'constructor', 'prototype']; + for (const key of dangerousKeys) { + if (key in parsed) { + delete (parsed as Record)[key]; + } + } + } + + return parsed as T; + } catch { + return null; + } +} + +// ============================================================================ +// Rate Limiting +// ============================================================================ + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const rateLimitStore = new Map(); + +/** + * Check if an action is rate limited + * + * @param key - The rate limit key (e.g., 'api:username') + * @param maxAttempts - Maximum attempts allowed + * @param windowMs - Time window in milliseconds + * @returns True if rate limited (should block), false otherwise + */ +export function isRateLimited( + key: string, + maxAttempts: number, + windowMs: number +): boolean { + const now = Date.now(); + const entry = rateLimitStore.get(key); + + if (!entry || now > entry.resetAt) { + rateLimitStore.set(key, { + count: 1, + resetAt: now + windowMs, + }); + return false; + } + + if (entry.count >= maxAttempts) { + return true; + } + + entry.count++; + return false; +} + +/** + * Reset rate limit for a key + * + * @param key - The rate limit key to reset + */ +export function resetRateLimit(key: string): void { + rateLimitStore.delete(key); +} + +/** + * Get remaining attempts for a rate-limited action + * + * @param key - The rate limit key + * @param maxAttempts - Maximum attempts allowed + * @returns Number of remaining attempts + */ +export function getRemainingAttempts(key: string, maxAttempts: number): number { + const entry = rateLimitStore.get(key); + if (!entry || Date.now() > entry.resetAt) { + return maxAttempts; + } + return Math.max(0, maxAttempts - entry.count); +} + +// ============================================================================ +// Content Security Policy Helpers +// ============================================================================ + +/** + * Generate a nonce for CSP + * + * @returns A base64-encoded nonce + */ +export function generateCspNonce(): string { + const array = crypto.getRandomValues(new Uint8Array(16)); + return btoa(String.fromCharCode(...array)); +} + +/** + * CSP directives for secure applications + */ +export const DEFAULT_CSP_DIRECTIVES = { + 'default-src': "'self'", + 'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production + 'style-src': "'self' 'unsafe-inline'", + 'img-src': "'self' data: https:", + 'font-src': "'self'", + 'connect-src': "'self' ws: wss:", + 'frame-ancestors': "'none'", + 'base-uri': "'self'", + 'form-action': "'self'", +}; + +/** + * Build a Content Security Policy header value + * + * @param directives - CSP directives + * @returns The CSP header value + */ +export function buildCspHeader( + directives: Partial = DEFAULT_CSP_DIRECTIVES +): string { + const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives }; + return Object.entries(merged) + .map(([key, value]) => `${key} ${value}`) + .join('; '); +} + +// ============================================================================ +// Security Headers Validation +// ============================================================================ + +/** + * Check if security headers are properly set (for browser environments) + */ +export function checkSecurityHeaders(): { + secure: boolean; + issues: string[]; +} { + const issues: string[] = []; + + // Check if running over HTTPS + if (typeof window !== 'undefined') { + if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { + issues.push('Application is not running over HTTPS'); + } + + // Check for mixed content + if (window.location.protocol === 'https:') { + // This would require DOM inspection to detect mixed content + } + } + + return { + secure: issues.length === 0, + issues, + }; +} + +// ============================================================================ +// Secure Random Generation +// ============================================================================ + +/** + * Generate a secure random token + * + * @param length - Token length in bytes + * @returns Hex-encoded random token + */ +export function generateSecureToken(length: number = 32): string { + const array = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(array) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Generate a secure random ID + * + * @param prefix - Optional prefix + * @returns A secure random ID + */ +export function generateSecureId(prefix: string = ''): string { + const timestamp = Date.now().toString(36); + const random = generateSecureToken(8); + return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`; +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index f14bebb..efb28c9 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -3,11 +3,38 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; import { ToastProvider } from './components/ui/Toast'; +import { GlobalErrorBoundary } from './components/ui/ErrorBoundary'; + +// Global error handler for uncaught errors +const handleGlobalError = (error: Error, errorInfo: React.ErrorInfo) => { + console.error('[GlobalErrorHandler] Uncaught error:', error); + console.error('[GlobalErrorHandler] Component stack:', errorInfo.componentStack); + + // In production, you could send this to an error reporting service + // e.g., Sentry, LogRocket, etc. + if (import.meta.env.PROD) { + // sendToErrorReportingService(error, errorInfo); + } +}; + +// Global reset handler - reload the page +const handleGlobalReset = () => { + console.log('[GlobalErrorHandler] Resetting application...'); + // Clear any cached state + localStorage.removeItem('app-state'); + sessionStorage.clear(); +}; ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/desktop/src/store/chatStore.ts b/desktop/src/store/chatStore.ts index 642d6ce..5d0e2bf 100644 --- a/desktop/src/store/chatStore.ts +++ b/desktop/src/store/chatStore.ts @@ -1,10 +1,12 @@ -import { create } from 'zustand'; +import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client'; import { intelligenceClient } from '../lib/intelligence-client'; import { getMemoryExtractor } from '../lib/memory-extractor'; import { getAgentSwarm } from '../lib/agent-swarm'; import { getSkillDiscovery } from '../lib/skill-discovery'; +import { useOfflineStore, isOffline } from './offlineStore'; +import { useConnectionStore } from './connectionStore'; export interface MessageFile { name: string; @@ -21,7 +23,7 @@ export interface CodeBlock { export interface Message { id: string; - role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow'; + role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system'; content: string; timestamp: Date; runId?: string; @@ -77,11 +79,13 @@ interface ChatState { agents: Agent[]; currentAgent: Agent | null; isStreaming: boolean; + isLoading: boolean; currentModel: string; sessionKey: string | null; addMessage: (message: Message) => void; updateMessage: (id: string, updates: Partial) => void; + setIsLoading: (loading: boolean) => void; setCurrentAgent: (agent: Agent) => void; syncAgents: (profiles: AgentProfileLike[]) => void; setCurrentModel: (model: string) => void; @@ -185,6 +189,7 @@ export const useChatStore = create()( agents: [DEFAULT_AGENT], currentAgent: DEFAULT_AGENT, isStreaming: false, + isLoading: false, currentModel: 'glm-5', sessionKey: null, @@ -198,6 +203,8 @@ export const useChatStore = create()( ), })), + setIsLoading: (loading) => set({ isLoading: loading }), + setCurrentAgent: (agent) => set((state) => { if (state.currentAgent?.id === agent.id) { @@ -295,6 +302,32 @@ export const useChatStore = create()( const effectiveAgentId = resolveGatewayAgentId(currentAgent); const agentId = currentAgent?.id || 'zclaw-main'; + // Check if offline - queue message instead of sending + if (isOffline()) { + const { queueMessage } = useOfflineStore.getState(); + const queueId = queueMessage(content, effectiveAgentId, effectiveSessionKey); + console.log(`[Chat] Offline - message queued: ${queueId}`); + + // Show a system message about offline queueing + const systemMsg: Message = { + id: `system_${Date.now()}`, + role: 'system', + content: `后端服务不可用,消息已保存到本地队列。恢复连接后将自动发送。`, + timestamp: new Date(), + }; + addMessage(systemMsg); + + // Add user message for display + const userMsg: Message = { + id: `user_${Date.now()}`, + role: 'user', + content, + timestamp: new Date(), + }; + addMessage(userMsg); + return; + } + // Check context compaction threshold before adding new message try { const messages = get().messages.map(m => ({ role: m.role, content: m.content })); @@ -368,134 +401,107 @@ export const useChatStore = create()( try { const client = getGatewayClient(); - // Try streaming first (OpenFang WebSocket) - // Note: onDelta is empty - stream updates handled by initStreamListener to avoid duplication - if (client.getState() === 'connected') { - const { runId } = await client.chatStream( - enhancedContent, - { - onDelta: () => { /* Handled by initStreamListener to prevent duplication */ }, - onTool: (tool: string, input: string, output: string) => { - const toolMsg: Message = { - id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, - role: 'tool', - content: output || input, - timestamp: new Date(), - runId, - toolName: tool, - toolInput: input, - toolOutput: output, - }; - set((state) => ({ messages: [...state.messages, toolMsg] })); - }, - onHand: (name: string, status: string, result?: unknown) => { - const handMsg: Message = { - id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, - role: 'hand', - content: result - ? (typeof result === 'string' ? result : JSON.stringify(result, null, 2)) - : `Hand: ${name} - ${status}`, - timestamp: new Date(), - runId, - handName: name, - handStatus: status, - handResult: result, - }; - set((state) => ({ messages: [...state.messages, handMsg] })); - }, - onComplete: () => { - const state = get(); + // Check connection state first + const connectionState = useConnectionStore.getState().connectionState; - // Save conversation to persist across refresh - const conversations = upsertActiveConversation([...state.conversations], state); - const currentConvId = state.currentConversationId || conversations[0]?.id; - - set({ - isStreaming: false, - conversations, - currentConversationId: currentConvId, - messages: state.messages.map((m) => - m.id === assistantId ? { ...m, streaming: false, runId } : m - ), - }); - - // Async memory extraction after stream completes - const msgs = get().messages - .filter(m => m.role === 'user' || m.role === 'assistant') - .map(m => ({ role: m.role, content: m.content })); - getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err => - console.warn('[Chat] Memory extraction failed:', err) - ); - // Track conversation for reflection trigger - intelligenceClient.reflection.recordConversation().catch(err => - console.warn('[Chat] Recording conversation failed:', err) - ); - intelligenceClient.reflection.shouldReflect().then(shouldReflect => { - if (shouldReflect) { - intelligenceClient.reflection.reflect(agentId, []).catch(err => - console.warn('[Chat] Reflection failed:', err) - ); - } - }); - }, - onError: (error: string) => { - set((state) => ({ - isStreaming: false, - messages: state.messages.map((m) => - m.id === assistantId - ? { ...m, content: `⚠️ ${error}`, streaming: false, error } - : m - ), - })); - }, - }, - { - sessionKey: effectiveSessionKey, - agentId: effectiveAgentId, - } - ); - - if (!sessionKey) { - set({ sessionKey: effectiveSessionKey }); - } - - // Store runId on the message for correlation - set((state) => ({ - messages: state.messages.map((m) => - m.id === assistantId ? { ...m, runId } : m - ), - })); - return; + if (connectionState !== 'connected') { + // Connection lost during send - update error + throw new Error(`Not connected (state: ${connectionState})`); } - // Fallback to REST API (non-streaming) - const result = await client.chat(enhancedContent, { - sessionKey: effectiveSessionKey, - agentId: effectiveAgentId, - }); + // Try streaming first (OpenFang WebSocket) + const { runId } = await client.chatStream( + enhancedContent, + { + onDelta: () => { /* Handled by initStreamListener to prevent duplication */ }, + onTool: (tool: string, input: string, output: string) => { + const toolMsg: Message = { + id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + role: 'tool', + content: output || input, + timestamp: new Date(), + runId, + toolName: tool, + toolInput: input, + toolOutput: output, + }; + set((state) => ({ messages: [...state.messages, toolMsg] })); + }, + onHand: (name: string, status: string, result?: unknown) => { + const handMsg: Message = { + id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + role: 'hand', + content: result + ? (typeof result === 'string' ? result : JSON.stringify(result, null, 2)) + : `Hand: ${name} - ${status}`, + timestamp: new Date(), + runId, + handName: name, + handStatus: status, + handResult: result, + }; + set((state) => ({ messages: [...state.messages, handMsg] })); + }, + onComplete: () => { + const state = get(); + + // Save conversation to persist across refresh + const conversations = upsertActiveConversation([...state.conversations], state); + const currentConvId = state.currentConversationId || conversations[0]?.id; + + set({ + isStreaming: false, + conversations, + currentConversationId: currentConvId, + messages: state.messages.map((m) => + m.id === assistantId ? { ...m, streaming: false, runId } : m + ), + }); + + // Async memory extraction after stream completes + const msgs = get().messages + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ role: m.role, content: m.content })); + getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err => { + console.warn('[Chat] Memory extraction failed:', err); + }); + // Track conversation for reflection trigger + intelligenceClient.reflection.recordConversation().catch(err => { + console.warn('[Chat] Recording conversation failed:', err); + }); + intelligenceClient.reflection.shouldReflect().then(shouldReflect => { + if (shouldReflect) { + intelligenceClient.reflection.reflect(agentId, []).catch(err => { + console.warn('[Chat] Reflection failed:', err); + }); + } + }); + }, + onError: (error: string) => { + set((state) => ({ + isStreaming: false, + messages: state.messages.map((m) => + m.id === assistantId + ? { ...m, content: `⚠️ ${error}`, streaming: false, error } + : m + ), + })); + }, + }, + { + sessionKey: effectiveSessionKey, + agentId: effectiveAgentId, + } + ); if (!sessionKey) { set({ sessionKey: effectiveSessionKey }); } - // OpenFang returns response directly (no WebSocket streaming) - if (result.response) { - set((state) => ({ - isStreaming: false, - messages: state.messages.map((m) => - m.id === assistantId - ? { ...m, content: result.response || '', streaming: false } - : m - ), - })); - return; - } - - // The actual streaming content comes via the 'agent' event listener - // set in initStreamListener(). The runId links events to this message. + // Store runId on the message for correlation set((state) => ({ messages: state.messages.map((m) => - m.id === assistantId ? { ...m, runId: result.runId } : m + m.id === assistantId ? { ...m, runId } : m ), })); } catch (err: unknown) { @@ -686,3 +692,9 @@ export const useChatStore = create()( }, ), ); + +// Dev-only: Expose chatStore to window for E2E testing +if (import.meta.env.DEV && typeof window !== 'undefined') { + (window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {}; + (window as any).__ZCLAW_STORES__.chat = useChatStore; +} diff --git a/desktop/src/store/gatewayStore.ts b/desktop/src/store/gatewayStore.ts index 680fddd..7549e08 100644 --- a/desktop/src/store/gatewayStore.ts +++ b/desktop/src/store/gatewayStore.ts @@ -347,5 +347,11 @@ if (import.meta.env.DEV && typeof window !== 'undefined') { (window as any).__ZCLAW_STORES__.config = useConfigStore; (window as any).__ZCLAW_STORES__.security = useSecurityStore; (window as any).__ZCLAW_STORES__.session = useSessionStore; + // Dynamically import chatStore to avoid circular dependency + import('./chatStore').then(({ useChatStore }) => { + (window as any).__ZCLAW_STORES__.chat = useChatStore; + }).catch(() => { + // Ignore if chatStore is not available + }); } diff --git a/desktop/src/store/offlineStore.ts b/desktop/src/store/offlineStore.ts new file mode 100644 index 0000000..ecb758a --- /dev/null +++ b/desktop/src/store/offlineStore.ts @@ -0,0 +1,358 @@ +/** + * Offline Store + * + * Manages offline state, message queue, and reconnection logic. + * Provides graceful degradation when backend is unavailable. + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { useConnectionStore, getConnectionState } from './connectionStore'; + +// === Types === + +export interface QueuedMessage { + id: string; + content: string; + agentId?: string; + sessionKey?: string; + timestamp: number; + retryCount: number; + lastError?: string; + status: 'pending' | 'sending' | 'failed' | 'sent'; +} + +export interface OfflineState { + isOffline: boolean; + isReconnecting: boolean; + reconnectAttempt: number; + nextReconnectDelay: number; + lastOnlineTime: number | null; + queuedMessages: QueuedMessage[]; + maxRetryCount: number; + maxQueueSize: number; +} + +export interface OfflineActions { + // State management + setOffline: (offline: boolean) => void; + setReconnecting: (reconnecting: boolean, attempt?: number) => void; + + // Message queue operations + queueMessage: (content: string, agentId?: string, sessionKey?: string) => string; + updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => void; + removeMessage: (id: string) => void; + clearQueue: () => void; + retryAllMessages: () => Promise; + + // Reconnection + scheduleReconnect: () => void; + cancelReconnect: () => void; + attemptReconnect: () => Promise; + + // Getters + getPendingMessages: () => QueuedMessage[]; + hasPendingMessages: () => boolean; +} + +export type OfflineStore = OfflineState & OfflineActions; + +// === Constants === + +const INITIAL_RECONNECT_DELAY = 1000; // 1 second +const MAX_RECONNECT_DELAY = 60000; // 60 seconds +const RECONNECT_BACKOFF_FACTOR = 1.5; +const MAX_RETRY_COUNT = 5; +const MAX_QUEUE_SIZE = 100; + +// === Helper Functions === + +function calculateNextDelay(currentDelay: number): number { + return Math.min( + currentDelay * RECONNECT_BACKOFF_FACTOR, + MAX_RECONNECT_DELAY + ); +} + +function generateMessageId(): string { + return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +// === Store Implementation === + +let reconnectTimer: ReturnType | null = null; +let healthCheckInterval: ReturnType | null = null; + +export const useOfflineStore = create()( + persist( + (set, get) => ({ + // === Initial State === + isOffline: false, + isReconnecting: false, + reconnectAttempt: 0, + nextReconnectDelay: INITIAL_RECONNECT_DELAY, + lastOnlineTime: null, + queuedMessages: [], + maxRetryCount: MAX_RETRY_COUNT, + maxQueueSize: MAX_QUEUE_SIZE, + + // === State Management === + + setOffline: (offline: boolean) => { + const wasOffline = get().isOffline; + set({ + isOffline: offline, + lastOnlineTime: offline ? get().lastOnlineTime : Date.now(), + }); + + // Start reconnect process when going offline + if (offline && !wasOffline) { + get().scheduleReconnect(); + } else if (!offline && wasOffline) { + // Back online - try to send queued messages + get().cancelReconnect(); + set({ reconnectAttempt: 0, nextReconnectDelay: INITIAL_RECONNECT_DELAY }); + get().retryAllMessages(); + } + }, + + setReconnecting: (reconnecting: boolean, attempt?: number) => { + set({ + isReconnecting: reconnecting, + reconnectAttempt: attempt ?? get().reconnectAttempt, + }); + }, + + // === Message Queue Operations === + + queueMessage: (content: string, agentId?: string, sessionKey?: string) => { + const state = get(); + + // Check queue size limit + if (state.queuedMessages.length >= state.maxQueueSize) { + // Remove oldest pending message + const filtered = state.queuedMessages.filter((m, i) => + i > 0 || m.status !== 'pending' + ); + set({ queuedMessages: filtered }); + } + + const id = generateMessageId(); + const message: QueuedMessage = { + id, + content, + agentId, + sessionKey, + timestamp: Date.now(), + retryCount: 0, + status: 'pending', + }; + + set((s) => ({ + queuedMessages: [...s.queuedMessages, message], + })); + + console.log(`[OfflineStore] Message queued: ${id}`); + return id; + }, + + updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => { + set((s) => ({ + queuedMessages: s.queuedMessages.map((m) => + m.id === id + ? { + ...m, + status, + lastError: error, + retryCount: status === 'failed' ? m.retryCount + 1 : m.retryCount, + } + : m + ), + })); + }, + + removeMessage: (id: string) => { + set((s) => ({ + queuedMessages: s.queuedMessages.filter((m) => m.id !== id), + })); + }, + + clearQueue: () => { + set({ queuedMessages: [] }); + }, + + retryAllMessages: async () => { + const state = get(); + const pending = state.queuedMessages.filter( + (m) => m.status === 'pending' || m.status === 'failed' + ); + + if (pending.length === 0) return; + + // Check if connected + if (getConnectionState() !== 'connected') { + console.log('[OfflineStore] Not connected, cannot retry messages'); + return; + } + + console.log(`[OfflineStore] Retrying ${pending.length} queued messages`); + + for (const msg of pending) { + if (msg.retryCount >= state.maxRetryCount) { + console.log(`[OfflineStore] Message ${msg.id} exceeded max retries`); + get().updateMessageStatus(msg.id, 'failed', 'Max retry count exceeded'); + continue; + } + + get().updateMessageStatus(msg.id, 'sending'); + + try { + // Import gateway client dynamically to avoid circular dependency + const { getGatewayClient } = await import('../lib/gateway-client'); + const client = getGatewayClient(); + + await client.chat(msg.content, { + sessionKey: msg.sessionKey, + agentId: msg.agentId, + }); + + get().updateMessageStatus(msg.id, 'sent'); + // Remove sent message after a short delay + setTimeout(() => get().removeMessage(msg.id), 1000); + console.log(`[OfflineStore] Message ${msg.id} sent successfully`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Send failed'; + get().updateMessageStatus(msg.id, 'failed', errorMessage); + console.warn(`[OfflineStore] Message ${msg.id} failed:`, errorMessage); + } + } + }, + + // === Reconnection === + + scheduleReconnect: () => { + const state = get(); + + // Don't schedule if already online + if (!state.isOffline) return; + + // Cancel any existing timer + get().cancelReconnect(); + + const attempt = state.reconnectAttempt + 1; + const delay = state.nextReconnectDelay; + + console.log(`[OfflineStore] Scheduling reconnect attempt ${attempt} in ${delay}ms`); + + set({ + isReconnecting: true, + reconnectAttempt: attempt, + nextReconnectDelay: calculateNextDelay(delay), + }); + + reconnectTimer = setTimeout(() => { + get().attemptReconnect(); + }, delay); + }, + + cancelReconnect: () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + set({ isReconnecting: false }); + }, + + attemptReconnect: async () => { + console.log('[OfflineStore] Attempting to reconnect...'); + + try { + // Try to connect via connection store + await useConnectionStore.getState().connect(); + + // Check if now connected + if (getConnectionState() === 'connected') { + console.log('[OfflineStore] Reconnection successful'); + get().setOffline(false); + return true; + } + } catch (err) { + console.warn('[OfflineStore] Reconnection failed:', err); + } + + // Still offline, schedule next attempt + get().setReconnecting(false); + get().scheduleReconnect(); + return false; + }, + + // === Getters === + + getPendingMessages: () => { + return get().queuedMessages.filter( + (m) => m.status === 'pending' || m.status === 'failed' + ); + }, + + hasPendingMessages: () => { + return get().queuedMessages.some( + (m) => m.status === 'pending' || m.status === 'failed' + ); + }, + }), + { + name: 'zclaw-offline-storage', + partialize: (state) => ({ + queuedMessages: state.queuedMessages.filter( + (m) => m.status === 'pending' || m.status === 'failed' + ), + lastOnlineTime: state.lastOnlineTime, + }), + } + ) +); + +// === Connection State Monitor === + +/** + * Start monitoring connection state and update offline store accordingly. + * Should be called once during app initialization. + */ +export function startOfflineMonitor(): () => void { + const checkConnection = () => { + const connectionState = getConnectionState(); + const isOffline = connectionState !== 'connected'; + + if (isOffline !== useOfflineStore.getState().isOffline) { + useOfflineStore.getState().setOffline(isOffline); + } + }; + + // Initial check + checkConnection(); + + // Subscribe to connection state changes + const unsubscribe = useConnectionStore.subscribe((state, prevState) => { + if (state.connectionState !== prevState.connectionState) { + const isOffline = state.connectionState !== 'connected'; + useOfflineStore.getState().setOffline(isOffline); + } + }); + + // Periodic health check (every 30 seconds) + healthCheckInterval = setInterval(checkConnection, 30000); + + return () => { + unsubscribe(); + if (healthCheckInterval) { + clearInterval(healthCheckInterval); + healthCheckInterval = null; + } + }; +} + +// === Exported Accessors === + +export const isOffline = () => useOfflineStore.getState().isOffline; +export const getQueuedMessages = () => useOfflineStore.getState().queuedMessages; +export const hasPendingMessages = () => useOfflineStore.getState().hasPendingMessages(); diff --git a/desktop/tests/e2e/fixtures/mock-gateway.ts b/desktop/tests/e2e/fixtures/mock-gateway.ts index 12ac00f..a18edbb 100644 --- a/desktop/tests/e2e/fixtures/mock-gateway.ts +++ b/desktop/tests/e2e/fixtures/mock-gateway.ts @@ -4,7 +4,7 @@ * 基于实际 API 端点: http://127.0.0.1:50051 */ -import { Page } from '@playwright/test'; +import { Page, WebSocketRoute } from '@playwright/test'; /** * Mock 响应数据模板 - 基于实际 API 响应格式 @@ -440,7 +440,7 @@ export async function setupMockGateway( }); // Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId} - await page.route('**/api/hands/*/runs/*', async (route) => { + await page.route('**/api/hands/*/runs/**', async (route) => { if (simulateDelay) await delay(delayMs); const method = route.request().method(); const url = route.request().url(); @@ -468,6 +468,13 @@ export async function setupMockGateway( completedAt: new Date().toISOString(), }), }); + } else { + // Fallback for any other requests + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); } }); @@ -481,6 +488,26 @@ export async function setupMockGateway( }); }); + // Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve + await page.route('**/api/hands/*/runs/*/approve', async (route) => { + if (simulateDelay) await delay(delayMs); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'approved' }), + }); + }); + + // Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel + await page.route('**/api/hands/*/runs/*/cancel', async (route) => { + if (simulateDelay) await delay(delayMs); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'cancelled' }), + }); + }); + // ======================================== // Workflow 端点 // ======================================== @@ -777,6 +804,153 @@ export async function mockTimeout(page: Page, path: string): Promise { }); } +/** + * WebSocket Mock 配置 + */ +export interface MockWebSocketConfig { + /** 模拟响应内容 */ + responseContent?: string; + /** 是否模拟流式响应 */ + streaming?: boolean; + /** 流式响应的块延迟 (ms) */ + chunkDelay?: number; + /** 是否模拟错误 */ + simulateError?: boolean; + /** 错误消息 */ + errorMessage?: string; +} + +/** + * 存储 WebSocket Mock 配置 + */ +let wsConfig: MockWebSocketConfig = { + responseContent: 'This is a mock streaming response from the WebSocket server.', + streaming: true, + chunkDelay: 50, +}; + +/** + * 设置 WebSocket Mock 配置 + */ +export function setWebSocketConfig(config: Partial): void { + wsConfig = { ...wsConfig, ...config }; +} + +/** + * Mock Agent WebSocket 流式响应 + * 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接 + */ +export async function mockAgentWebSocket( + page: Page, + config: Partial = {} +): Promise { + const finalConfig = { ...wsConfig, ...config }; + + await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => { + // Handle incoming messages from the page + ws.onMessage(async (message) => { + try { + const data = JSON.parse(message); + + // Handle chat message + if (data.type === 'message' || data.content) { + // Send connected event first + ws.send(JSON.stringify({ + type: 'connected', + agent_id: 'default-agent', + })); + + // Simulate error if configured + if (finalConfig.simulateError) { + ws.send(JSON.stringify({ + type: 'error', + message: finalConfig.errorMessage || 'Mock WebSocket error', + })); + ws.close({ code: 1011, reason: 'Error' }); + return; + } + + const responseText = finalConfig.responseContent || 'Mock response'; + + if (finalConfig.streaming) { + // Send typing indicator + ws.send(JSON.stringify({ + type: 'typing', + state: 'start', + })); + + // Stream response in chunks + const words = responseText.split(' '); + let current = ''; + + for (let i = 0; i < words.length; i++) { + current += (current ? ' ' : '') + words[i]; + + // Send text delta every few words + if (current.length >= 10 || i === words.length - 1) { + await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50)); + ws.send(JSON.stringify({ + type: 'text_delta', + content: current, + })); + current = ''; + } + } + + // Send typing stop + ws.send(JSON.stringify({ + type: 'typing', + state: 'stop', + })); + + // Send phase done + ws.send(JSON.stringify({ + type: 'phase', + phase: 'done', + })); + } else { + // Non-streaming response + ws.send(JSON.stringify({ + type: 'response', + content: responseText, + input_tokens: 100, + output_tokens: responseText.split(' ').length, + })); + } + + // Close connection after response + ws.close({ code: 1000, reason: 'Stream complete' }); + } + } catch (err) { + console.error('WebSocket mock error:', err); + ws.send(JSON.stringify({ + type: 'error', + message: 'Failed to parse message', + })); + } + }); + + // Handle connection close from page + ws.onClose(() => { + // Clean up + }); + }); +} + +/** + * 设置完整的 Gateway Mock (包括 WebSocket) + */ +export async function setupMockGatewayWithWebSocket( + page: Page, + config: MockGatewayConfig & { wsConfig?: Partial } = {} +): Promise { + // Setup HTTP mocks + await setupMockGateway(page, config); + + // Setup WebSocket mock + await mockAgentWebSocket(page, config.wsConfig || {}); +} + // 辅助函数 function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/desktop/tests/e2e/fixtures/store-inspectors.ts b/desktop/tests/e2e/fixtures/store-inspectors.ts index b2d20ac..ef02694 100644 --- a/desktop/tests/e2e/fixtures/store-inspectors.ts +++ b/desktop/tests/e2e/fixtures/store-inspectors.ts @@ -68,8 +68,23 @@ export const storeInspectors = { /** * 获取持久化的 Chat Store 状态 + * 优先从运行时获取,如果不可用则从 localStorage 获取 */ async getChatState(page: Page): Promise { + // First try to get runtime state (more reliable for E2E tests) + const runtimeState = await page.evaluate(() => { + const stores = (window as any).__ZCLAW_STORES__; + if (stores && stores.chat) { + return stores.chat.getState() as T; + } + return null; + }); + + if (runtimeState) { + return runtimeState; + } + + // Fallback to localStorage return page.evaluate((key) => { const stored = localStorage.getItem(key); if (!stored) return null; diff --git a/desktop/tests/e2e/specs/core-features.spec.ts b/desktop/tests/e2e/specs/core-features.spec.ts index fbb2cf1..919f4eb 100644 --- a/desktop/tests/e2e/specs/core-features.spec.ts +++ b/desktop/tests/e2e/specs/core-features.spec.ts @@ -15,6 +15,7 @@ import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorRes import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors'; import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions'; import { networkHelpers } from '../utils/network-helpers'; +import { setupMockGatewayWithWebSocket, setWebSocketConfig } from '../fixtures/mock-gateway'; // Test configuration test.setTimeout(120000); @@ -168,16 +169,15 @@ test.describe('Chat Message Tests', () => { test.describe.configure({ mode: 'parallel' }); // Parallel for isolation test('CHAT-MSG-01: Send message and receive response', async ({ page }) => { - // Setup mock gateway - await setupMockGateway(page); + // Setup mock gateway with WebSocket support + const mockResponse = 'This is a mock AI response for testing purposes.'; + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: mockResponse, streaming: true } + }); await skipOnboarding(page); await page.goto(BASE_URL); await waitForAppReady(page); - // Mock agent message response - const mockResponse = 'This is a mock AI response for testing purposes.'; - await mockAgentMessageResponse(page, mockResponse); - // Find chat input const chatInput = page.locator('textarea').first(); await expect(chatInput).toBeVisible({ timeout: 10000 }); @@ -209,12 +209,13 @@ test.describe('Chat Message Tests', () => { }); test('CHAT-MSG-02: Message updates store state', async ({ page }) => { - // Setup fresh page - await setupMockGateway(page); + // Setup fresh page with WebSocket support + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: 'Store state test response', streaming: true } + }); await skipOnboarding(page); await page.goto(BASE_URL); await waitForAppReady(page); - await mockAgentMessageResponse(page, 'Store state test response'); // Clear any existing messages first await storeInspectors.clearStore(page, STORE_NAMES.CHAT); @@ -243,12 +244,13 @@ test.describe('Chat Message Tests', () => { }); test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => { - // Setup fresh page - await setupMockGateway(page); + // Setup fresh page with WebSocket support + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: 'Streaming test response with longer content', streaming: true, chunkDelay: 100 } + }); await skipOnboarding(page); await page.goto(BASE_URL); await waitForAppReady(page); - await mockAgentMessageResponse(page, 'Streaming test response with longer content'); const chatInput = page.locator('textarea').first(); await chatInput.fill('Write a short poem'); @@ -276,8 +278,10 @@ test.describe('Chat Message Tests', () => { }); test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => { - // Setup fresh page with error mock - await mockErrorResponse(page, 'health', 500, 'Internal Server Error'); + // Setup fresh page with error mock - WebSocket will simulate error + await setupMockGatewayWithWebSocket(page, { + wsConfig: { simulateError: true, errorMessage: 'WebSocket connection failed' } + }); await skipOnboarding(page); await page.goto(BASE_URL); await waitForAppReady(page); @@ -294,12 +298,13 @@ test.describe('Chat Message Tests', () => { }); test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => { - // Setup fresh page - await setupMockGateway(page); + // Setup fresh page with WebSocket support + await setupMockGatewayWithWebSocket(page, { + wsConfig: { responseContent: 'Response to sequential message', streaming: true } + }); await skipOnboarding(page); await page.goto(BASE_URL); await waitForAppReady(page); - await mockAgentMessageResponse(page, 'Response to sequential message'); // Clear existing messages await storeInspectors.clearStore(page, STORE_NAMES.CHAT); diff --git a/desktop/tests/lib/security.test.ts b/desktop/tests/lib/security.test.ts new file mode 100644 index 0000000..79b4bae --- /dev/null +++ b/desktop/tests/lib/security.test.ts @@ -0,0 +1,500 @@ +/** + * Security Module Tests + * + * Unit tests for crypto utilities, security utils, and audit logging. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// ============================================================================ +// Crypto Utils Tests +// ============================================================================ + +describe('Crypto Utils', () => { + // Import dynamically to handle Web Crypto API + let cryptoUtils: typeof import('../../src/lib/crypto-utils'); + + beforeEach(async () => { + cryptoUtils = await import('../../src/lib/crypto-utils'); + }); + + describe('arrayToBase64 / base64ToArray', () => { + it('should convert Uint8Array to base64 and back', () => { + const original = new Uint8Array([72, 101, 108, 108, 111]); + const base64 = cryptoUtils.arrayToBase64(original); + const decoded = cryptoUtils.base64ToArray(base64); + + expect(decoded).toEqual(original); + }); + + it('should handle empty array', () => { + const empty = new Uint8Array([]); + expect(cryptoUtils.arrayToBase64(empty)).toBe(''); + expect(cryptoUtils.base64ToArray('')).toEqual(empty); + }); + }); + + describe('deriveKey', () => { + it('should derive a CryptoKey from a master key', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + + expect(key).toBeDefined(); + expect(key.type).toBe('secret'); + }); + + it('should derive the same key for the same input', async () => { + const key1 = await cryptoUtils.deriveKey('test-master-key'); + const key2 = await cryptoUtils.deriveKey('test-master-key'); + + // Both should be valid CryptoKey objects + expect(key1).toBeDefined(); + expect(key2).toBeDefined(); + }); + + it('should use custom salt', async () => { + const customSalt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const key = await cryptoUtils.deriveKey('test-master-key', customSalt); + + expect(key).toBeDefined(); + }); + }); + + describe('encrypt / decrypt', () => { + it('should encrypt and decrypt a string', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + const plaintext = 'Hello, World!'; + + const encrypted = await cryptoUtils.encrypt(plaintext, key); + expect(encrypted.iv).toBeDefined(); + expect(encrypted.data).toBeDefined(); + expect(encrypted.version).toBe(1); + + const decrypted = await cryptoUtils.decrypt(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + + it('should produce different IVs for same plaintext', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + const plaintext = 'Same text'; + + const encrypted1 = await cryptoUtils.encrypt(plaintext, key); + const encrypted2 = await cryptoUtils.encrypt(plaintext, key); + + expect(encrypted1.iv).not.toBe(encrypted2.iv); + }); + + it('should handle empty string', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + const plaintext = ''; + + const encrypted = await cryptoUtils.encrypt(plaintext, key); + const decrypted = await cryptoUtils.decrypt(encrypted, key); + + expect(decrypted).toBe(''); + }); + + it('should handle unicode characters', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + const plaintext = '中文测试 日本語 한국어 🎉'; + + const encrypted = await cryptoUtils.encrypt(plaintext, key); + const decrypted = await cryptoUtils.decrypt(encrypted, key); + + expect(decrypted).toBe(plaintext); + }); + }); + + describe('encryptObject / decryptObject', () => { + it('should encrypt and decrypt an object', async () => { + const key = await cryptoUtils.deriveKey('test-master-key'); + const obj = { name: 'test', count: 42, nested: { a: 1 } }; + + const encrypted = await cryptoUtils.encryptObject(obj, key); + const decrypted = await cryptoUtils.decryptObject(encrypted, key); + + expect(decrypted).toEqual(obj); + }); + }); + + describe('generateMasterKey', () => { + it('should generate a base64 string', () => { + const key = cryptoUtils.generateMasterKey(); + + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(0); + }); + + it('should generate unique keys', () => { + const key1 = cryptoUtils.generateMasterKey(); + const key2 = cryptoUtils.generateMasterKey(); + + expect(key1).not.toBe(key2); + }); + }); + + describe('hashSha256', () => { + it('should produce consistent hash', async () => { + const input = 'test-input'; + const hash1 = await cryptoUtils.hashSha256(input); + const hash2 = await cryptoUtils.hashSha256(input); + + expect(hash1).toBe(hash2); + expect(hash1.length).toBe(64); // SHA-256 produces 64 hex chars + }); + }); + + describe('constantTimeEqual', () => { + it('should return true for equal arrays', () => { + const a = new Uint8Array([1, 2, 3, 4]); + const b = new Uint8Array([1, 2, 3, 4]); + + expect(cryptoUtils.constantTimeEqual(a, b)).toBe(true); + }); + + it('should return false for different arrays', () => { + const a = new Uint8Array([1, 2, 3, 4]); + const b = new Uint8Array([1, 2, 3, 5]); + + expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false); + }); + + it('should return false for different length arrays', () => { + const a = new Uint8Array([1, 2, 3]); + const b = new Uint8Array([1, 2, 3, 4]); + + expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false); + }); + }); + + describe('isValidEncryptedData', () => { + it('should validate correct structure', () => { + const valid = { iv: 'abc', data: 'xyz' }; + expect(cryptoUtils.isValidEncryptedData(valid)).toBe(true); + }); + + it('should reject invalid structures', () => { + expect(cryptoUtils.isValidEncryptedData(null)).toBe(false); + expect(cryptoUtils.isValidEncryptedData({})).toBe(false); + expect(cryptoUtils.isValidEncryptedData({ iv: '' })).toBe(false); + expect(cryptoUtils.isValidEncryptedData({ data: '' })).toBe(false); + }); + }); +}); + +// ============================================================================ +// Security Utils Tests +// ============================================================================ + +describe('Security Utils', () => { + let securityUtils: typeof import('../security-utils'); + + beforeEach(async () => { + securityUtils = await import('../security-utils'); + }); + + describe('escapeHtml', () => { + it('should escape HTML special characters', () => { + const input = ''; + const escaped = securityUtils.escapeHtml(input); + + expect(escaped).toBe('<script>alert("xss")</script>'); + }); + + it('should handle plain text', () => { + const input = 'Hello, World!'; + expect(securityUtils.escapeHtml(input)).toBe(input); + }); + + it('should handle ampersand', () => { + expect(securityUtils.escapeHtml('a & b')).toBe('a & b'); + }); + }); + + describe('sanitizeHtml', () => { + it('should remove script tags', () => { + const html = '

Hello

'; + const sanitized = securityUtils.sanitizeHtml(html); + + expect(sanitized).not.toContain('')).toBeNull(); + }); + + it('should reject localhost when not allowed', () => { + expect( + securityUtils.validateUrl('http://localhost:3000', { allowLocalhost: false }) + ).toBeNull(); + }); + + it('should allow localhost when allowed', () => { + const url = 'http://localhost:3000'; + expect( + securityUtils.validateUrl(url, { allowLocalhost: true }) + ).toBe(url); + }); + }); + + describe('validatePath', () => { + it('should reject path traversal', () => { + expect(securityUtils.validatePath('../../../etc/passwd')).toBeNull(); + }); + + it('should reject null bytes', () => { + expect(securityUtils.validatePath('file\0.txt')).toBeNull(); + }); + + it('should reject absolute paths when not allowed', () => { + expect( + securityUtils.validatePath('/etc/passwd', { allowAbsolute: false }) + ).toBeNull(); + }); + + it('should validate relative paths', () => { + const result = securityUtils.validatePath('folder/file.txt'); + expect(result).toBe('folder/file.txt'); + }); + }); + + describe('isValidEmail', () => { + it('should validate correct emails', () => { + expect(securityUtils.isValidEmail('test@example.com')).toBe(true); + expect(securityUtils.isValidEmail('user.name@subdomain.example.com')).toBe(true); + }); + + it('should reject invalid emails', () => { + expect(securityUtils.isValidEmail('not-an-email')).toBe(false); + expect(securityUtils.isValidEmail('@example.com')).toBe(false); + expect(securityUtils.isValidEmail('test@')).toBe(false); + }); + }); + + describe('validatePasswordStrength', () => { + it('should accept strong passwords', () => { + const result = securityUtils.validatePasswordStrength('Str0ng!Pass'); + expect(result.valid).toBe(true); + expect(result.score).toBeGreaterThan(50); + }); + + it('should reject short passwords', () => { + const result = securityUtils.validatePasswordStrength('short'); + expect(result.valid).toBe(false); + expect(result.issues).toContain('Password must be at least 8 characters'); + }); + + it('should detect weak patterns', () => { + const result = securityUtils.validatePasswordStrength('password123'); + expect(result.issues).toContain('Password contains a common pattern'); + }); + }); + + describe('sanitizeFilename', () => { + it('should remove path separators', () => { + expect(securityUtils.sanitizeFilename('../test.txt')).toBe('.._test.txt'); + }); + + it('should remove dangerous characters', () => { + const sanitized = securityUtils.sanitizeFilename('file<>:"|?*.txt'); + expect(sanitized).not.toMatch(/[<>:"|?*]/); + }); + + it('should handle normal filenames', () => { + expect(securityUtils.sanitizeFilename('document.pdf')).toBe('document.pdf'); + }); + }); + + describe('sanitizeJson', () => { + it('should parse valid JSON', () => { + const result = securityUtils.sanitizeJson('{"name":"test"}'); + expect(result).toEqual({ name: 'test' }); + }); + + it('should remove prototype pollution keys', () => { + const result = securityUtils.sanitizeJson('{"__proto__":{"admin":true},"name":"test"}'); + expect(result).not.toHaveProperty('__proto__'); + }); + + it('should return null for invalid JSON', () => { + expect(securityUtils.sanitizeJson('not json')).toBeNull(); + }); + }); + + describe('isRateLimited', () => { + beforeEach(() => { + // Clear rate limit store + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should allow first request', () => { + expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false); + }); + + it('should block after limit reached', () => { + for (let i = 0; i < 5; i++) { + securityUtils.isRateLimited('test-key', 5, 60000); + } + expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true); + }); + + it('should reset after window expires', () => { + for (let i = 0; i < 5; i++) { + securityUtils.isRateLimited('test-key', 5, 60000); + } + expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true); + + // Advance time past window + vi.advanceTimersByTime(61000); + expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false); + }); + }); + + describe('generateSecureToken', () => { + it('should generate hex string of correct length', () => { + const token = securityUtils.generateSecureToken(16); + expect(token.length).toBe(32); // 16 bytes = 32 hex chars + }); + + it('should generate unique tokens', () => { + const token1 = securityUtils.generateSecureToken(); + const token2 = securityUtils.generateSecureToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('generateSecureId', () => { + it('should generate ID with prefix', () => { + const id = securityUtils.generateSecureId('user'); + expect(id.startsWith('user_')).toBe(true); + }); + + it('should generate ID without prefix', () => { + const id = securityUtils.generateSecureId(); + expect(id).toMatch(/^\w+_\w+$/); + }); + }); +}); + +// ============================================================================ +// Security Audit Tests +// ============================================================================ + +describe('Security Audit', () => { + let securityAudit: typeof import('../security-audit'); + + beforeEach(async () => { + securityAudit = await import('../security-audit'); + localStorage.clear(); + }); + + afterEach(() => { + securityAudit.clearSecurityAuditLog(); + }); + + describe('logSecurityEvent', () => { + it('should log security events', () => { + securityAudit.logSecurityEvent('auth_login', 'User logged in', { userId: '123' }); + + const events = securityAudit.getSecurityEvents(); + expect(events.length).toBe(1); + expect(events[0].type).toBe('auth_login'); + expect(events[0].message).toBe('User logged in'); + }); + + it('should determine severity automatically', () => { + securityAudit.logSecurityEvent('security_violation', 'Test violation'); + securityAudit.logSecurityEvent('auth_login', 'Test login'); + + const events = securityAudit.getSecurityEvents(); + expect(events[0].severity).toBe('critical'); + expect(events[1].severity).toBe('info'); + }); + }); + + describe('getSecurityEventsByType', () => { + it('should filter events by type', () => { + securityAudit.logSecurityEvent('auth_login', 'Login 1'); + securityAudit.logSecurityEvent('auth_logout', 'Logout'); + securityAudit.logSecurityEvent('auth_login', 'Login 2'); + + const loginEvents = securityAudit.getSecurityEventsByType('auth_login'); + expect(loginEvents.length).toBe(2); + }); + }); + + describe('getSecurityEventsBySeverity', () => { + it('should filter events by severity', () => { + securityAudit.logSecurityEvent('auth_login', 'Login', {}, 'info'); + securityAudit.logSecurityEvent('auth_failed', 'Failed', {}, 'warning'); + + const infoEvents = securityAudit.getSecurityEventsBySeverity('info'); + expect(infoEvents.length).toBe(1); + }); + }); + + describe('generateSecurityAuditReport', () => { + it('should generate a report', () => { + securityAudit.logSecurityEvent('auth_login', 'Login'); + securityAudit.logSecurityEvent('auth_failed', 'Failed'); + securityAudit.logSecurityEvent('security_violation', 'Violation'); + + const report = securityAudit.generateSecurityAuditReport(); + + expect(report.totalEvents).toBe(3); + expect(report.eventsByType.auth_login).toBe(1); + expect(report.eventsBySeverity.critical).toBe(1); + }); + }); + + describe('setAuditEnabled', () => { + it('should disable logging when set to false', () => { + securityAudit.setAuditEnabled(false); + securityAudit.logSecurityEvent('auth_login', 'Should not log'); + securityAudit.setAuditEnabled(true); + + const events = securityAudit.getSecurityEvents(); + // Only the config_changed event should be logged + const loginEvents = events.filter(e => e.type === 'auth_login'); + expect(loginEvents.length).toBe(0); + }); + }); +});