diff --git a/Dockerfile b/Dockerfile index e7a0237a..3f110451 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,7 +102,6 @@ RUN git config --system --add safe.directory '*' && \ USER automaker # Environment variables -ENV NODE_ENV=production ENV PORT=3008 ENV DATA_DIR=/data diff --git a/apps/server/package.json b/apps/server/package.json index 9f27c2a3..1eb415a8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -9,6 +9,7 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "dev:test": "tsx src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/", @@ -29,6 +30,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 +39,8 @@ "ws": "^8.18.3" }, "devDependencies": { + "@types/cookie": "^0.6.0", + "@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..7360cbd7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,15 +9,19 @@ import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; +import cookie from 'cookie'; 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, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; +import { requireJsonContentType } from './middleware/require-json-content-type.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'; @@ -91,7 +95,7 @@ const app = express(); // Middleware // Custom colored logger showing only endpoint and status code (configurable via ENABLE_REQUEST_LOGGING env var) if (ENABLE_REQUEST_LOGGING) { - morgan.token('status-colored', (req, res) => { + morgan.token('status-colored', (_req, res) => { const status = res.statusCode; if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors @@ -105,17 +109,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 +174,20 @@ setInterval(() => { } }, VALIDATION_CLEANUP_INTERVAL_MS); -// Mount API routes - health is unauthenticated for monitoring +// Require Content-Type: application/json for all API POST/PUT/PATCH requests +// This helps prevent CSRF and content-type confusion attacks +app.use('/api', requireJsonContentType); + +// 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 +220,55 @@ 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}`); + + // Convert URL search params to query object + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + // Parse cookies from header + const cookieHeader = request.headers.cookie; + const cookies = cookieHeader ? cookie.parse(cookieHeader) : {}; + + // Use shared authentication logic for standard auth methods + if ( + checkRawAuthentication( + request.headers as Record, + query, + cookies + ) + ) { + return true; + } + + // Additionally check for short-lived WebSocket connection token (WebSocket-specific) + const wsToken = url.searchParams.get('wsToken'); + if (wsToken && validateWsConnectionToken(wsToken)) { + 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..d8629d61 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -1,54 +1,378 @@ /** * 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 +const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens + +// Session store - persisted to file for survival across server restarts +const validSessions = new Map(); + +// Short-lived WebSocket connection tokens (in-memory only, not persisted) +const wsConnectionTokens = new Map(); + +// Clean up expired WebSocket tokens periodically +setInterval(() => { + const now = Date.now(); + wsConnectionTokens.forEach((data, token) => { + if (data.expiresAt <= now) { + wsConnectionTokens.delete(token); + } + }); +}, 60 * 1000); // Clean up every minute + +/** + * 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 (async) + */ +async function saveSessions(): Promise { + try { + await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); + const sessions = Array.from(validSessions.entries()); + await fs.promises.writeFile(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 (unless suppressed for production logging) +if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') { + 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. ║ +╚═══════════════════════════════════════════════════════════════════════╝ +`); +} else { + console.log('[Auth] API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)'); +} + +/** + * Generate a cryptographically secure session token + */ +function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Create a new session and return the token + */ +export async function createSession(): Promise { + const token = generateSessionToken(); + const now = Date.now(); + validSessions.set(token, { + createdAt: now, + expiresAt: now + SESSION_MAX_AGE_MS, + }); + await saveSessions(); // Persist to file + return token; +} + +/** + * Validate a session token + * Note: This returns synchronously but triggers async persistence if session expired + */ +export function validateSession(token: string): boolean { + const session = validSessions.get(token); + if (!session) return false; + + if (Date.now() > session.expiresAt) { + validSessions.delete(token); + // Fire-and-forget: persist removal asynchronously + saveSessions().catch((err) => console.error('[Auth] Error saving sessions:', err)); + return false; + } + + return true; +} + +/** + * Invalidate a session token + */ +export async function invalidateSession(token: string): Promise { + validSessions.delete(token); + await saveSessions(); // Persist removal +} + +/** + * Create a short-lived WebSocket connection token + * Used for initial WebSocket handshake authentication + */ +export function createWsConnectionToken(): string { + const token = generateSessionToken(); + const now = Date.now(); + wsConnectionTokens.set(token, { + createdAt: now, + expiresAt: now + WS_TOKEN_MAX_AGE_MS, + }); + return token; +} + +/** + * Validate a WebSocket connection token + * These tokens are single-use and short-lived (5 minutes) + * Token is invalidated immediately after first successful use + */ +export function validateWsConnectionToken(token: string): boolean { + const tokenData = wsConnectionTokens.get(token); + if (!tokenData) return false; + + // Always delete the token (single-use) + wsConnectionTokens.delete(token); + + // Check if expired + if (Date.now() > tokenData.expiresAt) { + return false; + } + + return true; +} + +/** + * Validate the API key using timing-safe comparison + * Prevents timing attacks that could leak information about the key + */ +export function validateApiKey(key: string): boolean { + if (!key || typeof key !== 'string') return false; + + // Both buffers must be the same length for timingSafeEqual + const keyBuffer = Buffer.from(key); + const apiKeyBuffer = Buffer.from(API_KEY); + + // If lengths differ, compare against a dummy to maintain constant time + if (keyBuffer.length !== apiKeyBuffer.length) { + crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer); + return false; + } + + return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer); +} + +/** + * 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 result type + */ +type AuthResult = + | { authenticated: true } + | { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' }; + +/** + * Core authentication check - shared between middleware and status check + * Extracts auth credentials from various sources and validates them + */ +function checkAuthentication( + headers: Record, + query: Record, + cookies: Record +): AuthResult { + // Check for API key in header (Electron mode) + const headerKey = headers['x-api-key'] as string | undefined; + if (headerKey) { + if (validateApiKey(headerKey)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_api_key' }; + } + + // Check for session token in header (web mode with explicit token) + const sessionTokenHeader = headers['x-session-token'] as string | undefined; + if (sessionTokenHeader) { + if (validateSession(sessionTokenHeader)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_session' }; + } + + // Check for API key in query parameter (fallback) + const queryKey = query.apiKey; + if (queryKey) { + if (validateApiKey(queryKey)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_api_key' }; + } + + // Check for session cookie (web mode) + const sessionToken = cookies[SESSION_COOKIE_NAME]; + if (sessionToken && validateSession(sessionToken)) { + return { authenticated: true }; + } + + return { authenticated: false, errorType: 'no_auth' }; +} /** * 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) { + const result = checkAuthentication( + req.headers as Record, + req.query as Record, + (req.cookies || {}) as Record + ); + + if (result.authenticated) { 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; + // Return appropriate error based on what failed + switch (result.errorType) { + case 'invalid_api_key': + res.status(403).json({ + success: false, + error: 'Invalid API key.', + }); + break; + case 'invalid_session': + res.status(403).json({ + success: false, + error: 'Invalid or expired session token.', + }); + break; + case 'no_auth': + default: + res.status(401).json({ + success: false, + error: 'Authentication required.', + }); } - - if (providedKey !== API_KEY) { - res.status(403).json({ - success: false, - error: 'Invalid API key.', - }); - return; - } - - next(); } /** - * Check if authentication is enabled + * Check if authentication is enabled (always true now) */ export function isAuthEnabled(): boolean { - return !!API_KEY; + return true; } /** @@ -56,7 +380,31 @@ 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 { + const result = checkAuthentication( + req.headers as Record, + req.query as Record, + (req.cookies || {}) as Record + ); + return result.authenticated; +} + +/** + * Check if raw credentials are authenticated + * Used for WebSocket authentication where we don't have Express request objects + */ +export function checkRawAuthentication( + headers: Record, + query: Record, + cookies: Record +): boolean { + return checkAuthentication(headers, query, cookies).authenticated; +} diff --git a/apps/server/src/middleware/require-json-content-type.ts b/apps/server/src/middleware/require-json-content-type.ts new file mode 100644 index 00000000..ea02f480 --- /dev/null +++ b/apps/server/src/middleware/require-json-content-type.ts @@ -0,0 +1,50 @@ +/** + * Middleware to enforce Content-Type: application/json for request bodies + * + * This security middleware prevents malicious requests by requiring proper + * Content-Type headers for all POST, PUT, and PATCH requests. + * + * Rejecting requests without proper Content-Type helps prevent: + * - CSRF attacks via form submissions (which use application/x-www-form-urlencoded) + * - Content-type confusion attacks + * - Malformed request exploitation + */ + +import type { Request, Response, NextFunction } from 'express'; + +// HTTP methods that typically include request bodies +const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH']; + +/** + * Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests + * + * Returns 415 Unsupported Media Type if: + * - The request method is POST, PUT, or PATCH + * - AND the Content-Type header is missing or not application/json + * + * Allows requests to pass through if: + * - The request method is GET, DELETE, OPTIONS, HEAD, etc. + * - OR the Content-Type is properly set to application/json (with optional charset) + */ +export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void { + // Skip validation for methods that don't require a body + if (!METHODS_REQUIRING_JSON.includes(req.method)) { + next(); + return; + } + + const contentType = req.headers['content-type']; + + // Check if Content-Type header exists and contains application/json + // Allows for charset parameter: "application/json; charset=utf-8" + if (!contentType || !contentType.toLowerCase().includes('application/json')) { + res.status(415).json({ + success: false, + error: 'Unsupported Media Type', + message: 'Content-Type header must be application/json', + }); + return; + } + + next(); +} diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts new file mode 100644 index 00000000..575000a8 --- /dev/null +++ b/apps/server/src/routes/auth/index.ts @@ -0,0 +1,247 @@ +/** + * 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 type { Request } from 'express'; +import { + validateApiKey, + createSession, + invalidateSession, + getSessionCookieOptions, + getSessionCookieName, + isRequestAuthenticated, + createWsConnectionToken, +} from '../../lib/auth.js'; + +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window +const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window + +// Check if we're in test mode - disable rate limiting for E2E tests +const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true'; + +// In-memory rate limit tracking (resets on server restart) +const loginAttempts = new Map(); + +// Clean up old rate limit entries periodically (every 5 minutes) +setInterval( + () => { + const now = Date.now(); + loginAttempts.forEach((data, ip) => { + if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) { + loginAttempts.delete(ip); + } + }); + }, + 5 * 60 * 1000 +); + +/** + * Get client IP address from request + * Handles X-Forwarded-For header for reverse proxy setups + */ +function getClientIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + // X-Forwarded-For can be a comma-separated list; take the first (original client) + const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0]; + return forwardedIp.trim(); + } + return req.ip || req.socket.remoteAddress || 'unknown'; +} + +/** + * Check if an IP is rate limited + * Returns { limited: boolean, retryAfter?: number } + */ +function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } { + const now = Date.now(); + const attempt = loginAttempts.get(ip); + + if (!attempt) { + return { limited: false }; + } + + // Check if window has expired + if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) { + loginAttempts.delete(ip); + return { limited: false }; + } + + // Check if over limit + if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) { + const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000); + return { limited: true, retryAfter }; + } + + return { limited: false }; +} + +/** + * Record a login attempt for rate limiting + */ +function recordLoginAttempt(ip: string): void { + const now = Date.now(); + const attempt = loginAttempts.get(ip); + + if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) { + // Start new window + loginAttempts.set(ip, { count: 1, windowStart: now }); + } else { + // Increment existing window + attempt.count++; + } +} + +/** + * 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 } + * + * Rate limited to 5 attempts per minute per IP to prevent brute force attacks. + */ + router.post('/login', async (req, res) => { + const clientIp = getClientIp(req); + + // Skip rate limiting in test mode to allow parallel E2E tests + if (!isTestMode) { + // Check rate limit before processing + const rateLimit = checkRateLimit(clientIp); + if (rateLimit.limited) { + res.status(429).json({ + success: false, + error: 'Too many login attempts. Please try again later.', + retryAfter: rateLimit.retryAfter, + }); + return; + } + } + + const { apiKey } = req.body as { apiKey?: string }; + + if (!apiKey) { + res.status(400).json({ + success: false, + error: 'API key is required.', + }); + return; + } + + // Record this attempt (only for actual API key validation attempts, skip in test mode) + if (!isTestMode) { + recordLoginAttempt(clientIp); + } + + if (!validateApiKey(apiKey)) { + res.status(401).json({ + success: false, + error: 'Invalid API key.', + }); + return; + } + + // Create session and set cookie + const sessionToken = await 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 + * + * Generates a short-lived WebSocket connection token if the user has a valid session. + * This token is used for initial WebSocket handshake authentication and expires in 5 minutes. + * The token is NOT the session cookie value - it's a separate, short-lived token. + */ + router.get('/token', (req, res) => { + // Validate the session is still valid (via cookie, API key, or session token header) + if (!isRequestAuthenticated(req)) { + res.status(401).json({ + success: false, + error: 'Authentication required.', + }); + return; + } + + // Generate a new short-lived WebSocket connection token + const wsToken = createWsConnectionToken(); + + res.json({ + success: true, + token: wsToken, + expiresIn: 300, // 5 minutes in seconds + }); + }); + + /** + * POST /api/auth/logout + * + * Clears the session cookie and invalidates the session. + */ + router.post('/logout', async (req, res) => { + const cookieName = getSessionCookieName(); + const sessionToken = req.cookies?.[cookieName] as string | undefined; + + if (sessionToken) { + await 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/server/tests/unit/lib/auth.test.ts b/apps/server/tests/unit/lib/auth.test.ts index 91c1c461..70f50def 100644 --- a/apps/server/tests/unit/lib/auth.test.ts +++ b/apps/server/tests/unit/lib/auth.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createMockExpressContext } from '../../utils/mocks.js'; +import fs from 'fs'; +import path from 'path'; /** * Note: auth.ts reads AUTOMAKER_API_KEY at module load time. @@ -8,26 +10,13 @@ import { createMockExpressContext } from '../../utils/mocks.js'; describe('auth.ts', () => { beforeEach(() => { vi.resetModules(); + delete process.env.AUTOMAKER_API_KEY; + delete process.env.AUTOMAKER_HIDE_API_KEY; + delete process.env.NODE_ENV; }); - describe('authMiddleware - no API key', () => { - it('should call next() when no API key is set', async () => { - delete process.env.AUTOMAKER_API_KEY; - - const { authMiddleware } = await import('@/lib/auth.js'); - const { req, res, next } = createMockExpressContext(); - - authMiddleware(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - }); - - describe('authMiddleware - with API key', () => { - it('should reject request without API key header', async () => { - process.env.AUTOMAKER_API_KEY = 'test-secret-key'; - + describe('authMiddleware', () => { + it('should reject request without any authentication', async () => { const { authMiddleware } = await import('@/lib/auth.js'); const { req, res, next } = createMockExpressContext(); @@ -36,7 +25,7 @@ describe('auth.ts', () => { expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ success: false, - error: 'Authentication required. Provide X-API-Key header.', + error: 'Authentication required.', }); expect(next).not.toHaveBeenCalled(); }); @@ -70,46 +59,340 @@ describe('auth.ts', () => { expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); + + it('should authenticate with session token in header', async () => { + const { authMiddleware, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + const { req, res, next } = createMockExpressContext(); + req.headers['x-session-token'] = token; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should reject invalid session token in header', async () => { + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.headers['x-session-token'] = 'invalid-token'; + + authMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid or expired session token.', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should authenticate with API key in query parameter', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.query.apiKey = 'test-secret-key'; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should authenticate with session cookie', async () => { + const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js'); + const token = await createSession(); + const cookieName = getSessionCookieName(); + const { req, res, next } = createMockExpressContext(); + req.cookies = { [cookieName]: token }; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('createSession', () => { + it('should create a new session and return token', async () => { + const { createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + it('should create unique tokens for each session', async () => { + const { createSession } = await import('@/lib/auth.js'); + const token1 = await createSession(); + const token2 = await createSession(); + + expect(token1).not.toBe(token2); + }); + }); + + describe('validateSession', () => { + it('should validate a valid session token', async () => { + const { createSession, validateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(validateSession(token)).toBe(true); + }); + + it('should reject invalid session token', async () => { + const { validateSession } = await import('@/lib/auth.js'); + + expect(validateSession('invalid-token')).toBe(false); + }); + + it('should reject expired session token', async () => { + vi.useFakeTimers(); + const { createSession, validateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + // Advance time past session expiration (30 days) + vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000); + + expect(validateSession(token)).toBe(false); + vi.useRealTimers(); + }); + }); + + describe('invalidateSession', () => { + it('should invalidate a session token', async () => { + const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(validateSession(token)).toBe(true); + await invalidateSession(token); + expect(validateSession(token)).toBe(false); + }); + }); + + describe('createWsConnectionToken', () => { + it('should create a WebSocket connection token', async () => { + const { createWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + it('should create unique tokens', async () => { + const { createWsConnectionToken } = await import('@/lib/auth.js'); + const token1 = createWsConnectionToken(); + const token2 = createWsConnectionToken(); + + expect(token1).not.toBe(token2); + }); + }); + + describe('validateWsConnectionToken', () => { + it('should validate a valid WebSocket token', async () => { + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(validateWsConnectionToken(token)).toBe(true); + }); + + it('should reject invalid WebSocket token', async () => { + const { validateWsConnectionToken } = await import('@/lib/auth.js'); + + expect(validateWsConnectionToken('invalid-token')).toBe(false); + }); + + it('should reject expired WebSocket token', async () => { + vi.useFakeTimers(); + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + // Advance time past token expiration (5 minutes) + vi.advanceTimersByTime(6 * 60 * 1000); + + expect(validateWsConnectionToken(token)).toBe(false); + vi.useRealTimers(); + }); + + it('should invalidate token after first use (single-use)', async () => { + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(validateWsConnectionToken(token)).toBe(true); + // Token should be deleted after first use + expect(validateWsConnectionToken(token)).toBe(false); + }); + }); + + describe('validateApiKey', () => { + it('should validate correct API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('test-secret-key')).toBe(true); + }); + + it('should reject incorrect API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('wrong-key')).toBe(false); + }); + + it('should reject empty string', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('')).toBe(false); + }); + + it('should reject null/undefined', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey(null as any)).toBe(false); + expect(validateApiKey(undefined as any)).toBe(false); + }); + + it('should use timing-safe comparison for different lengths', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + // Key with different length should be rejected without timing leak + expect(validateApiKey('short')).toBe(false); + expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false); + }); + }); + + describe('getSessionCookieOptions', () => { + it('should return cookie options with httpOnly true', async () => { + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.httpOnly).toBe(true); + expect(options.sameSite).toBe('strict'); + expect(options.path).toBe('/'); + expect(options.maxAge).toBeGreaterThan(0); + }); + + it('should set secure to true in production', async () => { + process.env.NODE_ENV = 'production'; + + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.secure).toBe(true); + }); + + it('should set secure to false in non-production', async () => { + process.env.NODE_ENV = 'development'; + + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.secure).toBe(false); + }); + }); + + describe('getSessionCookieName', () => { + it('should return the session cookie name', async () => { + const { getSessionCookieName } = await import('@/lib/auth.js'); + const name = getSessionCookieName(); + + expect(name).toBe('automaker_session'); + }); + }); + + describe('isRequestAuthenticated', () => { + it('should return true for authenticated request with API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { isRequestAuthenticated } = await import('@/lib/auth.js'); + const { req } = createMockExpressContext(); + req.headers['x-api-key'] = 'test-secret-key'; + + expect(isRequestAuthenticated(req)).toBe(true); + }); + + it('should return false for unauthenticated request', async () => { + const { isRequestAuthenticated } = await import('@/lib/auth.js'); + const { req } = createMockExpressContext(); + + expect(isRequestAuthenticated(req)).toBe(false); + }); + + it('should return true for authenticated request with session token', async () => { + const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + const { req } = createMockExpressContext(); + req.headers['x-session-token'] = token; + + expect(isRequestAuthenticated(req)).toBe(true); + }); + }); + + describe('checkRawAuthentication', () => { + it('should return true for valid API key in headers', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true); + }); + + it('should return true for valid session token in headers', async () => { + const { checkRawAuthentication, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true); + }); + + it('should return true for valid API key in query', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true); + }); + + it('should return true for valid session cookie', async () => { + const { checkRawAuthentication, createSession, getSessionCookieName } = + await import('@/lib/auth.js'); + const token = await createSession(); + const cookieName = getSessionCookieName(); + + expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true); + }); + + it('should return false for invalid credentials', async () => { + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({}, {}, {})).toBe(false); + }); }); describe('isAuthEnabled', () => { - it('should return false when no API key is set', async () => { - delete process.env.AUTOMAKER_API_KEY; - - const { isAuthEnabled } = await import('@/lib/auth.js'); - expect(isAuthEnabled()).toBe(false); - }); - - it('should return true when API key is set', async () => { - process.env.AUTOMAKER_API_KEY = 'test-key'; - + it('should always return true (auth is always required)', async () => { const { isAuthEnabled } = await import('@/lib/auth.js'); expect(isAuthEnabled()).toBe(true); }); }); describe('getAuthStatus', () => { - it('should return disabled status when no API key', async () => { - delete process.env.AUTOMAKER_API_KEY; - - const { getAuthStatus } = await import('@/lib/auth.js'); - const status = getAuthStatus(); - - expect(status).toEqual({ - enabled: false, - method: 'none', - }); - }); - - it('should return enabled status when API key is set', async () => { - process.env.AUTOMAKER_API_KEY = 'test-key'; - + it('should return enabled status with api_key_or_session method', async () => { const { getAuthStatus } = await import('@/lib/auth.js'); const status = getAuthStatus(); expect(status).toEqual({ enabled: true, - method: 'api_key', + method: 'api_key_or_session', }); }); }); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 15abbcdc..586a737b 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -347,4 +347,386 @@ describe('agent-service.ts', () => { expect(fs.writeFile).toHaveBeenCalled(); }); }); + + describe('createSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should create a new session with metadata', async () => { + const session = await service.createSession('Test Session', '/test/project', '/test/dir'); + + expect(session.id).toBeDefined(); + expect(session.name).toBe('Test Session'); + expect(session.projectPath).toBe('/test/project'); + expect(session.workingDirectory).toBeDefined(); + expect(session.createdAt).toBeDefined(); + expect(session.updatedAt).toBeDefined(); + }); + + it('should use process.cwd() if no working directory provided', async () => { + const session = await service.createSession('Test Session'); + + expect(session.workingDirectory).toBeDefined(); + }); + + it('should validate working directory', async () => { + // Set ALLOWED_ROOT_DIRECTORY to restrict paths + const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY; + process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects'; + + // Re-import platform to initialize with new env var + vi.resetModules(); + const { initAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const { AgentService } = await import('@/services/agent-service.js'); + const testService = new AgentService('/test/data', mockEvents as any); + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await expect( + testService.createSession('Test Session', undefined, '/invalid/path') + ).rejects.toThrow(); + + // Restore original value + if (originalAllowedRoot) { + process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot; + } else { + delete process.env.ALLOWED_ROOT_DIRECTORY; + } + vi.resetModules(); + const { initAllowedPaths: reinit } = await import('@automaker/platform'); + reinit(); + }); + }); + + describe('setSessionModel', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should set model for existing session', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}'); + const result = await service.setSessionModel('session-1', 'claude-sonnet-4-20250514'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-20250514'); + + expect(result).toBe(false); + }); + }); + + describe('updateSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should update session metadata', async () => { + const result = await service.updateSession('session-1', { name: 'Updated Name' }); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('Updated Name'); + expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z'); + }); + + it('should return null for non-existent session', async () => { + const result = await service.updateSession('nonexistent', { name: 'Updated Name' }); + + expect(result).toBeNull(); + }); + }); + + describe('archiveSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should archive a session', async () => { + const result = await service.archiveSession('session-1'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.archiveSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('unarchiveSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + archived: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should unarchive a session', async () => { + const result = await service.unarchiveSession('session-1'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.unarchiveSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('deleteSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + }); + + it('should delete a session', async () => { + const result = await service.deleteSession('session-1'); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should return false for non-existent session', async () => { + const result = await service.deleteSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('listSessions', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session 1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + archived: false, + }, + 'session-2': { + id: 'session-2', + name: 'Test Session 2', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-03T00:00:00Z', + archived: true, + }, + }) + ); + }); + + it('should list non-archived sessions by default', async () => { + const sessions = await service.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe('session-1'); + }); + + it('should include archived sessions when requested', async () => { + const sessions = await service.listSessions(true); + + expect(sessions.length).toBe(2); + }); + + it('should sort sessions by updatedAt descending', async () => { + const sessions = await service.listSessions(true); + + expect(sessions[0].id).toBe('session-2'); + expect(sessions[1].id).toBe('session-1'); + }); + }); + + describe('addToQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should add prompt to queue', async () => { + const result = await service.addToQueue('session-1', { + message: 'Test prompt', + imagePaths: ['/test/image.png'], + model: 'claude-sonnet-4-20250514', + }); + + expect(result.success).toBe(true); + expect(result.queuedPrompt).toBeDefined(); + expect(result.queuedPrompt?.message).toBe('Test prompt'); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.addToQueue('nonexistent', { + message: 'Test prompt', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); + + describe('getQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should return queue for session', async () => { + await service.addToQueue('session-1', { message: 'Test prompt' }); + const result = service.getQueue('session-1'); + + expect(result.success).toBe(true); + expect(result.queue).toBeDefined(); + expect(result.queue?.length).toBe(1); + }); + + it('should return error for non-existent session', () => { + const result = service.getQueue('nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); + + describe('removeFromQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + + const addResult = await service.addToQueue('session-1', { message: 'Test prompt' }); + vi.clearAllMocks(); + }); + + it('should remove prompt from queue', async () => { + const queueResult = service.getQueue('session-1'); + const promptId = queueResult.queue![0].id; + + const result = await service.removeFromQueue('session-1', promptId); + + expect(result.success).toBe(true); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.removeFromQueue('nonexistent', 'prompt-id'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + + it('should return error for non-existent prompt', async () => { + const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Prompt not found in queue'); + }); + }); + + describe('clearQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + + await service.addToQueue('session-1', { message: 'Test prompt 1' }); + await service.addToQueue('session-1', { message: 'Test prompt 2' }); + vi.clearAllMocks(); + }); + + it('should clear all prompts from queue', async () => { + const result = await service.clearQueue('session-1'); + + expect(result.success).toBe(true); + const queueResult = service.getQueue('session-1'); + expect(queueResult.queue?.length).toBe(0); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.clearQueue('nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index d45a2797..b069e28c 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -28,7 +28,7 @@ "postinstall": "electron-builder install-app-deps", "preview": "vite preview", "lint": "npx eslint", - "pretest": "node scripts/setup-e2e-fixtures.mjs", + "pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs", "test": "playwright test", "test:headed": "playwright test --headed", "dev:electron:wsl": "cross-env vite", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index d9d7f1d5..80ba9af3 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -3,21 +3,24 @@ import { defineConfig, devices } from '@playwright/test'; const port = process.env.TEST_PORT || 3007; const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === 'true'; -const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true'; +// Always use mock agent for tests (disables rate limiting, uses mock Claude responses) +const mockAgent = true; export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: undefined, + retries: 0, + workers: 1, // Run sequentially to avoid auth conflicts with shared server reporter: 'html', timeout: 30000, use: { baseURL: `http://localhost:${port}`, - trace: 'on-first-retry', + trace: 'on-failure', screenshot: 'only-on-failure', }, + // Global setup - authenticate before each test + globalSetup: require.resolve('./tests/global-setup.ts'), projects: [ { name: 'chromium', @@ -29,16 +32,22 @@ export default defineConfig({ : { webServer: [ // Backend server - runs with mock agent enabled in CI + // Uses dev:test (no file watching) to avoid port conflicts from server restarts { - command: `cd ../server && npm run dev`, + command: `cd ../server && npm run dev:test`, url: `http://localhost:${serverPort}/api/health`, - reuseExistingServer: true, + // Don't reuse existing server to ensure we use the test API key + reuseExistingServer: false, timeout: 60000, env: { ...process.env, PORT: String(serverPort), // Enable mock agent in CI to avoid real API calls AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false', + // Set a test API key for web mode authentication + AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests', + // Hide the API key banner to reduce log noise + AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing }, }, @@ -51,8 +60,8 @@ export default defineConfig({ env: { ...process.env, VITE_SKIP_SETUP: 'true', - // Skip electron plugin in CI - no display available for Electron - VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined, + // Always skip electron plugin during tests - prevents duplicate server spawning + VITE_SKIP_ELECTRON: 'true', }, }, ], diff --git a/apps/ui/scripts/kill-test-servers.mjs b/apps/ui/scripts/kill-test-servers.mjs new file mode 100644 index 00000000..02121c74 --- /dev/null +++ b/apps/ui/scripts/kill-test-servers.mjs @@ -0,0 +1,44 @@ +/** + * Kill any existing servers on test ports before running tests + * This ensures the test server starts fresh with the correct API key + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008; +const UI_PORT = process.env.TEST_PORT || 3007; + +async function killProcessOnPort(port) { + try { + const { stdout } = await execAsync(`lsof -ti:${port}`); + const pids = stdout.trim().split('\n').filter(Boolean); + + if (pids.length > 0) { + console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`); + for (const pid of pids) { + try { + await execAsync(`kill -9 ${pid}`); + console.log(`[KillTestServers] Killed process ${pid}`); + } catch (error) { + // Process might have already exited + } + } + // Wait a moment for the port to be released + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } catch (error) { + // No process on port, which is fine + } +} + +async function main() { + console.log('[KillTestServers] Checking for existing test servers...'); + await killProcessOnPort(Number(SERVER_PORT)); + await killProcessOnPort(Number(UI_PORT)); + console.log('[KillTestServers] Done'); +} + +main().catch(console.error); 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..a30ca4ec --- /dev/null +++ b/apps/ui/src/components/views/login-view.tsx @@ -0,0 +1,110 @@ +/** + * 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" + data-testid="login-api-key-input" + /> +
+ + {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..1bbd9709 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,18 @@ 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) + // 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; // 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..f7991873 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, getSessionToken } from '@/lib/http-api-client'; // Font size constraints const MIN_FONT_SIZE = 8; @@ -485,6 +486,40 @@ export function TerminalPanel({ const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const wsUrl = serverUrl.replace(/^http/, 'ws'); + // Fetch a short-lived WebSocket token for secure authentication + const fetchWsToken = useCallback(async (): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + const response = await fetch(`${serverUrl}/api/auth/token`, { + headers, + credentials: 'include', + }); + + if (!response.ok) { + console.warn('[Terminal] Failed to fetch wsToken:', response.status); + return null; + } + + const data = await response.json(); + if (data.success && data.token) { + return data.token; + } + + return null; + } catch (error) { + console.error('[Terminal] Error fetching wsToken:', error); + return null; + } + }, [serverUrl]); + // Draggable - only the drag handle triggers drag const { attributes: dragAttributes, @@ -939,9 +974,24 @@ export function TerminalPanel({ const terminal = xtermRef.current; if (!terminal) return; - const connect = () => { - // Build WebSocket URL with token + const connect = async () => { + // 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)}`; + } else { + // In web mode, fetch a short-lived wsToken for secure authentication + const wsToken = await fetchWsToken(); + if (wsToken) { + url += `&wsToken=${encodeURIComponent(wsToken)}`; + } + // Cookies are also sent automatically with same-origin WebSocket + } + + // Add terminal password token if required if (authToken) { url += `&token=${encodeURIComponent(authToken)}`; } @@ -1154,7 +1204,7 @@ export function TerminalPanel({ wsRef.current = null; } }; - }, [sessionId, authToken, wsUrl, isTerminalReady]); + }, [sessionId, authToken, wsUrl, isTerminalReady, fetchWsToken]); // Handle resize with debouncing const handleResize = useCallback(() => { 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..b856bd51 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,12 +41,232 @@ 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) + * After login succeeds, verifies the session is actually working by making + * a request to an authenticated endpoint. + */ +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'); + + // Verify the session is actually working by making a request to an authenticated endpoint + const verified = await verifySession(); + if (!verified) { + console.error('[HTTP Client] Login appeared successful but session verification failed'); + return { + success: false, + error: 'Session verification failed. Please try again.', + }; + } + console.log('[HTTP Client] Login verified successfully'); + } + + return data; + } catch (error) { + console.error('[HTTP Client] Login failed:', error); + return { success: false, error: 'Network error' }; + } +}; + +/** + * Check if the session cookie is still valid by making a request to an authenticated endpoint. + * Note: This does NOT retrieve the session token - on page refresh we rely on cookies alone. + * The session token is only available after a fresh login. + */ +export const fetchSessionToken = async (): Promise => { + // On page refresh, we can't retrieve the session token (it's stored in HTTP-only cookie). + // We just verify the cookie is valid by checking auth status. + // The session token is only stored in memory after a fresh login. + try { + const response = await fetch(`${getServerUrl()}/api/auth/status`, { + credentials: 'include', // Send the session cookie + }); + + if (!response.ok) { + console.log('[HTTP Client] Failed to check auth status'); + return false; + } + + const data = await response.json(); + if (data.success && data.authenticated) { + console.log('[HTTP Client] Session cookie is valid'); + return true; + } + + console.log('[HTTP Client] Session cookie is not authenticated'); + return false; + } catch (error) { + console.error('[HTTP Client] Failed to check session:', 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 }; + } +}; + +/** + * Verify that the current session is still valid by making a request to an authenticated endpoint. + * If the session has expired or is invalid, clears the session and returns false. + * This should be called: + * 1. After login to verify the cookie was set correctly + * 2. On app load to verify the session hasn't expired + */ +export const verifySession = async (): Promise => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add session token header if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + // Make a request to an authenticated endpoint to verify the session + // We use /api/settings/status as it requires authentication and is lightweight + const response = await fetch(`${getServerUrl()}/api/settings/status`, { + headers, + credentials: 'include', + }); + + // Check for authentication errors + if (response.status === 401 || response.status === 403) { + console.warn('[HTTP Client] Session verification failed - session expired or invalid'); + // Clear the session since it's no longer valid + clearSessionToken(); + // Try to clear the cookie via logout (fire and forget) + fetch(`${getServerUrl()}/api/auth/logout`, { + method: 'POST', + credentials: 'include', + }).catch(() => {}); + return false; + } + + if (!response.ok) { + console.warn('[HTTP Client] Session verification failed with status:', response.status); + return false; + } + + console.log('[HTTP Client] Session verified successfully'); + return true; + } catch (error) { + console.error('[HTTP Client] Session verification error:', error); + return false; } - return null; }; type EventType = @@ -79,6 +299,44 @@ export class HttpApiClient implements ElectronAPI { this.connectWebSocket(); } + /** + * Fetch a short-lived WebSocket token from the server + * Used for secure WebSocket authentication without exposing session tokens in URLs + */ + private async fetchWsToken(): Promise { + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add session token header if available + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + + const response = await fetch(`${this.serverUrl}/api/auth/token`, { + headers, + credentials: 'include', + }); + + if (!response.ok) { + console.warn('[HttpApiClient] Failed to fetch wsToken:', response.status); + return null; + } + + const data = await response.json(); + if (data.success && data.token) { + return data.token; + } + + return null; + } catch (error) { + console.error('[HttpApiClient] Error fetching wsToken:', error); + return null; + } + } + private connectWebSocket(): void { if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) { return; @@ -86,8 +344,37 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; - try { + // In Electron mode, use API key directly + const apiKey = getApiKey(); + if (apiKey) { const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`); + return; + } + + // In web mode, fetch a short-lived wsToken first + this.fetchWsToken() + .then((wsToken) => { + const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; + if (wsToken) { + this.establishWebSocket(`${wsUrl}?wsToken=${encodeURIComponent(wsToken)}`); + } else { + // Fallback: try connecting without token (will fail if not authenticated) + console.warn('[HttpApiClient] No wsToken available, attempting connection anyway'); + this.establishWebSocket(wsUrl); + } + }) + .catch((error) => { + console.error('[HttpApiClient] Failed to prepare WebSocket connection:', error); + this.isConnecting = false; + }); + } + + /** + * Establish the actual WebSocket connection + */ + private establishWebSocket(wsUrl: string): void { + try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { @@ -155,10 +442,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 +463,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 +481,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 +491,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..23a4fa30 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; +import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; @@ -22,6 +23,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 +73,53 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Initialize authentication + // - Electron mode: Uses API key from IPC (header-based auth) + // - Web mode: Uses HTTP-only session cookie + 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, verify the session cookie is still valid + // by making a request to an authenticated endpoint + const isValid = await verifySession(); + + if (isValid) { + setIsAuthenticated(true); + setAuthChecked(true); + return; + } + + // Session is invalid or expired - redirect to login + console.log('Session invalid or expired - redirecting to login'); + setIsAuthenticated(false); + setAuthChecked(true); + + if (location.pathname !== '/login') { + navigate({ to: '/login' }); + } + } catch (error) { + console.error('Failed to initialize auth:', error); + setAuthChecked(true); + // On error, redirect to login to be safe + if (location.pathname !== '/login') { + navigate({ to: '/login' }); + } + } + }; + + initAuth(); + }, [location.pathname, navigate]); + // Wait for setup store hydration before enforcing routing rules useEffect(() => { if (useSetupStore.persist?.hasHydrated?.()) { @@ -147,8 +197,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/apps/ui/tests/agent/start-new-chat-session.spec.ts b/apps/ui/tests/agent/start-new-chat-session.spec.ts index add1444d..b4726879 100644 --- a/apps/ui/tests/agent/start-new-chat-session.spec.ts +++ b/apps/ui/tests/agent/start-new-chat-session.spec.ts @@ -16,6 +16,7 @@ import { clickNewSessionButton, waitForNewSession, countSessionItems, + authenticateForTests, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('agent-session-test'); @@ -61,6 +62,7 @@ test.describe('Agent Chat Session', () => { test('should start a new agent chat session', async ({ page }) => { await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await authenticateForTests(page); await page.goto('/'); await waitForNetworkIdle(page); diff --git a/apps/ui/tests/context/add-context-image.spec.ts b/apps/ui/tests/context/add-context-image.spec.ts index ce07ce65..bc40ec31 100644 --- a/apps/ui/tests/context/add-context-image.spec.ts +++ b/apps/ui/tests/context/add-context-image.spec.ts @@ -14,6 +14,7 @@ import { navigateToContext, waitForContextFile, waitForNetworkIdle, + authenticateForTests, } from '../utils'; test.describe('Add Context Image', () => { @@ -117,13 +118,26 @@ test.describe('Add Context Image', () => { test('should import an image file to context', async ({ page }) => { await setupProjectWithFixture(page, getFixturePath()); + await page.goto('/'); await waitForNetworkIdle(page); + // Check if we're on the login screen and authenticate if needed + const loginInput = page.locator('input[type="password"][placeholder*="API key"]'); + const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false); + if (isLoginScreen) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInput.fill(apiKey); + await page.locator('button:has-text("Login")').click(); + await page.waitForURL('**/', { timeout: 5000 }); + await waitForNetworkIdle(page); + } + await navigateToContext(page); - // Get the file input element and set the file + // Wait for the file input to be attached to the DOM before setting files const fileInput = page.locator('[data-testid="file-import-input"]'); + await expect(fileInput).toBeAttached({ timeout: 10000 }); // Use setInputFiles to upload the image await fileInput.setInputFiles(testImagePath); diff --git a/apps/ui/tests/context/context-file-management.spec.ts b/apps/ui/tests/context/context-file-management.spec.ts index cef8a212..670ed477 100644 --- a/apps/ui/tests/context/context-file-management.spec.ts +++ b/apps/ui/tests/context/context-file-management.spec.ts @@ -18,6 +18,7 @@ import { getByTestId, waitForNetworkIdle, getContextEditorContent, + authenticateForTests, } from '../utils'; test.describe('Context File Management', () => { @@ -31,6 +32,7 @@ test.describe('Context File Management', () => { test('should create a new markdown context file', async ({ page }) => { await setupProjectWithFixture(page, getFixturePath()); + await authenticateForTests(page); await page.goto('/'); await waitForNetworkIdle(page); diff --git a/apps/ui/tests/context/delete-context-file.spec.ts b/apps/ui/tests/context/delete-context-file.spec.ts index 4c4d8a53..db59c223 100644 --- a/apps/ui/tests/context/delete-context-file.spec.ts +++ b/apps/ui/tests/context/delete-context-file.spec.ts @@ -18,6 +18,7 @@ import { clickElement, fillInput, waitForNetworkIdle, + authenticateForTests, } from '../utils'; test.describe('Delete Context File', () => { @@ -33,6 +34,7 @@ test.describe('Delete Context File', () => { const fileName = 'to-delete.md'; await setupProjectWithFixture(page, getFixturePath()); + await authenticateForTests(page); await page.goto('/'); await waitForNetworkIdle(page); diff --git a/apps/ui/tests/features/add-feature-to-backlog.spec.ts b/apps/ui/tests/features/add-feature-to-backlog.spec.ts index 2231e0be..83cd7b32 100644 --- a/apps/ui/tests/features/add-feature-to-backlog.spec.ts +++ b/apps/ui/tests/features/add-feature-to-backlog.spec.ts @@ -15,6 +15,8 @@ import { clickAddFeature, fillAddFeatureDialog, confirmAddFeature, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('feature-backlog-test'); @@ -61,7 +63,11 @@ test.describe('Feature Backlog', () => { await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + // Authenticate before navigating + await authenticateForTests(page); await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/features/edit-feature.spec.ts b/apps/ui/tests/features/edit-feature.spec.ts index 6efd1763..8b1c9dca 100644 --- a/apps/ui/tests/features/edit-feature.spec.ts +++ b/apps/ui/tests/features/edit-feature.spec.ts @@ -16,6 +16,8 @@ import { fillAddFeatureDialog, confirmAddFeature, clickElement, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('edit-feature-test'); @@ -63,7 +65,10 @@ test.describe('Edit Feature', () => { await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await authenticateForTests(page); await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/features/feature-manual-review-flow.spec.ts b/apps/ui/tests/features/feature-manual-review-flow.spec.ts index 5b175b35..b28399dc 100644 --- a/apps/ui/tests/features/feature-manual-review-flow.spec.ts +++ b/apps/ui/tests/features/feature-manual-review-flow.spec.ts @@ -19,6 +19,8 @@ import { setupRealProject, waitForNetworkIdle, getKanbanColumn, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('manual-review-test'); @@ -83,7 +85,10 @@ test.describe('Feature Manual Review Flow', () => { test('should manually verify a feature in waiting_approval column', async ({ page }) => { await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await authenticateForTests(page); await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts b/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts index e6a668c6..68d4d8b6 100644 --- a/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts +++ b/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts @@ -19,6 +19,8 @@ import { fillAddFeatureDialog, confirmAddFeature, isSkipTestsBadgeVisible, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('skip-tests-toggle-test'); @@ -65,7 +67,10 @@ test.describe('Feature Skip Tests Badge', () => { await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await authenticateForTests(page); await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/git/worktree-integration.spec.ts b/apps/ui/tests/git/worktree-integration.spec.ts index 9af95638..b95755dd 100644 --- a/apps/ui/tests/git/worktree-integration.spec.ts +++ b/apps/ui/tests/git/worktree-integration.spec.ts @@ -13,6 +13,8 @@ import { createTempDirPath, setupProjectWithPath, waitForBoardView, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; const TEST_TEMP_DIR = createTempDirPath('worktree-tests'); @@ -47,7 +49,10 @@ test.describe('Worktree Integration', () => { test('should display worktree selector with main branch', async ({ page }) => { await setupProjectWithPath(page, testRepo.path); + await authenticateForTests(page); await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await waitForBoardView(page); diff --git a/apps/ui/tests/global-setup.ts b/apps/ui/tests/global-setup.ts new file mode 100644 index 00000000..5b09a1a0 --- /dev/null +++ b/apps/ui/tests/global-setup.ts @@ -0,0 +1,12 @@ +/** + * Global setup for all e2e tests + * This runs once before all tests start + */ + +async function globalSetup() { + // Note: Server killing is handled by the pretest script in package.json + // GlobalSetup runs AFTER webServer starts, so we can't kill the server here + console.log('[GlobalSetup] Setup complete'); +} + +export default globalSetup; diff --git a/apps/ui/tests/profiles/profiles-crud.spec.ts b/apps/ui/tests/profiles/profiles-crud.spec.ts index 743cdeb6..818d1827 100644 --- a/apps/ui/tests/profiles/profiles-crud.spec.ts +++ b/apps/ui/tests/profiles/profiles-crud.spec.ts @@ -14,12 +14,17 @@ import { saveProfile, waitForSuccessToast, countCustomProfiles, + authenticateForTests, + handleLoginScreenIfPresent, } from '../utils'; test.describe('AI Profiles', () => { test('should create a new profile', async ({ page }) => { await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await authenticateForTests(page); await page.goto('/'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await waitForNetworkIdle(page); await navigateToProfiles(page); diff --git a/apps/ui/tests/projects/new-project-creation.spec.ts b/apps/ui/tests/projects/new-project-creation.spec.ts index 3feff75c..142d7841 100644 --- a/apps/ui/tests/projects/new-project-creation.spec.ts +++ b/apps/ui/tests/projects/new-project-creation.spec.ts @@ -7,7 +7,13 @@ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils'; +import { + createTempDirPath, + cleanupTempDir, + setupWelcomeView, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; const TEST_TEMP_DIR = createTempDirPath('project-creation-test'); @@ -26,8 +32,10 @@ test.describe('Project Creation', () => { const projectName = `test-project-${Date.now()}`; await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + await authenticateForTests(page); await page.goto('/'); await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/projects/open-existing-project.spec.ts b/apps/ui/tests/projects/open-existing-project.spec.ts index c47cbcf7..c3acff36 100644 --- a/apps/ui/tests/projects/open-existing-project.spec.ts +++ b/apps/ui/tests/projects/open-existing-project.spec.ts @@ -11,7 +11,13 @@ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils'; +import { + createTempDirPath, + cleanupTempDir, + setupWelcomeView, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; // Create unique temp dir for this test run const TEST_TEMP_DIR = createTempDirPath('open-project-test'); @@ -74,8 +80,10 @@ test.describe('Open Project', () => { }); // Navigate to the app + await authenticateForTests(page); await page.goto('/'); await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); // Wait for welcome view to be visible await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); diff --git a/apps/ui/tests/utils/api/client.ts b/apps/ui/tests/utils/api/client.ts index d5b3f7f7..f713eff9 100644 --- a/apps/ui/tests/utils/api/client.ts +++ b/apps/ui/tests/utils/api/client.ts @@ -4,7 +4,7 @@ */ import { Page, APIResponse } from '@playwright/test'; -import { API_ENDPOINTS } from '../core/constants'; +import { API_BASE_URL, API_ENDPOINTS } from '../core/constants'; // ============================================================================ // Types @@ -270,3 +270,92 @@ export async function apiListBranches( ): Promise<{ response: APIResponse; data: ListBranchesResponse }> { return new WorktreeApiClient(page).listBranches(worktreePath); } + +// ============================================================================ +// Authentication Utilities +// ============================================================================ + +/** + * Authenticate with the server using an API key + * This sets a session cookie that will be used for subsequent requests + * Uses browser context to ensure cookies are properly set + */ +export async function authenticateWithApiKey(page: Page, apiKey: string): Promise { + try { + // Ensure we're on a page (needed for cookies to work) + const currentUrl = page.url(); + if (!currentUrl || currentUrl === 'about:blank') { + await page.goto('http://localhost:3007', { waitUntil: 'domcontentloaded' }); + } + + // Use browser context fetch to ensure cookies are set in the browser + const response = await page.evaluate( + async ({ url, apiKey }) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ apiKey }), + }); + const data = await res.json(); + return { success: data.success, token: data.token }; + }, + { url: `${API_BASE_URL}/api/auth/login`, apiKey } + ); + + if (response.success && response.token) { + // Manually set the cookie in the browser context + // The server sets a cookie named 'automaker_session' (see SESSION_COOKIE_NAME in auth.ts) + await page.context().addCookies([ + { + name: 'automaker_session', + value: response.token, + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); + + // Verify the session is working by polling auth status + // This replaces arbitrary timeout with actual condition check + let attempts = 0; + const maxAttempts = 10; + while (attempts < maxAttempts) { + const statusResponse = await page.evaluate( + async ({ url }) => { + const res = await fetch(url, { + credentials: 'include', + }); + return res.json(); + }, + { url: `${API_BASE_URL}/api/auth/status` } + ); + + if (statusResponse.authenticated === true) { + return true; + } + attempts++; + // Use a very short wait between polling attempts (this is acceptable for polling) + await page.waitForFunction(() => true, { timeout: 50 }); + } + + return false; + } + + return false; + } catch (error) { + console.error('Authentication error:', error); + return false; + } +} + +/** + * Authenticate using the API key from environment variable + * Falls back to a test default if AUTOMAKER_API_KEY is not set + */ +export async function authenticateForTests(page: Page): Promise { + // Use the API key from environment, or a test default + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + return authenticateWithApiKey(page, apiKey); +} diff --git a/apps/ui/tests/utils/core/interactions.ts b/apps/ui/tests/utils/core/interactions.ts index b1b6e971..f7604c57 100644 --- a/apps/ui/tests/utils/core/interactions.ts +++ b/apps/ui/tests/utils/core/interactions.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { Page, expect } from '@playwright/test'; import { getByTestId, getButtonByText } from './elements'; /** @@ -48,6 +48,72 @@ export async function pressShortcut(page: Page, key: string): Promise { await page.keyboard.press(key); } +/** + * Navigate to a URL with authentication + * This wrapper ensures authentication happens before navigation + */ +export async function gotoWithAuth(page: Page, url: string): Promise { + const { authenticateForTests } = await import('../api/client'); + await authenticateForTests(page); + await page.goto(url); +} + +/** + * Handle login screen if it appears after navigation + * Returns true if login was handled, false if no login screen was found + */ +export async function handleLoginScreenIfPresent(page: Page): Promise { + // Check for login screen by waiting for either login input or app-container to be visible + // Use data-testid selector (preferred) with fallback to the old selector + const loginInput = page + .locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]') + .first(); + const appContent = page.locator( + '[data-testid="welcome-view"], [data-testid="board-view"], [data-testid="context-view"], [data-testid="agent-view"]' + ); + + // Race between login screen and actual content + const loginVisible = await Promise.race([ + loginInput + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false), + appContent + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => false) + .catch(() => false), + ]); + + if (loginVisible) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInput.fill(apiKey); + + // Wait a moment for the button to become enabled + await page.waitForTimeout(100); + + // Wait for button to be enabled (it's disabled when input is empty) + const loginButton = page + .locator('[data-testid="login-submit-button"], button:has-text("Login")') + .first(); + await expect(loginButton).toBeEnabled({ timeout: 5000 }); + await loginButton.click(); + + // Wait for navigation away from login - either to content or URL change + await Promise.race([ + page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10000 }), + appContent.first().waitFor({ state: 'visible', timeout: 10000 }), + ]).catch(() => {}); + + // Wait for page to load + await page.waitForLoadState('load'); + + return true; + } + + return false; +} + /** * Press a number key (0-9) on the keyboard */ diff --git a/apps/ui/tests/utils/navigation/views.ts b/apps/ui/tests/utils/navigation/views.ts index 1abe0bb6..5713b309 100644 --- a/apps/ui/tests/utils/navigation/views.ts +++ b/apps/ui/tests/utils/navigation/views.ts @@ -1,16 +1,37 @@ import { Page } from '@playwright/test'; import { clickElement } from '../core/interactions'; import { waitForElement } from '../core/waiting'; +import { authenticateForTests } from '../api/client'; /** * Navigate to the board/kanban view * Note: Navigates directly to /board since index route shows WelcomeView */ export async function navigateToBoard(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + // Navigate directly to /board route await page.goto('/board'); await page.waitForLoadState('load'); + // Check if we're on the login screen and handle it + const loginInput = page + .locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]') + .first(); + const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false); + if (isLoginScreen) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInput.fill(apiKey); + await page.waitForTimeout(100); + await page + .locator('[data-testid="login-submit-button"], button:has-text("Login")') + .first() + .click(); + await page.waitForURL('**/board', { timeout: 5000 }); + await page.waitForLoadState('load'); + } + // Wait for the board view to be visible await waitForElement(page, 'board-view', { timeout: 10000 }); } @@ -20,10 +41,30 @@ export async function navigateToBoard(page: Page): Promise { * Note: Navigates directly to /context since index route shows WelcomeView */ export async function navigateToContext(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + // Navigate directly to /context route await page.goto('/context'); await page.waitForLoadState('load'); + // Check if we're on the login screen and handle it + const loginInputCtx = page + .locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]') + .first(); + const isLoginScreenCtx = await loginInputCtx.isVisible({ timeout: 2000 }).catch(() => false); + if (isLoginScreenCtx) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInputCtx.fill(apiKey); + await page.waitForTimeout(100); + await page + .locator('[data-testid="login-submit-button"], button:has-text("Login")') + .first() + .click(); + await page.waitForURL('**/context', { timeout: 5000 }); + await page.waitForLoadState('load'); + } + // Wait for loading to complete (if present) const loadingElement = page.locator('[data-testid="context-view-loading"]'); try { @@ -37,7 +78,8 @@ export async function navigateToContext(page: Page): Promise { } // Wait for the context view to be visible - await waitForElement(page, 'context-view', { timeout: 10000 }); + // Increase timeout to handle slower server startup + await waitForElement(page, 'context-view', { timeout: 15000 }); } /** @@ -45,6 +87,9 @@ export async function navigateToContext(page: Page): Promise { * Note: Navigates directly to /spec since index route shows WelcomeView */ export async function navigateToSpec(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + // Navigate directly to /spec route await page.goto('/spec'); await page.waitForLoadState('load'); @@ -75,10 +120,30 @@ export async function navigateToSpec(page: Page): Promise { * Note: Navigates directly to /agent since index route shows WelcomeView */ export async function navigateToAgent(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + // Navigate directly to /agent route await page.goto('/agent'); await page.waitForLoadState('load'); + // Check if we're on the login screen and handle it + const loginInputAgent = page + .locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]') + .first(); + const isLoginScreenAgent = await loginInputAgent.isVisible({ timeout: 2000 }).catch(() => false); + if (isLoginScreenAgent) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInputAgent.fill(apiKey); + await page.waitForTimeout(100); + await page + .locator('[data-testid="login-submit-button"], button:has-text("Login")') + .first() + .click(); + await page.waitForURL('**/agent', { timeout: 5000 }); + await page.waitForLoadState('load'); + } + // Wait for the agent view to be visible await waitForElement(page, 'agent-view', { timeout: 10000 }); } @@ -88,6 +153,9 @@ export async function navigateToAgent(page: Page): Promise { * Note: Navigates directly to /settings since index route shows WelcomeView */ export async function navigateToSettings(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + // Navigate directly to /settings route await page.goto('/settings'); await page.waitForLoadState('load'); @@ -113,8 +181,31 @@ export async function navigateToSetup(page: Page): Promise { * Navigate to the welcome view (clear project selection) */ export async function navigateToWelcome(page: Page): Promise { + // Authenticate before navigating + await authenticateForTests(page); + await page.goto('/'); await page.waitForLoadState('load'); + + // Check if we're on the login screen and handle it + const loginInputWelcome = page + .locator('[data-testid="login-api-key-input"], input[type="password"][placeholder*="API key"]') + .first(); + const isLoginScreenWelcome = await loginInputWelcome + .isVisible({ timeout: 2000 }) + .catch(() => false); + if (isLoginScreenWelcome) { + const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; + await loginInputWelcome.fill(apiKey); + await page.waitForTimeout(100); + await page + .locator('[data-testid="login-submit-button"], button:has-text("Login")') + .first() + .click(); + await page.waitForURL('**/', { timeout: 5000 }); + await page.waitForLoadState('load'); + } + await waitForElement(page, 'welcome-view', { timeout: 10000 }); } diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 99d7a5dd..611ff588 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -8,3 +8,4 @@ services: # Set root directory for all projects and file operations # Users can only create/open projects within this directory - ALLOWED_ROOT_DIRECTORY=/projects + - NODE_ENV=development 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/docs/plans/2025-12-29-api-security-hardening-design.md b/docs/plans/2025-12-29-api-security-hardening-design.md new file mode 100644 index 00000000..54c0ca67 --- /dev/null +++ b/docs/plans/2025-12-29-api-security-hardening-design.md @@ -0,0 +1,94 @@ +# API Security Hardening Design + +**Date:** 2025-12-29 +**Branch:** protect-api-with-api-key +**Status:** Approved + +## Overview + +Security improvements for the API authentication system before merging the PR. These changes harden the existing implementation for production deployment scenarios (local, Docker, internet-exposed). + +## Fixes to Implement + +### 1. Use Short-Lived wsToken for WebSocket Authentication + +**Problem:** The client currently passes `sessionToken` in WebSocket URL query parameters. Query params get logged and can leak credentials. + +**Solution:** Update the client to: + +1. Fetch a wsToken from `/api/auth/token` before each WebSocket connection +2. Use `wsToken` query param instead of `sessionToken` +3. Never put session tokens in URLs + +**Files to modify:** + +- `apps/ui/src/lib/http-api-client.ts` - Update `connectWebSocket()` to fetch wsToken first + +--- + +### 2. Add Environment Variable to Hide API Key from Logs + +**Problem:** The API key is printed to console on startup, which gets captured by logging systems in production. + +**Solution:** Add `AUTOMAKER_HIDE_API_KEY=true` env var to suppress the banner. + +**Files to modify:** + +- `apps/server/src/lib/auth.ts` - Wrap console.log banner in env var check + +--- + +### 3. Add Rate Limiting to Login Endpoint + +**Problem:** No brute force protection on `/api/auth/login`. Attackers could attempt many API keys. + +**Solution:** Add basic in-memory rate limiting: + +- ~5 attempts per minute per IP +- In-memory Map tracking (resets on server restart) +- Return 429 Too Many Requests when exceeded + +**Files to modify:** + +- `apps/server/src/routes/auth/index.ts` - Add rate limiting logic to login handler + +--- + +### 4. Use Timing-Safe Comparison for API Key + +**Problem:** Using `===` for API key comparison is vulnerable to timing attacks. + +**Solution:** Use `crypto.timingSafeEqual()` for constant-time comparison. + +**Files to modify:** + +- `apps/server/src/lib/auth.ts` - Update `validateApiKey()` function + +--- + +### 5. Make WebSocket Tokens Single-Use + +**Problem:** wsTokens can be reused within the 5-minute window. If intercepted, attackers have time to use them. + +**Solution:** Delete the token after first successful validation. + +**Files to modify:** + +- `apps/server/src/lib/auth.ts` - Update `validateWsConnectionToken()` to delete after use + +--- + +## Implementation Order + +1. Fix #4 (timing-safe comparison) - Simple, isolated change +2. Fix #5 (single-use wsToken) - Simple, isolated change +3. Fix #2 (hide API key env var) - Simple, isolated change +4. Fix #3 (rate limiting) - Moderate complexity +5. Fix #1 (client wsToken usage) - Requires coordination with server + +## Testing Notes + +- Test login with rate limiting (verify 429 after 5 attempts) +- Test WebSocket connection with new wsToken flow +- Test wsToken is invalidated after first use +- Verify `AUTOMAKER_HIDE_API_KEY=true` suppresses banner 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..d8190a03 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,8 @@ "ws": "^8.18.3" }, "devDependencies": { + "@types/cookie": "^0.6.0", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/morgan": "^1.9.10", @@ -452,7 +455,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 +1038,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 +1080,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", @@ -1900,6 +1900,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1921,6 +1922,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1937,6 +1939,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1951,6 +1954,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2718,6 +2722,7 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -2842,6 +2847,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2858,6 +2864,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2874,6 +2881,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2982,6 +2990,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3004,6 +3013,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3026,6 +3036,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3111,6 +3122,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 +3145,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3152,6 +3165,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3551,7 +3565,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 +3580,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3581,6 +3597,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3597,6 +3614,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3613,6 +3631,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3629,6 +3648,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3645,6 +3665,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3661,6 +3682,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3677,6 +3699,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -3767,7 +3790,6 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5208,6 +5230,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 +5564,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 +5870,23 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "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 +6132,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 +6142,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6210,7 +6247,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 +6740,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 +6838,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6864,7 +6898,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 +7496,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7995,7 +8027,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 +8261,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 +8333,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 +8431,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 +8732,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 +9058,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9027,6 +9079,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9277,7 +9330,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 +9644,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 +11311,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11321,6 +11373,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13748,6 +13801,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13764,6 +13818,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13781,6 +13836,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13969,7 +14025,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 +14034,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 +14392,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14526,7 +14581,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 +14629,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14625,6 +14680,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14647,6 +14703,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14669,6 +14726,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14685,6 +14743,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14701,6 +14760,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14717,6 +14777,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14733,6 +14794,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14749,6 +14811,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14765,6 +14828,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14781,6 +14845,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14803,6 +14868,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14825,6 +14891,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14847,6 +14914,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14869,6 +14937,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14891,6 +14960,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15359,6 +15429,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 +15599,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15591,6 +15663,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15688,7 +15761,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15893,7 +15965,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16265,7 +16336,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16355,8 +16425,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 +16451,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16425,7 +16493,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -16683,7 +16750,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16752,7 +16818,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",