Merge pull request #332 from AutoMaker-Org/centeralize-fs-access

feat: implement secure file system access and path validation
This commit is contained in:
Web Dev Cody
2025-12-31 21:45:19 -05:00
committed by GitHub
52 changed files with 2612 additions and 670 deletions

View File

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

View File

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

2
.nvmrc Normal file
View File

@@ -0,0 +1,2 @@
22

View File

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

View File

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

View File

@@ -10,8 +10,8 @@
import type { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import * as secureFs from './secure-fs.js';
const DATA_DIR = process.env.DATA_DIR || './data';
const API_KEY_FILE = path.join(DATA_DIR, '.api-key');
@@ -41,8 +41,8 @@ setInterval(() => {
*/
function loadSessions(): void {
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = fs.readFileSync(SESSIONS_FILE, 'utf-8');
if (secureFs.existsSync(SESSIONS_FILE)) {
const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string;
const sessions = JSON.parse(data) as Array<
[string, { createdAt: number; expiresAt: number }]
>;
@@ -74,9 +74,9 @@ function loadSessions(): void {
*/
async function saveSessions(): Promise<void> {
try {
await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true });
const sessions = Array.from(validSessions.entries());
await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), {
encoding: 'utf-8',
mode: 0o600,
});
@@ -101,8 +101,8 @@ function ensureApiKey(): string {
// Try to read from file
try {
if (fs.existsSync(API_KEY_FILE)) {
const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim();
if (secureFs.existsSync(API_KEY_FILE)) {
const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim();
if (key) {
console.log('[Auth] Loaded API key from file');
return key;
@@ -115,8 +115,8 @@ function ensureApiKey(): string {
// Generate new key
const newKey = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true });
secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Auth] Generated new API key');
} catch (error) {
console.error('[Auth] Failed to save API key:', error);

View File

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

View File

@@ -15,6 +15,32 @@ import type {
ModelDefinition,
} from './types.js';
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
'ANTHROPIC_API_KEY',
'PATH',
'HOME',
'SHELL',
'TERM',
'USER',
'LANG',
'LC_ALL',
];
/**
* Build environment for the SDK with only explicitly allowed variables
*/
function buildEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
export class ClaudeProvider extends BaseProvider {
getName(): string {
return 'claude';
@@ -57,6 +83,8 @@ export class ClaudeProvider extends BaseProvider {
systemPrompt,
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
...(allowedTools && shouldRestrictTools && { allowedTools }),
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
/**
* Health check routes
*
* NOTE: Only the basic health check (/) is unauthenticated.
* NOTE: Only the basic health check (/) and environment check are unauthenticated.
* The /detailed endpoint requires authentication.
*/
import { Router } from 'express';
import { createIndexHandler } from './routes/index.js';
import { createEnvironmentHandler } from './routes/environment.js';
/**
* Create unauthenticated health routes (basic check only)
@@ -18,6 +19,10 @@ export function createHealthRoutes(): Router {
// Basic health check - no sensitive info
router.get('/', createIndexHandler());
// Environment info including containerization status
// This is unauthenticated so the UI can check on startup
router.get('/environment', createEnvironmentHandler());
return router;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,27 +5,14 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
const execEnv = {
...process.env,
PATH: extendedPath,
PATH: getExtendedPath(),
};
export interface GhStatus {
@@ -55,25 +42,16 @@ async function getGhStatus(): Promise<GhStatus> {
status.path = stdout.trim().split(/\r?\n/)[0];
status.installed = true;
} catch {
// gh not in PATH, try common locations
const commonPaths = isWindows
? [
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'),
path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'),
]
: [
'/opt/homebrew/bin/gh',
'/usr/local/bin/gh',
path.join(os.homedir(), '.local', 'bin', 'gh'),
'/home/linuxbrew/.linuxbrew/bin/gh',
];
// gh not in PATH, try common locations from centralized system paths
const commonPaths = getGitHubCliPaths();
for (const p of commonPaths) {
try {
await fs.access(p);
status.path = p;
status.installed = true;
break;
if (await systemPathAccess(p)) {
status.path = p;
status.installed = true;
break;
}
} catch {
// Not found at this path
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-4">
<ShieldX className="w-12 h-12 text-destructive" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Access Denied</h1>
<p className="text-muted-foreground">
You declined to accept the risks of running Automaker outside a sandbox environment.
</p>
</div>
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
<div className="flex items-start gap-3">
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 space-y-2">
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
<p className="text-sm text-muted-foreground">
Run Automaker in a containerized sandbox environment:
</p>
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<div className="pt-2">
<Button
variant="outline"
onClick={handleReload}
className="gap-2"
data-testid="sandbox-retry"
>
<RefreshCw className="w-4 h-4" />
Reload &amp; Try Again
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="bg-popover border-border max-w-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6" />
Sandbox Environment Not Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<p className="text-muted-foreground">
<strong>Warning:</strong> 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.
</p>
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Agents can read, modify, or delete files on your system</li>
<li>Agents can execute arbitrary commands and install software</li>
<li>Agents can access environment variables and credentials</li>
<li>Unintended side effects from agent actions may affect your system</li>
</ul>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
For safer operation, consider running Automaker in Docker:
</p>
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2 pt-4">
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
Deny &amp; Exit
</Button>
<Button
variant="destructive"
onClick={onConfirm}
className="px-4"
data-testid="sandbox-confirm"
>
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -294,6 +294,32 @@ export const verifySession = async (): Promise<boolean> => {
}
};
/**
* 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);
});

View File

@@ -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<typeof setTimeout> | 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<void> {
const staticPath = path.join(__dirname, '../dist');
@@ -253,20 +277,24 @@ async function startStaticServer(): Promise<void> {
} 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<void> {
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
async function startServer(): Promise<void> {
// Find Node.js executable (handles desktop launcher scenarios)
@@ -318,8 +347,20 @@ async function startServer(): Promise<void> {
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<void> {
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<void> {
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<void> {
? 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<void> {
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();
});

View File

@@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Window management
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
// App control
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
});
console.log('[Preload] Electron API exposed (TypeScript)');

View File

@@ -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<SandboxStatus>(() => {
// 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 <SandboxRejectionScreen />;
}
// Show loading while checking sandbox environment
if (sandboxStatus === 'pending') {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Checking environment...</div>
</main>
);
}
// Show login page (full screen, no sidebar)
if (isLoginRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of login page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
@@ -228,6 +330,12 @@ function RootLayoutContent() {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of setup page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
@@ -249,6 +357,13 @@ function RootLayoutContent() {
}`}
/>
<Toaster richColors position="bottom-right" />
{/* Show sandbox dialog if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}

View File

@@ -172,6 +172,7 @@ export const useSetupStore = create<SetupState & SetupActions>()(
}),
{
name: 'automaker-setup',
version: 1, // Add version field for proper hydration (matches app-store pattern)
partialize: (state) => ({
isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<void> {
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<Record<string, string>> {
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<string, string> {
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<string, string> {
const result: Record<string, string> = {};
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<void> {
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<void> {
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;
}

View File

@@ -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<boolean> {
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<string> {
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<string[]> {
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<fsSync.Stats> {
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<string | null> {
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<string | null> {
return findFirstExistingPath(getGitHubCliPaths());
}
/**
* Check if Claude CLI is installed and return its path
*/
export async function findClaudeCliPath(): Promise<string | null> {
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<ClaudeAuthIndicators> {
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;
}

View File

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

View File

@@ -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.`;
/**

View File

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

View File

@@ -140,3 +140,6 @@ export type {
PipelineStatus,
FeatureStatusWithPipeline,
} from './pipeline.js';
// Port configuration
export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js';

15
libs/types/src/ports.ts Normal file
View File

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

View File

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

View File

@@ -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<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding?: BufferEncoding) => Promise<string | Buffer>;
}
/**
* 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<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding: string) => Promise<string>;
};
fsModule?: ContextFsModule;
}
/**
@@ -64,12 +70,12 @@ function getContextDir(projectPath: string): string {
*/
async function loadContextMetadata(
contextDir: string,
fsModule: typeof fs
fsModule: ContextFsModule
): Promise<ContextMetadata> {
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<ContextFilesResult> {
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<Array<{ name: string; path: string; description?: string }>> {
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,

View File

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