diff --git a/.claude/commands/gh-issue.md b/.claude/commands/gh-issue.md new file mode 100644 index 00000000..22c4925b --- /dev/null +++ b/.claude/commands/gh-issue.md @@ -0,0 +1,74 @@ +# GitHub Issue Fix Command + +Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid. + +## Usage + +This command accepts a GitHub issue number as input (e.g., `123`). + +## Instructions + +1. **Get the issue number from the user** + - The issue number should be provided as an argument to this command + - If no number is provided, ask the user for it + +2. **Fetch the GitHub issue** + - Determine the current project path (check if there's a current project context) + - Verify the project has a GitHub remote: + ```bash + git remote get-url origin + ``` + - Fetch the issue details using GitHub CLI: + ```bash + gh issue view --json number,title,state,author,createdAt,labels,url,body,assignees + ``` + - If the command fails, report the error and stop + +3. **Verify the issue is real and valid** + - Check that the issue exists (not 404) + - Check the issue state: + - If **closed**: Inform the user and ask if they still want to proceed + - If **open**: Proceed with validation + - Review the issue content: + - Read the title and body to understand what needs to be fixed + - Check labels for context (bug, enhancement, etc.) + - Note any assignees or linked PRs + +4. **Validate the issue** + - Determine if this is a legitimate issue that needs fixing: + - Is the description clear and actionable? + - Does it describe a real problem or feature request? + - Are there any obvious signs it's spam or invalid? + - If the issue seems invalid or unclear: + - Report findings to the user + - Ask if they want to proceed anyway + - Stop if user confirms it's not valid + +5. **If the issue is valid, proceed to fix it** + - Analyze what needs to be done based on the issue description + - Check the current codebase state: + - Run relevant tests to see current behavior + - Check if the issue is already fixed + - Look for related code that might need changes + - Implement the fix: + - Make necessary code changes + - Update or add tests as needed + - Ensure the fix addresses the issue description + - Verify the fix: + - Run tests to ensure nothing broke + - If possible, manually verify the fix addresses the issue + +6. **Report summary** + - Issue number and title + - Issue state (open/closed) + - Whether the issue was validated as real + - What was fixed (if anything) + - Any tests that were updated or added + - Next steps (if any) + +## Error Handling + +- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop +- If the project doesn't have a GitHub remote, report error and stop +- If the issue number doesn't exist, report error and stop +- If the issue is unclear or invalid, report findings and ask user before proceeding diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 00000000..f768ab53 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,77 @@ +# Release Command + +Bump the package.json version (major, minor, or patch) and build the Electron app with the new version. + +## Usage + +This command accepts a version bump type as input: + +- `patch` - Bump patch version (0.1.0 -> 0.1.1) +- `minor` - Bump minor version (0.1.0 -> 0.2.0) +- `major` - Bump major version (0.1.0 -> 1.0.0) + +## Instructions + +1. **Get the bump type from the user** + - The bump type should be provided as an argument (patch, minor, or major) + - If no type is provided, ask the user which type they want + +2. **Bump the version** + - Run the version bump script: + ```bash + node apps/ui/scripts/bump-version.mjs + ``` + - This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync) + - Verify the version was updated correctly by checking the output + +3. **Build the Electron app** + - Run the electron build: + ```bash + npm run build:electron --workspace=apps/ui + ``` + - The build process automatically: + - Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`) + - Injects the version into the app via Vite's `__APP_VERSION__` constant + - Displays the version below the logo in the sidebar + +4. **Commit the version bump** + - Stage the updated package.json files: + ```bash + git add apps/ui/package.json apps/server/package.json + ``` + - Commit with a release message: + ```bash + git commit -m "chore: release v" + ``` + +5. **Create and push the git tag** + - Create an annotated tag for the release: + ```bash + git tag -a v -m "Release v" + ``` + - Push the commit and tag to remote: + ```bash + git push && git push --tags + ``` + +6. **Verify the release** + - Check that the build completed successfully + - Confirm the version appears correctly in the built artifacts + - The version will be displayed in the app UI below the logo + - Verify the tag is visible on the remote repository + +## Version Centralization + +The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`: + +- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName` +- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`) +- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints) +- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string` + +This ensures consistency across: + +- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`) +- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`) +- Server health endpoints (`/` and `/detailed`) +- Package metadata (both UI and server packages stay in sync) diff --git a/.claude/commands/validate-build.md b/.claude/commands/validate-build.md new file mode 100644 index 00000000..790992b1 --- /dev/null +++ b/.claude/commands/validate-build.md @@ -0,0 +1,49 @@ +# Project Build and Fix Command + +Run all builds and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run the build** + + ```bash + npm run build + ``` + + This builds all packages and the UI application. + +2. **If the build succeeds**, report success and stop. + +3. **If the build fails**, analyze the failures: + - Note which build step failed and the error messages + - Check for TypeScript compilation errors, missing dependencies, or configuration issues + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the failure**: + - **If the failure is due to intentional changes** (new features, refactoring, dependency updates): + - Fix any TypeScript type errors introduced by the changes + - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts) + - Ensure all new dependencies are properly installed + - Fix import paths or module resolution issues + + - **If the failure appears to be a regression** (broken imports, missing files, configuration errors): + - Fix the source code to restore the build + - Check for accidentally deleted files or broken references + - Verify build configuration files are correct + +5. **Common build issues to check**: + - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports + - **Missing dependencies**: Run `npm install` if packages are missing + - **Import/export errors**: Fix incorrect import paths or missing exports + - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs + - **Package build order**: Ensure `build:packages` completes before building apps + +6. **How to decide if it's intentional vs regression**: + - Look at the git diff and commit messages + - If the change was deliberate and introduced new code that needs fixing → fix the new code + - If the change broke existing functionality that should still build → fix the regression + - When in doubt, ask the user + +7. **After making fixes**, re-run the build to verify everything compiles successfully. + +8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.). diff --git a/.claude/commands/validate-tests.md b/.claude/commands/validate-tests.md new file mode 100644 index 00000000..3a19b5d1 --- /dev/null +++ b/.claude/commands/validate-tests.md @@ -0,0 +1,36 @@ +# Project Test and Fix Command + +Run all tests and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run all tests** + + ```bash + npm run test:all + ``` + +2. **If all tests pass**, report success and stop. + +3. **If any tests fail**, analyze the failures: + - Note which tests failed and their error messages + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the change**: + - **If the logic change is intentional** (new feature, refactor, behavior change): + - Update the failing tests to match the new expected behavior + - The tests should reflect what the code NOW does correctly + + - **If the logic change appears to be a bug** (regression, unintended side effect): + - Fix the source code to restore the expected behavior + - Do NOT modify the tests - they are catching a real bug + +5. **How to decide if it's a bug vs intentional change**: + - Look at the git diff and commit messages + - If the change was deliberate and the test expectations are now outdated → update tests + - If the change broke existing functionality that should still work → fix the code + - When in doubt, ask the user + +6. **After making fixes**, re-run the tests to verify everything passes. + +7. **Report summary** of what was fixed (tests updated vs code fixed). diff --git a/.claude_settings.json b/.claude_settings.json deleted file mode 100644 index 969f1214..00000000 --- a/.claude_settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "defaultMode": "acceptEdits", - "allow": [ - "Read(./**)", - "Write(./**)", - "Edit(./**)", - "Glob(./**)", - "Grep(./**)", - "Bash(*)", - "mcp__puppeteer__puppeteer_navigate", - "mcp__puppeteer__puppeteer_screenshot", - "mcp__puppeteer__puppeteer_click", - "mcp__puppeteer__puppeteer_fill", - "mcp__puppeteer__puppeteer_select", - "mcp__puppeteer__puppeteer_hover", - "mcp__puppeteer__puppeteer_evaluate" - ] - } -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..af6bb48b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,117 @@ +name: Bug Report +description: File a bug report to help us improve Automaker +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible. + + - type: dropdown + id: operating-system + attributes: + label: Operating System + description: What operating system are you using? + options: + - macOS + - Windows + - Linux + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: run-mode + attributes: + label: Run Mode + description: How are you running Automaker? + options: + - Electron (Desktop App) + - Web (Browser) + - Docker + default: 0 + validations: + required: true + + - type: input + id: app-version + attributes: + label: App Version + description: What version of Automaker are you using? (e.g., 0.1.0) + placeholder: '0.1.0' + validations: + required: true + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: What should have happened? + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: A clear and concise description of what actually happened. + placeholder: What actually happened? + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + placeholder: Drag and drop screenshots here or paste image URLs + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: If applicable, paste relevant logs or error messages. + placeholder: Paste logs here... + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context about the problem here. + placeholder: Any additional information that might be helpful... + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this bug hasn't been reported already + required: true + - label: I have provided all required information above + required: true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..42126c05 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/CHANGELOG_RATE_LIMIT_HANDLING.md b/CHANGELOG_RATE_LIMIT_HANDLING.md deleted file mode 100644 index 35e73b0e..00000000 --- a/CHANGELOG_RATE_LIMIT_HANDLING.md +++ /dev/null @@ -1,134 +0,0 @@ -# Improved Error Handling for Rate Limiting - -## Problem - -When running multiple features concurrently in auto-mode, the Claude API rate limits were being exceeded, resulting in cryptic error messages: - -``` -Error: Claude Code process exited with code 1 -``` - -This error provided no actionable information to users about: - -- What went wrong (rate limit exceeded) -- How long to wait before retrying -- How to prevent it in the future - -## Root Cause - -The Claude Agent SDK was terminating with exit code 1 when hitting rate limits (HTTP 429), but the error details were not being properly surfaced to the user. The error handling code only showed the generic exit code message instead of the actual API error. - -## Solution - -Implemented comprehensive rate limit error handling across the stack: - -### 1. Enhanced Error Classification (libs/utils) - -Added new error type and detection functions: - -- **New error type**: `'rate_limit'` added to `ErrorType` union -- **`isRateLimitError()`**: Detects 429 and rate_limit errors -- **`extractRetryAfter()`**: Extracts retry duration from error messages -- **Updated `classifyError()`**: Includes rate limit classification with retry-after metadata -- **Updated `getUserFriendlyErrorMessage()`**: Provides clear, actionable messages for rate limit errors - -### 2. Improved Claude Provider Error Handling (apps/server) - -Enhanced `ClaudeProvider.executeQuery()` to: - -- Classify all errors using the enhanced error utilities -- Detect rate limit errors specifically -- Provide user-friendly error messages with: - - Clear explanation of the problem (rate limit exceeded) - - Retry-after duration when available - - Actionable tip: reduce `maxConcurrency` in auto-mode -- Preserve original error details for debugging - -### 3. Comprehensive Test Coverage - -Added 8 new tests covering: - -- Rate limit error detection (429, rate_limit keywords) -- Retry-after extraction from various message formats -- Error classification with retry metadata -- User-friendly message generation -- Edge cases (null/undefined, non-rate-limit errors) - -**Total test suite**: 162 tests passing ✅ - -## User-Facing Changes - -### Before - -``` -[AutoMode] Feature touch-gesture-support failed: Error: Claude Code process exited with code 1 -``` - -### After - -``` -[AutoMode] Feature touch-gesture-support failed: Rate limit exceeded (429). Please wait 60 seconds before retrying. - -Tip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits. -``` - -## Benefits - -1. **Clear communication**: Users understand exactly what went wrong -2. **Actionable guidance**: Users know how long to wait and how to prevent future errors -3. **Better debugging**: Original error details preserved for technical investigation -4. **Type safety**: New `isRateLimit` and `retryAfter` fields properly typed in `ErrorInfo` -5. **Comprehensive testing**: All edge cases covered with automated tests - -## Technical Details - -### Files Modified - -- `libs/types/src/error.ts` - Added `'rate_limit'` type and `retryAfter` field -- `libs/utils/src/error-handler.ts` - Added rate limit detection and extraction logic -- `libs/utils/src/index.ts` - Exported new utility functions -- `libs/utils/tests/error-handler.test.ts` - Added 8 new test cases -- `apps/server/src/providers/claude-provider.ts` - Enhanced error handling with user-friendly messages - -### API Changes - -**ErrorInfo interface** (backwards compatible): - -```typescript -interface ErrorInfo { - type: ErrorType; // Now includes 'rate_limit' - message: string; - isAbort: boolean; - isAuth: boolean; - isCancellation: boolean; - isRateLimit: boolean; // NEW - retryAfter?: number; // NEW (seconds to wait) - originalError: unknown; -} -``` - -**New utility functions**: - -```typescript -isRateLimitError(error: unknown): boolean -extractRetryAfter(error: unknown): number | undefined -``` - -## Future Improvements - -This PR lays the groundwork for future enhancements: - -1. **Automatic retry with exponential backoff**: Use `retryAfter` to implement smart retry logic -2. **Global rate limiter**: Track requests to prevent hitting limits proactively -3. **Concurrency auto-adjustment**: Dynamically reduce concurrency when rate limits are detected -4. **User notifications**: Show toast/banner when rate limits are approaching - -## Testing - -Run tests with: - -```bash -npm run test -w @automaker/utils -``` - -All 162 tests pass, including 8 new rate limit tests. diff --git a/README.md b/README.md index 67dd17dd..c8e1b84e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Automaker Logo + Automaker Logo

