diff --git a/apps/server/package.json b/apps/server/package.json index 9f27c2a3..e40559a7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,6 +29,7 @@ "@automaker/types": "^1.0.0", "@automaker/utils": "^1.0.0", "@modelcontextprotocol/sdk": "^1.25.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", @@ -37,6 +38,7 @@ "ws": "^8.18.3" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/morgan": "^1.9.10", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4c8f0421..42689144 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,15 +9,22 @@ import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import dotenv from 'dotenv'; import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { initAllowedPaths } from '@automaker/platform'; -import { authMiddleware, getAuthStatus } from './lib/auth.js'; +import { + authMiddleware, + validateSession, + validateApiKey, + getSessionCookieName, +} from './lib/auth.js'; +import { createAuthRoutes } from './routes/auth/index.js'; import { createFsRoutes } from './routes/fs/index.js'; -import { createHealthRoutes } from './routes/health/index.js'; +import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js'; import { createAgentRoutes } from './routes/agent/index.js'; import { createSessionsRoutes } from './routes/sessions/index.js'; import { createFeaturesRoutes } from './routes/features/index.js'; @@ -105,17 +112,43 @@ if (ENABLE_REQUEST_LOGGING) { }) ); } -// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks -// from malicious websites. MCP server endpoints can execute arbitrary commands, -// so allowing any origin would enable RCE from any website visited while Automaker runs. -const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007']; +// CORS configuration +// When using credentials (cookies), origin cannot be '*' +// We dynamically allow the requesting origin for local development app.use( cors({ - origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS, + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps, curl, Electron) + if (!origin) { + callback(null, true); + return; + } + + // If CORS_ORIGIN is set, use it (can be comma-separated list) + const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()); + if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') { + if (allowedOrigins.includes(origin)) { + callback(null, origin); + } else { + callback(new Error('Not allowed by CORS')); + } + return; + } + + // For local development, allow localhost origins + if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) { + callback(null, origin); + return; + } + + // Reject other origins by default for security + callback(new Error('Not allowed by CORS')); + }, credentials: true, }) ); app.use(express.json({ limit: '50mb' })); +app.use(cookieParser()); // Create shared event emitter for streaming const events: EventEmitter = createEventEmitter(); @@ -144,12 +177,16 @@ setInterval(() => { } }, VALIDATION_CLEANUP_INTERVAL_MS); -// Mount API routes - health is unauthenticated for monitoring +// Mount API routes - health and auth are unauthenticated app.use('/api/health', createHealthRoutes()); +app.use('/api/auth', createAuthRoutes()); // Apply authentication to all other routes app.use('/api', authMiddleware); +// Protected health endpoint with detailed info +app.get('/api/health/detailed', createDetailedHandler()); + app.use('/api/fs', createFsRoutes(events)); app.use('/api/agent', createAgentRoutes(agentService, events)); app.use('/api/sessions', createSessionsRoutes(agentService)); @@ -182,10 +219,70 @@ const wss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true }); const terminalService = getTerminalService(); +/** + * Authenticate WebSocket upgrade requests + * Checks for API key in header/query, session token in header/query, OR valid session cookie + */ +function authenticateWebSocket(request: import('http').IncomingMessage): boolean { + const url = new URL(request.url || '', `http://${request.headers.host}`); + + // Check for API key in header (Electron mode) + const headerKey = request.headers['x-api-key'] as string | undefined; + if (headerKey && validateApiKey(headerKey)) { + return true; + } + + // Check for session token in header (web mode with explicit token) + const sessionTokenHeader = request.headers['x-session-token'] as string | undefined; + if (sessionTokenHeader && validateSession(sessionTokenHeader)) { + return true; + } + + // Check for API key in query param (fallback for WebSocket) + const queryKey = url.searchParams.get('apiKey'); + if (queryKey && validateApiKey(queryKey)) { + return true; + } + + // Check for session token in query param (fallback for WebSocket in web mode) + const queryToken = url.searchParams.get('sessionToken'); + if (queryToken && validateSession(queryToken)) { + return true; + } + + // Check for session cookie (web mode) + const cookieHeader = request.headers.cookie; + if (cookieHeader) { + const cookieName = getSessionCookieName(); + const cookies = cookieHeader.split(';').reduce( + (acc, cookie) => { + const [key, value] = cookie.trim().split('='); + acc[key] = value; + return acc; + }, + {} as Record + ); + const sessionToken = cookies[cookieName]; + if (sessionToken && validateSession(sessionToken)) { + return true; + } + } + + return false; +} + // Handle HTTP upgrade requests manually to route to correct WebSocket server server.on('upgrade', (request, socket, head) => { const { pathname } = new URL(request.url || '', `http://${request.headers.host}`); + // Authenticate all WebSocket connections + if (!authenticateWebSocket(request)) { + console.log('[WebSocket] Authentication failed, rejecting connection'); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + if (pathname === '/api/events') { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 145c7b9d..e643c6b2 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -1,39 +1,224 @@ /** * Authentication middleware for API security * - * Supports API key authentication via header or environment variable. + * Supports two authentication methods: + * 1. Header-based (X-API-Key) - Used by Electron mode + * 2. Cookie-based (HTTP-only session cookie) - Used by web mode + * + * Auto-generates an API key on first run if none is configured. */ import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; -// API key from environment (optional - if not set, auth is disabled) -const API_KEY = process.env.AUTOMAKER_API_KEY; +const DATA_DIR = process.env.DATA_DIR || './data'; +const API_KEY_FILE = path.join(DATA_DIR, '.api-key'); +const SESSIONS_FILE = path.join(DATA_DIR, '.sessions'); +const SESSION_COOKIE_NAME = 'automaker_session'; +const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Session store - persisted to file for survival across server restarts +const validSessions = new Map(); + +/** + * Load sessions from file on startup + */ +function loadSessions(): void { + try { + if (fs.existsSync(SESSIONS_FILE)) { + const data = fs.readFileSync(SESSIONS_FILE, 'utf-8'); + const sessions = JSON.parse(data) as Array< + [string, { createdAt: number; expiresAt: number }] + >; + const now = Date.now(); + let loadedCount = 0; + let expiredCount = 0; + + for (const [token, session] of sessions) { + // Only load non-expired sessions + if (session.expiresAt > now) { + validSessions.set(token, session); + loadedCount++; + } else { + expiredCount++; + } + } + + if (loadedCount > 0 || expiredCount > 0) { + console.log(`[Auth] Loaded ${loadedCount} sessions (${expiredCount} expired)`); + } + } + } catch (error) { + console.warn('[Auth] Error loading sessions:', error); + } +} + +/** + * Save sessions to file + */ +function saveSessions(): void { + try { + fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true }); + const sessions = Array.from(validSessions.entries()); + fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600 }); + } catch (error) { + console.error('[Auth] Failed to save sessions:', error); + } +} + +// Load existing sessions on startup +loadSessions(); + +/** + * Ensure an API key exists - either from env var, file, or generate new one. + * This provides CSRF protection by requiring a secret key for all API requests. + */ +function ensureApiKey(): string { + // First check environment variable (Electron passes it this way) + if (process.env.AUTOMAKER_API_KEY) { + console.log('[Auth] Using API key from environment variable'); + return process.env.AUTOMAKER_API_KEY; + } + + // Try to read from file + try { + if (fs.existsSync(API_KEY_FILE)) { + const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim(); + if (key) { + console.log('[Auth] Loaded API key from file'); + return key; + } + } + } catch (error) { + console.warn('[Auth] Error reading API key file:', error); + } + + // Generate new key + const newKey = crypto.randomUUID(); + try { + fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); + fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); + console.log('[Auth] Generated new API key'); + } catch (error) { + console.error('[Auth] Failed to save API key:', error); + } + return newKey; +} + +// API key - always generated/loaded on startup for CSRF protection +const API_KEY = ensureApiKey(); + +// Print API key to console for web mode users +console.log(` +╔═══════════════════════════════════════════════════════════════════════╗ +║ 🔐 API Key for Web Mode Authentication ║ +╠═══════════════════════════════════════════════════════════════════════╣ +║ ║ +║ When accessing via browser, you'll be prompted to enter this key: ║ +║ ║ +║ ${API_KEY} +║ ║ +║ In Electron mode, authentication is handled automatically. ║ +╚═══════════════════════════════════════════════════════════════════════╝ +`); + +/** + * Generate a cryptographically secure session token + */ +function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Create a new session and return the token + */ +export function createSession(): string { + const token = generateSessionToken(); + const now = Date.now(); + validSessions.set(token, { + createdAt: now, + expiresAt: now + SESSION_MAX_AGE_MS, + }); + saveSessions(); // Persist to file + return token; +} + +/** + * Validate a session token + */ +export function validateSession(token: string): boolean { + const session = validSessions.get(token); + if (!session) return false; + + if (Date.now() > session.expiresAt) { + validSessions.delete(token); + saveSessions(); // Persist removal + return false; + } + + return true; +} + +/** + * Invalidate a session token + */ +export function invalidateSession(token: string): void { + validSessions.delete(token); + saveSessions(); // Persist removal +} + +/** + * Validate the API key + */ +export function validateApiKey(key: string): boolean { + return key === API_KEY; +} + +/** + * Get session cookie options + */ +export function getSessionCookieOptions(): { + httpOnly: boolean; + secure: boolean; + sameSite: 'strict' | 'lax' | 'none'; + maxAge: number; + path: string; +} { + return { + httpOnly: true, // JavaScript cannot access this cookie + secure: process.env.NODE_ENV === 'production', // HTTPS only in production + sameSite: 'strict', // Only sent for same-site requests (CSRF protection) + maxAge: SESSION_MAX_AGE_MS, + path: '/', + }; +} + +/** + * Get the session cookie name + */ +export function getSessionCookieName(): string { + return SESSION_COOKIE_NAME; +} /** * Authentication middleware * - * If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header. - * If not set, allows all requests (development mode). + * Accepts either: + * 1. X-API-Key header (for Electron mode) + * 2. X-Session-Token header (for web mode with explicit token) + * 3. apiKey query parameter (fallback for cases where headers can't be set) + * 4. Session cookie (for web mode) */ export function authMiddleware(req: Request, res: Response, next: NextFunction): void { - // If no API key is configured, allow all requests - if (!API_KEY) { - next(); - return; - } - - // Check for API key in header - const providedKey = req.headers['x-api-key'] as string | undefined; - - if (!providedKey) { - res.status(401).json({ - success: false, - error: 'Authentication required. Provide X-API-Key header.', - }); - return; - } - - if (providedKey !== API_KEY) { + // Check for API key in header (Electron mode) + const headerKey = req.headers['x-api-key'] as string | undefined; + if (headerKey) { + if (headerKey === API_KEY) { + next(); + return; + } res.status(403).json({ success: false, error: 'Invalid API key.', @@ -41,14 +226,53 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return; } - next(); + // Check for session token in header (web mode with explicit token) + const sessionTokenHeader = req.headers['x-session-token'] as string | undefined; + if (sessionTokenHeader) { + if (validateSession(sessionTokenHeader)) { + next(); + return; + } + res.status(403).json({ + success: false, + error: 'Invalid or expired session token.', + }); + return; + } + + // Check for API key in query parameter (fallback) + const queryKey = req.query.apiKey as string | undefined; + if (queryKey) { + if (queryKey === API_KEY) { + next(); + return; + } + res.status(403).json({ + success: false, + error: 'Invalid API key.', + }); + return; + } + + // Check for session cookie (web mode) + const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined; + if (sessionToken && validateSession(sessionToken)) { + next(); + return; + } + + // No valid authentication + res.status(401).json({ + success: false, + error: 'Authentication required.', + }); } /** - * Check if authentication is enabled + * Check if authentication is enabled (always true now) */ export function isAuthEnabled(): boolean { - return !!API_KEY; + return true; } /** @@ -56,7 +280,38 @@ export function isAuthEnabled(): boolean { */ export function getAuthStatus(): { enabled: boolean; method: string } { return { - enabled: !!API_KEY, - method: API_KEY ? 'api_key' : 'none', + enabled: true, + method: 'api_key_or_session', }; } + +/** + * Check if a request is authenticated (for status endpoint) + */ +export function isRequestAuthenticated(req: Request): boolean { + // Check API key header + const headerKey = req.headers['x-api-key'] as string | undefined; + if (headerKey && headerKey === API_KEY) { + return true; + } + + // Check session token header + const sessionTokenHeader = req.headers['x-session-token'] as string | undefined; + if (sessionTokenHeader && validateSession(sessionTokenHeader)) { + return true; + } + + // Check query parameter + const queryKey = req.query.apiKey as string | undefined; + if (queryKey && queryKey === API_KEY) { + return true; + } + + // Check cookie + const sessionToken = req.cookies?.[SESSION_COOKIE_NAME] as string | undefined; + if (sessionToken && validateSession(sessionToken)) { + return true; + } + + return false; +} diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts new file mode 100644 index 00000000..f8932b41 --- /dev/null +++ b/apps/server/src/routes/auth/index.ts @@ -0,0 +1,150 @@ +/** + * Auth routes - Login, logout, and status endpoints + * + * Security model: + * - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie + * - Electron mode: Uses X-API-Key header (handled automatically via IPC) + * + * The session cookie is: + * - HTTP-only: JavaScript cannot read it (protects against XSS) + * - SameSite=Strict: Only sent for same-site requests (protects against CSRF) + * + * Mounted at /api/auth in the main server (BEFORE auth middleware). + */ + +import { Router } from 'express'; +import { + validateApiKey, + createSession, + invalidateSession, + getSessionCookieOptions, + getSessionCookieName, + isRequestAuthenticated, +} from '../../lib/auth.js'; + +/** + * Create auth routes + * + * @returns Express Router with auth endpoints + */ +export function createAuthRoutes(): Router { + const router = Router(); + + /** + * GET /api/auth/status + * + * Returns whether the current request is authenticated. + * Used by the UI to determine if login is needed. + */ + router.get('/status', (req, res) => { + const authenticated = isRequestAuthenticated(req); + res.json({ + success: true, + authenticated, + required: true, + }); + }); + + /** + * POST /api/auth/login + * + * Validates the API key and sets a session cookie. + * Body: { apiKey: string } + */ + router.post('/login', (req, res) => { + const { apiKey } = req.body as { apiKey?: string }; + + if (!apiKey) { + res.status(400).json({ + success: false, + error: 'API key is required.', + }); + return; + } + + if (!validateApiKey(apiKey)) { + res.status(401).json({ + success: false, + error: 'Invalid API key.', + }); + return; + } + + // Create session and set cookie + const sessionToken = createSession(); + const cookieOptions = getSessionCookieOptions(); + const cookieName = getSessionCookieName(); + + res.cookie(cookieName, sessionToken, cookieOptions); + res.json({ + success: true, + message: 'Logged in successfully.', + // Return token for explicit header-based auth (works around cross-origin cookie issues) + token: sessionToken, + }); + }); + + /** + * GET /api/auth/token + * + * Returns the session token if the user has a valid session cookie. + * This allows the UI to get a token for explicit header-based auth + * after a page refresh (when the token in memory is lost). + */ + router.get('/token', (req, res) => { + const cookieName = getSessionCookieName(); + const sessionToken = req.cookies?.[cookieName] as string | undefined; + + if (!sessionToken) { + res.status(401).json({ + success: false, + error: 'No session cookie found.', + }); + return; + } + + // Validate the session is still valid + if (!isRequestAuthenticated(req)) { + res.status(401).json({ + success: false, + error: 'Session expired.', + }); + return; + } + + // Return the existing session token for header-based auth + res.json({ + success: true, + token: sessionToken, + }); + }); + + /** + * POST /api/auth/logout + * + * Clears the session cookie and invalidates the session. + */ + router.post('/logout', (req, res) => { + const cookieName = getSessionCookieName(); + const sessionToken = req.cookies?.[cookieName] as string | undefined; + + if (sessionToken) { + invalidateSession(sessionToken); + } + + // Clear the cookie + res.clearCookie(cookieName, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + }); + + res.json({ + success: true, + message: 'Logged out successfully.', + }); + }); + + return router; +} diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 31439e66..688fdbc5 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -1,16 +1,25 @@ /** * Health check routes + * + * NOTE: Only the basic health check (/) is unauthenticated. + * The /detailed endpoint requires authentication. */ import { Router } from 'express'; import { createIndexHandler } from './routes/index.js'; -import { createDetailedHandler } from './routes/detailed.js'; +/** + * Create unauthenticated health routes (basic check only) + * Used by load balancers and container orchestration + */ export function createHealthRoutes(): Router { const router = Router(); + // Basic health check - no sensitive info router.get('/', createIndexHandler()); - router.get('/detailed', createDetailedHandler()); return router; } + +// Re-export detailed handler for use in authenticated routes +export { createDetailedHandler } from './routes/detailed.js'; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 946b7b23..d8c7d083 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -12,12 +12,13 @@ import { ClaudeUsage } from '../routes/claude/types.js'; * * Platform-specific implementations: * - macOS: Uses 'expect' command for PTY - * - Windows: Uses node-pty for PTY + * - Windows/Linux: Uses node-pty for PTY */ export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout private isWindows = os.platform() === 'win32'; + private isLinux = os.platform() === 'linux'; /** * Check if Claude CLI is available on the system @@ -48,8 +49,8 @@ export class ClaudeUsageService { * Uses platform-specific PTY implementation */ private executeClaudeUsageCommand(): Promise { - if (this.isWindows) { - return this.executeClaudeUsageCommandWindows(); + if (this.isWindows || this.isLinux) { + return this.executeClaudeUsageCommandPty(); } return this.executeClaudeUsageCommandMac(); } @@ -147,17 +148,23 @@ export class ClaudeUsageService { } /** - * Windows implementation using node-pty + * Windows/Linux implementation using node-pty */ - private executeClaudeUsageCommandWindows(): Promise { + private executeClaudeUsageCommandPty(): Promise { return new Promise((resolve, reject) => { let output = ''; let settled = false; let hasSeenUsageData = false; - const workingDirectory = process.env.USERPROFILE || os.homedir() || 'C:\\'; + const workingDirectory = this.isWindows + ? process.env.USERPROFILE || os.homedir() || 'C:\\' + : process.env.HOME || os.homedir() || '/tmp'; - const ptyProcess = pty.spawn('cmd.exe', ['/c', 'claude', '/usage'], { + // Use platform-appropriate shell and command + const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; + const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; + + const ptyProcess = pty.spawn(shell, args, { name: 'xterm-256color', cols: 120, rows: 30, diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index c469ab38..227f16e1 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { cn } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45; export function ClaudeUsagePopover() { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); + const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Check if CLI is verified/authenticated + const isCliVerified = + claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; + // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes const isStale = useMemo(() => { return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; @@ -68,14 +74,17 @@ export function ClaudeUsagePopover() { [setClaudeUsage] ); - // Auto-fetch on mount if data is stale + // Auto-fetch on mount if data is stale (only if CLI is verified) useEffect(() => { - if (isStale) { + if (isStale && isCliVerified) { fetchUsage(true); } - }, [isStale, fetchUsage]); + }, [isStale, isCliVerified, fetchUsage]); useEffect(() => { + // Skip if CLI is not verified + if (!isCliVerified) return; + // Initial fetch when opened if (open) { if (!claudeUsage || isStale) { @@ -94,7 +103,7 @@ export function ClaudeUsagePopover() { return () => { if (intervalId) clearInterval(intervalId); }; - }, [open, claudeUsage, isStale, fetchUsage]); + }, [open, claudeUsage, isStale, isCliVerified, fetchUsage]); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index 02552c28..ce09f63b 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd'; import { getJSON, setJSON } from '@/lib/storage'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { useOSDetection } from '@/hooks'; +import { apiPost } from '@/lib/api-fetch'; interface DirectoryEntry { name: string; @@ -98,16 +99,7 @@ export function FileBrowserDialog({ setWarning(''); try { - // Get server URL from environment or default - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; - - const response = await fetch(`${serverUrl}/api/fs/browse`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dirPath }), - }); - - const result: BrowseResult = await response.json(); + const result = await apiPost('/api/fs/browse', { dirPath }); if (result.success) { setCurrentPath(result.currentPath); diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index e3ffbe93..bc20f37a 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; interface BoardHeaderProps { projectName: string; @@ -34,12 +35,18 @@ export function BoardHeader({ isMounted, }: BoardHeaderProps) { const apiKeys = useAppStore((state) => state.apiKeys); + const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); // Hide usage tracking when using API key (only show for Claude Code CLI users) + // Check both user-entered API key and environment variable ANTHROPIC_API_KEY // Also hide on Windows for now (CLI usage command not supported) + // Only show if CLI has been verified/authenticated const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - const showUsageTracking = !apiKeys.anthropic && !isWindows; + const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey; + const isCliVerified = + claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; + const showUsageTracking = !hasApiKey && !isWindows && isCliVerified; return (
diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx new file mode 100644 index 00000000..cc8563c3 --- /dev/null +++ b/apps/ui/src/components/views/login-view.tsx @@ -0,0 +1,104 @@ +/** + * Login View - Web mode authentication + * + * Prompts user to enter the API key shown in server console. + * On successful login, sets an HTTP-only session cookie. + */ + +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { login } from '@/lib/http-api-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { KeyRound, AlertCircle, Loader2 } from 'lucide-react'; + +export function LoginView() { + const navigate = useNavigate(); + const [apiKey, setApiKey] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const result = await login(apiKey.trim()); + if (result.success) { + // Redirect to home/board on success + navigate({ to: '/' }); + } else { + setError(result.error || 'Invalid API key'); + } + } catch (err) { + setError('Failed to connect to server'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

