diff --git a/.claude/commands/validate-build.md b/.claude/commands/validate-build.md new file mode 100644 index 00000000..790992b1 --- /dev/null +++ b/.claude/commands/validate-build.md @@ -0,0 +1,49 @@ +# Project Build and Fix Command + +Run all builds and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run the build** + + ```bash + npm run build + ``` + + This builds all packages and the UI application. + +2. **If the build succeeds**, report success and stop. + +3. **If the build fails**, analyze the failures: + - Note which build step failed and the error messages + - Check for TypeScript compilation errors, missing dependencies, or configuration issues + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the failure**: + - **If the failure is due to intentional changes** (new features, refactoring, dependency updates): + - Fix any TypeScript type errors introduced by the changes + - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts) + - Ensure all new dependencies are properly installed + - Fix import paths or module resolution issues + + - **If the failure appears to be a regression** (broken imports, missing files, configuration errors): + - Fix the source code to restore the build + - Check for accidentally deleted files or broken references + - Verify build configuration files are correct + +5. **Common build issues to check**: + - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports + - **Missing dependencies**: Run `npm install` if packages are missing + - **Import/export errors**: Fix incorrect import paths or missing exports + - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs + - **Package build order**: Ensure `build:packages` completes before building apps + +6. **How to decide if it's intentional vs regression**: + - Look at the git diff and commit messages + - If the change was deliberate and introduced new code that needs fixing → fix the new code + - If the change broke existing functionality that should still build → fix the regression + - When in doubt, ask the user + +7. **After making fixes**, re-run the build to verify everything compiles successfully. + +8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.). diff --git a/.claude/commands/validate-tests.md b/.claude/commands/validate-tests.md new file mode 100644 index 00000000..3a19b5d1 --- /dev/null +++ b/.claude/commands/validate-tests.md @@ -0,0 +1,36 @@ +# Project Test and Fix Command + +Run all tests and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run all tests** + + ```bash + npm run test:all + ``` + +2. **If all tests pass**, report success and stop. + +3. **If any tests fail**, analyze the failures: + - Note which tests failed and their error messages + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the change**: + - **If the logic change is intentional** (new feature, refactor, behavior change): + - Update the failing tests to match the new expected behavior + - The tests should reflect what the code NOW does correctly + + - **If the logic change appears to be a bug** (regression, unintended side effect): + - Fix the source code to restore the expected behavior + - Do NOT modify the tests - they are catching a real bug + +5. **How to decide if it's a bug vs intentional change**: + - Look at the git diff and commit messages + - If the change was deliberate and the test expectations are now outdated → update tests + - If the change broke existing functionality that should still work → fix the code + - When in doubt, ask the user + +6. **After making fixes**, re-run the tests to verify everything passes. + +7. **Report summary** of what was fixed (tests updated vs code fixed). diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..42126c05 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/apps/server/.env.example b/apps/server/.env.example index 9fbb4cbd..3afb5d4e 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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 diff --git a/apps/server/package.json b/apps/server/package.json index 1eb415a8..d923fa9b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -5,6 +5,9 @@ "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.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", "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" } } diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index d8629d61..5f24b319 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -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 { 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); diff --git a/apps/server/src/lib/secure-fs.ts b/apps/server/src/lib/secure-fs.ts index 30095285..de8dba26 100644 --- a/apps/server/src/lib/secure-fs.ts +++ b/apps/server/src/lib/secure-fs.ts @@ -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, diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 286a733f..33494535 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -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 { + const env: Record = {}; + 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 }), diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index e4821b4a..bce87740 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -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): Record | null = null; try { - stat = fs.statSync(actualPath); + stat = secureFs.statSync(actualPath); logger.info( `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` ); diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts index c3cd4c65..68259291 100644 --- a/apps/server/src/routes/fs/routes/browse.ts +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -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: diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 374fe18f..8659eb5a 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -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' }); diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 688fdbc5..083a8703 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -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; } diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 00000000..ee5f7d53 --- /dev/null +++ b/apps/server/src/routes/health/routes/environment.ts @@ -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); + }; +} diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts index 097d7a6c..ebac7644 100644 --- a/apps/server/src/routes/setup/common.ts +++ b/apps/server/src/routes/setup/common.ts @@ -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 { /** * Helper to persist API keys to .env file + * Uses centralized secureFs.writeEnvKey for path validation */ export async function persistApiKeyToEnv(key: string, value: string): Promise { 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); diff --git a/apps/server/src/routes/setup/get-claude-status.ts b/apps/server/src/routes/setup/get-claude-status.ts index 922d363f..3ddd8ed4 100644 --- a/apps/server/src/routes/setup/get-claude-status.ts +++ b/apps/server/src/routes/setup/get-claude-status.ts @@ -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 { diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index e64ff6b7..0fee1b8b 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -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 { 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); diff --git a/apps/server/src/routes/setup/routes/gh-status.ts b/apps/server/src/routes/setup/routes/gh-status.ts index e48b5c25..f78bbd6d 100644 --- a/apps/server/src/routes/setup/routes/gh-status.ts +++ b/apps/server/src/routes/setup/routes/gh-status.ts @@ -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 { 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 } diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts index a7f42509..7d4b5383 100644 --- a/apps/server/src/routes/terminal/routes/sessions.ts +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -22,12 +22,12 @@ export function createSessionsListHandler() { } export function createSessionsCreateHandler() { - return (req: Request, res: Response): void => { + return async (req: Request, res: Response): Promise => { 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, diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index fbf86d49..4f1b937c 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -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) => { diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 7d59633e..81a1585a 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -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 { 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 { // 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 = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !automakerEnvVars.includes(key)) { + cleanEnv[key] = value; + } + } + const env: Record = { - ...process.env, + ...cleanEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'automaker-terminal', diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index 44e823b0..88660f7f 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -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(); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index b069e28c..fb846c15 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -10,6 +10,9 @@ "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -35,87 +38,87 @@ "dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" }, "dependencies": { - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/types": "^1.0.0", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@lezer/highlight": "^1.2.3", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.141.6", - "@uiw/react-codemirror": "^4.25.4", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "dagre": "^0.8.5", - "dotenv": "^17.2.3", - "geist": "^1.5.1", - "lucide-react": "^0.562.0", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/theme-one-dark": "6.1.3", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "5.90.12", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", - "rehype-raw": "^7.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "usehooks-ts": "^3.1.1", - "zustand": "^5.0.9" + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zustand": "5.0.9" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "^1.29.2", - "lightningcss-darwin-x64": "^1.29.2", - "lightningcss-linux-arm-gnueabihf": "^1.29.2", - "lightningcss-linux-arm64-gnu": "^1.29.2", - "lightningcss-linux-arm64-musl": "^1.29.2", - "lightningcss-linux-x64-gnu": "^1.29.2", - "lightningcss-linux-x64-musl": "^1.29.2", - "lightningcss-win32-arm64-msvc": "^1.29.2", - "lightningcss-win32-x64-msvc": "^1.29.2" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@eslint/js": "^9.0.0", - "@playwright/test": "^1.57.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.141.7", - "@types/dagre": "^0.7.53", - "@types/node": "^22", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.50.0", - "@typescript-eslint/parser": "^8.50.0", - "@vitejs/plugin-react": "^5.1.2", - "cross-env": "^10.1.0", + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", "electron": "39.2.7", - "electron-builder": "^26.0.12", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", "typescript": "5.9.3", - "vite": "^7.3.0", - "vite-plugin-electron": "^0.29.0", - "vite-plugin-electron-renderer": "^0.14.6" + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" }, "build": { "appId": "com.automaker.app", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 80ba9af3..5ea2fb7b 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -49,6 +49,8 @@ export default defineConfig({ // Hide the API key banner to reduce log noise AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing + // Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true', }, }, // Frontend Vite dev server diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..32be56d4 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,90 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { useState } from 'react'; +import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRejectionScreen() { + const [copied, setCopied] = useState(false); + + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

+ You declined to accept the risks of running Automaker outside a sandbox environment. +

+
+ +
+
+ +
+

Run in Docker (Recommended)

+

+ Run Automaker in a containerized sandbox environment: +

+
+ {DOCKER_COMMAND} + +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx new file mode 100644 index 00000000..905d82a1 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -0,0 +1,112 @@ +/** + * Sandbox Risk Confirmation Dialog + * + * Shows when the app is running outside a containerized environment. + * Users must acknowledge the risks before proceeding. + */ + +import { useState } from 'react'; +import { ShieldAlert, Copy, Check } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +interface SandboxRiskDialogProps { + open: boolean; + onConfirm: () => void; + onDeny: () => void; +} + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + {}}> + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + showCloseButton={false} + > + + + + Sandbox Environment Not Detected + + +
+

