Merge pull request #321 from AutoMaker-Org/protect-api-with-api-key

adding more security to api endpoints to require api token for all ac…
This commit is contained in:
Web Dev Cody
2025-12-30 00:42:46 -05:00
committed by GitHub
51 changed files with 2952 additions and 249 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<string, string | undefined> = {};
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<string, string | string[] | undefined>,
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);

View File

@@ -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<string, { createdAt: number; expiresAt: number }>();
// Short-lived WebSocket connection tokens (in-memory only, not persisted)
const wsConnectionTokens = new Map<string, { createdAt: number; expiresAt: number }>();
// 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<void> {
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<string> {
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<void> {
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<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): 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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
(req.cookies || {}) as Record<string, string | undefined>
);
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<string, string | string[] | undefined>,
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

@@ -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();
}

View File

@@ -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<string, { count: number; windowStart: number }>();
// 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;
}

View File

@@ -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';

View File

@@ -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<string> {
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<string> {
private executeClaudeUsageCommandPty(): Promise<string> {
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,

View File

@@ -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',
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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",

View File

@@ -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',
},
},
],

View File

@@ -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);

View File

@@ -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<UsageError | null>(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) => {

View File

@@ -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<BrowseResult>('/api/fs/browse', { dirPath });
if (result.success) {
setCurrentPath(result.currentPath);

View File

@@ -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 (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter the API key shown in the server console to continue.
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
autoFocus
className="font-mono"
data-testid="login-api-key-input"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || !apiKey.trim()}
data-testid="login-submit-button"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Login'
)}
</Button>
</form>
{/* Help Text */}
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
<p className="font-medium">Where to find the API key:</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
<li>Look at the server terminal/console output</li>
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
<li>Copy the UUID displayed there</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -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 => {

View File

@@ -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<string, string> = {
'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<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
// 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<boolean> => {
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<string | null> => {
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<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
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<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
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);
}

View File

@@ -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<string | null> => {
try {
const headers: Record<string, string> = {
'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(() => {

View File

@@ -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<RequestInit, 'method' | 'headers' | 'body'> {
/** Additional headers to include (merged with auth headers) */
headers?: Record<string, string>;
/** 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<string, string>): Record<string, string> {
const headers: Record<string, string> = {
'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<Response> {
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<T>(
endpoint: string,
options: Omit<ApiFetchOptions, 'body'> = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'GET', options);
return response.json();
}
/**
* Make an authenticated POST request
*/
export async function apiPost<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'POST', { ...options, body });
return response.json();
}
/**
* Make an authenticated PUT request
*/
export async function apiPut<T>(
endpoint: string,
body?: unknown,
options: ApiFetchOptions = {}
): Promise<T> {
const response = await apiFetch(endpoint, 'PUT', { ...options, body });
return response.json();
}
/**
* Make an authenticated DELETE request
*/
export async function apiDelete<T>(endpoint: string, options: ApiFetchOptions = {}): Promise<T> {
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<Response> {
return apiFetch(endpoint, 'DELETE', options);
}

View File

@@ -431,6 +431,7 @@ export interface SaveImageResult {
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>;

View File

@@ -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<void> => {
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<boolean> => {
// 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<boolean> => {
try {
const headers: Record<string, string> = {
'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<string | null> {
try {
const headers: Record<string, string> = {
'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<string, string> = {
'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<T>(endpoint: string): Promise<T> {
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();
}

View File

@@ -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<typeof setTimeout> | 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<void> {
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) => {

View File

@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Get server URL for HTTP client
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
// Get API key for authentication
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
// Native dialogs - better UX than prompt()
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
ipcRenderer.invoke('dialog:openDirectory'),

View File

@@ -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 (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
</main>
);
}
// Wait for auth check before rendering protected routes (web mode only)
if (!isElectronMode() && !authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Loading...</div>
</main>
);
}
// Redirect to login if not authenticated (web mode)
if (!isElectronMode() && !isAuthenticated) {
return null; // Will redirect via useEffect
}
if (isSetupRoute) {
return (

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { LoginView } from '@/components/views/login-view';
export const Route = createFileRoute('/login')({
component: LoginView,
});

View File

@@ -176,6 +176,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup,
claudeAuthStatus: state.claudeAuthStatus,
}),
}
)

View File

@@ -464,6 +464,7 @@ export interface AutoModeAPI {
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
// Dialog APIs

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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<boolean> {
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<boolean> {
// 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);
}

View File

@@ -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<void> {
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<void> {
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<boolean> {
// 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
*/

View File

@@ -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<void> {
// 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<void> {
* Note: Navigates directly to /context since index route shows WelcomeView
*/
export async function navigateToContext(page: Page): Promise<void> {
// 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<void> {
}
// 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<void> {
* Note: Navigates directly to /spec since index route shows WelcomeView
*/
export async function navigateToSpec(page: Page): Promise<void> {
// 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<void> {
* Note: Navigates directly to /agent since index route shows WelcomeView
*/
export async function navigateToAgent(page: Page): Promise<void> {
// 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<void> {
* Note: Navigates directly to /settings since index route shows WelcomeView
*/
export async function navigateToSettings(page: Page): Promise<void> {
// 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<void> {
* Navigate to the welcome view (clear project selection)
*/
export async function navigateToWelcome(page: Page): Promise<void> {
// 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 });
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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');

129
package-lock.json generated
View File

@@ -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"
}

View File

@@ -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",