mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge upstream/main into fix/pipeline-resume-edge-cases
Resolved conflict in card-actions.tsx by:
- Keeping pipeline_status check from our branch (supports pipeline_step_* statuses)
- Adopting simplified Resume button logic from main (removed hasContext check and onVerify fallback)
The Resume button now appears for features with:
- status === 'in_progress'
- status.startsWith('pipeline_')
This combines our pipeline support fix with main's simplified button rendering logic.
This commit is contained in:
@@ -133,7 +133,11 @@ app.use(
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
||||
if (
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:') ||
|
||||
origin.startsWith('http://[::1]:')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as secureFs from './secure-fs.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
|
||||
@@ -41,8 +41,8 @@ setInterval(() => {
|
||||
*/
|
||||
function loadSessions(): void {
|
||||
try {
|
||||
if (fs.existsSync(SESSIONS_FILE)) {
|
||||
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
|
||||
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 }]
|
||||
>;
|
||||
@@ -74,9 +74,9 @@ function loadSessions(): void {
|
||||
*/
|
||||
async function saveSessions(): Promise<void> {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
|
||||
const sessions = Array.from(validSessions.entries());
|
||||
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
@@ -101,8 +101,8 @@ function ensureApiKey(): string {
|
||||
|
||||
// Try to read from file
|
||||
try {
|
||||
if (fs.existsSync(API_KEY_FILE)) {
|
||||
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
|
||||
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;
|
||||
@@ -115,8 +115,8 @@ function ensureApiKey(): string {
|
||||
// Generate new key
|
||||
const newKey = crypto.randomUUID();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
|
||||
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
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);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||
@@ -47,6 +48,128 @@ export function validateWorkingDirectory(cwd: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known cloud storage path patterns where sandbox mode is incompatible.
|
||||
*
|
||||
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
|
||||
* cloud storage providers' virtual filesystem implementations. This causes the
|
||||
* Claude process to exit with code 1 when sandbox is enabled for these paths.
|
||||
*
|
||||
* Affected providers (macOS paths):
|
||||
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
|
||||
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
|
||||
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
|
||||
* - iCloud Drive: ~/Library/Mobile Documents/
|
||||
* - Box: ~/Library/CloudStorage/Box-*
|
||||
*
|
||||
* @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue)
|
||||
*/
|
||||
|
||||
/**
|
||||
* macOS-specific cloud storage patterns that appear under ~/Library/
|
||||
* These are specific enough to use with includes() safely.
|
||||
*/
|
||||
const MACOS_CLOUD_STORAGE_PATTERNS = [
|
||||
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
|
||||
'/Library/Mobile Documents/', // iCloud Drive on macOS
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generic cloud storage folder names that need to be anchored to the home directory
|
||||
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
|
||||
*/
|
||||
const HOME_ANCHORED_CLOUD_FOLDERS = [
|
||||
'Google Drive', // Google Drive on some systems
|
||||
'Dropbox', // Dropbox on Linux/alternative installs
|
||||
'OneDrive', // OneDrive on Linux/alternative installs
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if a path is within a cloud storage location.
|
||||
*
|
||||
* Cloud storage providers use virtual filesystem implementations that are
|
||||
* incompatible with the Claude CLI sandbox feature, causing process crashes.
|
||||
*
|
||||
* Uses two detection strategies:
|
||||
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
|
||||
* 2. Generic folder names - anchored to home directory to avoid false positives
|
||||
*
|
||||
* @param cwd - The working directory path to check
|
||||
* @returns true if the path is in a cloud storage location
|
||||
*/
|
||||
export function isCloudStoragePath(cwd: string): boolean {
|
||||
const resolvedPath = path.resolve(cwd);
|
||||
|
||||
// Check macOS-specific patterns (these are specific enough to use includes)
|
||||
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check home-anchored patterns to avoid false positives
|
||||
// e.g., /home/user/my-project-about-dropbox/ should NOT match
|
||||
const home = os.homedir();
|
||||
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
|
||||
const cloudPath = path.join(home, folder);
|
||||
// Check if resolved path starts with the cloud storage path followed by a separator
|
||||
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
|
||||
if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sandbox compatibility check
|
||||
*/
|
||||
export interface SandboxCheckResult {
|
||||
/** Whether sandbox should be enabled */
|
||||
enabled: boolean;
|
||||
/** If disabled, the reason why */
|
||||
disabledReason?: 'cloud_storage' | 'user_setting';
|
||||
/** Human-readable message for logging/UI */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if sandbox mode should be enabled for a given configuration.
|
||||
*
|
||||
* Sandbox mode is automatically disabled for cloud storage paths because the
|
||||
* Claude CLI sandbox feature is incompatible with virtual filesystem
|
||||
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
|
||||
*
|
||||
* @param cwd - The working directory
|
||||
* @param enableSandboxMode - User's sandbox mode setting
|
||||
* @returns SandboxCheckResult with enabled status and reason if disabled
|
||||
*/
|
||||
export function checkSandboxCompatibility(
|
||||
cwd: string,
|
||||
enableSandboxMode?: boolean
|
||||
): SandboxCheckResult {
|
||||
// User has explicitly disabled sandbox mode
|
||||
if (enableSandboxMode === false) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'user_setting',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for cloud storage incompatibility (applies when enabled or undefined)
|
||||
if (isCloudStoragePath(cwd)) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'cloud_storage',
|
||||
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool presets for different use cases
|
||||
*/
|
||||
@@ -381,7 +504,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
* - Full tool access for code modification
|
||||
* - Standard turns for interactive sessions
|
||||
* - Model priority: explicit model > session model > chat default
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
@@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
@@ -425,7 +551,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* - Full tool access for code modification and implementation
|
||||
* - Extended turns for thorough feature implementation
|
||||
* - Uses default model (can be overridden)
|
||||
* - Sandbox mode controlled by enableSandboxMode setting
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build MCP-related options
|
||||
const mcpOptions = buildMcpOptions(config);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
@@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(config.enableSandboxMode && {
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function getEnableSandboxModeSetting(
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.enableSandboxMode ?? true;
|
||||
const result = globalSettings.enableSandboxMode ?? false;
|
||||
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
33
apps/server/src/lib/version.ts
Normal file
33
apps/server/src/lib/version.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -15,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';
|
||||
@@ -57,6 +83,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
systemPrompt,
|
||||
maxTurns,
|
||||
cwd,
|
||||
// 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 }),
|
||||
|
||||
@@ -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()}`
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*
|
||||
* NOTE: Only the basic health check (/) is unauthenticated.
|
||||
* 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 { createEnvironmentHandler } from './routes/environment.js';
|
||||
|
||||
/**
|
||||
* Create unauthenticated health routes (basic check only)
|
||||
@@ -18,6 +19,10 @@ export function createHealthRoutes(): Router {
|
||||
// Basic health check - no sensitive info
|
||||
router.get('/', createIndexHandler());
|
||||
|
||||
// Environment info including containerization status
|
||||
// This is unauthenticated so the UI can check on startup
|
||||
router.get('/environment', createEnvironmentHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
20
apps/server/src/routes/health/routes/environment.ts
Normal file
20
apps/server/src/routes/health/routes/environment.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -208,6 +208,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>();
|
||||
@@ -218,12 +222,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
|
||||
*/
|
||||
@@ -232,6 +313,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 = {
|
||||
@@ -520,6 +604,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,
|
||||
@@ -547,6 +634,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`);
|
||||
@@ -707,6 +809,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;
|
||||
}
|
||||
|
||||
@@ -1180,6 +1287,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,
|
||||
@@ -1195,6 +1305,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);
|
||||
@@ -2194,7 +2317,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
};
|
||||
|
||||
// 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`
|
||||
@@ -2232,6 +2357,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') {
|
||||
@@ -2687,6 +2813,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,
|
||||
|
||||
@@ -179,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);
|
||||
|
||||
@@ -193,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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,10 +9,14 @@ 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;
|
||||
@@ -41,6 +45,11 @@ export class MCPTestService {
|
||||
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({
|
||||
@@ -49,7 +58,7 @@ export class MCPTestService {
|
||||
});
|
||||
|
||||
// Create transport based on server type
|
||||
const transport = await this.createTransport(serverConfig);
|
||||
transport = await this.createTransport(serverConfig);
|
||||
|
||||
// Connect with timeout
|
||||
await Promise.race([
|
||||
@@ -98,13 +107,47 @@ export class MCPTestService {
|
||||
connectionTime,
|
||||
};
|
||||
} finally {
|
||||
// Clean up client connection
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user