+ Warning: This application is running outside of a containerized + sandbox environment. AI agents will have direct access to your filesystem and can + execute commands on your system. +

+ +
+

Potential Risks:

+
    +
  • Agents can read, modify, or delete files on your system
  • +
  • Agents can execute arbitrary commands and install software
  • +
  • Agents can access environment variables and credentials
  • +
  • Unintended side effects from agent actions may affect your system
  • +
+
+ +
+

+ For safer operation, consider running Automaker in Docker: +

+
+ {DOCKER_COMMAND} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5b3abeab..0a17eae1 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -432,6 +432,7 @@ export interface SaveImageResult { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openDirectory: () => Promise; openFile: (options?: object) => Promise; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 296fcfb1..a0455c98 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -294,6 +294,32 @@ export const verifySession = async (): Promise => { } }; +/** + * Check if the server is running in a containerized (sandbox) environment. + * This endpoint is unauthenticated so it can be checked before login. + */ +export const checkSandboxEnvironment = async (): Promise<{ + isContainerized: boolean; + error?: string; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/health/environment`, { + method: 'GET', + }); + + if (!response.ok) { + console.warn('[HTTP Client] Failed to check sandbox environment'); + return { isContainerized: false, error: 'Failed to check environment' }; + } + + const data = await response.json(); + return { isContainerized: data.isContainerized ?? false }; + } catch (error) { + console.error('[HTTP Client] Sandbox environment check failed:', error); + return { isContainerized: false, error: 'Network error' }; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' @@ -609,14 +635,15 @@ export class HttpApiClient implements ElectronAPI { const result = await this.post<{ success: boolean; path?: string; + isAllowed?: boolean; error?: string; }>('/api/fs/validate-path', { filePath: path }); - if (result.success && result.path) { + if (result.success && result.path && result.isAllowed !== false) { return { canceled: false, filePaths: [result.path] }; } - console.error('Invalid directory:', result.error); + console.error('Invalid directory:', result.error || 'Path not allowed'); return { canceled: true, filePaths: [] }; } @@ -1629,3 +1656,10 @@ export function getHttpApiClient(): HttpApiClient { } return httpApiClientInstance; } + +// Start API key initialization immediately when this module is imported +// This ensures the init promise is created early, even before React components mount +// The actual async work happens in the background and won't block module loading +initApiKey().catch((error) => { + console.error('[HTTP Client] Failed to initialize API key:', error); +}); diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index b2f7bd86..e56a2583 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -3,15 +3,36 @@ * * This version spawns the backend server and uses HTTP API for most operations. * Only native features (dialogs, shell) use IPC. + * + * SECURITY: All file system access uses centralized methods from @automaker/platform. */ import path from 'path'; import { spawn, execSync, ChildProcess } from 'child_process'; -import fs from 'fs'; import crypto from 'crypto'; import http, { Server } from 'http'; import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron'; -import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform'; +import { + findNodeExecutable, + buildEnhancedPath, + initAllowedPaths, + isPathAllowed, + getAllowedRootDirectory, + // Electron userData operations + setElectronUserDataPath, + electronUserDataReadFileSync, + electronUserDataWriteFileSync, + electronUserDataExists, + // Electron app bundle operations + setElectronAppPaths, + electronAppExists, + electronAppReadFileSync, + electronAppStatSync, + electronAppStat, + electronAppReadFile, + // System path operations + systemPathExists, +} from '@automaker/platform'; // Development environment const isDev = !app.isPackaged; @@ -64,21 +85,19 @@ let saveWindowBoundsTimeout: ReturnType | null = null; let apiKey: string | null = null; /** - * Get path to API key file in user data directory + * Get the relative path to API key file within userData */ -function getApiKeyPath(): string { - return path.join(app.getPath('userData'), '.api-key'); -} +const API_KEY_FILENAME = '.api-key'; /** * Ensure an API key exists - load from file or generate new one. * This key is passed to the server for CSRF protection. + * Uses centralized electronUserData methods for path validation. */ function ensureApiKey(): string { - const keyPath = getApiKeyPath(); try { - if (fs.existsSync(keyPath)) { - const key = fs.readFileSync(keyPath, 'utf-8').trim(); + if (electronUserDataExists(API_KEY_FILENAME)) { + const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim(); if (key) { apiKey = key; console.log('[Electron] Loaded existing API key'); @@ -92,7 +111,7 @@ function ensureApiKey(): string { // Generate new key apiKey = crypto.randomUUID(); try { - fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 }); + electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 }); console.log('[Electron] Generated new API key'); } catch (error) { console.error('[Electron] Failed to save API key:', error); @@ -102,6 +121,7 @@ function ensureApiKey(): string { /** * Get icon path - works in both dev and production, cross-platform + * Uses centralized electronApp methods for path validation. */ function getIconPath(): string | null { let iconFile: string; @@ -117,8 +137,13 @@ function getIconPath(): string | null { ? path.join(__dirname, '../public', iconFile) : path.join(__dirname, '../dist/public', iconFile); - if (!fs.existsSync(iconPath)) { - console.warn(`[Electron] Icon not found at: ${iconPath}`); + try { + if (!electronAppExists(iconPath)) { + console.warn(`[Electron] Icon not found at: ${iconPath}`); + return null; + } + } catch (error) { + console.warn(`[Electron] Icon check failed: ${iconPath}`, error); return null; } @@ -126,20 +151,18 @@ function getIconPath(): string | null { } /** - * Get path to window bounds settings file + * Relative path to window bounds settings file within userData */ -function getWindowBoundsPath(): string { - return path.join(app.getPath('userData'), 'window-bounds.json'); -} +const WINDOW_BOUNDS_FILENAME = 'window-bounds.json'; /** * Load saved window bounds from disk + * Uses centralized electronUserData methods for path validation. */ function loadWindowBounds(): WindowBounds | null { try { - const boundsPath = getWindowBoundsPath(); - if (fs.existsSync(boundsPath)) { - const data = fs.readFileSync(boundsPath, 'utf-8'); + if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) { + const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME); const bounds = JSON.parse(data) as WindowBounds; // Validate the loaded data has required fields if ( @@ -159,11 +182,11 @@ function loadWindowBounds(): WindowBounds | null { /** * Save window bounds to disk + * Uses centralized electronUserData methods for path validation. */ function saveWindowBounds(bounds: WindowBounds): void { try { - const boundsPath = getWindowBoundsPath(); - fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8'); + electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2)); console.log('[Electron] Window bounds saved'); } catch (error) { console.warn('[Electron] Failed to save window bounds:', (error as Error).message); @@ -241,6 +264,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds { /** * Start static file server for production builds + * Uses centralized electronApp methods for serving static files from app bundle. */ async function startStaticServer(): Promise { const staticPath = path.join(__dirname, '../dist'); @@ -253,20 +277,24 @@ async function startStaticServer(): Promise { } else if (!path.extname(filePath)) { // For client-side routing, serve index.html for paths without extensions const possibleFile = filePath + '.html'; - if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) { + try { + if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) { + filePath = path.join(staticPath, 'index.html'); + } else if (electronAppExists(possibleFile)) { + filePath = possibleFile; + } + } catch { filePath = path.join(staticPath, 'index.html'); - } else if (fs.existsSync(possibleFile)) { - filePath = possibleFile; } } - fs.stat(filePath, (err, stats) => { + electronAppStat(filePath, (err, stats) => { if (err || !stats?.isFile()) { filePath = path.join(staticPath, 'index.html'); } - fs.readFile(filePath, (error, content) => { - if (error) { + electronAppReadFile(filePath, (error, content) => { + if (error || !content) { response.writeHead(500); response.end('Server Error'); return; @@ -308,6 +336,7 @@ async function startStaticServer(): Promise { /** * Start the backend server + * Uses centralized methods for path validation. */ async function startServer(): Promise { // Find Node.js executable (handles desktop launcher scenarios) @@ -318,8 +347,20 @@ async function startServer(): Promise { const command = nodeResult.nodePath; // Validate that the found Node executable actually exists - if (command !== 'node' && !fs.existsSync(command)) { - throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + // systemPathExists is used because node-finder returns system paths + if (command !== 'node') { + let exists: boolean; + try { + exists = systemPathExists(command); + } catch (error) { + const originalError = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}` + ); + } + if (!exists) { + throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`); + } } let args: string[]; @@ -332,11 +373,22 @@ async function startServer(): Promise { const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx'); let tsxCliPath: string; - if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) { - tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); - } else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) { - tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); - } else { + // Check for tsx in app bundle paths + try { + if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs'); + } else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) { + tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs'); + } else { + try { + tsxCliPath = require.resolve('tsx/cli.mjs', { + paths: [path.join(__dirname, '../../server')], + }); + } catch { + throw new Error("Could not find tsx. Please run 'npm install' in the server directory."); + } + } + } catch { try { tsxCliPath = require.resolve('tsx/cli.mjs', { paths: [path.join(__dirname, '../../server')], @@ -351,7 +403,11 @@ async function startServer(): Promise { serverPath = path.join(process.resourcesPath, 'server', 'index.js'); args = [serverPath]; - if (!fs.existsSync(serverPath)) { + try { + if (!electronAppExists(serverPath)) { + throw new Error(`Server not found at: ${serverPath}`); + } + } catch { throw new Error(`Server not found at: ${serverPath}`); } } @@ -360,6 +416,13 @@ async function startServer(): Promise { ? path.join(process.resourcesPath, 'server', 'node_modules') : path.join(__dirname, '../../server/node_modules'); + // Server root directory - where .env file is located + // In dev: apps/server (not apps/server/src) + // In production: resources/server + const serverRoot = app.isPackaged + ? path.join(process.resourcesPath, 'server') + : path.join(__dirname, '../../server'); + // Build enhanced PATH that includes Node.js directory (cross-platform) const enhancedPath = buildEnhancedPath(command, process.env.PATH || ''); if (enhancedPath !== process.env.PATH) { @@ -383,10 +446,11 @@ async function startServer(): Promise { console.log('[Electron] Starting backend server...'); console.log('[Electron] Server path:', serverPath); + console.log('[Electron] Server root (cwd):', serverRoot); console.log('[Electron] NODE_PATH:', serverNodeModules); serverProcess = spawn(command, args, { - cwd: path.dirname(serverPath), + cwd: serverRoot, env, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -541,6 +605,28 @@ app.whenReady().then(async () => { console.warn('[Electron] Failed to set userData path:', (error as Error).message); } + // Initialize centralized path helpers for Electron + // This must be done before any file operations + setElectronUserDataPath(app.getPath('userData')); + + // In development mode, allow access to the entire project root (for source files, node_modules, etc.) + // In production, only allow access to the built app directory and resources + if (isDev) { + // __dirname is apps/ui/dist-electron, so go up 3 levels to get project root + const projectRoot = path.join(__dirname, '../../..'); + setElectronAppPaths([__dirname, projectRoot]); + } else { + setElectronAppPaths(__dirname, process.resourcesPath); + } + console.log('[Electron] Initialized path security helpers'); + + // Initialize security settings for path validation + // Set DATA_DIR before initializing so it's available for security checks + process.env.DATA_DIR = app.getPath('userData'); + // ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user + // (it will be passed to server process, but we also need it in main process for dialog validation) + initAllowedPaths(); + if (process.platform === 'darwin' && app.dock) { const iconPath = getIconPath(); if (iconPath) { @@ -631,6 +717,22 @@ ipcMain.handle('dialog:openDirectory', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); + + // Validate selected path against ALLOWED_ROOT_DIRECTORY if configured + if (!result.canceled && result.filePaths.length > 0) { + const selectedPath = result.filePaths[0]; + if (!isPathAllowed(selectedPath)) { + const allowedRoot = getAllowedRootDirectory(); + const errorMessage = allowedRoot + ? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}` + : 'The selected directory is not allowed.'; + + await dialog.showErrorBox('Directory Not Allowed', errorMessage); + + return { canceled: true, filePaths: [] }; + } + } + return result; }); @@ -736,3 +838,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { // Always use the smaller minimum width - horizontal scrolling handles any overflow mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT); }); + +// Quit the application (used when user denies sandbox risk confirmation) +ipcMain.handle('app:quit', () => { + console.log('[Electron] Quitting application via IPC request'); + app.quit(); +}); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index 4a1aa6f1..0955ab1b 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Window management updateMinWidth: (sidebarExpanded: boolean): Promise => ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded), + + // App control + quit: (): Promise => ipcRenderer.invoke('app:quit'), }); console.log('[Preload] Electron API exposed (TypeScript)'); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 23a4fa30..79262add 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -8,10 +8,21 @@ import { } from '@/contexts/file-browser-context'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import { getElectronAPI } from '@/lib/electron'; -import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; +import { getElectronAPI, isElectron } from '@/lib/electron'; +import { + initApiKey, + isElectronMode, + verifySession, + checkSandboxEnvironment, +} from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; +import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; +import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; + +// Session storage key for sandbox risk acknowledgment +const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; +const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied'; function RootLayoutContent() { const location = useLocation(); @@ -27,6 +38,20 @@ function RootLayoutContent() { const [isAuthenticated, setIsAuthenticated] = useState(false); const { openFileBrowser } = useFileBrowser(); + // Sandbox environment check state + type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; + const [sandboxStatus, setSandboxStatus] = useState(() => { + // Check if user previously denied in this session + if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) { + return 'denied'; + } + // Check if user previously acknowledged in this session + if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) { + return 'confirmed'; + } + return 'pending'; + }); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -73,6 +98,63 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Check sandbox environment on mount + useEffect(() => { + // Skip if already decided + if (sandboxStatus !== 'pending') { + return; + } + + const checkSandbox = async () => { + try { + const result = await checkSandboxEnvironment(); + + if (result.isContainerized) { + // Running in a container, no warning needed + setSandboxStatus('containerized'); + } else { + // Not containerized, show warning dialog + setSandboxStatus('needs-confirmation'); + } + } catch (error) { + console.error('[Sandbox] Failed to check environment:', error); + // On error, assume not containerized and show warning + setSandboxStatus('needs-confirmation'); + } + }; + + checkSandbox(); + }, [sandboxStatus]); + + // Handle sandbox risk confirmation + const handleSandboxConfirm = useCallback(() => { + sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true'); + setSandboxStatus('confirmed'); + }, []); + + // Handle sandbox risk denial + const handleSandboxDeny = useCallback(async () => { + sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true'); + + if (isElectron()) { + // In Electron mode, quit the application + // Use window.electronAPI directly since getElectronAPI() returns the HTTP client + try { + const electronAPI = window.electronAPI; + if (electronAPI?.quit) { + await electronAPI.quit(); + } else { + console.error('[Sandbox] quit() not available on electronAPI'); + } + } catch (error) { + console.error('[Sandbox] Failed to quit app:', error); + } + } else { + // In web mode, show rejection screen + setSandboxStatus('denied'); + } + }, []); + // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie @@ -201,11 +283,31 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; + // Show rejection screen if user denied sandbox risk (web mode only) + if (sandboxStatus === 'denied' && !isElectron()) { + return ; + } + + // Show loading while checking sandbox environment + if (sandboxStatus === 'pending') { + return ( +
+
Checking environment...
+
+ ); + } + // Show login page (full screen, no sidebar) if (isLoginRoute) { return (
+ {/* Show sandbox dialog on top of login page if needed */} +
); } @@ -228,6 +330,12 @@ function RootLayoutContent() { return (
+ {/* Show sandbox dialog on top of setup page if needed */} +
); } @@ -249,6 +357,13 @@ function RootLayoutContent() { }`} /> + + {/* Show sandbox dialog if needed */} + ); } diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 1c84e59a..281fc539 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -172,6 +172,7 @@ export const useSetupStore = create()( }), { name: 'automaker-setup', + version: 1, // Add version field for proper hydration (matches app-store pattern) partialize: (state) => ({ isFirstRun: state.isFirstRun, setupComplete: state.setupComplete, diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 44985d6b..15c61f8c 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -465,6 +465,7 @@ export interface AutoModeAPI { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; // Dialog APIs diff --git a/docker-compose.yml b/docker-compose.yml index 8bbf2e84..2026ff0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: # Optional - CORS origin (default allows all) - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} + + # Internal - indicates the API is running in a containerized sandbox environment + # This is used by the UI to determine if sandbox risk warnings should be shown + - IS_CONTAINERIZED=true volumes: # ONLY named volumes - these are isolated from your host filesystem # This volume persists data between restarts but is container-managed diff --git a/init.mjs b/init.mjs index 4fcf8b08..f9d7d69c 100644 --- a/init.mjs +++ b/init.mjs @@ -4,10 +4,14 @@ * Automaker - Cross-Platform Development Environment Setup and Launch Script * * This script works on Windows, macOS, and Linux. + * + * SECURITY NOTE: This script uses a restricted fs wrapper that only allows + * operations within the script's directory (__dirname). This is a standalone + * launch script that runs before the platform library is available. */ import { execSync } from 'child_process'; -import fs from 'fs'; +import fsNative from 'fs'; import http from 'http'; import path from 'path'; import readline from 'readline'; @@ -21,6 +25,45 @@ const crossSpawn = require('cross-spawn'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// ============================================================================= +// Restricted fs wrapper - only allows operations within __dirname +// ============================================================================= + +/** + * Validate that a path is within the script's directory + * @param {string} targetPath - Path to validate + * @returns {string} - Resolved path if valid + * @throws {Error} - If path is outside __dirname + */ +function validateScriptPath(targetPath) { + const resolved = path.resolve(__dirname, targetPath); + const normalizedBase = path.resolve(__dirname); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error( + `[init.mjs] Security: Path access denied outside script directory: ${targetPath}` + ); + } + return resolved; +} + +/** + * Restricted fs operations - only within script directory + */ +const fs = { + existsSync(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validateScriptPath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.createWriteStream(validated); + }, +}; + // Colors for terminal output (works on modern terminals including Windows) const colors = { green: '\x1b[0;32m', diff --git a/libs/dependency-resolver/package.json b/libs/dependency-resolver/package.json index 0ba6f756..4f7c30fd 100644 --- a/libs/dependency-resolver/package.json +++ b/libs/dependency-resolver/package.json @@ -25,12 +25,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/git-utils/package.json b/libs/git-utils/package.json index a34ac9af..ee8fbb79 100644 --- a/libs/git-utils/package.json +++ b/libs/git-utils/package.json @@ -18,13 +18,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0" + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/model-resolver/package.json b/libs/model-resolver/package.json index 742144f7..06a0d252 100644 --- a/libs/model-resolver/package.json +++ b/libs/model-resolver/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/package.json b/libs/platform/package.json index 35663d05..21729ef9 100644 --- a/libs/platform/package.json +++ b/libs/platform/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0", - "p-limit": "^6.2.0" + "@automaker/types": "1.0.0", + "p-limit": "6.2.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/platform/src/config/ports.ts b/libs/platform/src/config/ports.ts new file mode 100644 index 00000000..1089e966 --- /dev/null +++ b/libs/platform/src/config/ports.ts @@ -0,0 +1,8 @@ +/** + * Centralized port configuration for AutoMaker + * + * Re-exports from @automaker/types for backward compatibility. + * The canonical definition is in @automaker/types to allow browser-safe imports. + */ + +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from '@automaker/types'; diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index eba84101..81ffe224 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -55,3 +55,66 @@ export { type NodeFinderResult, type NodeFinderOptions, } from './node-finder.js'; + +// System paths for tool detection (GitHub CLI, Claude CLI, Node.js, etc.) +export * as systemPaths from './system-paths.js'; +export { + // CLI tool paths + getGitHubCliPaths, + getClaudeCliPaths, + getClaudeConfigDir, + getClaudeCredentialPaths, + getClaudeSettingsPath, + getClaudeStatsCachePath, + getClaudeProjectsDir, + getShellPaths, + getExtendedPath, + // Node.js paths + getNvmPaths, + getFnmPaths, + getNodeSystemPaths, + getScoopNodePath, + getChocolateyNodePath, + getWslVersionPath, + // System path operations + systemPathExists, + systemPathAccess, + systemPathIsExecutable, + systemPathReadFile, + systemPathReadFileSync, + systemPathWriteFileSync, + systemPathReaddir, + systemPathReaddirSync, + systemPathStatSync, + systemPathStat, + isAllowedSystemPath, + // High-level methods + findFirstExistingPath, + findGitHubCliPath, + findClaudeCliPath, + getClaudeAuthIndicators, + type ClaudeAuthIndicators, + // Electron userData operations + setElectronUserDataPath, + getElectronUserDataPath, + isElectronUserDataPath, + electronUserDataReadFileSync, + electronUserDataWriteFileSync, + electronUserDataExists, + // Script directory operations + setScriptBaseDir, + getScriptBaseDir, + scriptDirExists, + scriptDirMkdirSync, + scriptDirCreateWriteStream, + // Electron app bundle operations + setElectronAppPaths, + electronAppExists, + electronAppReadFileSync, + electronAppStatSync, + electronAppStat, + electronAppReadFile, +} from './system-paths.js'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './config/ports.js'; diff --git a/libs/platform/src/node-finder.ts b/libs/platform/src/node-finder.ts index ed2cbb03..cb771a00 100644 --- a/libs/platform/src/node-finder.ts +++ b/libs/platform/src/node-finder.ts @@ -3,12 +3,25 @@ * * Handles finding Node.js when the app is launched from desktop environments * (macOS Finder, Windows Explorer, Linux desktop) where PATH may be limited. + * + * Uses centralized system-paths module for all file system access. */ import { execSync } from 'child_process'; -import fs from 'fs'; import path from 'path'; import os from 'os'; +import { + systemPathExists, + systemPathIsExecutable, + systemPathReaddirSync, + systemPathReadFileSync, + getNvmPaths, + getFnmPaths, + getNodeSystemPaths, + getScoopNodePath, + getChocolateyNodePath, + getWslVersionPath, +} from './system-paths.js'; /** Pattern to match version directories (e.g., "v18.17.0", "18.17.0", "v18") */ const VERSION_DIR_PATTERN = /^v?\d+/; @@ -45,18 +58,11 @@ export interface NodeFinderOptions { /** * Check if a file exists and is executable - * On Windows, only checks existence (X_OK is not meaningful) + * Uses centralized systemPathIsExecutable for path validation */ function isExecutable(filePath: string): boolean { try { - if (process.platform === 'win32') { - // On Windows, fs.constants.X_OK is not meaningful - just check existence - fs.accessSync(filePath, fs.constants.F_OK); - } else { - // On Unix-like systems, check for execute permission - fs.accessSync(filePath, fs.constants.X_OK); - } - return true; + return systemPathIsExecutable(filePath); } catch { return false; } @@ -71,11 +77,14 @@ function findNodeFromVersionManager( basePath: string, binSubpath: string = 'bin/node' ): string | null { - if (!fs.existsSync(basePath)) return null; + try { + if (!systemPathExists(basePath)) return null; + } catch { + return null; + } try { - const allVersions = fs - .readdirSync(basePath) + const allVersions = systemPathReaddirSync(basePath) .filter((v) => VERSION_DIR_PATTERN.test(v)) // Semantic version sort - newest first using localeCompare with numeric option .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })); @@ -101,39 +110,30 @@ function findNodeFromVersionManager( /** * Find Node.js on macOS */ -function findNodeMacOS(homeDir: string): NodeFinderResult | null { - // Check Homebrew paths in order of preference - const homebrewPaths = [ - // Apple Silicon - '/opt/homebrew/bin/node', - // Intel - '/usr/local/bin/node', - ]; - - for (const nodePath of homebrewPaths) { +function findNodeMacOS(_homeDir: string): NodeFinderResult | null { + // Check system paths (Homebrew, system) + const systemPaths = getNodeSystemPaths(); + for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { - return { nodePath, source: 'homebrew' }; + // Determine source based on path + if (nodePath.includes('homebrew') || nodePath === '/usr/local/bin/node') { + return { nodePath, source: 'homebrew' }; + } + return { nodePath, source: 'system' }; } } - // System Node - if (isExecutable('/usr/bin/node')) { - return { nodePath: '/usr/bin/node', source: 'system' }; - } - // NVM installation - const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); - const nvmNode = findNodeFromVersionManager(nvmPath); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } } - // fnm installation (multiple possible locations) - const fnmPaths = [ - path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), - path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), - ]; - + // fnm installation + const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { @@ -147,15 +147,9 @@ function findNodeMacOS(homeDir: string): NodeFinderResult | null { /** * Find Node.js on Linux */ -function findNodeLinux(homeDir: string): NodeFinderResult | null { - // Common Linux paths - const systemPaths = [ - '/usr/bin/node', - '/usr/local/bin/node', - // Snap installation - '/snap/bin/node', - ]; - +function findNodeLinux(_homeDir: string): NodeFinderResult | null { + // Check system paths + const systemPaths = getNodeSystemPaths(); for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'system' }; @@ -163,18 +157,16 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { } // NVM installation - const nvmPath = path.join(homeDir, '.nvm', 'versions', 'node'); - const nvmNode = findNodeFromVersionManager(nvmPath); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm' }; + } } // fnm installation - const fnmPaths = [ - path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), - path.join(homeDir, '.fnm', 'node-versions'), - ]; - + const fnmPaths = getFnmPaths(); for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath); if (fnmNode) { @@ -188,40 +180,27 @@ function findNodeLinux(homeDir: string): NodeFinderResult | null { /** * Find Node.js on Windows */ -function findNodeWindows(homeDir: string): NodeFinderResult | null { +function findNodeWindows(_homeDir: string): NodeFinderResult | null { // Program Files paths - const programFilesPaths = [ - path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), - path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'nodejs', 'node.exe'), - ]; - - for (const nodePath of programFilesPaths) { + const systemPaths = getNodeSystemPaths(); + for (const nodePath of systemPaths) { if (isExecutable(nodePath)) { return { nodePath, source: 'program-files' }; } } // NVM for Windows - const nvmWindowsPath = path.join( - process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), - 'nvm' - ); - const nvmNode = findNodeFromVersionManager(nvmWindowsPath, 'node.exe'); - if (nvmNode) { - return { nodePath: nvmNode, source: 'nvm-windows' }; + const nvmPaths = getNvmPaths(); + for (const nvmPath of nvmPaths) { + const nvmNode = findNodeFromVersionManager(nvmPath, 'node.exe'); + if (nvmNode) { + return { nodePath: nvmNode, source: 'nvm-windows' }; + } } - // fnm on Windows (prioritize canonical installation path over shell shims) - const fnmWindowsPaths = [ - path.join(homeDir, '.fnm', 'node-versions'), - path.join( - process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), - 'fnm', - 'node-versions' - ), - ]; - - for (const fnmBasePath of fnmWindowsPaths) { + // fnm on Windows + const fnmPaths = getFnmPaths(); + for (const fnmBasePath of fnmPaths) { const fnmNode = findNodeFromVersionManager(fnmBasePath, 'node.exe'); if (fnmNode) { return { nodePath: fnmNode, source: 'fnm' }; @@ -229,17 +208,13 @@ function findNodeWindows(homeDir: string): NodeFinderResult | null { } // Scoop installation - const scoopPath = path.join(homeDir, 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); + const scoopPath = getScoopNodePath(); if (isExecutable(scoopPath)) { return { nodePath: scoopPath, source: 'scoop' }; } // Chocolatey installation - const chocoPath = path.join( - process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', - 'bin', - 'node.exe' - ); + const chocoPath = getChocolateyNodePath(); if (isExecutable(chocoPath)) { return { nodePath: chocoPath, source: 'chocolatey' }; } diff --git a/libs/platform/src/secure-fs.ts b/libs/platform/src/secure-fs.ts index b5b716cb..95ec503a 100644 --- a/libs/platform/src/secure-fs.ts +++ b/libs/platform/src/secure-fs.ts @@ -11,7 +11,7 @@ */ import fs from 'fs/promises'; -import type { Dirent } from 'fs'; +import fsSync, { type Dirent, type Stats } from 'fs'; import path from 'path'; import pLimit from 'p-limit'; import { validatePath } from './security.js'; @@ -165,17 +165,26 @@ export async function readFile( }, `readFile(${filePath})`); } +/** + * Options for writeFile + */ +export interface WriteFileOptions { + encoding?: BufferEncoding; + mode?: number; + flag?: string; +} + /** * Wrapper around fs.writeFile that validates path first */ export async function writeFile( filePath: string, data: string | Buffer, - encoding?: BufferEncoding + optionsOrEncoding?: BufferEncoding | WriteFileOptions ): Promise { const validatedPath = validatePath(filePath); return executeWithRetry( - () => fs.writeFile(validatedPath, data, encoding), + () => fs.writeFile(validatedPath, data, optionsOrEncoding), `writeFile(${filePath})` ); } @@ -305,3 +314,316 @@ export function joinPath(...pathSegments: string[]): string { export function resolvePath(...pathSegments: string[]): string { return path.resolve(...pathSegments); } + +// ============================================================================= +// Synchronous File System Methods +// ============================================================================= + +/** + * Options for writeFileSync + */ +export interface WriteFileSyncOptions { + encoding?: BufferEncoding; + mode?: number; + flag?: string; +} + +/** + * Synchronous wrapper around fs.existsSync that validates path first + */ +export function existsSync(filePath: string): boolean { + const validatedPath = validatePath(filePath); + return fsSync.existsSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.readFileSync that validates path first + */ +export function readFileSync(filePath: string, encoding?: BufferEncoding): string | Buffer { + const validatedPath = validatePath(filePath); + if (encoding) { + return fsSync.readFileSync(validatedPath, encoding); + } + return fsSync.readFileSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.writeFileSync that validates path first + */ +export function writeFileSync( + filePath: string, + data: string | Buffer, + options?: WriteFileSyncOptions +): void { + const validatedPath = validatePath(filePath); + fsSync.writeFileSync(validatedPath, data, options); +} + +/** + * Synchronous wrapper around fs.mkdirSync that validates path first + */ +export function mkdirSync( + dirPath: string, + options?: { recursive?: boolean; mode?: number } +): string | undefined { + const validatedPath = validatePath(dirPath); + return fsSync.mkdirSync(validatedPath, options); +} + +/** + * Synchronous wrapper around fs.readdirSync that validates path first + */ +export function readdirSync(dirPath: string, options?: { withFileTypes?: false }): string[]; +export function readdirSync(dirPath: string, options: { withFileTypes: true }): Dirent[]; +export function readdirSync( + dirPath: string, + options?: { withFileTypes?: boolean } +): string[] | Dirent[] { + const validatedPath = validatePath(dirPath); + if (options?.withFileTypes === true) { + return fsSync.readdirSync(validatedPath, { withFileTypes: true }); + } + return fsSync.readdirSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.statSync that validates path first + */ +export function statSync(filePath: string): Stats { + const validatedPath = validatePath(filePath); + return fsSync.statSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.accessSync that validates path first + */ +export function accessSync(filePath: string, mode?: number): void { + const validatedPath = validatePath(filePath); + fsSync.accessSync(validatedPath, mode); +} + +/** + * Synchronous wrapper around fs.unlinkSync that validates path first + */ +export function unlinkSync(filePath: string): void { + const validatedPath = validatePath(filePath); + fsSync.unlinkSync(validatedPath); +} + +/** + * Synchronous wrapper around fs.rmSync that validates path first + */ +export function rmSync(filePath: string, options?: { recursive?: boolean; force?: boolean }): void { + const validatedPath = validatePath(filePath); + fsSync.rmSync(validatedPath, options); +} + +// ============================================================================= +// Environment File Operations +// ============================================================================= + +/** + * Read and parse an .env file from a validated path + * Returns a record of key-value pairs + */ +export async function readEnvFile(envPath: string): Promise> { + const validatedPath = validatePath(envPath); + try { + const content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readEnvFile(${envPath})` + ); + return parseEnvContent(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +/** + * Read and parse an .env file synchronously from a validated path + */ +export function readEnvFileSync(envPath: string): Record { + const validatedPath = validatePath(envPath); + try { + const content = fsSync.readFileSync(validatedPath, 'utf-8'); + return parseEnvContent(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + throw error; + } +} + +/** + * Parse .env file content into a record + */ +function parseEnvContent(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.slice(0, equalIndex).trim(); + const value = trimmed.slice(equalIndex + 1).trim(); + result[key] = value; + } + } + + return result; +} + +/** + * Write or update a key-value pair in an .env file + * Preserves existing content and comments + */ +export async function writeEnvKey(envPath: string, key: string, value: string): Promise { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readFile(${envPath})` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // File doesn't exist, will create new one + } + + const newContent = updateEnvContent(content, key, value); + await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); +} + +/** + * Write or update a key-value pair in an .env file (synchronous) + */ +export function writeEnvKeySync(envPath: string, key: string, value: string): void { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = fsSync.readFileSync(validatedPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + // File doesn't exist, will create new one + } + + const newContent = updateEnvContent(content, key, value); + fsSync.writeFileSync(validatedPath, newContent); +} + +/** + * Remove a key from an .env file + */ +export async function removeEnvKey(envPath: string, key: string): Promise { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = await executeWithRetry( + () => fs.readFile(validatedPath, 'utf-8'), + `readFile(${envPath})` + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; // File doesn't exist, nothing to remove + } + throw error; + } + + const newContent = removeEnvKeyFromContent(content, key); + await executeWithRetry(() => fs.writeFile(validatedPath, newContent), `writeFile(${envPath})`); +} + +/** + * Remove a key from an .env file (synchronous) + */ +export function removeEnvKeySync(envPath: string, key: string): void { + const validatedPath = validatePath(envPath); + + let content = ''; + try { + content = fsSync.readFileSync(validatedPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; // File doesn't exist, nothing to remove + } + throw error; + } + + const newContent = removeEnvKeyFromContent(content, key); + fsSync.writeFileSync(validatedPath, newContent); +} + +/** + * Update .env content with a new key-value pair + */ +function updateEnvContent(content: string, key: string, value: string): string { + const lines = content.split('\n'); + const keyPrefix = `${key}=`; + let found = false; + + const newLines = lines.map((line) => { + if (line.trim().startsWith(keyPrefix)) { + found = true; + return `${key}=${value}`; + } + return line; + }); + + if (!found) { + // Add the key at the end + if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') { + newLines.push(`${key}=${value}`); + } else { + // Replace last empty line or add to empty file + if (newLines.length === 0 || (newLines.length === 1 && newLines[0] === '')) { + newLines[0] = `${key}=${value}`; + } else { + newLines[newLines.length - 1] = `${key}=${value}`; + } + } + } + + // Ensure file ends with newline + let result = newLines.join('\n'); + if (!result.endsWith('\n')) { + result += '\n'; + } + return result; +} + +/** + * Remove a key from .env content + */ +function removeEnvKeyFromContent(content: string, key: string): string { + const lines = content.split('\n'); + const keyPrefix = `${key}=`; + const newLines = lines.filter((line) => !line.trim().startsWith(keyPrefix)); + + // Remove trailing empty lines + while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { + newLines.pop(); + } + + // Ensure file ends with newline if there's content + let result = newLines.join('\n'); + if (result.length > 0 && !result.endsWith('\n')) { + result += '\n'; + } + return result; +} diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts new file mode 100644 index 00000000..2824d623 --- /dev/null +++ b/libs/platform/src/system-paths.ts @@ -0,0 +1,814 @@ +/** + * System Paths Configuration + * + * Centralized configuration for ALL system paths that automaker needs to access + * outside of the ALLOWED_ROOT_DIRECTORY. These are well-known system paths for + * tools like GitHub CLI, Claude CLI, Node.js version managers, etc. + * + * ALL file system access must go through this module or secureFs. + * Direct fs imports are NOT allowed anywhere else in the codebase. + * + * Categories of system paths: + * 1. CLI Tools: GitHub CLI, Claude CLI + * 2. Version Managers: NVM, fnm, Volta + * 3. Shells: /bin/zsh, /bin/bash, PowerShell + * 4. Electron userData: API keys, window bounds, app settings + * 5. Script directories: node_modules, logs (relative to script) + */ + +import os from 'os'; +import path from 'path'; +import fsSync from 'fs'; +import fs from 'fs/promises'; + +// ============================================================================= +// System Tool Path Definitions +// ============================================================================= + +/** + * Get common paths where GitHub CLI might be installed + */ +export function getGitHubCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + + if (isWindows) { + return [ + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'), + path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), + ].filter(Boolean); + } + + return [ + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + path.join(os.homedir(), '.local', 'bin', 'gh'), + '/home/linuxbrew/.linuxbrew/bin/gh', + ]; +} + +/** + * Get common paths where Claude CLI might be installed + */ +export function getClaudeCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + + if (isWindows) { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return [ + 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'), + ]; + } + + return [ + 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'), + ]; +} + +/** + * Get the Claude configuration directory path + */ +export function getClaudeConfigDir(): string { + return path.join(os.homedir(), '.claude'); +} + +/** + * Get paths to Claude credential files + */ +export function getClaudeCredentialPaths(): string[] { + const claudeDir = getClaudeConfigDir(); + return [path.join(claudeDir, '.credentials.json'), path.join(claudeDir, 'credentials.json')]; +} + +/** + * Get path to Claude settings file + */ +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} + +/** + * Get path to Claude stats cache file + */ +export function getClaudeStatsCachePath(): string { + return path.join(getClaudeConfigDir(), 'stats-cache.json'); +} + +/** + * Get path to Claude projects/sessions directory + */ +export function getClaudeProjectsDir(): string { + return path.join(getClaudeConfigDir(), 'projects'); +} + +/** + * Get common shell paths for shell detection + * Includes both full paths and short names to match $SHELL or PATH entries + */ +export function getShellPaths(): string[] { + if (process.platform === 'win32') { + return [ + // Full paths (most specific first) + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + // COMSPEC environment variable (typically cmd.exe) + process.env.COMSPEC || 'C:\\Windows\\System32\\cmd.exe', + // Short names (for PATH resolution) + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + } + + // POSIX (macOS, Linux) + return [ + // Full paths + '/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', + // Short names (for PATH resolution or $SHELL matching) + 'zsh', + 'bash', + 'sh', + ]; +} + +// ============================================================================= +// Node.js Version Manager Paths +// ============================================================================= + +/** + * Get NVM installation paths + */ +export function getNvmPaths(): string[] { + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + return [path.join(appData, 'nvm')]; + } + + return [path.join(homeDir, '.nvm', 'versions', 'node')]; +} + +/** + * Get fnm installation paths + */ +export function getFnmPaths(): string[] { + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + return [ + path.join(homeDir, '.fnm', 'node-versions'), + path.join(localAppData, 'fnm', 'node-versions'), + ]; + } + + if (process.platform === 'darwin') { + return [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'), + ]; + } + + return [ + path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'), + path.join(homeDir, '.fnm', 'node-versions'), + ]; +} + +/** + * Get common Node.js installation paths (not version managers) + */ +export function getNodeSystemPaths(): string[] { + if (process.platform === 'win32') { + return [ + path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'nodejs', 'node.exe'), + path.join( + process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', + 'nodejs', + 'node.exe' + ), + ]; + } + + if (process.platform === 'darwin') { + return ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']; + } + + // Linux + return ['/usr/bin/node', '/usr/local/bin/node', '/snap/bin/node']; +} + +/** + * Get Scoop installation path for Node.js (Windows) + */ +export function getScoopNodePath(): string { + return path.join(os.homedir(), 'scoop', 'apps', 'nodejs', 'current', 'node.exe'); +} + +/** + * Get Chocolatey installation path for Node.js (Windows) + */ +export function getChocolateyNodePath(): string { + return path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'node.exe' + ); +} + +/** + * Get WSL detection path + */ +export function getWslVersionPath(): string { + return '/proc/version'; +} + +/** + * Extended PATH environment for finding system tools + */ +export function getExtendedPath(): string { + const paths = [ + process.env.PATH, + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin`, + ]; + + return paths.filter(Boolean).join(process.platform === 'win32' ? ';' : ':'); +} + +// ============================================================================= +// System Path Access Methods (Unconstrained - only for system tool detection) +// ============================================================================= + +/** + * Check if a file exists at a system path (synchronous) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for checking system tool installation paths. + */ +export function systemPathExists(filePath: string): boolean { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.existsSync(filePath); +} + +/** + * Check if a file is accessible at a system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for checking system tool installation paths. + */ +export async function systemPathAccess(filePath: string): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Check if a file has execute permission (synchronous) + * On Windows, only checks existence (X_OK is not meaningful) + */ +export function systemPathIsExecutable(filePath: string): boolean { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + try { + if (process.platform === 'win32') { + fsSync.accessSync(filePath, fsSync.constants.F_OK); + } else { + fsSync.accessSync(filePath, fsSync.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +/** + * Read a file from an allowed system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + * Only use for reading Claude config files and similar system configs. + */ +export async function systemPathReadFile( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fs.readFile(filePath, encoding); +} + +/** + * Read a file from an allowed system path (synchronous) + */ +export function systemPathReadFileSync( + filePath: string, + encoding: BufferEncoding = 'utf-8' +): string { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.readFileSync(filePath, encoding); +} + +/** + * Write a file to an allowed system path (synchronous) + */ +export function systemPathWriteFileSync( + filePath: string, + data: string, + options?: { encoding?: BufferEncoding; mode?: number } +): void { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + fsSync.writeFileSync(filePath, data, options); +} + +/** + * Read directory contents from an allowed system path (async) + * IMPORTANT: This bypasses ALLOWED_ROOT_DIRECTORY restrictions. + */ +export async function systemPathReaddir(dirPath: string): Promise { + if (!isAllowedSystemPath(dirPath)) { + throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`); + } + return fs.readdir(dirPath); +} + +/** + * Read directory contents from an allowed system path (synchronous) + */ +export function systemPathReaddirSync(dirPath: string): string[] { + if (!isAllowedSystemPath(dirPath)) { + throw new Error(`[SystemPaths] Access denied: ${dirPath} is not an allowed system path`); + } + return fsSync.readdirSync(dirPath); +} + +/** + * Get file stats from a system path (synchronous) + */ +export function systemPathStatSync(filePath: string): fsSync.Stats { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fsSync.statSync(filePath); +} + +/** + * Get file stats from a system path (async) + */ +export async function systemPathStat(filePath: string): Promise { + if (!isAllowedSystemPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not an allowed system path`); + } + return fs.stat(filePath); +} + +// ============================================================================= +// Path Validation +// ============================================================================= + +/** + * All paths that are allowed for system tool detection + */ +function getAllAllowedSystemPaths(): string[] { + return [ + // GitHub CLI paths + ...getGitHubCliPaths(), + // Claude CLI paths + ...getClaudeCliPaths(), + // Claude config directory and files + getClaudeConfigDir(), + ...getClaudeCredentialPaths(), + getClaudeSettingsPath(), + getClaudeStatsCachePath(), + getClaudeProjectsDir(), + // Shell paths + ...getShellPaths(), + // Node.js system paths + ...getNodeSystemPaths(), + getScoopNodePath(), + getChocolateyNodePath(), + // WSL detection + getWslVersionPath(), + ]; +} + +/** + * Get all allowed directories (for recursive access) + */ +function getAllAllowedSystemDirs(): string[] { + return [ + // Claude config + getClaudeConfigDir(), + getClaudeProjectsDir(), + // Version managers (need recursive access for version directories) + ...getNvmPaths(), + ...getFnmPaths(), + ]; +} + +/** + * Check if a path is an allowed system path + * Paths must either be exactly in the allowed list, or be inside an allowed directory + */ +export function isAllowedSystemPath(filePath: string): boolean { + const normalizedPath = path.resolve(filePath); + const allowedPaths = getAllAllowedSystemPaths(); + + // Check for exact match + if (allowedPaths.includes(normalizedPath)) { + return true; + } + + // Check if the path is inside an allowed directory + const allowedDirs = getAllAllowedSystemDirs(); + + for (const allowedDir of allowedDirs) { + const normalizedAllowedDir = path.resolve(allowedDir); + // Check if path is exactly the allowed dir or inside it + if ( + normalizedPath === normalizedAllowedDir || + normalizedPath.startsWith(normalizedAllowedDir + path.sep) + ) { + return true; + } + } + + return false; +} + +// ============================================================================= +// Electron userData Operations +// ============================================================================= + +// Store the Electron userData path (set by Electron main process) +let electronUserDataPath: string | null = null; + +/** + * Set the Electron userData path (called from Electron main process) + */ +export function setElectronUserDataPath(userDataPath: string): void { + electronUserDataPath = userDataPath; +} + +/** + * Get the Electron userData path + */ +export function getElectronUserDataPath(): string | null { + return electronUserDataPath; +} + +/** + * Check if a path is within the Electron userData directory + */ +export function isElectronUserDataPath(filePath: string): boolean { + if (!electronUserDataPath) return false; + const normalizedPath = path.resolve(filePath); + const normalizedUserData = path.resolve(electronUserDataPath); + return ( + normalizedPath === normalizedUserData || + normalizedPath.startsWith(normalizedUserData + path.sep) + ); +} + +/** + * Read a file from Electron userData directory + */ +export function electronUserDataReadFileSync( + relativePath: string, + encoding: BufferEncoding = 'utf-8' +): string { + if (!electronUserDataPath) { + throw new Error('[SystemPaths] Electron userData path not initialized'); + } + const fullPath = path.join(electronUserDataPath, relativePath); + return fsSync.readFileSync(fullPath, encoding); +} + +/** + * Write a file to Electron userData directory + */ +export function electronUserDataWriteFileSync( + relativePath: string, + data: string, + options?: { encoding?: BufferEncoding; mode?: number } +): void { + if (!electronUserDataPath) { + throw new Error('[SystemPaths] Electron userData path not initialized'); + } + const fullPath = path.join(electronUserDataPath, relativePath); + fsSync.writeFileSync(fullPath, data, options); +} + +/** + * Check if a file exists in Electron userData directory + */ +export function electronUserDataExists(relativePath: string): boolean { + if (!electronUserDataPath) return false; + const fullPath = path.join(electronUserDataPath, relativePath); + return fsSync.existsSync(fullPath); +} + +// ============================================================================= +// Script Directory Operations (for init.mjs and similar) +// ============================================================================= + +// Store the script's base directory +let scriptBaseDir: string | null = null; + +/** + * Set the script base directory + */ +export function setScriptBaseDir(baseDir: string): void { + scriptBaseDir = baseDir; +} + +/** + * Get the script base directory + */ +export function getScriptBaseDir(): string | null { + return scriptBaseDir; +} + +/** + * Check if a file exists relative to script base directory + */ +export function scriptDirExists(relativePath: string): boolean { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + return fsSync.existsSync(fullPath); +} + +/** + * Create a directory relative to script base directory + */ +export function scriptDirMkdirSync(relativePath: string, options?: { recursive?: boolean }): void { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + fsSync.mkdirSync(fullPath, options); +} + +/** + * Create a write stream for a file relative to script base directory + */ +export function scriptDirCreateWriteStream(relativePath: string): fsSync.WriteStream { + if (!scriptBaseDir) { + throw new Error('[SystemPaths] Script base directory not initialized'); + } + const fullPath = path.join(scriptBaseDir, relativePath); + return fsSync.createWriteStream(fullPath); +} + +// ============================================================================= +// Electron App Bundle Operations (for accessing app's own files) +// ============================================================================= + +// Store the Electron app bundle paths (can have multiple allowed directories) +let electronAppDirs: string[] = []; +let electronResourcesPath: string | null = null; + +/** + * Set the Electron app directories (called from Electron main process) + * In development mode, pass the project root to allow access to source files. + * In production mode, pass __dirname and process.resourcesPath. + * + * @param appDirOrDirs - Single directory or array of directories to allow + * @param resourcesPath - Optional resources path (for packaged apps) + */ +export function setElectronAppPaths(appDirOrDirs: string | string[], resourcesPath?: string): void { + electronAppDirs = Array.isArray(appDirOrDirs) ? appDirOrDirs : [appDirOrDirs]; + electronResourcesPath = resourcesPath || null; +} + +/** + * Check if a path is within the Electron app bundle (any of the allowed directories) + */ +function isElectronAppPath(filePath: string): boolean { + const normalizedPath = path.resolve(filePath); + + // Check against all allowed app directories + for (const appDir of electronAppDirs) { + const normalizedAppDir = path.resolve(appDir); + if ( + normalizedPath === normalizedAppDir || + normalizedPath.startsWith(normalizedAppDir + path.sep) + ) { + return true; + } + } + + // Check against resources path (for packaged apps) + if (electronResourcesPath) { + const normalizedResources = path.resolve(electronResourcesPath); + if ( + normalizedPath === normalizedResources || + normalizedPath.startsWith(normalizedResources + path.sep) + ) { + return true; + } + } + + return false; +} + +/** + * Check if a file exists within the Electron app bundle + */ +export function electronAppExists(filePath: string): boolean { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.existsSync(filePath); +} + +/** + * Read a file from the Electron app bundle + */ +export function electronAppReadFileSync(filePath: string): Buffer { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.readFileSync(filePath); +} + +/** + * Get file stats from the Electron app bundle + */ +export function electronAppStatSync(filePath: string): fsSync.Stats { + if (!isElectronAppPath(filePath)) { + throw new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`); + } + return fsSync.statSync(filePath); +} + +/** + * Get file stats from the Electron app bundle (async with callback for compatibility) + */ +export function electronAppStat( + filePath: string, + callback: (err: NodeJS.ErrnoException | null, stats: fsSync.Stats | undefined) => void +): void { + if (!isElectronAppPath(filePath)) { + callback( + new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`), + undefined + ); + return; + } + fsSync.stat(filePath, callback); +} + +/** + * Read a file from the Electron app bundle (async with callback for compatibility) + */ +export function electronAppReadFile( + filePath: string, + callback: (err: NodeJS.ErrnoException | null, data: Buffer | undefined) => void +): void { + if (!isElectronAppPath(filePath)) { + callback( + new Error(`[SystemPaths] Access denied: ${filePath} is not within Electron app bundle`), + undefined + ); + return; + } + fsSync.readFile(filePath, callback); +} + +// ============================================================================= +// High-level Tool Detection Methods +// ============================================================================= + +/** + * Find the first existing path from a list of system paths + */ +export async function findFirstExistingPath(paths: string[]): Promise { + for (const p of paths) { + if (await systemPathAccess(p)) { + return p; + } + } + return null; +} + +/** + * Check if GitHub CLI is installed and return its path + */ +export async function findGitHubCliPath(): Promise { + return findFirstExistingPath(getGitHubCliPaths()); +} + +/** + * Check if Claude CLI is installed and return its path + */ +export async function findClaudeCliPath(): Promise { + return findFirstExistingPath(getClaudeCliPaths()); +} + +/** + * Get Claude authentication status by checking various indicators + */ +export interface ClaudeAuthIndicators { + hasCredentialsFile: boolean; + hasSettingsFile: boolean; + hasStatsCacheWithActivity: boolean; + hasProjectsSessions: boolean; + credentials: { + hasOAuthToken: boolean; + hasApiKey: boolean; + } | null; +} + +export async function getClaudeAuthIndicators(): Promise { + const result: ClaudeAuthIndicators = { + hasCredentialsFile: false, + hasSettingsFile: false, + hasStatsCacheWithActivity: false, + hasProjectsSessions: false, + credentials: null, + }; + + // Check settings file + try { + if (await systemPathAccess(getClaudeSettingsPath())) { + result.hasSettingsFile = true; + } + } catch { + // Ignore errors + } + + // Check stats cache for recent activity + try { + const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + result.hasStatsCacheWithActivity = true; + } + } catch { + // Ignore errors + } + + // Check for sessions in projects directory + try { + const sessions = await systemPathReaddir(getClaudeProjectsDir()); + if (sessions.length > 0) { + result.hasProjectsSessions = true; + } + } catch { + // Ignore errors + } + + // Check credentials files + const credentialPaths = getClaudeCredentialPaths(); + for (const credPath of credentialPaths) { + try { + const content = await systemPathReadFile(credPath); + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken: !!(credentials.oauth_token || credentials.access_token), + hasApiKey: !!credentials.api_key, + }; + break; + } catch { + // Continue to next path + } + } + + return result; +} diff --git a/libs/prompts/package.json b/libs/prompts/package.json index e5954174..0012859f 100644 --- a/libs/prompts/package.json +++ b/libs/prompts/package.json @@ -18,12 +18,15 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/types": "^1.0.0" + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 09e4f644..c0ae7e0b 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -16,6 +16,7 @@ import type { ResolvedBacklogPlanPrompts, ResolvedEnhancementPrompts, } from '@automaker/types'; +import { STATIC_PORT, SERVER_PORT } from '@automaker/types'; /** * ======================================================================== @@ -208,6 +209,9 @@ This feature depends on: {{dependencies}} **Verification:** {{verificationInstructions}} {{/if}} + +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. `; export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation @@ -299,6 +303,9 @@ You have access to several tools: 4. Ask questions when requirements are unclear 5. Guide users toward good software design principles +**CRITICAL - Port Protection:** +NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. + Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`; /** diff --git a/libs/types/package.json b/libs/types/package.json index acd0ba75..3a5c2a83 100644 --- a/libs/types/package.json +++ b/libs/types/package.json @@ -15,8 +15,11 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "@types/node": "22.19.3", + "typescript": "5.9.3" } } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 30a903e1..be714877 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -140,3 +140,6 @@ export type { PipelineStatus, FeatureStatusWithPipeline, } from './pipeline.js'; + +// Port configuration +export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; diff --git a/libs/types/src/ports.ts b/libs/types/src/ports.ts new file mode 100644 index 00000000..451ecdd7 --- /dev/null +++ b/libs/types/src/ports.ts @@ -0,0 +1,15 @@ +/** + * Centralized port configuration for AutoMaker + * + * These ports are reserved for the Automaker application and should never be + * killed or terminated by AI agents during feature implementation. + */ + +/** Port for the static/UI server (Vite dev server) */ +export const STATIC_PORT = 3007; + +/** Port for the backend API server (Express + WebSocket) */ +export const SERVER_PORT = 3008; + +/** Array of all reserved Automaker ports */ +export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; diff --git a/libs/utils/package.json b/libs/utils/package.json index c7d612e8..118747be 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -17,13 +17,16 @@ ], "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "dependencies": { - "@automaker/platform": "^1.0.0", - "@automaker/types": "^1.0.0" + "@automaker/platform": "1.0.0", + "@automaker/types": "1.0.0" }, "devDependencies": { - "@types/node": "^22.10.5", - "typescript": "^5.7.3", - "vitest": "^4.0.16" + "@types/node": "22.19.3", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/libs/utils/src/context-loader.ts b/libs/utils/src/context-loader.ts index 0e94092b..ee04b980 100644 --- a/libs/utils/src/context-loader.ts +++ b/libs/utils/src/context-loader.ts @@ -10,7 +10,7 @@ */ import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; /** * Metadata structure for context files @@ -38,6 +38,16 @@ export interface ContextFilesResult { formattedPrompt: string; } +/** + * File system module interface for context loading + * Compatible with secureFs from @automaker/platform + */ +export interface ContextFsModule { + access: (path: string) => Promise; + readdir: (path: string) => Promise; + readFile: (path: string, encoding?: BufferEncoding) => Promise; +} + /** * Options for loading context files */ @@ -45,11 +55,7 @@ export interface LoadContextFilesOptions { /** Project path to load context from */ projectPath: string; /** Optional custom secure fs module (for dependency injection) */ - fsModule?: { - access: (path: string) => Promise; - readdir: (path: string) => Promise; - readFile: (path: string, encoding: string) => Promise; - }; + fsModule?: ContextFsModule; } /** @@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string { */ async function loadContextMetadata( contextDir: string, - fsModule: typeof fs + fsModule: ContextFsModule ): Promise { const metadataPath = path.join(contextDir, 'context-metadata.json'); try { const content = await fsModule.readFile(metadataPath, 'utf-8'); - return JSON.parse(content); + return JSON.parse(content as string); } catch { // Metadata file doesn't exist yet - that's fine return { files: {} }; @@ -148,7 +154,7 @@ ${formattedFiles.join('\n\n---\n\n')} export async function loadContextFiles( options: LoadContextFilesOptions ): Promise { - const { projectPath, fsModule = fs } = options; + const { projectPath, fsModule = secureFs } = options; const contextDir = path.resolve(getContextDir(projectPath)); try { @@ -169,7 +175,7 @@ export async function loadContextFiles( } // Load metadata for descriptions - const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs); + const metadata = await loadContextMetadata(contextDir, fsModule); // Load each file with its content and metadata const files: ContextFileInfo[] = []; @@ -180,7 +186,7 @@ export async function loadContextFiles( files.push({ name: fileName, path: filePath, - content, + content: content as string, description: metadata.files[fileName]?.description, }); } catch (error) { @@ -209,7 +215,7 @@ export async function loadContextFiles( export async function getContextFilesSummary( options: LoadContextFilesOptions ): Promise> { - const { projectPath, fsModule = fs } = options; + const { projectPath, fsModule = secureFs } = options; const contextDir = path.resolve(getContextDir(projectPath)); try { @@ -225,7 +231,7 @@ export async function getContextFilesSummary( return []; } - const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs); + const metadata = await loadContextMetadata(contextDir, fsModule); return textFiles.map((fileName) => ({ name: fileName, diff --git a/package.json b/package.json index fb5d89b6..9aff9d1a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "automaker", "version": "1.0.0", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "workspaces": [ "apps/*", "libs/*" @@ -53,13 +56,13 @@ ] }, "dependencies": { - "cross-spawn": "^7.0.6", - "rehype-sanitize": "^6.0.0", - "tree-kill": "^1.2.2" + "cross-spawn": "7.0.6", + "rehype-sanitize": "6.0.0", + "tree-kill": "1.2.2" }, "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4" + "husky": "9.1.7", + "lint-staged": "16.2.7", + "prettier": "3.7.4" } }