Merge branch 'main' into fix/windows-mcp-orphaned-processes

This commit is contained in:
webdevcody
2026-01-01 02:09:54 -05:00
97 changed files with 4348 additions and 3225 deletions

View File

@@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY=
# CORS origin - which domains can access the API
# Use "*" for development, set specific origin for production
CORS_ORIGIN=*
CORS_ORIGIN=http://localhost:3007
# ============================================
# OPTIONAL - Server

View File

@@ -1,10 +1,13 @@
{
"name": "@automaker/server",
"version": "0.1.0",
"version": "0.7.2",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"private": true,
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"type": "module",
"main": "dist/index.js",
"scripts": {
@@ -21,35 +24,35 @@
"test:unit": "vitest run tests/unit"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.72",
"@automaker/dependency-resolver": "^1.0.0",
"@automaker/git-utils": "^1.0.0",
"@automaker/model-resolver": "^1.0.0",
"@automaker/platform": "^1.0.0",
"@automaker/prompts": "^1.0.0",
"@automaker/types": "^1.0.0",
"@automaker/utils": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"morgan": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "0.1.76",
"@automaker/dependency-resolver": "1.0.0",
"@automaker/git-utils": "1.0.0",
"@automaker/model-resolver": "1.0.0",
"@automaker/platform": "1.0.0",
"@automaker/prompts": "1.0.0",
"@automaker/types": "1.0.0",
"@automaker/utils": "1.0.0",
"@modelcontextprotocol/sdk": "1.25.1",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"dotenv": "17.2.3",
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.3"
"ws": "8.18.3"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/morgan": "^1.9.10",
"@types/node": "^22",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.16",
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^4.0.16"
"@types/cookie": "0.6.0",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/morgan": "1.9.10",
"@types/node": "22.19.3",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "4.0.16",
"@vitest/ui": "4.0.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

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

View File

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

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

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

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

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

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

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

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

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

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

@@ -190,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>();
@@ -200,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
*/
@@ -214,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 = {
@@ -502,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,
@@ -529,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`);
@@ -689,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;
}
@@ -926,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,
@@ -941,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);
@@ -1940,7 +2063,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`
@@ -1978,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') {
@@ -2433,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

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

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

View File

@@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise<TestRepo> {
// Initialize git repo
await execAsync('git init', { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it
const gitEnv = {
...process.env,
GIT_AUTHOR_NAME: 'Test User',
GIT_AUTHOR_EMAIL: 'test@example.com',
GIT_COMMITTER_NAME: 'Test User',
GIT_COMMITTER_EMAIL: 'test@example.com',
};
// Create initial commit
await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync('git add .', { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });

View File

@@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
await execAsync('git init', { cwd: repoPath });
await execAsync('git config user.email "test@example.com"', {
cwd: repoPath,
});
await execAsync('git config user.name "Test User"', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config
// Intentionally skip creating an initial commit
}

View File

@@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TerminalService, getTerminalService } from '@/services/terminal-service.js';
import * as pty from 'node-pty';
import * as os from 'os';
import * as fs from 'fs';
import * as platform from '@automaker/platform';
import * as secureFs from '@/lib/secure-fs.js';
vi.mock('node-pty');
vi.mock('fs');
vi.mock('os');
vi.mock('@automaker/platform', async () => {
const actual = await vi.importActual('@automaker/platform');
return {
...actual,
systemPathExists: vi.fn(),
systemPathReadFileSync: vi.fn(),
getWslVersionPath: vi.fn(),
getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing
isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests
};
});
vi.mock('@/lib/secure-fs.js');
describe('terminal-service.ts', () => {
let service: TerminalService;
let mockPtyProcess: any;
// Shell paths for each platform (matching system-paths.ts)
const linuxShellPaths = [
'/bin/zsh',
'/bin/bash',
'/bin/sh',
'/usr/bin/zsh',
'/usr/bin/bash',
'/usr/bin/sh',
'/usr/local/bin/zsh',
'/usr/local/bin/bash',
'/opt/homebrew/bin/zsh',
'/opt/homebrew/bin/bash',
'zsh',
'bash',
'sh',
];
const windowsShellPaths = [
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe',
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'C:\\Windows\\System32\\cmd.exe',
'pwsh.exe',
'pwsh',
'powershell.exe',
'powershell',
'cmd.exe',
'cmd',
];
beforeEach(() => {
vi.clearAllMocks();
service = new TerminalService();
@@ -29,6 +71,13 @@ describe('terminal-service.ts', () => {
vi.mocked(os.homedir).mockReturnValue('/home/user');
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue('x64');
// Default mocks for system paths and secureFs
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('');
vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version');
vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
});
afterEach(() => {
@@ -38,7 +87,8 @@ describe('terminal-service.ts', () => {
describe('detectShell', () => {
it('should detect PowerShell Core on Windows when available', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe';
});
@@ -50,7 +100,8 @@ describe('terminal-service.ts', () => {
it('should fall back to PowerShell on Windows if Core not available', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
});
@@ -62,7 +113,8 @@ describe('terminal-service.ts', () => {
it('should fall back to cmd.exe on Windows if no PowerShell', () => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths);
vi.mocked(platform.systemPathExists).mockReturnValue(false);
const result = service.detectShell();
@@ -73,7 +125,7 @@ describe('terminal-service.ts', () => {
it('should detect user shell on macOS', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(platform.systemPathExists).mockReturnValue(true);
const result = service.detectShell();
@@ -84,7 +136,7 @@ describe('terminal-service.ts', () => {
it('should fall back to zsh on macOS if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === '/bin/zsh';
});
@@ -97,7 +149,10 @@ describe('terminal-service.ts', () => {
it('should fall back to bash on macOS if zsh not available', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false);
// zsh not available, but bash is
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === '/bin/bash';
});
const result = service.detectShell();
@@ -108,7 +163,7 @@ describe('terminal-service.ts', () => {
it('should detect user shell on Linux', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(platform.systemPathExists).mockReturnValue(true);
const result = service.detectShell();
@@ -119,7 +174,7 @@ describe('terminal-service.ts', () => {
it('should fall back to bash on Linux if user shell not available', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockImplementation((path: any) => {
vi.mocked(platform.systemPathExists).mockImplementation((path: string) => {
return path === '/bin/bash';
});
@@ -132,7 +187,7 @@ describe('terminal-service.ts', () => {
it('should fall back to sh on Linux if bash not available', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({});
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(platform.systemPathExists).mockReturnValue(false);
const result = service.detectShell();
@@ -143,8 +198,10 @@ describe('terminal-service.ts', () => {
it('should detect WSL and use appropriate shell', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
'Linux version 5.10.0-microsoft-standard-WSL2'
);
const result = service.detectShell();
@@ -155,43 +212,45 @@ describe('terminal-service.ts', () => {
describe('isWSL', () => {
it('should return true if /proc/version contains microsoft', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2');
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue(
'Linux version 5.10.0-microsoft-standard-WSL2'
);
expect(service.isWSL()).toBe(true);
});
it('should return true if /proc/version contains wsl', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2');
expect(service.isWSL()).toBe(true);
});
it('should return true if WSL_DISTRO_NAME is set', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' });
expect(service.isWSL()).toBe(true);
});
it('should return true if WSLENV is set', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' });
expect(service.isWSL()).toBe(true);
});
it('should return false if not in WSL', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(platform.systemPathExists).mockReturnValue(false);
vi.spyOn(process, 'env', 'get').mockReturnValue({});
expect(service.isWSL()).toBe(false);
});
it('should return false if error reading /proc/version', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
@@ -203,7 +262,7 @@ describe('terminal-service.ts', () => {
it('should return platform information', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(os.arch).mockReturnValue('x64');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const info = service.getPlatformInfo();
@@ -216,20 +275,21 @@ describe('terminal-service.ts', () => {
});
describe('createSession', () => {
it('should create a new terminal session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should create a new terminal session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({
const session = await service.createSession({
cwd: '/test/dir',
cols: 100,
rows: 30,
});
expect(session.id).toMatch(/^term-/);
expect(session.cwd).toBe('/test/dir');
expect(session.shell).toBe('/bin/bash');
expect(session).not.toBeNull();
expect(session!.id).toMatch(/^term-/);
expect(session!.cwd).toBe('/test/dir');
expect(session!.shell).toBe('/bin/bash');
expect(pty.spawn).toHaveBeenCalledWith(
'/bin/bash',
['--login'],
@@ -241,12 +301,12 @@ describe('terminal-service.ts', () => {
);
});
it('should use default cols and rows if not provided', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should use default cols and rows if not provided', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
service.createSession();
await service.createSession();
expect(pty.spawn).toHaveBeenCalledWith(
expect.any(String),
@@ -258,66 +318,68 @@ describe('terminal-service.ts', () => {
);
});
it('should fall back to home directory if cwd does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockImplementation(() => {
throw new Error('ENOENT');
});
it('should fall back to home directory if cwd does not exist', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT'));
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({
const session = await service.createSession({
cwd: '/nonexistent',
});
expect(session.cwd).toBe('/home/user');
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/home/user');
});
it('should fall back to home directory if cwd is not a directory', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any);
it('should fall back to home directory if cwd is not a directory', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({
const session = await service.createSession({
cwd: '/file.txt',
});
expect(session.cwd).toBe('/home/user');
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/home/user');
});
it('should fix double slashes in path', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should fix double slashes in path', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({
const session = await service.createSession({
cwd: '//test/dir',
});
expect(session.cwd).toBe('/test/dir');
expect(session).not.toBeNull();
expect(session!.cwd).toBe('/test/dir');
});
it('should preserve WSL UNC paths', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should preserve WSL UNC paths', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession({
const session = await service.createSession({
cwd: '//wsl$/Ubuntu/home',
});
expect(session.cwd).toBe('//wsl$/Ubuntu/home');
expect(session).not.toBeNull();
expect(session!.cwd).toBe('//wsl$/Ubuntu/home');
});
it('should handle data events from PTY', () => {
it('should handle data events from PTY', async () => {
vi.useFakeTimers();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const dataCallback = vi.fn();
service.onData(dataCallback);
service.createSession();
await service.createSession();
// Simulate data event
const onDataHandler = mockPtyProcess.onData.mock.calls[0][0];
@@ -331,33 +393,34 @@ describe('terminal-service.ts', () => {
vi.useRealTimers();
});
it('should handle exit events from PTY', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should handle exit events from PTY', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const exitCallback = vi.fn();
service.onExit(exitCallback);
const session = service.createSession();
const session = await service.createSession();
// Simulate exit event
const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0];
onExitHandler({ exitCode: 0 });
expect(exitCallback).toHaveBeenCalledWith(session.id, 0);
expect(service.getSession(session.id)).toBeUndefined();
expect(session).not.toBeNull();
expect(exitCallback).toHaveBeenCalledWith(session!.id, 0);
expect(service.getSession(session!.id)).toBeUndefined();
});
});
describe('write', () => {
it('should write data to existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should write data to existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession();
const result = service.write(session.id, 'ls\n');
const session = await service.createSession();
const result = service.write(session!.id, 'ls\n');
expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n');
@@ -372,13 +435,13 @@ describe('terminal-service.ts', () => {
});
describe('resize', () => {
it('should resize existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should resize existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession();
const result = service.resize(session.id, 120, 40);
const session = await service.createSession();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
@@ -391,30 +454,30 @@ describe('terminal-service.ts', () => {
expect(mockPtyProcess.resize).not.toHaveBeenCalled();
});
it('should handle resize errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should handle resize errors', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.resize.mockImplementation(() => {
throw new Error('Resize failed');
});
const session = service.createSession();
const result = service.resize(session.id, 120, 40);
const session = await service.createSession();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(false);
});
});
describe('killSession', () => {
it('should kill existing session', () => {
it('should kill existing session', async () => {
vi.useFakeTimers();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession();
const result = service.killSession(session.id);
const session = await service.createSession();
const result = service.killSession(session!.id);
expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM');
@@ -423,7 +486,7 @@ describe('terminal-service.ts', () => {
vi.advanceTimersByTime(1000);
expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL');
expect(service.getSession(session.id)).toBeUndefined();
expect(service.getSession(session!.id)).toBeUndefined();
vi.useRealTimers();
});
@@ -434,29 +497,29 @@ describe('terminal-service.ts', () => {
expect(result).toBe(false);
});
it('should handle kill errors', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should handle kill errors', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error('Kill failed');
});
const session = service.createSession();
const result = service.killSession(session.id);
const session = await service.createSession();
const result = service.killSession(session!.id);
expect(result).toBe(false);
});
});
describe('getSession', () => {
it('should return existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should return existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession();
const retrieved = service.getSession(session.id);
const session = await service.createSession();
const retrieved = service.getSession(session!.id);
expect(retrieved).toBe(session);
});
@@ -469,15 +532,15 @@ describe('terminal-service.ts', () => {
});
describe('getScrollback', () => {
it('should return scrollback buffer for existing session', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should return scrollback buffer for existing session', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session = service.createSession();
session.scrollbackBuffer = 'test scrollback';
const session = await service.createSession();
session!.scrollbackBuffer = 'test scrollback';
const scrollback = service.getScrollback(session.id);
const scrollback = service.getScrollback(session!.id);
expect(scrollback).toBe('test scrollback');
});
@@ -490,19 +553,21 @@ describe('terminal-service.ts', () => {
});
describe('getAllSessions', () => {
it('should return all active sessions', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should return all active sessions', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = service.createSession({ cwd: '/dir1' });
const session2 = service.createSession({ cwd: '/dir2' });
const session1 = await service.createSession({ cwd: '/dir1' });
const session2 = await service.createSession({ cwd: '/dir2' });
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2);
expect(sessions[0].id).toBe(session1.id);
expect(sessions[1].id).toBe(session2.id);
expect(session1).not.toBeNull();
expect(session2).not.toBeNull();
expect(sessions[0].id).toBe(session1!.id);
expect(sessions[1].id).toBe(session2!.id);
expect(sessions[0].cwd).toBe('/dir1');
expect(sessions[1].cwd).toBe('/dir2');
});
@@ -535,30 +600,32 @@ describe('terminal-service.ts', () => {
});
describe('cleanup', () => {
it('should clean up all sessions', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should clean up all sessions', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
const session1 = service.createSession();
const session2 = service.createSession();
const session1 = await service.createSession();
const session2 = await service.createSession();
service.cleanup();
expect(service.getSession(session1.id)).toBeUndefined();
expect(service.getSession(session2.id)).toBeUndefined();
expect(session1).not.toBeNull();
expect(session2).not.toBeNull();
expect(service.getSession(session1!.id)).toBeUndefined();
expect(service.getSession(session2!.id)).toBeUndefined();
expect(service.getAllSessions()).toHaveLength(0);
});
it('should handle cleanup errors gracefully', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any);
it('should handle cleanup errors gracefully', async () => {
vi.mocked(platform.systemPathExists).mockReturnValue(true);
vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any);
vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' });
mockPtyProcess.kill.mockImplementation(() => {
throw new Error('Kill failed');
});
service.createSession();
await service.createSession();
expect(() => service.cleanup()).not.toThrow();
});