> **[!TIP]** @@ -81,22 +81,6 @@ Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthro The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic. ---- - -> **[!CAUTION]** -> -> ## Security Disclaimer -> -> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** -> -> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it. -> -> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine. -> -> **[Read the full disclaimer](./DISCLAIMER.md)** - ---- - ## Community & Support Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows. @@ -624,6 +608,22 @@ data/ └── {sessionId}.json ``` +--- + +> **[!CAUTION]** +> +> ## Security Disclaimer +> +> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** +> +> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it. +> +> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine. +> +> **[Read the full disclaimer](./DISCLAIMER.md)** + +--- + ## Learn More ### Documentation diff --git a/apps/server/.env.example b/apps/server/.env.example index f87fe2aa..4210b63d 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -24,7 +24,7 @@ ALLOWED_ROOT_DIRECTORY= # CORS origin - which domains can access the API # Use "*" for development, set specific origin for production -CORS_ORIGIN=* +CORS_ORIGIN=http://localhost:3007 # ============================================ # OPTIONAL - Server diff --git a/apps/server/package.json b/apps/server/package.json index 1eb415a8..5ff9788f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,10 +1,13 @@ { "name": "@automaker/server", - "version": "0.1.0", + "version": "0.7.2", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "type": "module", "main": "dist/index.js", "scripts": { @@ -21,35 +24,35 @@ "test:unit": "vitest run tests/unit" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.72", - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/git-utils": "^1.0.0", - "@automaker/model-resolver": "^1.0.0", - "@automaker/platform": "^1.0.0", - "@automaker/prompts": "^1.0.0", - "@automaker/types": "^1.0.0", - "@automaker/utils": "^1.0.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "cookie-parser": "^1.4.7", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.2.1", - "morgan": "^1.10.1", + "@anthropic-ai/claude-agent-sdk": "0.1.76", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/git-utils": "1.0.0", + "@automaker/model-resolver": "1.0.0", + "@automaker/platform": "1.0.0", + "@automaker/prompts": "1.0.0", + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0", + "@modelcontextprotocol/sdk": "1.25.1", + "cookie-parser": "1.4.7", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.2.1", + "morgan": "1.10.1", "node-pty": "1.1.0-beta41", - "ws": "^8.18.3" + "ws": "8.18.3" }, "devDependencies": { - "@types/cookie": "^0.6.0", - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/morgan": "^1.9.10", - "@types/node": "^22", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.16", - "@vitest/ui": "^4.0.16", - "tsx": "^4.21.0", - "typescript": "^5", - "vitest": "^4.0.16" + "@types/cookie": "0.6.0", + "@types/cookie-parser": "1.4.10", + "@types/cors": "2.8.19", + "@types/express": "5.0.6", + "@types/morgan": "1.9.10", + "@types/node": "22.19.3", + "@types/ws": "8.18.1", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "4.0.16", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "4.0.16" } } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 769b63ab..0f97255f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -133,7 +133,11 @@ app.use( } // For local development, allow localhost origins - if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) { + if ( + origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:') || + origin.startsWith('http://[::1]:') + ) { callback(null, origin); return; } diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index d8629d61..5f24b319 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -10,8 +10,8 @@ import type { Request, Response, NextFunction } from 'express'; import crypto from 'crypto'; -import fs from 'fs'; import path from 'path'; +import * as secureFs from './secure-fs.js'; const DATA_DIR = process.env.DATA_DIR || './data'; const API_KEY_FILE = path.join(DATA_DIR, '.api-key'); @@ -41,8 +41,8 @@ setInterval(() => { */ function loadSessions(): void { try { - if (fs.existsSync(SESSIONS_FILE)) { - const data = fs.readFileSync(SESSIONS_FILE, 'utf-8'); + if (secureFs.existsSync(SESSIONS_FILE)) { + const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string; const sessions = JSON.parse(data) as Array< [string, { createdAt: number; expiresAt: number }] >; @@ -74,9 +74,9 @@ function loadSessions(): void { */ async function saveSessions(): Promise { try { - await fs.promises.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); + await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); const sessions = Array.from(validSessions.entries()); - await fs.promises.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { + await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { encoding: 'utf-8', mode: 0o600, }); @@ -101,8 +101,8 @@ function ensureApiKey(): string { // Try to read from file try { - if (fs.existsSync(API_KEY_FILE)) { - const key = fs.readFileSync(API_KEY_FILE, 'utf-8').trim(); + if (secureFs.existsSync(API_KEY_FILE)) { + const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim(); if (key) { console.log('[Auth] Loaded API key from file'); return key; @@ -115,8 +115,8 @@ function ensureApiKey(): string { // Generate new key const newKey = crypto.randomUUID(); try { - fs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); - fs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); + secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); + secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); console.log('[Auth] Generated new API key'); } catch (error) { console.error('[Auth] Failed to save API key:', error); diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 327ec059..d9b78398 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -16,6 +16,7 @@ */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; @@ -47,6 +48,128 @@ export function validateWorkingDirectory(cwd: string): void { } } +/** + * Known cloud storage path patterns where sandbox mode is incompatible. + * + * The Claude CLI sandbox feature uses filesystem isolation that conflicts with + * cloud storage providers' virtual filesystem implementations. This causes the + * Claude process to exit with code 1 when sandbox is enabled for these paths. + * + * Affected providers (macOS paths): + * - Dropbox: ~/Library/CloudStorage/Dropbox-* + * - Google Drive: ~/Library/CloudStorage/GoogleDrive-* + * - OneDrive: ~/Library/CloudStorage/OneDrive-* + * - iCloud Drive: ~/Library/Mobile Documents/ + * - Box: ~/Library/CloudStorage/Box-* + * + * @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue) + */ + +/** + * macOS-specific cloud storage patterns that appear under ~/Library/ + * These are specific enough to use with includes() safely. + */ +const MACOS_CLOUD_STORAGE_PATTERNS = [ + '/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS + '/Library/Mobile Documents/', // iCloud Drive on macOS +] as const; + +/** + * Generic cloud storage folder names that need to be anchored to the home directory + * to avoid false positives (e.g., /home/user/my-project-about-dropbox/). + */ +const HOME_ANCHORED_CLOUD_FOLDERS = [ + 'Google Drive', // Google Drive on some systems + 'Dropbox', // Dropbox on Linux/alternative installs + 'OneDrive', // OneDrive on Linux/alternative installs +] as const; + +/** + * Check if a path is within a cloud storage location. + * + * Cloud storage providers use virtual filesystem implementations that are + * incompatible with the Claude CLI sandbox feature, causing process crashes. + * + * Uses two detection strategies: + * 1. macOS-specific patterns (under ~/Library/) - checked via includes() + * 2. Generic folder names - anchored to home directory to avoid false positives + * + * @param cwd - The working directory path to check + * @returns true if the path is in a cloud storage location + */ +export function isCloudStoragePath(cwd: string): boolean { + const resolvedPath = path.resolve(cwd); + + // Check macOS-specific patterns (these are specific enough to use includes) + if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) { + return true; + } + + // Check home-anchored patterns to avoid false positives + // e.g., /home/user/my-project-about-dropbox/ should NOT match + const home = os.homedir(); + for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) { + const cloudPath = path.join(home, folder); + // Check if resolved path starts with the cloud storage path followed by a separator + // This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool + if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) { + return true; + } + } + + return false; +} + +/** + * Result of sandbox compatibility check + */ +export interface SandboxCheckResult { + /** Whether sandbox should be enabled */ + enabled: boolean; + /** If disabled, the reason why */ + disabledReason?: 'cloud_storage' | 'user_setting'; + /** Human-readable message for logging/UI */ + message?: string; +} + +/** + * Determine if sandbox mode should be enabled for a given configuration. + * + * Sandbox mode is automatically disabled for cloud storage paths because the + * Claude CLI sandbox feature is incompatible with virtual filesystem + * implementations used by cloud storage providers (Dropbox, Google Drive, etc.). + * + * @param cwd - The working directory + * @param enableSandboxMode - User's sandbox mode setting + * @returns SandboxCheckResult with enabled status and reason if disabled + */ +export function checkSandboxCompatibility( + cwd: string, + enableSandboxMode?: boolean +): SandboxCheckResult { + // User has explicitly disabled sandbox mode + if (enableSandboxMode === false) { + return { + enabled: false, + disabledReason: 'user_setting', + }; + } + + // Check for cloud storage incompatibility (applies when enabled or undefined) + if (isCloudStoragePath(cwd)) { + return { + enabled: false, + disabledReason: 'cloud_storage', + message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`, + }; + } + + // Sandbox is compatible and enabled (true or undefined defaults to enabled) + return { + enabled: true, + }; +} + /** * Tool presets for different use cases */ @@ -381,7 +504,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option * - Full tool access for code modification * - Standard turns for interactive sessions * - Model priority: explicit model > session model > chat default - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { @@ -397,6 +520,9 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), @@ -406,7 +532,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -425,7 +551,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { * - Full tool access for code modification and implementation * - Extended turns for thorough feature implementation * - Uses default model (can be overridden) - * - Sandbox mode controlled by enableSandboxMode setting + * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { @@ -438,6 +564,9 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build MCP-related options const mcpOptions = buildMcpOptions(config); + // Check sandbox compatibility (auto-disables for cloud storage paths) + const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), @@ -447,7 +576,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, - ...(config.enableSandboxMode && { + ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, diff --git a/apps/server/src/lib/secure-fs.ts b/apps/server/src/lib/secure-fs.ts index 30095285..de8dba26 100644 --- a/apps/server/src/lib/secure-fs.ts +++ b/apps/server/src/lib/secure-fs.ts @@ -6,6 +6,7 @@ import { secureFs } from '@automaker/platform'; export const { + // Async methods access, readFile, writeFile, @@ -20,6 +21,16 @@ export const { lstat, joinPath, resolvePath, + // Sync methods + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + accessSync, + unlinkSync, + rmSync, // Throttling configuration and monitoring configureThrottling, getThrottlingConfig, diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 36775315..b6e86ff2 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -74,7 +74,7 @@ export async function getEnableSandboxModeSetting( try { const globalSettings = await settingsService.getGlobalSettings(); - const result = globalSettings.enableSandboxMode ?? true; + const result = globalSettings.enableSandboxMode ?? false; logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`); return result; } catch (error) { diff --git a/apps/server/src/lib/version.ts b/apps/server/src/lib/version.ts new file mode 100644 index 00000000..61e182e3 --- /dev/null +++ b/apps/server/src/lib/version.ts @@ -0,0 +1,33 @@ +/** + * Version utility - Reads version from package.json + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let cachedVersion: string | null = null; + +/** + * Get the version from package.json + * Caches the result for performance + */ +export function getVersion(): string { + if (cachedVersion) { + return cachedVersion; + } + + try { + const packageJsonPath = join(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const version = packageJson.version || '0.0.0'; + cachedVersion = version; + return version; + } catch (error) { + console.warn('Failed to read version from package.json:', error); + return '0.0.0'; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 286a733f..33494535 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -15,6 +15,32 @@ import type { ModelDefinition, } from './types.js'; +// Explicit allowlist of environment variables to pass to the SDK. +// Only these vars are passed - nothing else from process.env leaks through. +const ALLOWED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; + +/** + * Build environment for the SDK with only explicitly allowed variables + */ +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + if (process.env[key]) { + env[key] = process.env[key]; + } + } + return env; +} + export class ClaudeProvider extends BaseProvider { getName(): string { return 'claude'; @@ -57,6 +83,8 @@ export class ClaudeProvider extends BaseProvider { systemPrompt, maxTurns, cwd, + // Pass only explicitly allowed environment variables to SDK + env: buildEnv(), // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index c445a243..baa24d9b 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -18,7 +18,7 @@ import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types'; import { resolveModelString } from '@automaker/model-resolver'; import { createCustomOptions } from '../../../lib/sdk-options.js'; import { ProviderFactory } from '../../../providers/provider-factory.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'; @@ -60,13 +60,13 @@ function filterSafeHeaders(headers: Record): Record | null = null; try { - stat = fs.statSync(actualPath); + stat = secureFs.statSync(actualPath); logger.info( `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` ); diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts index c3cd4c65..68259291 100644 --- a/apps/server/src/routes/fs/routes/browse.ts +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -6,7 +6,7 @@ import type { Request, Response } from 'express'; import * as secureFs from '../../../lib/secure-fs.js'; import os from 'os'; import path from 'path'; -import { getAllowedRootDirectory, PathNotAllowedError } from '@automaker/platform'; +import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createBrowseHandler() { @@ -40,9 +40,16 @@ export function createBrowseHandler() { return drives; }; - // Get parent directory + // Get parent directory - only if it's within the allowed root const parentPath = path.dirname(targetPath); - const hasParent = parentPath !== targetPath; + + // Determine if parent navigation should be allowed: + // 1. Must have a different parent (not at filesystem root) + // 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it + const hasParent = parentPath !== targetPath && isPathAllowed(parentPath); + + // Security: Don't expose parent path outside allowed root + const safeParentPath = hasParent ? parentPath : null; // Get available drives const drives = await detectDrives(); @@ -70,7 +77,7 @@ export function createBrowseHandler() { res.json({ success: true, currentPath: targetPath, - parentPath: hasParent ? parentPath : null, + parentPath: safeParentPath, directories, drives, }); @@ -84,7 +91,7 @@ export function createBrowseHandler() { res.json({ success: true, currentPath: targetPath, - parentPath: hasParent ? parentPath : null, + parentPath: safeParentPath, directories: [], drives, warning: diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts index 374fe18f..8659eb5a 100644 --- a/apps/server/src/routes/fs/routes/validate-path.ts +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import * as secureFs from '../../../lib/secure-fs.js'; import path from 'path'; -import { isPathAllowed } from '@automaker/platform'; +import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; export function createValidatePathHandler() { @@ -20,6 +20,20 @@ export function createValidatePathHandler() { const resolvedPath = path.resolve(filePath); + // Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists + if (!isPathAllowed(resolvedPath)) { + const allowedRoot = getAllowedRootDirectory(); + const errorMessage = allowedRoot + ? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` + : `Path not allowed: ${filePath}`; + res.status(403).json({ + success: false, + error: errorMessage, + isAllowed: false, + }); + return; + } + // Check if path exists try { const stats = await secureFs.stat(resolvedPath); @@ -32,7 +46,7 @@ export function createValidatePathHandler() { res.json({ success: true, path: resolvedPath, - isAllowed: isPathAllowed(resolvedPath), + isAllowed: true, }); } catch { res.status(400).json({ success: false, error: 'Path does not exist' }); diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 688fdbc5..083a8703 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -1,12 +1,13 @@ /** * Health check routes * - * NOTE: Only the basic health check (/) is unauthenticated. + * NOTE: Only the basic health check (/) and environment check are unauthenticated. * The /detailed endpoint requires authentication. */ import { Router } from 'express'; import { createIndexHandler } from './routes/index.js'; +import { createEnvironmentHandler } from './routes/environment.js'; /** * Create unauthenticated health routes (basic check only) @@ -18,6 +19,10 @@ export function createHealthRoutes(): Router { // Basic health check - no sensitive info router.get('/', createIndexHandler()); + // Environment info including containerization status + // This is unauthenticated so the UI can check on startup + router.get('/environment', createEnvironmentHandler()); + return router; } diff --git a/apps/server/src/routes/health/routes/detailed.ts b/apps/server/src/routes/health/routes/detailed.ts index 5aa2e6b1..d5198466 100644 --- a/apps/server/src/routes/health/routes/detailed.ts +++ b/apps/server/src/routes/health/routes/detailed.ts @@ -4,13 +4,14 @@ import type { Request, Response } from 'express'; import { getAuthStatus } from '../../../lib/auth.js'; +import { getVersion } from '../../../lib/version.js'; export function createDetailedHandler() { return (_req: Request, res: Response): void => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || '0.1.0', + version: getVersion(), uptime: process.uptime(), memory: process.memoryUsage(), dataDir: process.env.DATA_DIR || './data', diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 00000000..ee5f7d53 --- /dev/null +++ b/apps/server/src/routes/health/routes/environment.ts @@ -0,0 +1,20 @@ +/** + * GET /environment endpoint - Environment information including containerization status + * + * This endpoint is unauthenticated so the UI can check it on startup + * before login to determine if sandbox risk warnings should be shown. + */ + +import type { Request, Response } from 'express'; + +export interface EnvironmentResponse { + isContainerized: boolean; +} + +export function createEnvironmentHandler() { + return (_req: Request, res: Response): void => { + res.json({ + isContainerized: process.env.IS_CONTAINERIZED === 'true', + } satisfies EnvironmentResponse); + }; +} diff --git a/apps/server/src/routes/health/routes/index.ts b/apps/server/src/routes/health/routes/index.ts index 1501f6a6..f956a96f 100644 --- a/apps/server/src/routes/health/routes/index.ts +++ b/apps/server/src/routes/health/routes/index.ts @@ -3,13 +3,14 @@ */ import type { Request, Response } from 'express'; +import { getVersion } from '../../../lib/version.js'; export function createIndexHandler() { return (_req: Request, res: Response): void => { res.json({ status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || '0.1.0', + version: getVersion(), }); }; } diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts index 097d7a6c..ebac7644 100644 --- a/apps/server/src/routes/setup/common.ts +++ b/apps/server/src/routes/setup/common.ts @@ -4,7 +4,7 @@ import { createLogger } from '@automaker/utils'; import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; const logger = createLogger('Setup'); @@ -35,36 +35,13 @@ export function getAllApiKeys(): Record { /** * Helper to persist API keys to .env file + * Uses centralized secureFs.writeEnvKey for path validation */ export async function persistApiKeyToEnv(key: string, value: string): Promise { const envPath = path.join(process.cwd(), '.env'); try { - let envContent = ''; - try { - envContent = await fs.readFile(envPath, 'utf-8'); - } catch { - // .env file doesn't exist, we'll create it - } - - // Parse existing env content - const lines = envContent.split('\n'); - const keyRegex = new RegExp(`^${key}=`); - let found = false; - const newLines = lines.map((line) => { - if (keyRegex.test(line)) { - found = true; - return `${key}=${value}`; - } - return line; - }); - - if (!found) { - // Add the key at the end - newLines.push(`${key}=${value}`); - } - - await fs.writeFile(envPath, newLines.join('\n')); + await secureFs.writeEnvKey(envPath, key, value); logger.info(`[Setup] Persisted ${key} to .env file`); } catch (error) { logger.error(`[Setup] Failed to persist ${key} to .env:`, error); diff --git a/apps/server/src/routes/setup/get-claude-status.ts b/apps/server/src/routes/setup/get-claude-status.ts index 922d363f..3ddd8ed4 100644 --- a/apps/server/src/routes/setup/get-claude-status.ts +++ b/apps/server/src/routes/setup/get-claude-status.ts @@ -4,9 +4,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; -import fs from 'fs/promises'; +import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform'; import { getApiKey } from './common.js'; const execAsync = promisify(exec); @@ -37,42 +35,25 @@ export async function getClaudeStatus() { // Version command might not be available } } catch { - // Not in PATH, try common locations based on platform - const commonPaths = isWindows - ? (() => { - const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return [ - // Windows-specific paths - path.join(os.homedir(), '.local', 'bin', 'claude.exe'), - path.join(appData, 'npm', 'claude.cmd'), - path.join(appData, 'npm', 'claude'), - path.join(appData, '.npm-global', 'bin', 'claude.cmd'), - path.join(appData, '.npm-global', 'bin', 'claude'), - ]; - })() - : [ - // Unix (Linux/macOS) paths - path.join(os.homedir(), '.local', 'bin', 'claude'), - path.join(os.homedir(), '.claude', 'local', 'claude'), - '/usr/local/bin/claude', - path.join(os.homedir(), '.npm-global', 'bin', 'claude'), - ]; + // Not in PATH, try common locations from centralized system paths + const commonPaths = getClaudeCliPaths(); for (const p of commonPaths) { try { - await fs.access(p); - cliPath = p; - installed = true; - method = 'local'; + if (await systemPathAccess(p)) { + cliPath = p; + installed = true; + method = 'local'; - // Get version from this path - try { - const { stdout: versionOut } = await execAsync(`"${p}" --version`); - version = versionOut.trim(); - } catch { - // Version command might not be available + // Get version from this path + try { + const { stdout: versionOut } = await execAsync(`"${p}" --version`); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + break; } - break; } catch { // Not found at this path } @@ -82,7 +63,7 @@ export async function getClaudeStatus() { // Check authentication - detect all possible auth methods // Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth // apiKeys.anthropic stores direct API keys for pay-per-use - let auth = { + const auth = { authenticated: false, method: 'none' as string, hasCredentialsFile: false, @@ -97,76 +78,36 @@ export async function getClaudeStatus() { hasRecentActivity: false, }; - const claudeDir = path.join(os.homedir(), '.claude'); + // Use centralized system paths to check Claude authentication indicators + const indicators = await getClaudeAuthIndicators(); - // Check for recent Claude CLI activity - indicates working authentication - // The stats-cache.json file is only populated when the CLI is working properly - const statsCachePath = path.join(claudeDir, 'stats-cache.json'); - try { - const statsContent = await fs.readFile(statsCachePath, 'utf-8'); - const stats = JSON.parse(statsContent); + // Check for recent activity (indicates working authentication) + if (indicators.hasStatsCacheWithActivity) { + auth.hasRecentActivity = true; + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } - // Check if there's any activity (which means the CLI is authenticated and working) - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - auth.hasRecentActivity = true; - auth.hasCliAuth = true; + // Check for settings + sessions (indicates CLI is set up) + if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) { + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } + + // Check credentials file + if (indicators.hasCredentialsFile && indicators.credentials) { + auth.hasCredentialsFile = true; + if (indicators.credentials.hasOAuthToken) { + auth.hasStoredOAuthToken = true; + auth.oauthTokenValid = true; auth.authenticated = true; - auth.method = 'cli_authenticated'; - } - } catch { - // Stats file doesn't exist or is invalid - } - - // Check for settings.json - indicates CLI has been set up - const settingsPath = path.join(claudeDir, 'settings.json'); - try { - await fs.access(settingsPath); - // If settings exist but no activity, CLI might be set up but not authenticated - if (!auth.hasCliAuth) { - // Try to check for other indicators of auth - const sessionsDir = path.join(claudeDir, 'projects'); - try { - const sessions = await fs.readdir(sessionsDir); - if (sessions.length > 0) { - auth.hasCliAuth = true; - auth.authenticated = true; - auth.method = 'cli_authenticated'; - } - } catch { - // Sessions directory doesn't exist - } - } - } catch { - // Settings file doesn't exist - } - - // Check for credentials file (OAuth tokens from claude login) - // Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform - const credentialsPaths = [ - path.join(claudeDir, '.credentials.json'), - path.join(claudeDir, 'credentials.json'), - ]; - - for (const credentialsPath of credentialsPaths) { - try { - const credentialsContent = await fs.readFile(credentialsPath, 'utf-8'); - const credentials = JSON.parse(credentialsContent); - auth.hasCredentialsFile = true; - - // Check what type of token is in credentials - if (credentials.oauth_token || credentials.access_token) { - auth.hasStoredOAuthToken = true; - auth.oauthTokenValid = true; - auth.authenticated = true; - auth.method = 'oauth_token'; // Stored OAuth token from credentials file - } else if (credentials.api_key) { - auth.apiKeyValid = true; - auth.authenticated = true; - auth.method = 'api_key'; // Stored API key in credentials file - } - break; // Found and processed credentials file - } catch { - // No credentials file at this path or invalid format + auth.method = 'oauth_token'; + } else if (indicators.credentials.hasApiKey) { + auth.apiKeyValid = true; + auth.authenticated = true; + auth.method = 'api_key'; } } @@ -174,21 +115,21 @@ export async function getClaudeStatus() { if (auth.hasEnvApiKey) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var + auth.method = 'api_key_env'; } // In-memory stored OAuth token (from setup wizard - subscription auth) if (!auth.authenticated && getApiKey('anthropic_oauth_token')) { auth.authenticated = true; auth.oauthTokenValid = true; - auth.method = 'oauth_token'; // Stored OAuth token from setup wizard + auth.method = 'oauth_token'; } // In-memory stored API key (from settings UI - pay-per-use) if (!auth.authenticated && getApiKey('anthropic')) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = 'api_key'; // Manually stored API key + auth.method = 'api_key'; } return { diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index e64ff6b7..0fee1b8b 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -5,40 +5,22 @@ import type { Request, Response } from 'express'; import { createLogger } from '@automaker/utils'; import path from 'path'; -import fs from 'fs/promises'; +import { secureFs } from '@automaker/platform'; const logger = createLogger('Setup'); // In-memory storage reference (imported from common.ts pattern) -// We need to modify common.ts to export a deleteApiKey function import { setApiKey } from '../common.js'; /** * Remove an API key from the .env file + * Uses centralized secureFs.removeEnvKey for path validation */ async function removeApiKeyFromEnv(key: string): Promise { const envPath = path.join(process.cwd(), '.env'); try { - let envContent = ''; - try { - envContent = await fs.readFile(envPath, 'utf-8'); - } catch { - // .env file doesn't exist, nothing to delete - return; - } - - // Parse existing env content and remove the key - const lines = envContent.split('\n'); - const keyRegex = new RegExp(`^${key}=`); - const newLines = lines.filter((line) => !keyRegex.test(line)); - - // Remove empty lines at the end - while (newLines.length > 0 && newLines[newLines.length - 1].trim() === '') { - newLines.pop(); - } - - await fs.writeFile(envPath, newLines.join('\n') + (newLines.length > 0 ? '\n' : '')); + await secureFs.removeEnvKey(envPath, key); logger.info(`[Setup] Removed ${key} from .env file`); } catch (error) { logger.error(`[Setup] Failed to remove ${key} from .env:`, error); diff --git a/apps/server/src/routes/setup/routes/gh-status.ts b/apps/server/src/routes/setup/routes/gh-status.ts index e48b5c25..f78bbd6d 100644 --- a/apps/server/src/routes/setup/routes/gh-status.ts +++ b/apps/server/src/routes/setup/routes/gh-status.ts @@ -5,27 +5,14 @@ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; -import os from 'os'; -import path from 'path'; -import fs from 'fs/promises'; +import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); -// Extended PATH to include common tool installation locations -const extendedPath = [ - process.env.PATH, - '/opt/homebrew/bin', - '/usr/local/bin', - '/home/linuxbrew/.linuxbrew/bin', - `${process.env.HOME}/.local/bin`, -] - .filter(Boolean) - .join(':'); - const execEnv = { ...process.env, - PATH: extendedPath, + PATH: getExtendedPath(), }; export interface GhStatus { @@ -55,25 +42,16 @@ async function getGhStatus(): Promise { status.path = stdout.trim().split(/\r?\n/)[0]; status.installed = true; } catch { - // gh not in PATH, try common locations - const commonPaths = isWindows - ? [ - path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'), - path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), - ] - : [ - '/opt/homebrew/bin/gh', - '/usr/local/bin/gh', - path.join(os.homedir(), '.local', 'bin', 'gh'), - '/home/linuxbrew/.linuxbrew/bin/gh', - ]; + // gh not in PATH, try common locations from centralized system paths + const commonPaths = getGitHubCliPaths(); for (const p of commonPaths) { try { - await fs.access(p); - status.path = p; - status.installed = true; - break; + if (await systemPathAccess(p)) { + status.path = p; + status.installed = true; + break; + } } catch { // Not found at this path } diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts index a7f42509..7d4b5383 100644 --- a/apps/server/src/routes/terminal/routes/sessions.ts +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -22,12 +22,12 @@ export function createSessionsListHandler() { } export function createSessionsCreateHandler() { - return (req: Request, res: Response): void => { + return async (req: Request, res: Response): Promise => { try { const terminalService = getTerminalService(); const { cwd, cols, rows, shell } = req.body; - const session = terminalService.createSession({ + const session = await terminalService.createSession({ cwd, cols: cols || 80, rows: rows || 24, diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts index 6527ab77..4f63a382 100644 --- a/apps/server/src/routes/worktree/common.ts +++ b/apps/server/src/routes/worktree/common.ts @@ -158,8 +158,13 @@ export const logError = createLogError(logger); /** * Ensure the repository has at least one commit so git commands that rely on HEAD work. * Returns true if an empty commit was created, false if the repo already had commits. + * @param repoPath - Path to the git repository + * @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL) */ -export async function ensureInitialCommit(repoPath: string): Promise { +export async function ensureInitialCommit( + repoPath: string, + env?: Record +): Promise { try { await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); return false; @@ -167,6 +172,7 @@ export async function ensureInitialCommit(repoPath: string): Promise { try { await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, { cwd: repoPath, + env: { ...process.env, ...env }, }); logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`); return true; diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 943d3bdd..4eb2b2c9 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -100,7 +100,14 @@ export function createCreateHandler() { } // Ensure the repository has at least one commit so worktree commands referencing HEAD succeed - await ensureInitialCommit(projectPath); + // Pass git identity env vars so commits work without global git config + const gitEnv = { + GIT_AUTHOR_NAME: 'Automaker', + GIT_AUTHOR_EMAIL: 'automaker@localhost', + GIT_COMMITTER_NAME: 'Automaker', + GIT_COMMITTER_EMAIL: 'automaker@localhost', + }; + await ensureInitialCommit(projectPath, gitEnv); // First, check if git already has a worktree for this branch (anywhere) const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9e5c67ba..fbfde6fa 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -199,6 +199,10 @@ interface AutoModeConfig { projectPath: string; } +// Constants for consecutive failure tracking +const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures +const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive + export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); @@ -209,12 +213,89 @@ export class AutoModeService { private config: AutoModeConfig | null = null; private pendingApprovals = new Map(); private settingsService: SettingsService | null = null; + // Track consecutive failures to detect quota/API issues + private consecutiveFailures: { timestamp: number; error: string }[] = []; + private pausedDueToFailures = false; constructor(events: EventEmitter, settingsService?: SettingsService) { this.events = events; this.settingsService = settingsService ?? null; } + /** + * Track a failure and check if we should pause due to consecutive failures. + * This handles cases where the SDK doesn't return useful error messages. + */ + private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean { + const now = Date.now(); + + // Add this failure + this.consecutiveFailures.push({ timestamp: now, error: errorInfo.message }); + + // Remove old failures outside the window + this.consecutiveFailures = this.consecutiveFailures.filter( + (f) => now - f.timestamp < FAILURE_WINDOW_MS + ); + + // Check if we've hit the threshold + if (this.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) { + return true; // Should pause + } + + // Also immediately pause for known quota/rate limit errors + if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') { + return true; + } + + return false; + } + + /** + * Signal that we should pause due to repeated failures or quota exhaustion. + * This will pause the auto loop to prevent repeated failures. + */ + private signalShouldPause(errorInfo: { type: string; message: string }): void { + if (this.pausedDueToFailures) { + return; // Already paused + } + + this.pausedDueToFailures = true; + const failureCount = this.consecutiveFailures.length; + console.log( + `[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` + ); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_paused_failures', { + message: + failureCount >= CONSECUTIVE_FAILURE_THRESHOLD + ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.` + : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.', + errorType: errorInfo.type, + originalError: errorInfo.message, + failureCount, + projectPath: this.config?.projectPath, + }); + + // Stop the auto loop + this.stopAutoLoop(); + } + + /** + * Reset failure tracking (called when user manually restarts auto mode) + */ + private resetFailureTracking(): void { + this.consecutiveFailures = []; + this.pausedDueToFailures = false; + } + + /** + * Record a successful feature completion to reset consecutive failure count + */ + private recordSuccess(): void { + this.consecutiveFailures = []; + } + /** * Start the auto mode loop - continuously picks and executes pending features */ @@ -223,6 +304,9 @@ export class AutoModeService { throw new Error('Auto mode is already running'); } + // Reset failure tracking when user manually starts auto mode + this.resetFailureTracking(); + this.autoLoopRunning = true; this.autoLoopAbortController = new AbortController(); this.config = { @@ -518,6 +602,9 @@ export class AutoModeService { const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Record success to reset consecutive failure tracking + this.recordSuccess(); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, @@ -547,6 +634,21 @@ export class AutoModeService { errorType: errorInfo.type, projectPath, }); + + // Track this failure and check if we should pause auto mode + // This handles both specific quota/rate limit errors AND generic failures + // that may indicate quota exhaustion (SDK doesn't always return useful errors) + const shouldPause = this.trackFailureAndCheckPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + + if (shouldPause) { + this.signalShouldPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + } } } finally { console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); @@ -707,6 +809,11 @@ Complete the pipeline step instructions above. Review the previous work and appl this.cancelPlanApproval(featureId); running.abortController.abort(); + + // Remove from running features immediately to allow resume + // The abort signal will still propagate to stop any ongoing execution + this.runningFeatures.delete(featureId); + return true; } @@ -951,6 +1058,9 @@ Address the follow-up instructions above. Review the previous work and make the const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Record success to reset consecutive failure tracking + this.recordSuccess(); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, passes: true, @@ -968,6 +1078,19 @@ Address the follow-up instructions above. Review the previous work and make the errorType: errorInfo.type, projectPath, }); + + // Track this failure and check if we should pause auto mode + const shouldPause = this.trackFailureAndCheckPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + + if (shouldPause) { + this.signalShouldPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + } } } finally { this.runningFeatures.delete(featureId); @@ -1976,7 +2099,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }; // Execute via provider + console.log(`[AutoMode] Starting stream for feature ${featureId}...`); const stream = provider.executeQuery(executeOptions); + console.log(`[AutoMode] Stream created, starting to iterate...`); // Initialize with previous content if this is a follow-up, with a separator let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` @@ -2055,6 +2180,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Log raw stream event for debugging appendRawEvent(msg); + console.log(`[AutoMode] Stream message received:`, msg.type, msg.subtype || ''); if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { if (block.type === 'text') { @@ -2528,6 +2654,9 @@ Implement all the changes described in the plan above.`; // Only emit progress for non-marker text (marker was already handled above) if (!specDetected) { + console.log( + `[AutoMode] Emitting progress event for ${featureId}, content length: ${block.text?.length || 0}` + ); this.emitAutoModeEvent('auto_mode_progress', { featureId, content: block.text, diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index e04c7cb6..3256e912 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -192,9 +192,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); @@ -206,13 +205,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}` @@ -223,8 +222,12 @@ export class FeatureLoader { (error as Error).message ); } + return null; } - } + }); + + const results = await Promise.all(featurePromises); + const features = results.filter((f): f is Feature => f !== null); // Sort by creation order (feature IDs contain timestamp) features.sort((a, b) => { diff --git a/apps/server/src/services/mcp-test-service.ts b/apps/server/src/services/mcp-test-service.ts index d1662722..4232de60 100644 --- a/apps/server/src/services/mcp-test-service.ts +++ b/apps/server/src/services/mcp-test-service.ts @@ -9,10 +9,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; import type { MCPServerConfig, MCPToolInfo } from '@automaker/types'; import type { SettingsService } from './settings-service.js'; +const execAsync = promisify(exec); const DEFAULT_TIMEOUT = 10000; // 10 seconds +const IS_WINDOWS = process.platform === 'win32'; export interface MCPTestResult { success: boolean; @@ -41,6 +45,11 @@ export class MCPTestService { async testServer(serverConfig: MCPServerConfig): Promise { const startTime = Date.now(); let client: Client | null = null; + let transport: + | StdioClientTransport + | SSEClientTransport + | StreamableHTTPClientTransport + | null = null; try { client = new Client({ @@ -49,7 +58,7 @@ export class MCPTestService { }); // Create transport based on server type - const transport = await this.createTransport(serverConfig); + transport = await this.createTransport(serverConfig); // Connect with timeout await Promise.race([ @@ -98,13 +107,47 @@ export class MCPTestService { connectionTime, }; } finally { - // Clean up client connection - if (client) { - try { - await client.close(); - } catch { - // Ignore cleanup errors - } + // Clean up client connection and ensure process termination + await this.cleanupConnection(client, transport); + } + } + + /** + * Clean up MCP client connection and terminate spawned processes + * + * On Windows, child processes spawned via 'cmd /c' don't get terminated when the + * parent process is killed. We use taskkill with /t flag to kill the entire process tree. + * This prevents orphaned MCP server processes that would spam logs with ping warnings. + * + * IMPORTANT: We must run taskkill BEFORE client.close() because: + * - client.close() kills only the parent cmd.exe process + * - This orphans the child node.exe processes before we can kill them + * - taskkill /t needs the parent PID to exist to traverse the process tree + */ + private async cleanupConnection( + client: Client | null, + transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null + ): Promise { + // Get the PID before any cleanup (only available for stdio transports) + const pid = transport instanceof StdioClientTransport ? transport.pid : null; + + // On Windows with stdio transport, kill the entire process tree FIRST + // This must happen before client.close() which would orphan child processes + if (IS_WINDOWS && pid) { + try { + // taskkill /f = force, /t = kill process tree, /pid = process ID + await execAsync(`taskkill /f /t /pid ${pid}`); + } catch { + // Process may have already exited, which is fine + } + } + + // Now do the standard close (may be a no-op if taskkill already killed everything) + if (client) { + try { + await client.close(); + } catch { + // Expected if taskkill already terminated the process } } } diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 23a618cb..4673404e 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -126,6 +126,8 @@ export class SettingsService { * Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward * compatibility during schema migrations. * + * Also applies version-based migrations for breaking changes. + * * @returns Promise resolving to complete GlobalSettings object */ async getGlobalSettings(): Promise { @@ -136,7 +138,7 @@ export class SettingsService { const migratedPhaseModels = this.migratePhaseModels(settings); // Apply any missing defaults (for backwards compatibility) - return { + let result: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...settings, keyboardShortcuts: { @@ -145,6 +147,32 @@ export class SettingsService { }, phaseModels: migratedPhaseModels, }; + + // Version-based migrations + const storedVersion = settings.version || 1; + let needsSave = false; + + // Migration v1 -> v2: Force enableSandboxMode to false for existing users + // Sandbox mode can cause issues on some systems, so we're disabling it by default + if (storedVersion < 2) { + logger.info('Migrating settings from v1 to v2: disabling sandbox mode'); + result.enableSandboxMode = false; + result.version = SETTINGS_VERSION; + needsSave = true; + } + + // Save migrated settings if needed + if (needsSave) { + try { + await ensureDataDir(this.dataDir); + await atomicWriteJson(settingsPath, result); + logger.info('Settings migration complete'); + } catch (error) { + logger.error('Failed to save migrated settings:', error); + } + } + + return result; } /** diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 7d59633e..81a1585a 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -8,8 +8,18 @@ import * as pty from 'node-pty'; import { EventEmitter } from 'events'; import * as os from 'os'; -import * as fs from 'fs'; import * as path from 'path'; +// secureFs is used for user-controllable paths (working directory validation) +// to enforce ALLOWED_ROOT_DIRECTORY security boundary +import * as secureFs from '../lib/secure-fs.js'; +// System paths module handles shell binary checks and WSL detection +// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing +import { + systemPathExists, + systemPathReadFileSync, + getWslVersionPath, + getShellPaths, +} from '@automaker/platform'; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -60,60 +70,96 @@ export class TerminalService extends EventEmitter { /** * Detect the best shell for the current platform + * Uses getShellPaths() to iterate through allowed shell paths */ detectShell(): { shell: string; args: string[] } { const platform = os.platform(); + const shellPaths = getShellPaths(); - // Check if running in WSL + // Helper to get basename handling both path separators + const getBasename = (shellPath: string): string => { + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; + }; + + // Helper to get shell args based on shell name + const getShellArgs = (shell: string): string[] => { + const shellName = getBasename(shell).toLowerCase().replace('.exe', ''); + // PowerShell and cmd don't need --login + if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { + return []; + } + // sh doesn't support --login in all implementations + if (shellName === 'sh') { + return []; + } + // bash, zsh, and other POSIX shells support --login + return ['--login']; + }; + + // Check if running in WSL - prefer user's shell or bash with --login if (platform === 'linux' && this.isWSL()) { - // In WSL, prefer the user's configured shell or bash - const userShell = process.env.SHELL || '/bin/bash'; - if (fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; + const userShell = process.env.SHELL; + if (userShell) { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } + } + } + } + // Fall back to first available POSIX shell + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed, continue + } } return { shell: '/bin/bash', args: ['--login'] }; } - switch (platform) { - case 'win32': { - // Windows: prefer PowerShell, fall back to cmd - const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; - const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; - - if (fs.existsSync(pwshCore)) { - return { shell: pwshCore, args: [] }; + // For all platforms: first try user's shell if set + const userShell = process.env.SHELL; + if (userShell && platform !== 'win32') { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgs(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } } - if (fs.existsSync(pwsh)) { - return { shell: pwsh, args: [] }; - } - return { shell: 'cmd.exe', args: [] }; - } - - case 'darwin': { - // macOS: prefer user's shell, then zsh, then bash - const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (fs.existsSync('/bin/zsh')) { - return { shell: '/bin/zsh', args: ['--login'] }; - } - return { shell: '/bin/bash', args: ['--login'] }; - } - - case 'linux': - default: { - // Linux: prefer user's shell, then bash, then sh - const userShell = process.env.SHELL; - if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ['--login'] }; - } - if (fs.existsSync('/bin/bash')) { - return { shell: '/bin/bash', args: ['--login'] }; - } - return { shell: '/bin/sh', args: [] }; } } + + // Iterate through allowed shell paths and return first existing one + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgs(shell) }; + } + } catch { + // Path not allowed or doesn't exist, continue to next + } + } + + // Ultimate fallbacks based on platform + if (platform === 'win32') { + return { shell: 'cmd.exe', args: [] }; + } + return { shell: '/bin/sh', args: [] }; } /** @@ -122,8 +168,9 @@ export class TerminalService extends EventEmitter { isWSL(): boolean { try { // Check /proc/version for Microsoft/WSL indicators - if (fs.existsSync('/proc/version')) { - const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); + const wslVersionPath = getWslVersionPath(); + if (systemPathExists(wslVersionPath)) { + const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase(); return version.includes('microsoft') || version.includes('wsl'); } // Check for WSL environment variable @@ -157,8 +204,9 @@ export class TerminalService extends EventEmitter { /** * Validate and resolve a working directory path * Includes basic sanitization against null bytes and path normalization + * Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths */ - private resolveWorkingDirectory(requestedCwd?: string): string { + private async resolveWorkingDirectory(requestedCwd?: string): Promise { const homeDir = os.homedir(); // If no cwd requested, use home @@ -187,15 +235,19 @@ export class TerminalService extends EventEmitter { } // Check if path exists and is a directory + // Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary + // This prevents terminals from being opened in directories outside the allowed workspace try { - const stat = fs.statSync(cwd); - if (stat.isDirectory()) { + const statResult = await secureFs.stat(cwd); + if (statResult.isDirectory()) { return cwd; } console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`); return homeDir; } catch { - console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`); + console.warn( + `[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home` + ); return homeDir; } } @@ -228,7 +280,7 @@ export class TerminalService extends EventEmitter { * Create a new terminal session * Returns null if the maximum session limit has been reached */ - createSession(options: TerminalOptions = {}): TerminalSession | null { + async createSession(options: TerminalOptions = {}): Promise { // Check session limit if (this.sessions.size >= maxSessions) { console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`); @@ -241,12 +293,23 @@ export class TerminalService extends EventEmitter { const shell = options.shell || detectedShell; // Validate and resolve working directory - const cwd = this.resolveWorkingDirectory(options.cwd); + // Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY + const cwd = await this.resolveWorkingDirectory(options.cwd); // Build environment with some useful defaults // These settings ensure consistent terminal behavior across platforms + // First, create a clean copy of process.env excluding Automaker-specific variables + // that could pollute user shells (e.g., PORT would affect Next.js/other dev servers) + const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !automakerEnvVars.includes(key)) { + cleanEnv[key] = value; + } + } + const env: Record = { - ...process.env, + ...cleanEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'automaker-terminal', diff --git a/apps/server/tests/integration/helpers/git-test-repo.ts b/apps/server/tests/integration/helpers/git-test-repo.ts index 4ec95926..7871e8e8 100644 --- a/apps/server/tests/integration/helpers/git-test-repo.ts +++ b/apps/server/tests/integration/helpers/git-test-repo.ts @@ -22,13 +22,21 @@ export async function createTestGitRepo(): Promise { // Initialize git repo await execAsync('git init', { cwd: tmpDir }); - await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); - await execAsync('git config user.name "Test User"', { cwd: tmpDir }); + + // Use environment variables instead of git config to avoid affecting user's git config + // These env vars override git config without modifying it + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; // Create initial commit await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n'); - await execAsync('git add .', { cwd: tmpDir }); - await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); + await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); + await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); // Create main branch explicitly await execAsync('git branch -M main', { cwd: tmpDir }); diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts index 433b610a..6d274a0d 100644 --- a/apps/server/tests/integration/routes/worktree/create.integration.test.ts +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -15,10 +15,8 @@ describe('worktree create route - repositories without commits', () => { async function initRepoWithoutCommit() { repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); await execAsync('git init', { cwd: repoPath }); - await execAsync('git config user.email "test@example.com"', { - cwd: repoPath, - }); - await execAsync('git config user.name "Test User"', { cwd: repoPath }); + // Don't set git config - use environment variables in commit operations instead + // to avoid affecting user's git config // Intentionally skip creating an initial commit } diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index e5d4c7c0..3faea516 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -1,15 +1,161 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; describe('sdk-options.ts', () => { let originalEnv: NodeJS.ProcessEnv; + let homedirSpy: ReturnType; beforeEach(() => { originalEnv = { ...process.env }; vi.resetModules(); + // Spy on os.homedir and set default return value + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test'); }); afterEach(() => { process.env = originalEnv; + homedirSpy.mockRestore(); + }); + + describe('isCloudStoragePath', () => { + it('should detect Dropbox paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe( + true + ); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true); + }); + + it('should detect Google Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project') + ).toBe(true); + }); + + it('should detect OneDrive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe( + true + ); + }); + + it('should detect iCloud Drive paths on macOS', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect( + isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project') + ).toBe(true); + }); + + it('should detect home-anchored Dropbox paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true); + expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true); + }); + + it('should detect home-anchored Google Drive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true); + expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true); + }); + + it('should detect home-anchored OneDrive paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true); + expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true); + }); + + it('should return false for local paths', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false); + expect(isCloudStoragePath('/home/user/code/project')).toBe(false); + expect(isCloudStoragePath('/var/www/app')).toBe(false); + }); + + it('should return false for relative paths not in cloud storage', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('./project')).toBe(false); + expect(isCloudStoragePath('../other-project')).toBe(false); + }); + + // Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage + it('should NOT flag paths that merely contain "dropbox" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + // Projects with dropbox-like names + expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false); + expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false); + // Dropbox folder that's NOT in the home directory + expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false); + }); + + it('should NOT flag paths that merely contain "Google Drive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false); + expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false); + }); + + it('should NOT flag paths that merely contain "OneDrive" in the name', async () => { + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false); + expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false); + }); + + it('should handle different home directories correctly', async () => { + // Change the mocked home directory + homedirSpy.mockReturnValue('/home/linuxuser'); + const { isCloudStoragePath } = await import('@/lib/sdk-options.js'); + + // Should detect Dropbox under the Linux home directory + expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true); + // Should NOT detect Dropbox under the old home directory (since home changed) + expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false); + }); + }); + + describe('checkSandboxCompatibility', () => { + it('should return enabled=false when user disables sandbox', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', false); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('user_setting'); + }); + + it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + true + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + expect(result.message).toContain('cloud storage'); + }); + + it('should return enabled=true for local paths when sandbox enabled', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/projects/myapp', true); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility('/Users/test/project', undefined); + expect(result.enabled).toBe(true); + expect(result.disabledReason).toBeUndefined(); + }); + + it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => { + const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js'); + const result = checkSandboxCompatibility( + '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + undefined + ); + expect(result.enabled).toBe(false); + expect(result.disabledReason).toBe('cloud_storage'); + }); }); describe('TOOL_PRESETS', () => { @@ -224,13 +370,27 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createChatOptions } = await import('@/lib/sdk-options.js'); const options = createChatOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + expect(options.sandbox).toBeUndefined(); }); }); @@ -285,13 +445,48 @@ describe('sdk-options.ts', () => { expect(options.sandbox).toBeUndefined(); }); - it('should not set sandbox when enableSandboxMode is not provided', async () => { + it('should enable sandbox by default when enableSandboxMode is not provided', async () => { const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); const options = createAutoModeOptions({ cwd: '/test/path', }); + expect(options.sandbox).toEqual({ + enabled: true, + autoAllowBashIfSandboxed: true, + }); + }); + + it('should auto-disable sandbox for cloud storage paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + enableSandboxMode: true, + }); + + expect(options.sandbox).toBeUndefined(); + }); + + it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project', + }); + + expect(options.sandbox).toBeUndefined(); + }); + + it('should auto-disable sandbox for iCloud paths', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project', + enableSandboxMode: true, + }); + expect(options.sandbox).toBeUndefined(); }); }); diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index 261bb815..88660f7f 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -2,16 +2,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { TerminalService, getTerminalService } from '@/services/terminal-service.js'; import * as pty from 'node-pty'; import * as os from 'os'; -import * as fs from 'fs'; +import * as platform from '@automaker/platform'; +import * as secureFs from '@/lib/secure-fs.js'; vi.mock('node-pty'); -vi.mock('fs'); vi.mock('os'); +vi.mock('@automaker/platform', async () => { + const actual = await vi.importActual('@automaker/platform'); + return { + ...actual, + systemPathExists: vi.fn(), + systemPathReadFileSync: vi.fn(), + getWslVersionPath: vi.fn(), + getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing + isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests + }; +}); +vi.mock('@/lib/secure-fs.js'); describe('terminal-service.ts', () => { let service: TerminalService; let mockPtyProcess: any; + // Shell paths for each platform (matching system-paths.ts) + const linuxShellPaths = [ + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + 'zsh', + 'bash', + 'sh', + ]; + + const windowsShellPaths = [ + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe', + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + beforeEach(() => { vi.clearAllMocks(); service = new TerminalService(); @@ -29,6 +71,13 @@ describe('terminal-service.ts', () => { vi.mocked(os.homedir).mockReturnValue('/home/user'); vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); + + // Default mocks for system paths and secureFs + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue(''); + vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version'); + vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); }); afterEach(() => { @@ -38,7 +87,8 @@ describe('terminal-service.ts', () => { describe('detectShell', () => { it('should detect PowerShell Core on Windows when available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; }); @@ -50,7 +100,8 @@ describe('terminal-service.ts', () => { it('should fall back to PowerShell on Windows if Core not available', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; }); @@ -62,7 +113,8 @@ describe('terminal-service.ts', () => { it('should fall back to cmd.exe on Windows if no PowerShell', () => { vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -73,7 +125,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on macOS', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -84,7 +136,7 @@ describe('terminal-service.ts', () => { it('should fall back to zsh on macOS if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/zsh'; }); @@ -97,7 +149,10 @@ describe('terminal-service.ts', () => { it('should fall back to bash on macOS if zsh not available', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + // zsh not available, but bash is + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/bash'; + }); const result = service.detectShell(); @@ -108,7 +163,7 @@ describe('terminal-service.ts', () => { it('should detect user shell on Linux', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); const result = service.detectShell(); @@ -119,7 +174,7 @@ describe('terminal-service.ts', () => { it('should fall back to bash on Linux if user shell not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockImplementation((path: any) => { + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { return path === '/bin/bash'; }); @@ -132,7 +187,7 @@ describe('terminal-service.ts', () => { it('should fall back to sh on Linux if bash not available', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({}); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); const result = service.detectShell(); @@ -143,8 +198,10 @@ describe('terminal-service.ts', () => { it('should detect WSL and use appropriate shell', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); const result = service.detectShell(); @@ -155,43 +212,45 @@ describe('terminal-service.ts', () => { describe('isWSL', () => { it('should return true if /proc/version contains microsoft', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); expect(service.isWSL()).toBe(true); }); it('should return true if /proc/version contains wsl', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); expect(service.isWSL()).toBe(true); }); it('should return true if WSL_DISTRO_NAME is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' }); expect(service.isWSL()).toBe(true); }); it('should return true if WSLENV is set', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' }); expect(service.isWSL()).toBe(true); }); it('should return false if not in WSL', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(platform.systemPathExists).mockReturnValue(false); vi.spyOn(process, 'env', 'get').mockReturnValue({}); expect(service.isWSL()).toBe(false); }); it('should return false if error reading /proc/version', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockImplementation(() => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => { throw new Error('Permission denied'); }); @@ -203,7 +262,7 @@ describe('terminal-service.ts', () => { it('should return platform information', () => { vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.arch).mockReturnValue('x64'); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(platform.systemPathExists).mockReturnValue(true); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const info = service.getPlatformInfo(); @@ -216,21 +275,21 @@ describe('terminal-service.ts', () => { }); describe('createSession', () => { - // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) - it.skipIf(process.platform === 'win32')('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'], @@ -242,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), @@ -259,67 +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'); }); - // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) - it.skipIf(process.platform === 'win32')('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]; @@ -333,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'); @@ -374,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); @@ -393,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'); @@ -425,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(); }); @@ -436,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); }); @@ -471,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'); }); @@ -492,20 +553,21 @@ describe('terminal-service.ts', () => { }); describe('getAllSessions', () => { - // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) - it.skipIf(process.platform === 'win32')('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'); }); @@ -538,30 +600,32 @@ describe('terminal-service.ts', () => { }); describe('cleanup', () => { - it('should clean up all sessions', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should clean up all sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession(); - const session2 = service.createSession(); + const session1 = await service.createSession(); + const session2 = await service.createSession(); service.cleanup(); - expect(service.getSession(session1.id)).toBeUndefined(); - expect(service.getSession(session2.id)).toBeUndefined(); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(service.getSession(session1!.id)).toBeUndefined(); + expect(service.getSession(session2!.id)).toBeUndefined(); expect(service.getAllSessions()).toHaveLength(0); }); - it('should handle cleanup errors gracefully', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + it('should handle cleanup errors gracefully', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { throw new Error('Kill failed'); }); - service.createSession(); + await service.createSession(); expect(() => service.cleanup()).not.toThrow(); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index b069e28c..c4613dba 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.1.0", + "version": "0.7.2", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -10,6 +10,9 @@ "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "main": "dist-electron/main.js", "scripts": { "dev": "vite", @@ -35,87 +38,87 @@ "dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" }, "dependencies": { - "@automaker/dependency-resolver": "^1.0.0", - "@automaker/types": "^1.0.0", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@lezer/highlight": "^1.2.3", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.141.6", - "@uiw/react-codemirror": "^4.25.4", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", - "@xterm/xterm": "^5.5.0", - "@xyflow/react": "^12.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "dagre": "^0.8.5", - "dotenv": "^17.2.3", - "geist": "^1.5.1", - "lucide-react": "^0.562.0", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/theme-one-dark": "6.1.3", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "5.90.12", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", - "rehype-raw": "^7.0.0", - "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0", - "usehooks-ts": "^3.1.1", - "zustand": "^5.0.9" + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zustand": "5.0.9" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "^1.29.2", - "lightningcss-darwin-x64": "^1.29.2", - "lightningcss-linux-arm-gnueabihf": "^1.29.2", - "lightningcss-linux-arm64-gnu": "^1.29.2", - "lightningcss-linux-arm64-musl": "^1.29.2", - "lightningcss-linux-x64-gnu": "^1.29.2", - "lightningcss-linux-x64-musl": "^1.29.2", - "lightningcss-win32-arm64-msvc": "^1.29.2", - "lightningcss-win32-x64-msvc": "^1.29.2" + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.2", - "@eslint/js": "^9.0.0", - "@playwright/test": "^1.57.0", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.141.7", - "@types/dagre": "^0.7.53", - "@types/node": "^22", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.50.0", - "@typescript-eslint/parser": "^8.50.0", - "@vitejs/plugin-react": "^5.1.2", - "cross-env": "^10.1.0", + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", "electron": "39.2.7", - "electron-builder": "^26.0.12", - "eslint": "^9.39.2", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", "typescript": "5.9.3", - "vite": "^7.3.0", - "vite-plugin-electron": "^0.29.0", - "vite-plugin-electron-renderer": "^0.14.6" + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" }, "build": { "appId": "com.automaker.app", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 80ba9af3..5ea2fb7b 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -49,6 +49,8 @@ export default defineConfig({ // Hide the API key banner to reduce log noise AUTOMAKER_HIDE_API_KEY: 'true', // No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing + // Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true', }, }, // Frontend Vite dev server diff --git a/apps/ui/public/readme_logo.svg b/apps/ui/public/readme_logo.svg new file mode 100644 index 00000000..86177aea --- /dev/null +++ b/apps/ui/public/readme_logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + automaker. + + diff --git a/apps/ui/scripts/bump-version.mjs b/apps/ui/scripts/bump-version.mjs new file mode 100755 index 00000000..ae4d9516 --- /dev/null +++ b/apps/ui/scripts/bump-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Bumps the version in apps/ui/package.json and apps/server/package.json + * Usage: node scripts/bump-version.mjs [major|minor|patch] + * Example: node scripts/bump-version.mjs patch + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const bumpType = process.argv[2]?.toLowerCase(); + +if (!bumpType || !['major', 'minor', 'patch'].includes(bumpType)) { + console.error('Error: Bump type argument is required'); + console.error('Usage: node scripts/bump-version.mjs [major|minor|patch]'); + console.error('Example: node scripts/bump-version.mjs patch'); + process.exit(1); +} + +const uiPackageJsonPath = join(__dirname, '..', 'package.json'); +const serverPackageJsonPath = join(__dirname, '..', '..', 'server', 'package.json'); + +function bumpVersion(packageJsonPath, packageName) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const oldVersion = packageJson.version; + + // Parse version + const versionParts = oldVersion.split('.').map(Number); + if (versionParts.length !== 3) { + console.error(`Error: Invalid version format in ${packageName}: ${oldVersion}`); + console.error('Expected format: X.Y.Z (e.g., 1.2.3)'); + process.exit(1); + } + + // Bump version + let [major, minor, patch] = versionParts; + + switch (bumpType) { + case 'major': + major += 1; + minor = 0; + patch = 0; + break; + case 'minor': + minor += 1; + patch = 0; + break; + case 'patch': + patch += 1; + break; + } + + const newVersion = `${major}.${minor}.${patch}`; + packageJson.version = newVersion; + + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); + + return newVersion; + } catch (error) { + console.error(`Error bumping version in ${packageName}: ${error.message}`); + process.exit(1); + } +} + +try { + // Bump UI package version + const uiOldVersion = JSON.parse(readFileSync(uiPackageJsonPath, 'utf8')).version; + const uiNewVersion = bumpVersion(uiPackageJsonPath, '@automaker/ui'); + + // Bump server package version (sync with UI) + const serverOldVersion = JSON.parse(readFileSync(serverPackageJsonPath, 'utf8')).version; + const serverNewVersion = bumpVersion(serverPackageJsonPath, '@automaker/server'); + + // Verify versions match + if (uiNewVersion !== serverNewVersion) { + console.error(`Error: Version mismatch! UI: ${uiNewVersion}, Server: ${serverNewVersion}`); + process.exit(1); + } + + console.log(`✅ Bumped version from ${uiOldVersion} to ${uiNewVersion} (${bumpType})`); + console.log(`📦 Updated @automaker/ui: ${uiOldVersion} -> ${uiNewVersion}`); + console.log(`📦 Updated @automaker/server: ${serverOldVersion} -> ${serverNewVersion}`); + console.log(`📦 Version is now: ${uiNewVersion}`); +} catch (error) { + console.error(`Error bumping version: ${error.message}`); + process.exit(1); +} diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 2738ec79..c1acdfd9 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client'; import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; import { toast } from 'sonner'; import { @@ -62,7 +62,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa // Update preview image when background settings change useEffect(() => { if (currentProject && backgroundSettings.imagePath) { - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); // Add cache-busting query parameter to force browser to reload image const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..32be56d4 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,90 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { useState } from 'react'; +import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRejectionScreen() { + const [copied, setCopied] = useState(false); + + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

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