Authentication Required

+

+ Enter the API key shown in the server console to continue. +

+
+ + {/* Login Form */} +
+
+ + setApiKey(e.target.value)} + disabled={isLoading} + autoFocus + className="font-mono" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ + {/* Help Text */} +
+

Where to find the API key:

+
    +
  1. Look at the server terminal/console output
  2. +
  3. Find the box labeled "API Key for Web Mode Authentication"
  4. +
  5. Copy the UUID displayed there
  6. +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 70d19e5f..1c20e062 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore } from '@/store/setup-store'; import { useCliStatus, useSettingsView } from './settings-view/hooks'; import { NAV_ITEMS } from './settings-view/config/navigation'; @@ -55,11 +56,15 @@ export function SettingsView() { setEnableSandboxMode, } = useAppStore(); + const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + // Hide usage tracking when using API key (only show for Claude Code CLI users) + // Check both user-entered API key and environment variable ANTHROPIC_API_KEY // Also hide on Windows for now (CLI usage command not supported) const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - const showUsageTracking = !apiKeys.anthropic && !isWindows; + const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey; + const showUsageTracking = !hasApiKey && !isWindows; // Convert electron Project to settings-view Project type const convertProject = (project: ElectronProject | null): SettingsProject | null => { diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 9f5569f5..2d2068c5 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -46,6 +46,8 @@ import { defaultDropAnimationSideEffects, } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; +import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch'; +import { getApiKey } from '@/lib/http-api-client'; interface TerminalStatus { enabled: boolean; @@ -304,16 +306,13 @@ export function TerminalView() { await Promise.allSettled( sessionIds.map(async (sessionId) => { try { - await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { - method: 'DELETE', - headers, - }); + await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers }); } catch (err) { console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); } }) ); - }, [collectAllSessionIds, terminalState.authToken, serverUrl]); + }, [collectAllSessionIds, terminalState.authToken]); const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation // Helper to check if terminal creation should be debounced @@ -434,9 +433,10 @@ export function TerminalView() { try { setLoading(true); setError(null); - const response = await fetch(`${serverUrl}/api/terminal/status`); - const data = await response.json(); - if (data.success) { + const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>( + '/api/terminal/status' + ); + if (data.success && data.data) { setStatus(data.data); if (!data.data.passwordRequired) { setTerminalUnlocked(true); @@ -450,7 +450,7 @@ export function TerminalView() { } finally { setLoading(false); } - }, [serverUrl, setTerminalUnlocked]); + }, [setTerminalUnlocked]); // Fetch server session settings const fetchServerSettings = useCallback(async () => { @@ -460,15 +460,17 @@ export function TerminalView() { if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } - const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers }); - const data = await response.json(); - if (data.success) { + const data = await apiGet<{ + success: boolean; + data?: { currentSessions: number; maxSessions: number }; + }>('/api/terminal/settings', { headers }); + if (data.success && data.data) { setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions }); } } catch (err) { console.error('[Terminal] Failed to fetch server settings:', err); } - }, [serverUrl, terminalState.isUnlocked, terminalState.authToken]); + }, [terminalState.isUnlocked, terminalState.authToken]); useEffect(() => { fetchStatus(); @@ -483,22 +485,20 @@ export function TerminalView() { const sessionIds = collectAllSessionIds(); if (sessionIds.length === 0) return; - const headers: Record = { - 'Content-Type': 'application/json', - }; - if (terminalState.authToken) { - headers['X-Terminal-Token'] = terminalState.authToken; - } - // Try to use the bulk delete endpoint if available, otherwise delete individually - // Using sendBeacon for reliability during page unload + // Using sync XMLHttpRequest for reliability during page unload (async doesn't complete) sessionIds.forEach((sessionId) => { const url = `${serverUrl}/api/terminal/sessions/${sessionId}`; - // sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest - // which is more reliable during page unload than fetch try { const xhr = new XMLHttpRequest(); xhr.open('DELETE', url, false); // synchronous + xhr.withCredentials = true; // Include cookies for session auth + // Add API auth header + const apiKey = getApiKey(); + if (apiKey) { + xhr.setRequestHeader('X-API-Key', apiKey); + } + // Add terminal-specific auth if (terminalState.authToken) { xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken); } @@ -593,9 +593,7 @@ export function TerminalView() { let reconnectedSessions = 0; try { - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; // Get fresh auth token from store const authToken = useAppStore.getState().terminalState.authToken; if (authToken) { @@ -605,11 +603,9 @@ export function TerminalView() { // Helper to check if a session still exists on server const checkSessionExists = async (sessionId: string): Promise => { try { - const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { - method: 'GET', + const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, { headers, }); - const data = await response.json(); return data.success === true; } catch { return false; @@ -619,17 +615,12 @@ export function TerminalView() { // Helper to create a new terminal session const createSession = async (): Promise => { try { - const response = await fetch(`${serverUrl}/api/terminal/sessions`, { - method: 'POST', - headers, - body: JSON.stringify({ - cwd: currentPath, - cols: 80, - rows: 24, - }), - }); - const data = await response.json(); - return data.success ? data.data.id : null; + const data = await apiPost<{ success: boolean; data?: { id: string } }>( + '/api/terminal/sessions', + { cwd: currentPath, cols: 80, rows: 24 }, + { headers } + ); + return data.success && data.data ? data.data.id : null; } catch (err) { console.error('[Terminal] Failed to create terminal session:', err); return null; @@ -801,14 +792,12 @@ export function TerminalView() { setAuthError(null); try { - const response = await fetch(`${serverUrl}/api/terminal/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }), - }); - const data = await response.json(); + const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>( + '/api/terminal/auth', + { password } + ); - if (data.success) { + if (data.success && data.data) { setTerminalUnlocked(true, data.data.token); setPassword(''); } else { @@ -833,21 +822,14 @@ export function TerminalView() { } try { - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } - const response = await fetch(`${serverUrl}/api/terminal/sessions`, { - method: 'POST', + const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: JSON.stringify({ - cwd: currentProject?.path || undefined, - cols: 80, - rows: 24, - }), + body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 }, }); const data = await response.json(); @@ -892,21 +874,14 @@ export function TerminalView() { const tabId = addTerminalTab(); try { - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } - const response = await fetch(`${serverUrl}/api/terminal/sessions`, { - method: 'POST', + const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: JSON.stringify({ - cwd: currentProject?.path || undefined, - cols: 80, - rows: 24, - }), + body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 }, }); const data = await response.json(); @@ -959,10 +934,7 @@ export function TerminalView() { headers['X-Terminal-Token'] = terminalState.authToken; } - const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { - method: 'DELETE', - headers, - }); + const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers }); // Always remove from UI - even if server says 404 (session may have already exited) removeTerminalFromLayout(sessionId); @@ -1008,10 +980,7 @@ export function TerminalView() { await Promise.all( sessionIds.map(async (sessionId) => { try { - await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { - method: 'DELETE', - headers, - }); + await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers }); } catch (err) { console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); } diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 56266db6..13117624 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -40,6 +40,7 @@ import { } from '@/config/terminal-themes'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; +import { getApiKey } from '@/lib/http-api-client'; // Font size constraints const MIN_FONT_SIZE = 8; @@ -940,8 +941,17 @@ export function TerminalPanel({ if (!terminal) return; const connect = () => { - // Build WebSocket URL with token + // Build WebSocket URL with auth params let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`; + + // Add API key for Electron mode auth + const apiKey = getApiKey(); + if (apiKey) { + url += `&apiKey=${encodeURIComponent(apiKey)}`; + } + // In web mode, cookies are sent automatically with same-origin WebSocket + + // Add terminal password token if required if (authToken) { url += `&token=${encodeURIComponent(authToken)}`; } diff --git a/apps/ui/src/lib/api-fetch.ts b/apps/ui/src/lib/api-fetch.ts new file mode 100644 index 00000000..a9d00b8f --- /dev/null +++ b/apps/ui/src/lib/api-fetch.ts @@ -0,0 +1,161 @@ +/** + * Authenticated fetch utility + * + * Provides a wrapper around fetch that automatically includes: + * - X-API-Key header (for Electron mode) + * - X-Session-Token header (for web mode with explicit token) + * - credentials: 'include' (fallback for web mode session cookies) + * + * Use this instead of raw fetch() for all authenticated API calls. + */ + +import { getApiKey, getSessionToken } from './http-api-client'; + +// Server URL - configurable via environment variable +const getServerUrl = (): string => { + if (typeof window !== 'undefined') { + const envUrl = import.meta.env.VITE_SERVER_URL; + if (envUrl) return envUrl; + } + return 'http://localhost:3008'; +}; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export interface ApiFetchOptions extends Omit { + /** Additional headers to include (merged with auth headers) */ + headers?: Record; + /** Request body - will be JSON stringified if object */ + body?: unknown; + /** Skip authentication headers (for public endpoints like /api/health) */ + skipAuth?: boolean; +} + +/** + * Build headers for an authenticated request + */ +export function getAuthHeaders(additionalHeaders?: Record): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...additionalHeaders, + }; + + // Electron mode: use API key + const apiKey = getApiKey(); + if (apiKey) { + headers['X-API-Key'] = apiKey; + return headers; + } + + // Web mode: use session token if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + return headers; +} + +/** + * Make an authenticated fetch request to the API + * + * @param endpoint - API endpoint (e.g., '/api/fs/browse') + * @param method - HTTP method + * @param options - Additional options + * @returns Response from fetch + * + * @example + * ```ts + * // Simple GET + * const response = await apiFetch('/api/terminal/status', 'GET'); + * + * // POST with body + * const response = await apiFetch('/api/fs/browse', 'POST', { + * body: { dirPath: '/home/user' } + * }); + * + * // With additional headers + * const response = await apiFetch('/api/terminal/sessions', 'POST', { + * headers: { 'X-Terminal-Token': token }, + * body: { cwd: '/home/user' } + * }); + * ``` + */ +export async function apiFetch( + endpoint: string, + method: HttpMethod = 'GET', + options: ApiFetchOptions = {} +): Promise { + const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options; + + const headers = skipAuth + ? { 'Content-Type': 'application/json', ...additionalHeaders } + : getAuthHeaders(additionalHeaders); + + const fetchOptions: RequestInit = { + method, + headers, + credentials: 'include', + ...restOptions, + }; + + if (body !== undefined) { + fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + } + + const url = endpoint.startsWith('http') ? endpoint : `${getServerUrl()}${endpoint}`; + return fetch(url, fetchOptions); +} + +/** + * Make an authenticated GET request + */ +export async function apiGet( + endpoint: string, + options: Omit = {} +): Promise { + const response = await apiFetch(endpoint, 'GET', options); + return response.json(); +} + +/** + * Make an authenticated POST request + */ +export async function apiPost( + endpoint: string, + body?: unknown, + options: ApiFetchOptions = {} +): Promise { + const response = await apiFetch(endpoint, 'POST', { ...options, body }); + return response.json(); +} + +/** + * Make an authenticated PUT request + */ +export async function apiPut( + endpoint: string, + body?: unknown, + options: ApiFetchOptions = {} +): Promise { + const response = await apiFetch(endpoint, 'PUT', { ...options, body }); + return response.json(); +} + +/** + * Make an authenticated DELETE request + */ +export async function apiDelete(endpoint: string, options: ApiFetchOptions = {}): Promise { + const response = await apiFetch(endpoint, 'DELETE', options); + return response.json(); +} + +/** + * Make an authenticated DELETE request (returns raw response for status checking) + */ +export async function apiDeleteRaw( + endpoint: string, + options: ApiFetchOptions = {} +): Promise { + return apiFetch(endpoint, 'DELETE', options); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 82337f97..5b3abeab 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -431,6 +431,7 @@ export interface SaveImageResult { export interface ElectronAPI { ping: () => Promise; + getApiKey?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openDirectory: () => Promise; openFile: (options?: object) => Promise; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ba4f96af..f952c930 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,12 +41,163 @@ const getServerUrl = (): string => { return 'http://localhost:3008'; }; -// Get API key from environment variable -const getApiKey = (): string | null => { - if (typeof window !== 'undefined') { - return import.meta.env.VITE_AUTOMAKER_API_KEY || null; +// Cached API key for authentication (Electron mode only) +let cachedApiKey: string | null = null; +let apiKeyInitialized = false; + +// Cached session token for authentication (Web mode - explicit header auth) +let cachedSessionToken: string | null = null; + +// Get API key for Electron mode (returns cached value after initialization) +// Exported for use in WebSocket connections that need auth +export const getApiKey = (): string | null => cachedApiKey; + +// Get session token for Web mode (returns cached value after login or token fetch) +export const getSessionToken = (): string | null => cachedSessionToken; + +// Set session token (called after login or token fetch) +export const setSessionToken = (token: string | null): void => { + cachedSessionToken = token; +}; + +// Clear session token (called on logout) +export const clearSessionToken = (): void => { + cachedSessionToken = null; +}; + +/** + * Check if we're running in Electron mode + */ +export const isElectronMode = (): boolean => { + return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey; +}; + +/** + * Initialize API key for Electron mode authentication. + * In web mode, authentication uses HTTP-only cookies instead. + * + * This should be called early in app initialization. + */ +export const initApiKey = async (): Promise => { + if (apiKeyInitialized) return; + apiKeyInitialized = true; + + // Only Electron mode uses API key header auth + if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) { + try { + cachedApiKey = await window.electronAPI.getApiKey(); + if (cachedApiKey) { + console.log('[HTTP Client] Using API key from Electron'); + return; + } + } catch (error) { + console.warn('[HTTP Client] Failed to get API key from Electron:', error); + } + } + + // In web mode, authentication is handled via HTTP-only cookies + console.log('[HTTP Client] Web mode - using cookie-based authentication'); +}; + +/** + * Check authentication status with the server + */ +export const checkAuthStatus = async (): Promise<{ + authenticated: boolean; + required: boolean; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/auth/status`, { + credentials: 'include', + headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined, + }); + const data = await response.json(); + return { + authenticated: data.authenticated ?? false, + required: data.required ?? true, + }; + } catch (error) { + console.error('[HTTP Client] Failed to check auth status:', error); + return { authenticated: false, required: true }; + } +}; + +/** + * Login with API key (for web mode) + */ +export const login = async ( + apiKey: string +): Promise<{ success: boolean; error?: string; token?: string }> => { + try { + const response = await fetch(`${getServerUrl()}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ apiKey }), + }); + const data = await response.json(); + + // Store the session token if login succeeded + if (data.success && data.token) { + setSessionToken(data.token); + console.log('[HTTP Client] Session token stored after login'); + } + + return data; + } catch (error) { + console.error('[HTTP Client] Login failed:', error); + return { success: false, error: 'Network error' }; + } +}; + +/** + * Fetch session token from server (for page refresh when cookie exists) + * This retrieves the session token so it can be used for explicit header-based auth. + */ +export const fetchSessionToken = async (): Promise => { + try { + const response = await fetch(`${getServerUrl()}/api/auth/token`, { + credentials: 'include', // Send the session cookie + }); + + if (!response.ok) { + console.log('[HTTP Client] No valid session to get token from'); + return false; + } + + const data = await response.json(); + if (data.success && data.token) { + setSessionToken(data.token); + console.log('[HTTP Client] Session token retrieved from cookie session'); + return true; + } + + return false; + } catch (error) { + console.error('[HTTP Client] Failed to fetch session token:', error); + return false; + } +}; + +/** + * Logout (for web mode) + */ +export const logout = async (): Promise<{ success: boolean }> => { + try { + const response = await fetch(`${getServerUrl()}/api/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + + // Clear the cached session token + clearSessionToken(); + console.log('[HTTP Client] Session token cleared on logout'); + + return await response.json(); + } catch (error) { + console.error('[HTTP Client] Logout failed:', error); + return { success: false }; } - return null; }; type EventType = @@ -87,7 +238,22 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; try { - const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + + // In Electron mode, add API key as query param for WebSocket auth + // (WebSocket doesn't support custom headers in browser) + const apiKey = getApiKey(); + if (apiKey) { + wsUrl += `?apiKey=${encodeURIComponent(apiKey)}`; + } else { + // In web mode, add session token as query param + // (cookies may not work cross-origin, so use explicit token) + const sessionToken = getSessionToken(); + if (sessionToken) { + wsUrl += `?sessionToken=${encodeURIComponent(sessionToken)}`; + } + } + this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { @@ -155,10 +321,20 @@ export class HttpApiClient implements ElectronAPI { const headers: Record = { 'Content-Type': 'application/json', }; + + // Electron mode: use API key const apiKey = getApiKey(); if (apiKey) { headers['X-API-Key'] = apiKey; + return headers; } + + // Web mode: use session token if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + return headers; } @@ -166,14 +342,17 @@ export class HttpApiClient implements ElectronAPI { const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'POST', headers: this.getHeaders(), + credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); return response.json(); } private async get(endpoint: string): Promise { - const headers = this.getHeaders(); - const response = await fetch(`${this.serverUrl}${endpoint}`, { headers }); + const response = await fetch(`${this.serverUrl}${endpoint}`, { + headers: this.getHeaders(), + credentials: 'include', // Include cookies for session auth + }); return response.json(); } @@ -181,6 +360,7 @@ export class HttpApiClient implements ElectronAPI { const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'PUT', headers: this.getHeaders(), + credentials: 'include', // Include cookies for session auth body: body ? JSON.stringify(body) : undefined, }); return response.json(); @@ -190,6 +370,7 @@ export class HttpApiClient implements ElectronAPI { const response = await fetch(`${this.serverUrl}${endpoint}`, { method: 'DELETE', headers: this.getHeaders(), + credentials: 'include', // Include cookies for session auth }); return response.json(); } diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 834dd6f1..4e112f25 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -8,6 +8,7 @@ import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; +import crypto from 'crypto'; import http, { Server } from 'http'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; @@ -59,6 +60,46 @@ interface WindowBounds { // Debounce timer for saving window bounds let saveWindowBoundsTimeout: ReturnType | null = null; +// API key for CSRF protection +let apiKey: string | null = null; + +/** + * Get path to API key file in user data directory + */ +function getApiKeyPath(): string { + return path.join(app.getPath('userData'), '.api-key'); +} + +/** + * Ensure an API key exists - load from file or generate new one. + * This key is passed to the server for CSRF protection. + */ +function ensureApiKey(): string { + const keyPath = getApiKeyPath(); + try { + if (fs.existsSync(keyPath)) { + const key = fs.readFileSync(keyPath, 'utf-8').trim(); + if (key) { + apiKey = key; + console.log('[Electron] Loaded existing API key'); + return apiKey; + } + } + } catch (error) { + console.warn('[Electron] Error reading API key:', error); + } + + // Generate new key + apiKey = crypto.randomUUID(); + try { + fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 }); + console.log('[Electron] Generated new API key'); + } catch (error) { + console.error('[Electron] Failed to save API key:', error); + } + return apiKey; +} + /** * Get icon path - works in both dev and production, cross-platform */ @@ -331,6 +372,8 @@ async function startServer(): Promise { PORT: SERVER_PORT.toString(), DATA_DIR: app.getPath('userData'), NODE_PATH: serverNodeModules, + // Pass API key to server for CSRF protection + AUTOMAKER_API_KEY: apiKey!, // Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment // If not set, server will allow access to all paths ...(process.env.ALLOWED_ROOT_DIRECTORY && { @@ -509,6 +552,9 @@ app.whenReady().then(async () => { } } + // Generate or load API key for CSRF protection (before starting server) + ensureApiKey(); + try { // Start static file server in production if (app.isPackaged) { @@ -666,6 +712,11 @@ ipcMain.handle('server:getUrl', async () => { return `http://localhost:${SERVER_PORT}`; }); +// Get API key for authentication +ipcMain.handle('auth:getApiKey', () => { + return apiKey; +}); + // Window management - update minimum width based on sidebar state // Now uses a fixed small minimum since horizontal scrolling handles overflow ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index ff16f0e3..4a1aa6f1 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Get server URL for HTTP client getServerUrl: (): Promise => ipcRenderer.invoke('server:getUrl'), + // Get API key for authentication + getApiKey: (): Promise => ipcRenderer.invoke('auth:getApiKey'), + // Native dialogs - better UX than prompt() openDirectory: (): Promise => ipcRenderer.invoke('dialog:openDirectory'), diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 9802fd80..94bfe5e0 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -9,6 +9,12 @@ import { import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; +import { + initApiKey, + checkAuthStatus, + isElectronMode, + fetchSessionToken, +} from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; @@ -22,6 +28,8 @@ function RootLayoutContent() { const [setupHydrated, setSetupHydrated] = useState( () => useSetupStore.persist?.hasHydrated?.() ?? false ); + const [authChecked, setAuthChecked] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); const { openFileBrowser } = useFileBrowser(); // Hidden streamer panel - opens with "\" key @@ -70,6 +78,51 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Initialize authentication + // - Electron mode: Uses API key from IPC (header-based auth) + // - Web mode: Uses session token (fetched from cookie session for explicit header auth) + useEffect(() => { + const initAuth = async () => { + try { + // Initialize API key for Electron mode + await initApiKey(); + + // In Electron mode, we're always authenticated via header + if (isElectronMode()) { + setIsAuthenticated(true); + setAuthChecked(true); + return; + } + + // In web mode, try to fetch session token (works if cookie is valid) + // This allows explicit header-based auth which works better cross-origin + const tokenFetched = await fetchSessionToken(); + + if (tokenFetched) { + // We have a valid session - token is now stored in memory + setIsAuthenticated(true); + setAuthChecked(true); + return; + } + + // Fallback: check auth status via cookie + const status = await checkAuthStatus(); + setIsAuthenticated(status.authenticated); + setAuthChecked(true); + + // Redirect to login if not authenticated and not already on login page + if (!status.authenticated && location.pathname !== '/login') { + navigate({ to: '/login' }); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + setAuthChecked(true); + } + }; + + initAuth(); + }, [location.pathname, navigate]); + // Wait for setup store hydration before enforcing routing rules useEffect(() => { if (useSetupStore.persist?.hasHydrated?.()) { @@ -147,8 +200,32 @@ function RootLayoutContent() { } }, [deferredTheme]); - // Setup view is full-screen without sidebar + // Login and setup views are full-screen without sidebar const isSetupRoute = location.pathname === '/setup'; + const isLoginRoute = location.pathname === '/login'; + + // Show login page (full screen, no sidebar) + if (isLoginRoute) { + return ( +
