Merge branch 'main' into fix/sandbox-cloud-storage-compatibility

This commit is contained in:
webdevcody
2026-01-01 02:23:10 -05:00
194 changed files with 15306 additions and 4248 deletions

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';
@@ -50,6 +54,8 @@ import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
@@ -89,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
@@ -103,13 +109,47 @@ if (ENABLE_REQUEST_LOGGING) {
})
);
}
// 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 || '*',
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:') ||
origin.startsWith('http://[::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();
@@ -121,6 +161,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
const claudeUsageService = new ClaudeUsageService();
const mcpTestService = new MCPTestService(settingsService);
// Initialize services
(async () => {
@@ -137,18 +178,26 @@ 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));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes());
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes());
@@ -164,6 +213,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
// Create HTTP server
@@ -174,10 +224,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 path from 'path';
import * as secureFs from './secure-fs.js';
// 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 (secureFs.existsSync(SESSIONS_FILE)) {
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
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 secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
await secureFs.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 (secureFs.existsSync(API_KEY_FILE)) {
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).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 {
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
secureFs.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

@@ -18,7 +18,7 @@
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/**
@@ -231,6 +231,53 @@ function getBaseOptions(): Partial<Options> {
};
}
/**
* MCP permission options result
*/
interface McpPermissionOptions {
/** Whether tools should be restricted to a preset */
shouldRestrictTools: boolean;
/** Options to spread when MCP bypass is enabled */
bypassOptions: Partial<Options>;
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* Build MCP-related options based on configuration.
* Centralizes the logic for determining permission modes and tool restrictions
* when MCP servers are configured.
*
* @param config - The SDK options config
* @returns Object with MCP permission settings to spread into final options
*/
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
// Determine if we should bypass permissions based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
return {
shouldRestrictTools,
// Only include bypass options when MCP is configured and auto-approve is enabled
bypassOptions: shouldBypassPermissions
? {
permissionMode: 'bypassPermissions' as const,
// Required flag when using bypassPermissions mode
allowDangerouslySkipPermissions: true,
}
: {},
// Include MCP servers if configured
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
};
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
@@ -314,8 +361,25 @@ export interface CreateSdkOptionsConfig {
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Auto-approve MCP tool calls without permission prompts */
mcpAutoApproveTools?: boolean;
/** Allow unrestricted tools when MCP servers are enabled */
mcpUnrestrictedTools?: boolean;
}
// Re-export MCP types from @automaker/types for convenience
export type {
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from '@automaker/types';
/**
* Create SDK options for spec generation
*
@@ -425,6 +489,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
@@ -433,7 +500,10 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
@@ -442,6 +512,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
}),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}
@@ -462,6 +533,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
@@ -470,7 +544,10 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
@@ -479,6 +556,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
}),
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}
@@ -501,14 +579,27 @@ export function createCustomOptions(
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
: mcpOptions.shouldRestrictTools
? [...TOOL_PRESETS.readOnly]
: undefined;
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
...(config.sandbox && { sandbox: config.sandbox }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...claudeMdOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}

View File

@@ -6,6 +6,7 @@
import { secureFs } from '@automaker/platform';
export const {
// Async methods
access,
readFile,
writeFile,
@@ -20,6 +21,16 @@ export const {
lstat,
joinPath,
resolvePath,
// Sync methods
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
accessSync,
unlinkSync,
rmSync,
// Throttling configuration and monitoring
configureThrottling,
getThrottlingConfig,

View File

@@ -4,6 +4,16 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
mergeBacklogPlanPrompts,
mergeEnhancementPrompts,
} from '@automaker/prompts';
const logger = createLogger('SettingsHelper');
/**
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
@@ -20,7 +30,7 @@ export async function getAutoLoadClaudeMdSetting(
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd disabled`);
return false;
}
@@ -28,7 +38,7 @@ export async function getAutoLoadClaudeMdSetting(
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.autoLoadClaudeMd !== undefined) {
console.log(
logger.info(
`${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}`
);
return projectSettings.autoLoadClaudeMd;
@@ -37,10 +47,10 @@ export async function getAutoLoadClaudeMdSetting(
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.autoLoadClaudeMd ?? false;
console.log(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
logger.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error);
throw error;
}
}
@@ -58,17 +68,17 @@ export async function getEnableSandboxModeSetting(
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
console.log(`${logPrefix} SettingsService not available, sandbox mode disabled`);
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? true;
console.log(`${logPrefix} enableSandboxMode from global settings: ${result}`);
const result = globalSettings.enableSandboxMode ?? false;
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
console.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
@@ -136,3 +146,161 @@ function formatContextFileEntry(file: ContextFileInfo): string {
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}
/**
* Get enabled MCP servers from global settings, converted to SDK format.
* Returns an empty object if settings service is not available or no servers are configured.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
*/
export async function getMCPServersFromSettings(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<Record<string, McpServerConfig>> {
if (!settingsService) {
return {};
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const mcpServers = globalSettings.mcpServers || [];
// Filter to only enabled servers and convert to SDK format
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
if (enabledServers.length === 0) {
return {};
}
// Convert settings format to SDK format (keyed by name)
const sdkServers: Record<string, McpServerConfig> = {};
for (const server of enabledServers) {
sdkServers[server.name] = convertToSdkFormat(server);
}
logger.info(
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
);
return sdkServers;
} catch (error) {
logger.error(`${logPrefix} Failed to load MCP servers setting:`, error);
return {};
}
}
/**
* Get MCP permission settings from global settings.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to MCP permission settings
*/
export async function getMCPPermissionSettings(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
if (!settingsService) {
return defaults;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = {
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
};
logger.info(
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
return defaults;
}
}
/**
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
* Validates required fields and throws informative errors if missing.
*/
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
if (server.type === 'sse') {
if (!server.url) {
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
}
return {
type: 'sse',
url: server.url,
headers: server.headers,
};
}
if (server.type === 'http') {
if (!server.url) {
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
}
return {
type: 'http',
url: server.url,
headers: server.headers,
};
}
// Default to stdio
if (!server.command) {
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
}
return {
type: 'stdio',
command: server.command,
args: server.args,
env: server.env,
};
}
/**
* Get prompt customization from global settings and merge with defaults.
* Returns prompts merged with built-in defaults - custom prompts override defaults.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages
* @returns Promise resolving to merged prompts for all categories
*/
export async function getPromptCustomization(
settingsService?: SettingsService | null,
logPrefix = '[PromptHelper]'
): Promise<{
autoMode: ReturnType<typeof mergeAutoModePrompts>;
agent: ReturnType<typeof mergeAgentPrompts>;
backlogPlan: ReturnType<typeof mergeBacklogPlanPrompts>;
enhancement: ReturnType<typeof mergeEnhancementPrompts>;
}> {
let customization: PromptCustomization = {};
if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
customization = globalSettings.promptCustomization || {};
logger.info(`${logPrefix} Loaded prompt customization from settings`);
} catch (error) {
logger.error(`${logPrefix} Failed to load prompt customization:`, error);
// Fall through to use empty customization (all defaults)
}
} else {
logger.info(`${logPrefix} SettingsService not available, using default prompts`);
}
return {
autoMode: mergeAutoModePrompts(customization.autoMode),
agent: mergeAgentPrompts(customization.agent),
backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan),
enhancement: mergeEnhancementPrompts(customization.enhancement),
};
}

View File

@@ -0,0 +1,33 @@
/**
* Version utility - Reads version from package.json
*/
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let cachedVersion: string | null = null;
/**
* Get the version from package.json
* Caches the result for performance
*/
export function getVersion(): string {
if (cachedVersion) {
return cachedVersion;
}
try {
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version || '0.0.0';
cachedVersion = version;
return version;
} catch (error) {
console.warn('Failed to read version from package.json:', error);
return '0.0.0';
}
}

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

@@ -7,6 +7,7 @@
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
import type {
ExecuteOptions,
ProviderMessage,
@@ -14,6 +15,32 @@ import type {
ModelDefinition,
} from './types.js';
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
'ANTHROPIC_API_KEY',
'PATH',
'HOME',
'SHELL',
'TERM',
'USER',
'LANG',
'LC_ALL',
];
/**
* Build environment for the SDK with only explicitly allowed variables
*/
function buildEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
export class ClaudeProvider extends BaseProvider {
getName(): string {
return 'claude';
@@ -36,16 +63,35 @@ export class ClaudeProvider extends BaseProvider {
} = options;
// Build Claude SDK options
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
// the provider is the final point where SDK options are constructed.
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
const toolsToUse = allowedTools || defaultTools;
// Determine permission mode based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
const sdkOptions: Options = {
model,
systemPrompt,
maxTurns,
cwd,
allowedTools: toolsToUse,
permissionMode: 'default',
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
...(allowedTools && shouldRestrictTools && { allowedTools }),
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
// Required when using bypassPermissions mode
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
abortController,
// Resume existing SDK session if we have a session ID
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
@@ -55,6 +101,8 @@ export class ClaudeProvider extends BaseProvider {
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
// Forward MCP servers configuration
...(options.mcpServers && { mcpServers: options.mcpServers }),
};
// Build prompt payload
@@ -88,9 +136,32 @@ export class ClaudeProvider extends BaseProvider {
yield msg as ProviderMessage;
}
} catch (error) {
console.error('[ClaudeProvider] ERROR: executeQuery() error during execution:', error);
console.error('[ClaudeProvider] ERROR stack:', (error as Error).stack);
throw error;
// Enhance error with user-friendly message and classification
const errorInfo = classifyError(error);
const userMessage = getUserFriendlyErrorMessage(error);
console.error('[ClaudeProvider] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: (error as Error).stack,
});
// Build enhanced error message with additional guidance for rate limits
const message = errorInfo.isRateLimit
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
: userMessage;
const enhancedError = new Error(message);
(enhancedError as any).originalError = error;
(enhancedError as any).type = errorInfo.type;
if (errorInfo.isRateLimit) {
(enhancedError as any).retryAfter = errorInfo.retryAfter;
}
throw enhancedError;
}
}

View File

@@ -1,41 +1,19 @@
/**
* Shared types for AI model providers
*
* Re-exports types from @automaker/types for consistency across the codebase.
*/
/**
* Configuration for a provider instance
*/
export interface ProviderConfig {
apiKey?: string;
cliPath?: string;
env?: Record<string, string>;
}
/**
* Message in conversation history
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string | Array<{ type: string; text?: string; source?: object }>;
}
/**
* Options for executing a query via a provider
*/
export interface ExecuteOptions {
prompt: string | Array<{ type: string; text?: string; source?: object }>;
model: string;
cwd: string;
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, unknown>;
abortController?: AbortController;
conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
}
// Re-export all provider types from @automaker/types
export type {
ProviderConfig,
ConversationMessage,
ExecuteOptions,
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from '@automaker/types';
/**
* Content block in a provider message (matches Claude SDK format)

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

@@ -8,7 +8,7 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { logger, setRunningState, getErrorMessage } from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -79,72 +79,17 @@ export async function generateBacklogPlan(
content: `Loaded ${features.length} features from backlog`,
});
// Load prompts from settings
const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]');
// Build the system prompt
const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog.
You will be given the current list of features and a user request to modify the backlog.
const systemPrompt = prompts.backlogPlan.systemPrompt;
IMPORTANT CONTEXT (automatically injected):
- Remember to update the dependency graph if deleting existing features
- Remember to define dependencies on new features hooked into relevant existing ones
- Maintain dependency graph integrity (no orphaned dependencies)
- When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with:
1. Features to ADD (include title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId)
4. A summary of the changes
5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features)
Respond with ONLY a JSON object in this exact format:
\`\`\`json
{
"changes": [
{
"type": "add",
"feature": {
"title": "Feature title",
"description": "Feature description",
"category": "Category name",
"dependencies": ["existing-feature-id"],
"priority": 1
},
"reason": "Why this feature should be added"
},
{
"type": "update",
"featureId": "existing-feature-id",
"feature": {
"title": "Updated title"
},
"reason": "Why this feature should be updated"
},
{
"type": "delete",
"featureId": "feature-id-to-delete",
"reason": "Why this feature should be deleted"
}
],
"summary": "Brief overview of all proposed changes",
"dependencyUpdates": [
{
"featureId": "feature-that-depended-on-deleted",
"removedDependencies": ["deleted-feature-id"],
"addedDependencies": []
}
]
}
\`\`\``;
// Build the user prompt
const userPrompt = `Current Features in Backlog:
${formatFeaturesForPrompt(features)}
---
User Request: ${prompt}
Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`;
// Build the user prompt from template
const currentFeatures = formatFeaturesForPrompt(features);
const userPrompt = prompts.backlogPlan.userPromptTemplate
.replace('{{currentFeatures}}', currentFeatures)
.replace('{{userRequest}}', prompt);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',

View File

@@ -15,7 +15,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { createCustomOptions } from '../../../lib/sdk-options.js';
import * as fs from 'fs';
import * as secureFs from '../../../lib/secure-fs.js';
import * as path from 'path';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
@@ -57,13 +57,13 @@ function filterSafeHeaders(headers: Record<string, unknown>): Record<string, unk
*/
function findActualFilePath(requestedPath: string): string | null {
// First, try the exact path
if (fs.existsSync(requestedPath)) {
if (secureFs.existsSync(requestedPath)) {
return requestedPath;
}
// Try with Unicode normalization
const normalizedPath = requestedPath.normalize('NFC');
if (fs.existsSync(normalizedPath)) {
if (secureFs.existsSync(normalizedPath)) {
return normalizedPath;
}
@@ -72,12 +72,12 @@ function findActualFilePath(requestedPath: string): string | null {
const dir = path.dirname(requestedPath);
const baseName = path.basename(requestedPath);
if (!fs.existsSync(dir)) {
if (!secureFs.existsSync(dir)) {
return null;
}
try {
const files = fs.readdirSync(dir);
const files = secureFs.readdirSync(dir);
// Normalize the requested basename for comparison
// Replace various space-like characters with regular space for comparison
@@ -281,9 +281,9 @@ export function createDescribeImageHandler(
}
// Log path + stats (this is often where issues start: missing file, perms, size)
let stat: fs.Stats | null = null;
let stat: ReturnType<typeof secureFs.statSync> | null = null;
try {
stat = fs.statSync(actualPath);
stat = secureFs.statSync(actualPath);
logger.info(
`[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}`
);

View File

@@ -6,17 +6,19 @@
*/
import { Router } from 'express';
import type { SettingsService } from '../../services/settings-service.js';
import { createEnhanceHandler } from './routes/enhance.js';
/**
* Create the enhance-prompt router
*
* @param settingsService - Settings service for loading custom prompts
* @returns Express router with enhance-prompt endpoints
*/
export function createEnhancePromptRoutes(): Router {
export function createEnhancePromptRoutes(settingsService?: SettingsService): Router {
const router = Router();
router.post('/', createEnhanceHandler());
router.post('/', createEnhanceHandler(settingsService));
return router;
}

View File

@@ -10,8 +10,9 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getSystemPrompt,
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
@@ -83,9 +84,12 @@ async function extractTextFromStream(
/**
* Create the enhance request handler
*
* @param settingsService - Optional settings service for loading custom prompts
* @returns Express request handler for text enhancement
*/
export function createEnhanceHandler(): (req: Request, res: Response) => Promise<void> {
export function createEnhanceHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody;
@@ -128,8 +132,19 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`);
// Get the system prompt for this mode
const systemPrompt = getSystemPrompt(validMode);
// Load enhancement prompts from settings (merges custom + defaults)
const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]');
// Get the system prompt for this mode from merged prompts
const systemPromptMap: Record<EnhancementMode, string> = {
improve: prompts.enhancement.improveSystemPrompt,
technical: prompts.enhancement.technicalSystemPrompt,
simplify: prompts.enhancement.simplifySystemPrompt,
acceptance: prompts.enhancement.acceptanceSystemPrompt,
};
const systemPrompt = systemPromptMap[validMode];
logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`);
// Build the user prompt with few-shot examples
// This helps the model understand this is text transformation, not a coding task

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import os from 'os';
import path from 'path';
import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform';
import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createBrowseHandler() {
@@ -40,9 +40,16 @@ export function createBrowseHandler() {
return drives;
};
// Get parent directory
// Get parent directory - only if it's within the allowed root
const parentPath = path.dirname(targetPath);
const hasParent = parentPath !== targetPath;
// Determine if parent navigation should be allowed:
// 1. Must have a different parent (not at filesystem root)
// 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it
const hasParent = parentPath !== targetPath && isPathAllowed(parentPath);
// Security: Don't expose parent path outside allowed root
const safeParentPath = hasParent ? parentPath : null;
// Get available drives
const drives = await detectDrives();
@@ -70,7 +77,7 @@ export function createBrowseHandler() {
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
parentPath: safeParentPath,
directories,
drives,
});
@@ -84,7 +91,7 @@ export function createBrowseHandler() {
res.json({
success: true,
currentPath: targetPath,
parentPath: hasParent ? parentPath : null,
parentPath: safeParentPath,
directories: [],
drives,
warning:

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed } from '@automaker/platform';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {
@@ -20,6 +20,20 @@ export function createValidatePathHandler() {
const resolvedPath = path.resolve(filePath);
// Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists
if (!isPathAllowed(resolvedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
: `Path not allowed: ${filePath}`;
res.status(403).json({
success: false,
error: errorMessage,
isAllowed: false,
});
return;
}
// Check if path exists
try {
const stats = await secureFs.stat(resolvedPath);
@@ -32,7 +46,7 @@ export function createValidatePathHandler() {
res.json({
success: true,
path: resolvedPath,
isAllowed: isPathAllowed(resolvedPath),
isAllowed: true,
});
} catch {
res.status(400).json({ success: false, error: 'Path does not exist' });

View File

@@ -8,6 +8,7 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js';
import { createListCommentsHandler } from './routes/list-comments.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
@@ -27,6 +28,7 @@ export function createGitHubRoutes(
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/validate-issue',
validatePathParams('projectPath'),

View File

@@ -0,0 +1,212 @@
/**
* POST /issue-comments endpoint - Fetch comments for a GitHub issue
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import type { GitHubComment, IssueCommentsResult } from '@automaker/types';
import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
interface ListCommentsRequest {
projectPath: string;
issueNumber: number;
cursor?: string;
}
interface GraphQLComment {
id: string;
author: {
login: string;
avatarUrl?: string;
} | null;
body: string;
createdAt: string;
updatedAt: string;
}
interface GraphQLResponse {
data?: {
repository?: {
issue?: {
comments: {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
};
};
};
};
errors?: Array<{ message: string }>;
}
/** Timeout for GitHub API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
/**
* Validate cursor format (GraphQL cursors are typically base64 strings)
*/
function isValidCursor(cursor: string): boolean {
return /^[A-Za-z0-9+/=]+$/.test(cursor);
}
/**
* Fetch comments for a specific issue using GitHub GraphQL API
*/
async function fetchIssueComments(
projectPath: string,
owner: string,
repo: string,
issueNumber: number,
cursor?: string
): Promise<IssueCommentsResult> {
// Validate cursor format to prevent potential injection
if (cursor && !isValidCursor(cursor)) {
throw new Error('Invalid cursor format');
}
// Use GraphQL variables instead of string interpolation for safety
const query = `
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
comments(first: 50, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
}
}
}
}`;
const variables = {
owner,
repo,
issueNumber,
cursor: cursor || null,
};
const requestBody = JSON.stringify({ query, variables });
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
// Add timeout to prevent hanging indefinitely
const timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const commentsData = response.data?.repository?.issue?.comments;
if (!commentsData) {
throw new Error('Issue not found or no comments data available');
}
const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
id: node.id,
author: {
login: node.author?.login || 'ghost',
avatarUrl: node.author?.avatarUrl,
},
body: node.body,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
}));
return {
comments,
totalCount: commentsData.totalCount,
hasNextPage: commentsData.pageInfo.hasNextPage,
endCursor: commentsData.pageInfo.endCursor || undefined,
};
}
export function createListCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
// First check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await fetchIssueComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
issueNumber,
cursor
);
res.json({
success: true,
...result,
});
} catch (error) {
logError(error, `Fetch comments for issue failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -8,13 +8,21 @@
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
import type {
IssueValidationResult,
IssueValidationEvent,
AgentModel,
GitHubComment,
LinkedPRInfo,
} from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import {
trySetValidationRunning,
@@ -40,6 +48,10 @@ interface ValidateIssueRequestBody {
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku) */
model?: AgentModel;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
linkedPRs?: LinkedPRInfo[];
}
/**
@@ -57,7 +69,9 @@ async function runValidation(
model: AgentModel,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
settingsService?: SettingsService,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
@@ -76,8 +90,15 @@ async function runValidation(
}, VALIDATION_TIMEOUT_MS);
try {
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Build the prompt (include comments and linked PRs if provided)
const prompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
issueLabels,
comments,
linkedPRs
);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
@@ -102,16 +123,12 @@ async function runValidation(
// Execute the query
const stream = query({ prompt, options });
let validationResult: IssueValidationResult | null = null;
let responseText = '';
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
// Emit progress events for assistant text
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
@@ -128,7 +145,6 @@ async function runValidation(
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
@@ -148,7 +164,6 @@ async function runValidation(
// Require structured output
if (!validationResult) {
logger.error('No structured output received from Claude SDK');
logger.debug('Raw response text:', responseText);
throw new Error('Validation failed: no structured output received');
}
@@ -214,8 +229,30 @@ export function createValidateIssueHandler(
issueBody,
issueLabels,
model = 'opus',
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;
// Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost',
createdAt: c.createdAt,
body: c.body,
}));
// Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided
const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({
number: pr.number,
title: pr.title,
state: pr.state,
}));
logger.info(
`[ValidateIssue] Received validation request for issue #${issueNumber}` +
(rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') +
(rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '')
);
// Validate required fields
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
@@ -271,11 +308,12 @@ export function createValidateIssueHandler(
model,
events,
abortController,
settingsService
settingsService,
validationComments,
validationLinkedPRs
)
.catch((error) => {
.catch(() => {
// Error is already handled inside runValidation (event emitted)
logger.debug('Validation error caught in background handler:', error);
})
.finally(() => {
clearValidationStatus(projectPath, issueNumber);

View File

@@ -49,6 +49,34 @@ export const issueValidationSchema = {
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
description: 'Estimated effort to address the issue',
},
prAnalysis: {
type: 'object',
properties: {
hasOpenPR: {
type: 'boolean',
description: 'Whether there is an open PR linked to this issue',
},
prFixesIssue: {
type: 'boolean',
description: 'Whether the PR appears to fix the issue based on the diff',
},
prNumber: {
type: 'number',
description: 'The PR number that was analyzed',
},
prSummary: {
type: 'string',
description: 'Brief summary of what the PR changes',
},
recommendation: {
type: 'string',
enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'],
description:
'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR',
},
},
description: 'Analysis of linked pull requests if any exist',
},
},
required: ['verdict', 'confidence', 'reasoning'],
additionalProperties: false,
@@ -67,7 +95,8 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
1. **Read the issue carefully** - Understand what is being reported or requested
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
@@ -88,12 +117,32 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Analyzing Linked Pull Requests
When an issue has linked PRs (especially open ones), you MUST analyze them:
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
4. **Provide a recommendation**:
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
- \`no_pr\`: No relevant PR exists for this issue.
5. **Include prAnalysis in your response** with:
- hasOpenPR: true/false
- prFixesIssue: true/false (based on diff analysis)
- prNumber: the PR number you analyzed
- prSummary: brief description of what the PR changes
- recommendation: one of the above values
## Response Guidelines
- **Always include relatedFiles** when you find relevant code
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
@@ -103,6 +152,24 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/**
* Comment data structure for validation prompt
*/
export interface ValidationComment {
author: string;
createdAt: string;
body: string;
}
/**
* Linked PR data structure for validation prompt
*/
export interface ValidationLinkedPR {
number: number;
title: string;
state: string;
}
/**
* Build the user prompt for issue validation.
*
@@ -113,26 +180,60 @@ Be thorough in your analysis but focus on files that are directly relevant to th
* @param issueTitle - The issue title
* @param issueBody - The issue body/description
* @param issueLabels - Optional array of label names
* @param comments - Optional array of comments to include in analysis
* @param linkedPRs - Optional array of linked pull requests
* @returns Formatted prompt string for the validation request
*/
export function buildValidationPrompt(
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels?: string[]
issueLabels?: string[],
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): string {
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
let linkedPRsSection = '';
if (linkedPRs && linkedPRs.length > 0) {
const prsText = linkedPRs
.map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`)
.join('\n');
linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`;
}
let commentsSection = '';
if (comments && comments.length > 0) {
// Limit to most recent 10 comments to control prompt size
const recentComments = comments.slice(-10);
const commentsText = recentComments
.map(
(c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}`
)
.join('\n\n---\n\n');
commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`;
}
const hasWorkInProgress =
linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN');
const workInProgressNote = hasWorkInProgress
? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.'
: '';
return `Please validate the following GitHub issue by analyzing the codebase:
## Issue #${issueNumber}: ${issueTitle}
${labelsSection}
${linkedPRsSection}
### Description
${issueBody || '(No description provided)'}
${commentsSection}
${workInProgressNote}
---
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`;
}

View File

@@ -1,16 +1,30 @@
/**
* Health check routes
*
* NOTE: Only the basic health check (/) and environment check are unauthenticated.
* The /detailed endpoint requires authentication.
*/
import { Router } from 'express';
import { createIndexHandler } from './routes/index.js';
import { createDetailedHandler } from './routes/detailed.js';
import { createEnvironmentHandler } from './routes/environment.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());
// Environment info including containerization status
// This is unauthenticated so the UI can check on startup
router.get('/environment', createEnvironmentHandler());
return router;
}
// Re-export detailed handler for use in authenticated routes
export { createDetailedHandler } from './routes/detailed.js';

View File

@@ -4,13 +4,14 @@
import type { Request, Response } from 'express';
import { getAuthStatus } from '../../../lib/auth.js';
import { getVersion } from '../../../lib/version.js';
export function createDetailedHandler() {
return (_req: Request, res: Response): void => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
version: getVersion(),
uptime: process.uptime(),
memory: process.memoryUsage(),
dataDir: process.env.DATA_DIR || './data',

View File

@@ -0,0 +1,20 @@
/**
* GET /environment endpoint - Environment information including containerization status
*
* This endpoint is unauthenticated so the UI can check it on startup
* before login to determine if sandbox risk warnings should be shown.
*/
import type { Request, Response } from 'express';
export interface EnvironmentResponse {
isContainerized: boolean;
}
export function createEnvironmentHandler() {
return (_req: Request, res: Response): void => {
res.json({
isContainerized: process.env.IS_CONTAINERIZED === 'true',
} satisfies EnvironmentResponse);
};
}

View File

@@ -3,13 +3,14 @@
*/
import type { Request, Response } from 'express';
import { getVersion } from '../../../lib/version.js';
export function createIndexHandler() {
return (_req: Request, res: Response): void => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '0.1.0',
version: getVersion(),
});
};
}

View File

@@ -0,0 +1,20 @@
/**
* Common utilities for MCP routes
*/
/**
* Extract error message from unknown error
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Log error with prefix
*/
export function logError(error: unknown, message: string): void {
console.error(`[MCP] ${message}:`, error);
}

View File

@@ -0,0 +1,36 @@
/**
* MCP routes - HTTP API for testing MCP servers
*
* Provides endpoints for:
* - Testing MCP server connections
* - Listing available tools from MCP servers
*
* Mounted at /api/mcp in the main server.
*/
import { Router } from 'express';
import type { MCPTestService } from '../../services/mcp-test-service.js';
import { createTestServerHandler } from './routes/test-server.js';
import { createListToolsHandler } from './routes/list-tools.js';
/**
* Create MCP router with all endpoints
*
* Endpoints:
* - POST /test - Test MCP server connection
* - POST /tools - List tools from MCP server
*
* @param mcpTestService - Instance of MCPTestService for testing connections
* @returns Express Router configured with all MCP endpoints
*/
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
const router = Router();
// Test MCP server connection
router.post('/test', createTestServerHandler(mcpTestService));
// List tools from MCP server
router.post('/tools', createListToolsHandler(mcpTestService));
return router;
}

View File

@@ -0,0 +1,57 @@
/**
* POST /api/mcp/tools - List tools for an MCP server
*
* Lists available tools for an MCP server.
* Similar to test but focused on tool discovery.
*
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
* arbitrary serverConfig to prevent drive-by command execution attacks.
* Users must explicitly save a server config through the UI before testing.
*
* Request body:
* { serverId: string } - Get tools by server ID from settings
*
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
*/
import type { Request, Response } from 'express';
import type { MCPTestService } from '../../../services/mcp-test-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ListToolsRequest {
serverId: string;
}
/**
* Create handler factory for POST /api/mcp/tools
*/
export function createListToolsHandler(mcpTestService: MCPTestService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as ListToolsRequest;
if (!body.serverId || typeof body.serverId !== 'string') {
res.status(400).json({
success: false,
error: 'serverId is required',
});
return;
}
const result = await mcpTestService.testServerById(body.serverId);
// Return only tool-related information
res.json({
success: result.success,
tools: result.tools,
error: result.error,
});
} catch (error) {
logError(error, 'List tools failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/mcp/test - Test MCP server connection and list tools
*
* Tests connection to an MCP server and returns available tools.
*
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
* arbitrary serverConfig to prevent drive-by command execution attacks.
* Users must explicitly save a server config through the UI before testing.
*
* Request body:
* { serverId: string } - Test server by ID from settings
*
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
*/
import type { Request, Response } from 'express';
import type { MCPTestService } from '../../../services/mcp-test-service.js';
import { getErrorMessage, logError } from '../common.js';
interface TestServerRequest {
serverId: string;
}
/**
* Create handler factory for POST /api/mcp/test
*/
export function createTestServerHandler(mcpTestService: MCPTestService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as TestServerRequest;
if (!body.serverId || typeof body.serverId !== 'string') {
res.status(400).json({
success: false,
error: 'serverId is required',
});
return;
}
const result = await mcpTestService.testServerById(body.serverId);
res.json(result);
} catch (error) {
logError(error, 'Test server failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -9,8 +9,7 @@ import { getErrorMessage, logError } from '../common.js';
export function createIndexHandler(autoModeService: AutoModeService) {
return async (_req: Request, res: Response): Promise<void> => {
try {
const runningAgents = autoModeService.getRunningAgents();
const status = autoModeService.getStatus();
const runningAgents = await autoModeService.getRunningAgents();
res.json({
success: true,

View File

@@ -4,7 +4,7 @@
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('Setup');
@@ -35,36 +35,13 @@ export function getAllApiKeys(): Record<string, string> {
/**
* Helper to persist API keys to .env file
* Uses centralized secureFs.writeEnvKey for path validation
*/
export async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join('\n'));
await secureFs.writeEnvKey(envPath, key, value);
logger.info(`[Setup] Persisted ${key} to .env file`);
} catch (error) {
logger.error(`[Setup] Failed to persist ${key} to .env:`, error);

View File

@@ -4,9 +4,7 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
import { getApiKey } from './common.js';
const execAsync = promisify(exec);
@@ -37,42 +35,25 @@ export async function getClaudeStatus() {
// Version command might not be available
}
} catch {
// Not in PATH, try common locations based on platform
const commonPaths = isWindows
? (() => {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
return [
// Windows-specific paths
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
path.join(appData, 'npm', 'claude.cmd'),
path.join(appData, 'npm', 'claude'),
path.join(appData, '.npm-global', 'bin', 'claude.cmd'),
path.join(appData, '.npm-global', 'bin', 'claude'),
];
})()
: [
// Unix (Linux/macOS) paths
path.join(os.homedir(), '.local', 'bin', 'claude'),
path.join(os.homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
];
// Not in PATH, try common locations from centralized system paths
const commonPaths = getClaudeCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
cliPath = p;
installed = true;
method = 'local';
if (await systemPathAccess(p)) {
cliPath = p;
installed = true;
method = 'local';
// Get version from this path
try {
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
version = versionOut.trim();
} catch {
// Version command might not be available
// Get version from this path
try {
const { stdout: versionOut } = await execAsync(`"${p}" --version`);
version = versionOut.trim();
} catch {
// Version command might not be available
}
break;
}
break;
} catch {
// Not found at this path
}
@@ -82,7 +63,7 @@ export async function getClaudeStatus() {
// Check authentication - detect all possible auth methods
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
// apiKeys.anthropic stores direct API keys for pay-per-use
let auth = {
const auth = {
authenticated: false,
method: 'none' as string,
hasCredentialsFile: false,
@@ -97,76 +78,36 @@ export async function getClaudeStatus() {
hasRecentActivity: false,
};
const claudeDir = path.join(os.homedir(), '.claude');
// Use centralized system paths to check Claude authentication indicators
const indicators = await getClaudeAuthIndicators();
// Check for recent Claude CLI activity - indicates working authentication
// The stats-cache.json file is only populated when the CLI is working properly
const statsCachePath = path.join(claudeDir, 'stats-cache.json');
try {
const statsContent = await fs.readFile(statsCachePath, 'utf-8');
const stats = JSON.parse(statsContent);
// Check for recent activity (indicates working authentication)
if (indicators.hasStatsCacheWithActivity) {
auth.hasRecentActivity = true;
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
// Check if there's any activity (which means the CLI is authenticated and working)
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
auth.hasRecentActivity = true;
auth.hasCliAuth = true;
// Check for settings + sessions (indicates CLI is set up)
if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) {
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
// Check credentials file
if (indicators.hasCredentialsFile && indicators.credentials) {
auth.hasCredentialsFile = true;
if (indicators.credentials.hasOAuthToken) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Stats file doesn't exist or is invalid
}
// Check for settings.json - indicates CLI has been set up
const settingsPath = path.join(claudeDir, 'settings.json');
try {
await fs.access(settingsPath);
// If settings exist but no activity, CLI might be set up but not authenticated
if (!auth.hasCliAuth) {
// Try to check for other indicators of auth
const sessionsDir = path.join(claudeDir, 'projects');
try {
const sessions = await fs.readdir(sessionsDir);
if (sessions.length > 0) {
auth.hasCliAuth = true;
auth.authenticated = true;
auth.method = 'cli_authenticated';
}
} catch {
// Sessions directory doesn't exist
}
}
} catch {
// Settings file doesn't exist
}
// Check for credentials file (OAuth tokens from claude login)
// Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform
const credentialsPaths = [
path.join(claudeDir, '.credentials.json'),
path.join(claudeDir, 'credentials.json'),
];
for (const credentialsPath of credentialsPaths) {
try {
const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
const credentials = JSON.parse(credentialsContent);
auth.hasCredentialsFile = true;
// Check what type of token is in credentials
if (credentials.oauth_token || credentials.access_token) {
auth.hasStoredOAuthToken = true;
auth.oauthTokenValid = true;
auth.authenticated = true;
auth.method = 'oauth_token'; // Stored OAuth token from credentials file
} else if (credentials.api_key) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = 'api_key'; // Stored API key in credentials file
}
break; // Found and processed credentials file
} catch {
// No credentials file at this path or invalid format
auth.method = 'oauth_token';
} else if (indicators.credentials.hasApiKey) {
auth.apiKeyValid = true;
auth.authenticated = true;
auth.method = 'api_key';
}
}
@@ -174,21 +115,21 @@ export async function getClaudeStatus() {
if (auth.hasEnvApiKey) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var
auth.method = 'api_key_env';
}
// In-memory stored OAuth token (from setup wizard - subscription auth)
if (!auth.authenticated && getApiKey('anthropic_oauth_token')) {
auth.authenticated = true;
auth.oauthTokenValid = true;
auth.method = 'oauth_token'; // Stored OAuth token from setup wizard
auth.method = 'oauth_token';
}
// In-memory stored API key (from settings UI - pay-per-use)
if (!auth.authenticated && getApiKey('anthropic')) {
auth.authenticated = true;
auth.apiKeyValid = true;
auth.method = 'api_key'; // Manually stored API key
auth.method = 'api_key';
}
return {

View File

@@ -5,40 +5,22 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import path from 'path';
import fs from 'fs/promises';
import { secureFs } from '@automaker/platform';
const logger = createLogger('Setup');
// In-memory storage reference (imported from common.ts pattern)
// We need to modify common.ts to export a deleteApiKey function
import { setApiKey } from '../common.js';
/**
* Remove an API key from the .env file
* Uses centralized secureFs.removeEnvKey for path validation
*/
async function removeApiKeyFromEnv(key: string): Promise<void> {
const envPath = path.join(process.cwd(), '.env');
try {
let envContent = '';
try {
envContent = await fs.readFile(envPath, 'utf-8');
} catch {
// .env file doesn't exist, nothing to delete
return;
}
// Parse existing env content and remove the key
const lines = envContent.split('\n');
const keyRegex = new RegExp(`^${key}=`);
const newLines = lines.filter((line) => !keyRegex.test(line));
// Remove empty lines at the end
while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') {
newLines.pop();
}
await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : ''));
await secureFs.removeEnvKey(envPath, key);
logger.info(`[Setup] Removed ${key} from .env file`);
} catch (error) {
logger.error(`[Setup] Failed to remove ${key} from .env:`, error);

View File

@@ -5,27 +5,14 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
const execEnv = {
...process.env,
PATH: extendedPath,
PATH: getExtendedPath(),
};
export interface GhStatus {
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
]
: [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
// gh not in PATH, try common locations from centralized system paths
const commonPaths = getGitHubCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
status.path = p;
status.installed = true;
break;
if (await systemPathAccess(p)) {
status.path = p;
status.installed = true;
break;
}
} catch {
// Not found at this path
}
@@ -94,23 +72,37 @@ async function getGhStatus(): Promise<GhStatus> {
// Version command failed
}
// Check authentication status
// Check authentication status by actually making an API call
// gh auth status can return non-zero even when GH_TOKEN is valid
let apiCallSucceeded = false;
try {
const { stdout } = await execAsync('gh auth status', { env: execEnv });
// If this succeeds without error, we're authenticated
status.authenticated = true;
// Try to extract username from output
const userMatch =
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
const { stdout } = await execAsync('gh api user --jq ".login"', { env: execEnv });
const user = stdout.trim();
if (user) {
status.authenticated = true;
status.user = user;
apiCallSucceeded = true;
}
} catch (error: unknown) {
// Auth status returns non-zero if not authenticated
const err = error as { stderr?: string };
if (err.stderr?.includes('not logged in')) {
// If stdout is empty, fall through to gh auth status fallback
} catch {
// API call failed - fall through to gh auth status fallback
}
// Fallback: try gh auth status if API call didn't succeed
if (!apiCallSucceeded) {
try {
const { stdout } = await execAsync('gh auth status', { env: execEnv });
status.authenticated = true;
// Try to extract username from output
const userMatch =
stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) ||
stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i);
if (userMatch) {
status.user = userMatch[1];
}
} catch {
// Auth status returns non-zero if not authenticated
status.authenticated = false;
}
}

View File

@@ -22,12 +22,12 @@ export function createSessionsListHandler() {
}
export function createSessionsCreateHandler() {
return (req: Request, res: Response): void => {
return async (req: Request, res: Response): Promise<void> => {
try {
const terminalService = getTerminalService();
const { cwd, cols, rows, shell } = req.body;
const session = terminalService.createSession({
const session = await terminalService.createSession({
cwd,
cols: cols || 80,
rows: rows || 24,

View File

@@ -158,8 +158,13 @@ export const logError = createLogError(logger);
/**
* Ensure the repository has at least one commit so git commands that rely on HEAD work.
* Returns true if an empty commit was created, false if the repo already had commits.
* @param repoPath - Path to the git repository
* @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL)
*/
export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
export async function ensureInitialCommit(
repoPath: string,
env?: Record<string, string>
): Promise<boolean> {
try {
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
return false;
@@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise<boolean> {
try {
await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, {
cwd: repoPath,
env: { ...process.env, ...env },
});
logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`);
return true;

View File

@@ -100,7 +100,14 @@ export function createCreateHandler() {
}
// Ensure the repository has at least one commit so worktree commands referencing HEAD succeed
await ensureInitialCommit(projectPath);
// Pass git identity env vars so commits work without global git config
const gitEnv = {
GIT_AUTHOR_NAME: 'Automaker',
GIT_AUTHOR_EMAIL: 'automaker@localhost',
GIT_COMMITTER_NAME: 'Automaker',
GIT_COMMITTER_EMAIL: 'automaker@localhost',
};
await ensureInitialCommit(projectPath, gitEnv);
// First, check if git already has a worktree for this branch (anywhere)
const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName);

View File

@@ -12,6 +12,7 @@ import {
buildPromptWithImages,
isAbortError,
loadContextFiles,
createLogger,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -21,6 +22,9 @@ import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
interface Message {
@@ -73,6 +77,7 @@ export class AgentService {
private metadataFile: string;
private events: EventEmitter;
private settingsService: SettingsService | null = null;
private logger = createLogger('AgentService');
constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) {
this.stateDir = path.join(dataDir, 'agent-sessions');
@@ -146,12 +151,12 @@ export class AgentService {
}) {
const session = this.sessions.get(sessionId);
if (!session) {
console.error('[AgentService] ERROR: Session not found:', sessionId);
this.logger.error('ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`);
}
if (session.isRunning) {
console.error('[AgentService] ERROR: Agent already running for session:', sessionId);
this.logger.error('ERROR: Agent already running for session:', sessionId);
throw new Error('Agent is already processing a message');
}
@@ -173,7 +178,7 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
this.logger.error(`Failed to load image ${imagePath}:`, error);
}
}
}
@@ -227,6 +232,12 @@ export class AgentService {
'[AgentService]'
);
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
const contextResult = await loadContextFiles({
projectPath: effectiveWorkDir,
@@ -238,7 +249,7 @@ export class AgentService {
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Build combined system prompt with base prompt and context files
const baseSystemPrompt = this.getSystemPrompt();
const baseSystemPrompt = await this.getSystemPrompt();
const combinedSystemPrompt = contextFilesPrompt
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
: baseSystemPrompt;
@@ -252,6 +263,9 @@ export class AgentService {
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -275,6 +289,9 @@ export class AgentService {
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
};
// Build prompt content with images
@@ -377,7 +394,7 @@ export class AgentService {
return { success: false, aborted: true };
}
console.error('[AgentService] Error:', error);
this.logger.error('Error:', error);
session.isRunning = false;
session.abortController = null;
@@ -471,7 +488,7 @@ export class AgentService {
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error('[AgentService] Failed to save session:', error);
this.logger.error('Failed to save session:', error);
}
}
@@ -705,7 +722,7 @@ export class AgentService {
try {
await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8');
} catch (error) {
console.error('[AgentService] Failed to save queue state:', error);
this.logger.error('Failed to save queue state:', error);
}
}
@@ -754,7 +771,7 @@ export class AgentService {
model: nextPrompt.model,
});
} catch (error) {
console.error('[AgentService] Failed to process queued prompt:', error);
this.logger.error('Failed to process queued prompt:', error);
this.emitAgentEvent(sessionId, {
type: 'queue_error',
error: (error as Error).message,
@@ -767,38 +784,10 @@ export class AgentService {
this.events.emit('agent:stream', { sessionId, ...data });
}
private getSystemPrompt(): string {
return `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
private async getSystemPrompt(): Promise<string> {
// Load from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');
return prompts.agent.systemPrompt;
}
private generateId(): string {

View File

@@ -37,6 +37,9 @@ import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
const execAsync = promisify(exec);
@@ -65,162 +68,6 @@ interface PlanSpec {
tasks?: ParsedTask[];
}
const PLANNING_PROMPTS = {
lite: `## Planning Phase (Lite Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
Create a brief planning outline:
1. **Goal**: What are we accomplishing? (1 sentence)
2. **Approach**: How will we do it? (2-3 sentences)
3. **Files to Touch**: List files and what changes
4. **Tasks**: Numbered task list (3-7 items)
5. **Risks**: Any gotchas to watch for
After generating the outline, output:
"[PLAN_GENERATED] Planning outline complete."
Then proceed with implementation.`,
lite_with_approval: `## Planning Phase (Lite Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan.
Create a brief planning outline:
1. **Goal**: What are we accomplishing? (1 sentence)
2. **Approach**: How will we do it? (2-3 sentences)
3. **Files to Touch**: List files and what changes
4. **Tasks**: Numbered task list (3-7 items)
5. **Risks**: Any gotchas to watch for
After generating the outline, output:
"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.`,
spec: `## Specification Phase (Spec Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
Generate a specification with an actionable task breakdown. WAIT for approval before implementing.
### Specification Format
1. **Problem**: What problem are we solving? (user perspective)
2. **Solution**: Brief approach (1-2 sentences)
3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format
- GIVEN [context], WHEN [action], THEN [outcome]
4. **Files to Modify**:
| File | Purpose | Action |
|------|---------|--------|
| path/to/file | description | create/modify/delete |
5. **Implementation Tasks**:
Use this EXACT format for each task (the system will parse these):
\`\`\`tasks
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
- [ ] T003: [Description] | File: [path/to/file]
\`\`\`
Task ID rules:
- Sequential: T001, T002, T003, etc.
- Description: Clear action (e.g., "Create user model", "Add API endpoint")
- File: Primary file affected (helps with context)
- Order by dependencies (foundational tasks first)
6. **Verification**: How to confirm feature works
After generating the spec, output on its own line:
"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.
When approved, execute tasks SEQUENTIALLY in order. For each task:
1. BEFORE starting, output: "[TASK_START] T###: Description"
2. Implement the task
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
This allows real-time progress tracking during implementation.`,
full: `## Full Specification Phase (Full SDD Mode)
IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification.
Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing.
### Specification Format
1. **Problem Statement**: 2-3 sentences from user perspective
2. **User Story**: As a [user], I want [goal], so that [benefit]
3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN
- **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome]
- **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling]
- **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response]
4. **Technical Context**:
| Aspect | Value |
|--------|-------|
| Affected Files | list of files |
| Dependencies | external libs if any |
| Constraints | technical limitations |
| Patterns to Follow | existing patterns in codebase |
5. **Non-Goals**: What this feature explicitly does NOT include
6. **Implementation Tasks**:
Use this EXACT format for each task (the system will parse these):
\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
## Phase 2: Core Implementation
- [ ] T003: [Description] | File: [path/to/file]
- [ ] T004: [Description] | File: [path/to/file]
## Phase 3: Integration & Testing
- [ ] T005: [Description] | File: [path/to/file]
- [ ] T006: [Description] | File: [path/to/file]
\`\`\`
Task ID rules:
- Sequential across all phases: T001, T002, T003, etc.
- Description: Clear action verb + target
- File: Primary file affected
- Order by dependencies within each phase
- Phase structure helps organize complex work
7. **Success Metrics**: How we know it's done (measurable criteria)
8. **Risks & Mitigations**:
| Risk | Mitigation |
|------|------------|
| description | approach |
After generating the spec, output on its own line:
"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions."
DO NOT proceed with implementation until you receive explicit approval.
When approved, execute tasks SEQUENTIALLY by phase. For each task:
1. BEFORE starting, output: "[TASK_START] T###: Description"
2. Implement the task
3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary"
After completing all tasks in a phase, output:
"[PHASE_COMPLETE] Phase N complete"
This allows real-time progress tracking during implementation.`,
};
/**
* Parse tasks from generated spec content
* Looks for the ```tasks code block and extracts task lines
@@ -343,6 +190,10 @@ interface AutoModeConfig {
projectPath: string;
}
// Constants for consecutive failure tracking
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
export class AutoModeService {
private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>();
@@ -353,12 +204,89 @@ export class AutoModeService {
private config: AutoModeConfig | null = null;
private pendingApprovals = new Map<string, PendingApproval>();
private settingsService: SettingsService | null = null;
// Track consecutive failures to detect quota/API issues
private consecutiveFailures: { timestamp: number; error: string }[] = [];
private pausedDueToFailures = false;
constructor(events: EventEmitter, settingsService?: SettingsService) {
this.events = events;
this.settingsService = settingsService ?? null;
}
/**
* Track a failure and check if we should pause due to consecutive failures.
* This handles cases where the SDK doesn't return useful error messages.
*/
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
const now = Date.now();
// Add this failure
this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
// Remove old failures outside the window
this.consecutiveFailures = this.consecutiveFailures.filter(
(f) => now - f.timestamp < FAILURE_WINDOW_MS
);
// Check if we've hit the threshold
if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
return true; // Should pause
}
// Also immediately pause for known quota/rate limit errors
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
return true;
}
return false;
}
/**
* Signal that we should pause due to repeated failures or quota exhaustion.
* This will pause the auto loop to prevent repeated failures.
*/
private signalShouldPause(errorInfo: { type: string; message: string }): void {
if (this.pausedDueToFailures) {
return; // Already paused
}
this.pausedDueToFailures = true;
const failureCount = this.consecutiveFailures.length;
console.log(
`[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
);
// Emit event to notify UI
this.emitAutoModeEvent('auto_mode_paused_failures', {
message:
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
errorType: errorInfo.type,
originalError: errorInfo.message,
failureCount,
projectPath: this.config?.projectPath,
});
// Stop the auto loop
this.stopAutoLoop();
}
/**
* Reset failure tracking (called when user manually restarts auto mode)
*/
private resetFailureTracking(): void {
this.consecutiveFailures = [];
this.pausedDueToFailures = false;
}
/**
* Record a successful feature completion to reset consecutive failure count
*/
private recordSuccess(): void {
this.consecutiveFailures = [];
}
/**
* Start the auto mode loop - continuously picks and executes pending features
*/
@@ -367,6 +295,9 @@ export class AutoModeService {
throw new Error('Auto mode is already running');
}
// Reset failure tracking when user manually starts auto mode
this.resetFailureTracking();
this.autoLoopRunning = true;
this.autoLoopAbortController = new AbortController();
this.config = {
@@ -591,7 +522,7 @@ export class AutoModeService {
} else {
// Normal flow: build prompt with planning phase
const featurePrompt = this.buildFeaturePrompt(feature);
const planningPrefix = this.getPlanningPromptPrefix(feature);
const planningPrefix = await this.getPlanningPromptPrefix(feature);
prompt = planningPrefix + featurePrompt;
// Emit planning mode info
@@ -655,6 +586,9 @@ export class AutoModeService {
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
// Record success to reset consecutive failure tracking
this.recordSuccess();
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
@@ -682,6 +616,21 @@ export class AutoModeService {
errorType: errorInfo.type,
projectPath,
});
// Track this failure and check if we should pause auto mode
// This handles both specific quota/rate limit errors AND generic failures
// that may indicate quota exhaustion (SDK doesn't always return useful errors)
const shouldPause = this.trackFailureAndCheckPause({
type: errorInfo.type,
message: errorInfo.message,
});
if (shouldPause) {
this.signalShouldPause({
type: errorInfo.type,
message: errorInfo.message,
});
}
}
} finally {
console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`);
@@ -842,6 +791,11 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.cancelPlanApproval(featureId);
running.abortController.abort();
// Remove from running features immediately to allow resume
// The abort signal will still propagate to stop any ongoing execution
this.runningFeatures.delete(featureId);
return true;
}
@@ -1079,6 +1033,9 @@ Address the follow-up instructions above. Review the previous work and make the
const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified';
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
// Record success to reset consecutive failure tracking
this.recordSuccess();
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
passes: true,
@@ -1094,6 +1051,19 @@ Address the follow-up instructions above. Review the previous work and make the
errorType: errorInfo.type,
projectPath,
});
// Track this failure and check if we should pause auto mode
const shouldPause = this.trackFailureAndCheckPause({
type: errorInfo.type,
message: errorInfo.message,
});
if (shouldPause) {
this.signalShouldPause({
type: errorInfo.type,
message: errorInfo.message,
});
}
}
} finally {
this.runningFeatures.delete(featureId);
@@ -1372,18 +1342,43 @@ Format your response as a structured markdown document.`;
/**
* Get detailed info about all running agents
*/
getRunningAgents(): Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
}> {
return Array.from(this.runningFeatures.values()).map((rf) => ({
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
}));
async getRunningAgents(): Promise<
Array<{
featureId: string;
projectPath: string;
projectName: string;
isAutoMode: boolean;
title?: string;
description?: string;
}>
> {
const agents = await Promise.all(
Array.from(this.runningFeatures.values()).map(async (rf) => {
// Try to fetch feature data to get title and description
let title: string | undefined;
let description: string | undefined;
try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
}
} catch (error) {
// Silently ignore errors - title/description are optional
}
return {
featureId: rf.featureId,
projectPath: rf.projectPath,
projectName: path.basename(rf.projectPath),
isAutoMode: rf.isAutoMode,
title,
description,
};
})
);
return agents;
}
/**
@@ -1757,20 +1752,29 @@ Format your response as a structured markdown document.`;
/**
* Get the planning prompt prefix based on feature's planning mode
*/
private getPlanningPromptPrefix(feature: Feature): string {
private async getPlanningPromptPrefix(feature: Feature): Promise<string> {
const mode = feature.planningMode || 'skip';
if (mode === 'skip') {
return ''; // No planning phase
}
// Load prompts from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const planningPrompts: Record<string, string> = {
lite: prompts.autoMode.planningLite,
lite_with_approval: prompts.autoMode.planningLiteWithApproval,
spec: prompts.autoMode.planningSpec,
full: prompts.autoMode.planningFull,
};
// For lite mode, use the approval variant if requirePlanApproval is true
let promptKey: string = mode;
if (mode === 'lite' && feature.requirePlanApproval === true) {
promptKey = 'lite_with_approval';
}
const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS];
const planningPrompt = planningPrompts[promptKey];
if (!planningPrompt) {
return '';
}
@@ -1996,6 +2000,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
// Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({
cwd: workDir,
@@ -2003,6 +2013,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -2044,10 +2057,15 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
};
// Execute via provider
console.log(`[AutoMode] Starting stream for feature ${featureId}...`);
const stream = provider.executeQuery(executeOptions);
console.log(`[AutoMode] Stream created, starting to iterate...`);
// Initialize with previous content if this is a follow-up, with a separator
let responseText = previousContent
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
@@ -2085,6 +2103,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
};
streamLoop: for await (const msg of stream) {
console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || '');
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
@@ -2271,6 +2290,9 @@ After generating the revised spec, output:
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
let revisionText = '';
@@ -2408,6 +2430,9 @@ After generating the revised spec, output:
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
let taskOutput = '';
@@ -2497,6 +2522,9 @@ Implement all the changes described in the plan above.`;
cwd: workDir,
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
for await (const msg of continuationStream) {
@@ -2531,6 +2559,9 @@ Implement all the changes described in the plan above.`;
// Only emit progress for non-marker text (marker was already handled above)
if (!specDetected) {
console.log(
`[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}`
);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: block.text,

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,
@@ -172,7 +179,12 @@ export class ClaudeUsageService {
if (!settled) {
settled = true;
ptyProcess.kill();
reject(new Error('Command timed out'));
// Don't fail if we have data - return it instead
if (output.includes('Current session')) {
resolve(output);
} else {
reject(new Error('Command timed out'));
}
}
}, this.timeout);
@@ -186,6 +198,13 @@ export class ClaudeUsageService {
setTimeout(() => {
if (!settled) {
ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
setTimeout(() => {
if (!settled) {
ptyProcess.kill('SIGTERM');
}
}, 2000);
}
}, 2000);
}

View File

@@ -185,9 +185,8 @@ export class FeatureLoader {
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
const features: Feature[] = [];
for (const dir of featureDirs) {
// Load all features concurrently (secureFs has built-in concurrency limiting)
const featurePromises = featureDirs.map(async (dir) => {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
@@ -199,13 +198,13 @@ export class FeatureLoader {
logger.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
return null;
}
features.push(feature);
return feature as Feature;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
return null;
} else if (error instanceof SyntaxError) {
logger.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
@@ -216,8 +215,12 @@ export class FeatureLoader {
(error as Error).message
);
}
return null;
}
}
});
const results = await Promise.all(featurePromises);
const features = results.filter((f): f is Feature => f !== null);
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {

View File

@@ -0,0 +1,251 @@
/**
* MCP Test Service
*
* Provides functionality to test MCP server connections and list available tools.
* Supports stdio, SSE, and HTTP transport types.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
import type { SettingsService } from './settings-service.js';
const execAsync = promisify(exec);
const DEFAULT_TIMEOUT = 10000; // 10 seconds
const IS_WINDOWS = process.platform === 'win32';
export interface MCPTestResult {
success: boolean;
tools?: MCPToolInfo[];
error?: string;
connectionTime?: number;
serverInfo?: {
name?: string;
version?: string;
};
}
/**
* MCP Test Service for testing server connections and listing tools
*/
export class MCPTestService {
private settingsService: SettingsService;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
/**
* Test connection to an MCP server and list its tools
*/
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
const startTime = Date.now();
let client: Client | null = null;
let transport:
| StdioClientTransport
| SSEClientTransport
| StreamableHTTPClientTransport
| null = null;
try {
client = new Client({
name: 'automaker-mcp-test',
version: '1.0.0',
});
// Create transport based on server type
transport = await this.createTransport(serverConfig);
// Connect with timeout
await Promise.race([
client.connect(transport),
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
]);
// List tools with timeout
const toolsResult = await Promise.race([
client.listTools(),
this.timeout<{
tools: Array<{
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}>;
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
]);
const connectionTime = Date.now() - startTime;
// Convert tools to MCPToolInfo format
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
enabled: true,
})
);
return {
success: true,
tools,
connectionTime,
serverInfo: {
name: serverConfig.name,
version: undefined, // Could be extracted from server info if available
},
};
} catch (error) {
const connectionTime = Date.now() - startTime;
return {
success: false,
error: this.getErrorMessage(error),
connectionTime,
};
} finally {
// Clean up client connection and ensure process termination
await this.cleanupConnection(client, transport);
}
}
/**
* Clean up MCP client connection and terminate spawned processes
*
* On Windows, child processes spawned via 'cmd /c' don't get terminated when the
* parent process is killed. We use taskkill with /t flag to kill the entire process tree.
* This prevents orphaned MCP server processes that would spam logs with ping warnings.
*
* IMPORTANT: We must run taskkill BEFORE client.close() because:
* - client.close() kills only the parent cmd.exe process
* - This orphans the child node.exe processes before we can kill them
* - taskkill /t needs the parent PID to exist to traverse the process tree
*/
private async cleanupConnection(
client: Client | null,
transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null
): Promise<void> {
// Get the PID before any cleanup (only available for stdio transports)
const pid = transport instanceof StdioClientTransport ? transport.pid : null;
// On Windows with stdio transport, kill the entire process tree FIRST
// This must happen before client.close() which would orphan child processes
if (IS_WINDOWS && pid) {
try {
// taskkill /f = force, /t = kill process tree, /pid = process ID
await execAsync(`taskkill /f /t /pid ${pid}`);
} catch {
// Process may have already exited, which is fine
}
}
// Now do the standard close (may be a no-op if taskkill already killed everything)
if (client) {
try {
await client.close();
} catch {
// Expected if taskkill already terminated the process
}
}
}
/**
* Test server by ID (looks up config from settings)
*/
async testServerById(serverId: string): Promise<MCPTestResult> {
try {
const globalSettings = await this.settingsService.getGlobalSettings();
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
if (!serverConfig) {
return {
success: false,
error: `Server with ID "${serverId}" not found`,
};
}
return this.testServer(serverConfig);
} catch (error) {
return {
success: false,
error: this.getErrorMessage(error),
};
}
}
/**
* Create appropriate transport based on server type
*/
private async createTransport(
config: MCPServerConfig
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
if (config.type === 'sse') {
if (!config.url) {
throw new Error('URL is required for SSE transport');
}
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
const headers = config.headers;
return new SSEClientTransport(new URL(config.url), {
requestInit: headers ? { headers } : undefined,
eventSourceInit: headers
? {
fetch: (url: string | URL | Request, init?: RequestInit) => {
const fetchHeaders = new Headers(init?.headers || {});
for (const [key, value] of Object.entries(headers)) {
fetchHeaders.set(key, value);
}
return fetch(url, { ...init, headers: fetchHeaders });
},
}
: undefined,
});
}
if (config.type === 'http') {
if (!config.url) {
throw new Error('URL is required for HTTP transport');
}
return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers
? {
headers: config.headers,
}
: undefined,
});
}
// Default to stdio
if (!config.command) {
throw new Error('Command is required for stdio transport');
}
return new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
});
}
/**
* Create a timeout promise
*/
private timeout<T>(ms: number, message: string): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
/**
* Extract error message from unknown error
*/
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -124,6 +124,8 @@ export class SettingsService {
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
* compatibility during schema migrations.
*
* Also applies version-based migrations for breaking changes.
*
* @returns Promise resolving to complete GlobalSettings object
*/
async getGlobalSettings(): Promise<GlobalSettings> {
@@ -131,7 +133,7 @@ export class SettingsService {
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
// Apply any missing defaults (for backwards compatibility)
return {
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
keyboardShortcuts: {
@@ -139,6 +141,32 @@ export class SettingsService {
...settings.keyboardShortcuts,
},
};
// Version-based migrations
const storedVersion = settings.version || 1;
let needsSave = false;
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
// Sandbox mode can cause issues on some systems, so we're disabling it by default
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
result.version = SETTINGS_VERSION;
needsSave = true;
}
// Save migrated settings if needed
if (needsSave) {
try {
await ensureDataDir(this.dataDir);
await atomicWriteJson(settingsPath, result);
logger.info('Settings migration complete');
} catch (error) {
logger.error('Failed to save migrated settings:', error);
}
}
return result;
}
/**

View File

@@ -8,8 +8,18 @@
import * as pty from 'node-pty';
import { EventEmitter } from 'events';
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
// secureFs is used for user-controllable paths (working directory validation)
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js';
// System paths module handles shell binary checks and WSL detection
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
import {
systemPathExists,
systemPathReadFileSync,
getWslVersionPath,
getShellPaths,
} from '@automaker/platform';
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter {
/**
* Detect the best shell for the current platform
* Uses getShellPaths() to iterate through allowed shell paths
*/
detectShell(): { shell: string; args: string[] } {
const platform = os.platform();
const shellPaths = getShellPaths();
// Check if running in WSL
// Helper to get basename handling both path separators
const getBasename = (shellPath: string): string => {
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
};
// Helper to get shell args based on shell name
const getShellArgs = (shell: string): string[] => {
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
// PowerShell and cmd don't need --login
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
return [];
}
// sh doesn't support --login in all implementations
if (shellName === 'sh') {
return [];
}
// bash, zsh, and other POSIX shells support --login
return ['--login'];
};
// Check if running in WSL - prefer user's shell or bash with --login
if (platform === 'linux' && this.isWSL()) {
// In WSL, prefer the user's configured shell or bash
const userShell = process.env.SHELL || '/bin/bash';
if (fs.existsSync(userShell)) {
return { shell: userShell, args: ['--login'] };
const userShell = process.env.SHELL;
if (userShell) {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
}
} catch {
// Path not allowed, continue searching
}
}
}
}
// Fall back to first available POSIX shell
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
}
} catch {
// Path not allowed, continue
}
}
return { shell: '/bin/bash', args: ['--login'] };
}
switch (platform) {
case 'win32': {
// Windows: prefer PowerShell, fall back to cmd
const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
if (fs.existsSync(pwshCore)) {
return { shell: pwshCore, args: [] };
// For all platforms: first try user's shell if set
const userShell = process.env.SHELL;
if (userShell && platform !== 'win32') {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
}
} catch {
// Path not allowed, continue searching
}
}
if (fs.existsSync(pwsh)) {
return { shell: pwsh, args: [] };
}
return { shell: 'cmd.exe', args: [] };
}
case 'darwin': {
// macOS: prefer user's shell, then zsh, then bash
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ['--login'] };
}
if (fs.existsSync('/bin/zsh')) {
return { shell: '/bin/zsh', args: ['--login'] };
}
return { shell: '/bin/bash', args: ['--login'] };
}
case 'linux':
default: {
// Linux: prefer user's shell, then bash, then sh
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ['--login'] };
}
if (fs.existsSync('/bin/bash')) {
return { shell: '/bin/bash', args: ['--login'] };
}
return { shell: '/bin/sh', args: [] };
}
}
// Iterate through allowed shell paths and return first existing one
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
}
} catch {
// Path not allowed or doesn't exist, continue to next
}
}
// Ultimate fallbacks based on platform
if (platform === 'win32') {
return { shell: 'cmd.exe', args: [] };
}
return { shell: '/bin/sh', args: [] };
}
/**
@@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter {
isWSL(): boolean {
try {
// Check /proc/version for Microsoft/WSL indicators
if (fs.existsSync('/proc/version')) {
const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
const wslVersionPath = getWslVersionPath();
if (systemPathExists(wslVersionPath)) {
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
return version.includes('microsoft') || version.includes('wsl');
}
// Check for WSL environment variable
@@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter {
/**
* Validate and resolve a working directory path
* Includes basic sanitization against null bytes and path normalization
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
*/
private resolveWorkingDirectory(requestedCwd?: string): string {
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
const homeDir = os.homedir();
// If no cwd requested, use home
@@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter {
}
// Check if path exists and is a directory
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
// This prevents terminals from being opened in directories outside the allowed workspace
try {
const stat = fs.statSync(cwd);
if (stat.isDirectory()) {
const statResult = await secureFs.stat(cwd);
if (statResult.isDirectory()) {
return cwd;
}
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
console.warn(
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
);
return homeDir;
}
}
@@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter {
* Create a new terminal session
* Returns null if the maximum session limit has been reached
*/
createSession(options: TerminalOptions = {}): TerminalSession | null {
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Check session limit
if (this.sessions.size >= maxSessions) {
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
@@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter {
const shell = options.shell || detectedShell;
// Validate and resolve working directory
const cwd = this.resolveWorkingDirectory(options.cwd);
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
const cwd = await this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
// These settings ensure consistent terminal behavior across platforms
// First, create a clean copy of process.env excluding Automaker-specific variables
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
const cleanEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !automakerEnvVars.includes(key)) {
cleanEnv[key] = value;
}
}
const env: Record<string, string> = {
...process.env,
...cleanEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'automaker-terminal',