+
+ +
+
+ +
+

Run in Docker (Recommended)

+

+ Run Automaker in a containerized sandbox environment: +

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

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

+ +
+

Potential Risks:

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

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

+
+ {DOCKER_COMMAND} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx index 66345b92..ac8ed22d 100644 --- a/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx +++ b/apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx @@ -7,6 +7,8 @@ interface AutomakerLogoProps { } export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) { + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + return (
{!sidebarOpen ? ( -
+
+ + v{appVersion} +
) : ( -
- - - - - - - - - - - - +
+ - - - - - - - automaker. + + + + + + + + + + + + + + + + + + automaker. + +
+ + v{appVersion}
)} diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index 0a84ed8a..9df5e0e6 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { ImageIcon, X, Loader2, FileText } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; import { getElectronAPI } from '@/lib/electron'; +import { getServerUrlSync } from '@/lib/http-api-client'; import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store'; import { sanitizeFilename, @@ -93,7 +94,7 @@ export function DescriptionImageDropZone({ // Construct server URL for loading saved images const getImageServerUrl = useCallback( (imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; + const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); const projectPath = currentProject?.path || ''; return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; }, diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 047d9035..9274c6cf 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -206,6 +206,7 @@ export function BoardView() { checkContextExists, features: hookFeatures, isLoading, + featuresWithContext, setFeaturesWithContext, }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index df0d8707..b791216b 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -143,7 +143,7 @@ export function CardActions({ Verify - ) : hasContext && onResume ? ( + ) : onResume ? ( - ) : onVerify ? ( - ) : null} {onViewOutput && !feature.skipTests && (