+ +
+ ); + } + + // Wait for auth check before rendering protected routes (web mode only) + if (!isElectronMode() && !authChecked) { + return ( +
+
Loading...
+
+ ); + } + + // Redirect to login if not authenticated (web mode) + if (!isElectronMode() && !isAuthenticated) { + return null; // Will redirect via useEffect + } if (isSetupRoute) { return ( diff --git a/apps/ui/src/routes/login.tsx b/apps/ui/src/routes/login.tsx new file mode 100644 index 00000000..b5e4a111 --- /dev/null +++ b/apps/ui/src/routes/login.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { LoginView } from '@/components/views/login-view'; + +export const Route = createFileRoute('/login')({ + component: LoginView, +}); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index e345ac91..1c84e59a 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -176,6 +176,7 @@ export const useSetupStore = create()( isFirstRun: state.isFirstRun, setupComplete: state.setupComplete, skipClaudeSetup: state.skipClaudeSetup, + claudeAuthStatus: state.claudeAuthStatus, }), } ) diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index e6b6c8c0..44985d6b 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -464,6 +464,7 @@ export interface AutoModeAPI { export interface ElectronAPI { ping: () => Promise; + getApiKey?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; // Dialog APIs diff --git a/docker-compose.yml b/docker-compose.yml index bdf5ff19..8bbf2e84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: # Required - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # Optional - authentication (leave empty to disable) + # Optional - authentication, one will generate if left blank - AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-} # Optional - restrict to specific directory within container only @@ -49,7 +49,7 @@ services: - DATA_DIR=/data # Optional - CORS origin (default allows all) - - CORS_ORIGIN=${CORS_ORIGIN:-*} + - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} volumes: # ONLY named volumes - these are isolated from your host filesystem # This volume persists data between restarts but is container-managed diff --git a/init.mjs b/init.mjs index ab38ad8a..4fcf8b08 100644 --- a/init.mjs +++ b/init.mjs @@ -352,14 +352,21 @@ async function main() { fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); } - // Start server in background + // Start server in background, showing output in console AND logging to file const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); serverProcess = runNpm(['run', 'dev:server'], { stdio: ['ignore', 'pipe', 'pipe'], }); - serverProcess.stdout?.pipe(logStream); - serverProcess.stderr?.pipe(logStream); + // Pipe to both log file and console so user can see API key + serverProcess.stdout?.on('data', (data) => { + process.stdout.write(data); + logStream.write(data); + }); + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(data); + logStream.write(data); + }); log('Waiting for server to be ready...', 'yellow'); diff --git a/package-lock.json b/package-lock.json index 7a62bd87..f2450e8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@automaker/types": "^1.0.0", "@automaker/utils": "^1.0.0", "@modelcontextprotocol/sdk": "^1.25.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", @@ -45,6 +46,7 @@ "ws": "^8.18.3" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/morgan": "^1.9.10", @@ -452,7 +454,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1036,7 +1037,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1079,7 +1079,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1246,7 +1245,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -1900,6 +1899,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1921,6 +1921,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1937,6 +1938,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1951,6 +1953,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2718,6 +2721,7 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -2842,6 +2846,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2858,6 +2863,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2874,6 +2880,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2982,6 +2989,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3004,6 +3012,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3026,6 +3035,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3111,6 +3121,7 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3133,6 +3144,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3152,6 +3164,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3551,7 +3564,8 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@next/swc-darwin-arm64": { "version": "16.0.10", @@ -3565,6 +3579,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3581,6 +3596,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3597,6 +3613,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3613,6 +3630,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3629,6 +3647,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3645,6 +3664,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3661,6 +3681,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3677,6 +3698,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3767,7 +3789,6 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5208,6 +5229,7 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5541,7 +5563,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -5848,6 +5869,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -6093,7 +6124,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6104,7 +6134,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6210,7 +6239,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6704,8 +6732,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -6803,7 +6830,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6864,7 +6890,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7463,7 +7488,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7995,7 +8019,8 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cliui": { "version": "8.0.1", @@ -8228,6 +8253,25 @@ "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", "license": "MIT" }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -8281,7 +8325,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8378,7 +8423,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8680,7 +8724,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -9007,6 +9050,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9027,6 +9071,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9277,7 +9322,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9592,7 +9636,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11260,6 +11303,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11321,6 +11365,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13748,6 +13793,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13764,6 +13810,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13781,6 +13828,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13969,7 +14017,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13979,7 +14026,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14338,6 +14384,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14526,7 +14573,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -14575,6 +14621,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14625,6 +14672,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14647,6 +14695,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14669,6 +14718,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14685,6 +14735,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14701,6 +14752,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14717,6 +14769,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14733,6 +14786,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14749,6 +14803,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14765,6 +14820,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14781,6 +14837,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14803,6 +14860,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14825,6 +14883,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14847,6 +14906,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14869,6 +14929,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14891,6 +14952,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15359,6 +15421,7 @@ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", + "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -15528,6 +15591,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15591,6 +15655,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15688,7 +15753,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15893,7 +15957,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16265,7 +16328,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16355,8 +16417,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -16382,7 +16443,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16425,7 +16485,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -16683,7 +16742,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16752,7 +16810,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2679ac44..fb5d89b6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dev:electron:wsl": "npm run build:packages && npm run _dev:electron:wsl", "dev:electron:wsl:gpu": "npm run build:packages && npm run _dev:electron:wsl:gpu", "dev:server": "npm run build:packages && npm run _dev:server", + "dev:docker": "docker compose up --build", "dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"", "build": "npm run build:packages && npm run build --workspace=apps/ui", "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",