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 && (
+ setShowMessageQueue(!showMessageQueue)}
+ className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
+ >
+ {showMessageQueue ? (
+
+ ) : (
+
+ )}
+ {pendingCount} 条待发
+
+ )}
+
+
+
+ {/* 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' && (
+
removeMessage(message.id)}
+ className="p-1 text-gray-400 hover:text-red-500 transition-colors"
+ title="删除消息"
+ >
+
+
+ )}
+
+ );
+}
+
+/**
+ * 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 */}
+
+ {showDetails ? 'Hide' : 'Show'} technical details
+
+ ▼
+
+
+
+ {/* Technical Details */}
+ {showDetails && (
+
+
+ {errorInfo?.errorName || error.name}: {errorInfo?.errorMessage || error.message}
+ {errorInfo?.componentStack && `\n\nComponent Stack:${errorInfo.componentStack}`}
+
+
+ )}
{/* Actions */}
-
-
-
- Try Again
-
-
+
+
+
+
+ Try Again
+
+
+ Reload Page
+
+
Report Issue
-
-
-
- );
- }
-
- 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}
+
+
+
+ Retry
+
+
+
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+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);
+ });
+ });
+});