Merge main into feat/cursor-cli-integration

Carefully merged latest changes from main branch into the Cursor CLI integration
branch. This merge brings in important improvements and fixes while preserving
all Cursor-related functionality.

Key changes from main:
- Sandbox mode security improvements and cloud storage compatibility
- Version-based settings migrations (v2 schema)
- Port configuration centralization
- System paths utilities for CLI detection
- Enhanced error handling in HttpApiClient
- Windows MCP process cleanup fixes
- New validation and build commands
- GitHub issue templates and release process improvements

Resolved conflicts in:
- apps/server/src/routes/context/routes/describe-image.ts
  (Combined Cursor provider routing with secure-fs imports)
- apps/server/src/services/auto-mode-service.ts
  (Merged failure tracking with raw output logging)
- apps/server/tests/unit/services/terminal-service.test.ts
  (Updated to async tests with systemPathExists mocking)
- libs/platform/src/index.ts
  (Combined WSL utilities with system-paths exports)
- libs/types/src/settings.ts
  (Merged DEFAULT_PHASE_MODELS with SETTINGS_VERSION constants)

All Cursor CLI integration features remain intact including:
- CursorProvider and CliProvider base class
- Phase-based model configuration
- Provider registry and factory patterns
- WSL support for Windows
- Model override UI components
- Cursor-specific settings and configurations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-01 18:03:48 +01:00
100 changed files with 4782 additions and 3239 deletions

View File

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

View File

@@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-
export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal';
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
export { SandboxRiskDialog } from './sandbox-risk-dialog';
export { WorkspacePickerModal } from './workspace-picker-modal';

View File

@@ -0,0 +1,90 @@
/**
* Sandbox Rejection Screen
*
* Shown in web mode when user denies the sandbox risk confirmation.
* Prompts them to either restart the app in a container or reload to try again.
*/
import { useState } from 'react';
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
const DOCKER_COMMAND = 'npm run dev:docker';
export function SandboxRejectionScreen() {
const [copied, setCopied] = useState(false);
const handleReload = () => {
// Clear the rejection state and reload
sessionStorage.removeItem('automaker-sandbox-denied');
window.location.reload();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(DOCKER_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-4">
<ShieldX className="w-12 h-12 text-destructive" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Access Denied</h1>
<p className="text-muted-foreground">
You declined to accept the risks of running Automaker outside a sandbox environment.
</p>
</div>
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
<div className="flex items-start gap-3">
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 space-y-2">
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
<p className="text-sm text-muted-foreground">
Run Automaker in a containerized sandbox environment:
</p>
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<div className="pt-2">
<Button
variant="outline"
onClick={handleReload}
className="gap-2"
data-testid="sandbox-retry"
>
<RefreshCw className="w-4 h-4" />
Reload &amp; Try Again
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
/**
* Sandbox Risk Confirmation Dialog
*
* Shows when the app is running outside a containerized environment.
* Users must acknowledge the risks before proceeding.
*/
import { useState } from 'react';
import { ShieldAlert, Copy, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface SandboxRiskDialogProps {
open: boolean;
onConfirm: () => void;
onDeny: () => void;
}
const DOCKER_COMMAND = 'npm run dev:docker';
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(DOCKER_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="bg-popover border-border max-w-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6" />
Sandbox Environment Not Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<p className="text-muted-foreground">
<strong>Warning:</strong> This application is running outside of a containerized
sandbox environment. AI agents will have direct access to your filesystem and can
execute commands on your system.
</p>
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Agents can read, modify, or delete files on your system</li>
<li>Agents can execute arbitrary commands and install software</li>
<li>Agents can access environment variables and credentials</li>
<li>Unintended side effects from agent actions may affect your system</li>
</ul>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
For safer operation, consider running Automaker in Docker:
</p>
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2 pt-4">
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
Deny &amp; Exit
</Button>
<Button
variant="destructive"
onClick={onConfirm}
className="px-4"
data-testid="sandbox-confirm"
>
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,6 +7,8 @@ interface AutomakerLogoProps {
}
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
return (
<div
className={cn(
@@ -17,7 +19,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
data-testid="logo-button"
>
{!sidebarOpen ? (
<div className="relative flex items-center justify-center rounded-lg">
<div className="relative flex flex-col items-center justify-center rounded-lg gap-0.5">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
@@ -61,54 +63,62 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion}
</span>
</div>
) : (
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-expanded"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-expanded)"
<div className={cn('flex flex-col', 'hidden lg:flex')}>
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
<defs>
<linearGradient
id="bg-expanded"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-expanded)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
</span>
</div>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
v{appVersion}
</span>
</div>
)}

View File

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

View File

@@ -206,6 +206,7 @@ export function BoardView() {
checkContextExists,
features: hookFeatures,
isLoading,
featuresWithContext,
setFeaturesWithContext,
});

View File

@@ -143,7 +143,7 @@ export function CardActions({
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : hasContext && onResume ? (
) : onResume ? (
<Button
variant="default"
size="sm"
@@ -158,21 +158,6 @@ export function CardActions({
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button

View File

@@ -105,9 +105,21 @@ export function AgentOutputModal({
const api = getElectronAPI();
if (!api?.autoMode) return;
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
const unsubscribe = api.autoMode.onEvent((event) => {
console.log(
'[AgentOutputModal] Received event:',
event.type,
'featureId:',
'featureId' in event ? event.featureId : 'none',
'modalFeatureId:',
featureId
);
// Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) {
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
return;
}

View File

@@ -435,21 +435,33 @@ export function useBoardActions({
const handleResumeFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
console.log('[Board] handleResumeFeature called for feature:', feature.id);
if (!currentProject) {
console.error('[Board] No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error('Auto mode API not available');
console.error('[Board] Auto mode API not available');
return;
}
console.log('[Board] Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
});
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
console.log('[Board] resumeFeature result:', result);
if (result.success) {
console.log('[Board] Feature resume started successfully');
} else {

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getServerUrlSync } from '@/lib/http-api-client';
interface UseBoardBackgroundProps {
currentProject: { path: string; id: string } | null;
@@ -23,7 +24,7 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps)
return {
backgroundImage: `url(${
import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'
import.meta.env.VITE_SERVER_URL || getServerUrlSync()
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -12,6 +12,7 @@ interface UseBoardEffectsProps {
checkContextExists: (featureId: string) => Promise<boolean>;
features: any[];
isLoading: boolean;
featuresWithContext: Set<string>;
setFeaturesWithContext: (set: Set<string>) => void;
}
@@ -25,8 +26,14 @@ export function useBoardEffects({
checkContextExists,
features,
isLoading,
featuresWithContext,
setFeaturesWithContext,
}: UseBoardEffectsProps) {
// Keep a ref to the current featuresWithContext for use in event handlers
const featuresWithContextRef = useRef(featuresWithContext);
useEffect(() => {
featuresWithContextRef.current = featuresWithContext;
}, [featuresWithContext]);
// Make current project available globally for modal
useEffect(() => {
if (currentProject) {
@@ -146,4 +153,30 @@ export function useBoardEffects({
checkAllContexts();
}
}, [features, isLoading, checkContextExists, setFeaturesWithContext]);
// Re-check context when a feature stops, completes, or errors
// This ensures hasContext is updated even if the features array doesn't change
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent(async (event) => {
// When a feature stops (error/abort) or completes, re-check its context
if (
(event.type === 'auto_mode_error' || event.type === 'auto_mode_feature_complete') &&
event.featureId
) {
const hasContext = await checkContextExists(event.featureId);
if (hasContext) {
const newSet = new Set(featuresWithContextRef.current);
newSet.add(event.featureId);
setFeaturesWithContext(newSet);
}
}
});
return () => {
unsubscribe();
};
}, [checkContextExists, setFeaturesWithContext]);
}

View File

@@ -13,6 +13,7 @@ import {
SquarePlus,
Settings,
} from 'lucide-react';
import { getServerUrlSync } from '@/lib/http-api-client';
import {
useAppStore,
type TerminalPanelContent,
@@ -272,7 +273,7 @@ export function TerminalView() {
// Get the default run script from terminal settings
const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript);
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
// Helper to collect all session IDs from all tabs
const collectAllSessionIds = useCallback((): string[] => {

View File

@@ -40,7 +40,7 @@ import {
} from '@/config/terminal-themes';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken } from '@/lib/http-api-client';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -483,7 +483,7 @@ export function TerminalPanel({
[closeContextMenu, copySelection, pasteFromClipboard, selectAll, clearTerminal]
);
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
const wsUrl = serverUrl.replace(/^http/, 'ws');
// Fetch a short-lived WebSocket token for secure authentication

View File

@@ -18,7 +18,7 @@
*/
import { useEffect, useState, useRef } from 'react';
import { getHttpApiClient } from '@/lib/http-api-client';
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { isElectron } from '@/lib/electron';
import { getItem, removeItem } from '@/lib/storage';
import { useAppStore } from '@/store/app-store';
@@ -99,6 +99,10 @@ export function useSettingsMigration(): MigrationState {
}
try {
// Wait for API key to be initialized before making any API calls
// This prevents 401 errors on startup in Electron mode
await waitForApiKeyInit();
const api = getHttpApiClient();
// Check if server has settings files

View File

@@ -9,16 +9,10 @@
* Use this instead of raw fetch() for all authenticated API calls.
*/
import { getApiKey, getSessionToken } from './http-api-client';
import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return 'http://localhost:3008';
};
// Server URL - uses shared cached URL from http-api-client
const getServerUrl = (): string => getServerUrlSync();
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

View File

@@ -95,7 +95,7 @@ import type {
} from '@/types/electron';
// Import HTTP API client (ES module)
import { getHttpApiClient } from './http-api-client';
import { getHttpApiClient, getServerUrlSync } from './http-api-client';
// Feature type - Import from app-store
import type { Feature } from '@/store/app-store';
@@ -432,6 +432,7 @@ export interface SaveImageResult {
export interface ElectronAPI {
ping: () => Promise<string>;
getApiKey?: () => Promise<string | null>;
quit?: () => Promise<void>;
openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>;
openDirectory: () => Promise<DialogResult>;
openFile: (options?: object) => Promise<DialogResult>;
@@ -710,7 +711,7 @@ export const checkServerAvailable = async (): Promise<boolean> => {
serverCheckPromise = (async () => {
try {
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync();
const response = await fetch(`${serverUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(2000),

View File

@@ -32,8 +32,31 @@ import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
// Server URL - configurable via environment variable
// Cached server URL (set during initialization in Electron mode)
let cachedServerUrl: string | null = null;
/**
* Initialize server URL from Electron IPC.
* Must be called early in Electron mode before making API requests.
*/
export const initServerUrl = async (): Promise<void> => {
if (typeof window !== 'undefined' && window.electronAPI?.getServerUrl) {
try {
cachedServerUrl = await window.electronAPI.getServerUrl();
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
} catch (error) {
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
}
}
};
// Server URL - uses cached value from IPC or environment variable
const getServerUrl = (): string => {
// Use cached URL from Electron IPC if available
if (cachedServerUrl) {
return cachedServerUrl;
}
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
@@ -41,9 +64,15 @@ const getServerUrl = (): string => {
return 'http://localhost:3008';
};
/**
* Get the server URL (exported for use in other modules)
*/
export const getServerUrlSync = (): string => getServerUrl();
// Cached API key for authentication (Electron mode only)
let cachedApiKey: string | null = null;
let apiKeyInitialized = false;
let apiKeyInitPromise: Promise<void> | null = null;
// Cached session token for authentication (Web mode - explicit header auth)
let cachedSessionToken: string | null = null;
@@ -52,6 +81,17 @@ let cachedSessionToken: string | null = null;
// Exported for use in WebSocket connections that need auth
export const getApiKey = (): string | null => cachedApiKey;
/**
* Wait for API key initialization to complete.
* Returns immediately if already initialized.
*/
export const waitForApiKeyInit = (): Promise<void> => {
if (apiKeyInitialized) return Promise.resolve();
if (apiKeyInitPromise) return apiKeyInitPromise;
// If not started yet, start it now
return initApiKey();
};
// Get session token for Web mode (returns cached value after login or token fetch)
export const getSessionToken = (): string | null => cachedSessionToken;
@@ -73,30 +113,46 @@ export const isElectronMode = (): boolean => {
};
/**
* Initialize API key for Electron mode authentication.
* Initialize API key and server URL for Electron mode authentication.
* In web mode, authentication uses HTTP-only cookies instead.
*
* This should be called early in app initialization.
*/
export const initApiKey = async (): Promise<void> => {
// Return existing promise if already in progress
if (apiKeyInitPromise) return apiKeyInitPromise;
// Return immediately if already initialized
if (apiKeyInitialized) return;
apiKeyInitialized = true;
// Only Electron mode uses API key header auth
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
// Create and store the promise so concurrent calls wait for the same initialization
apiKeyInitPromise = (async () => {
try {
cachedApiKey = await window.electronAPI.getApiKey();
if (cachedApiKey) {
console.log('[HTTP Client] Using API key from Electron');
return;
}
} catch (error) {
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
}
}
// Initialize server URL from Electron IPC first (needed for API requests)
await initServerUrl();
// In web mode, authentication is handled via HTTP-only cookies
console.log('[HTTP Client] Web mode - using cookie-based authentication');
// Only Electron mode uses API key header auth
if (typeof window !== 'undefined' && window.electronAPI?.getApiKey) {
try {
cachedApiKey = await window.electronAPI.getApiKey();
if (cachedApiKey) {
console.log('[HTTP Client] Using API key from Electron');
return;
}
} catch (error) {
console.warn('[HTTP Client] Failed to get API key from Electron:', error);
}
}
// In web mode, authentication is handled via HTTP-only cookies
console.log('[HTTP Client] Web mode - using cookie-based authentication');
} finally {
// Mark as initialized after completion, regardless of success or failure
apiKeyInitialized = true;
}
})();
return apiKeyInitPromise;
};
/**
@@ -269,6 +325,32 @@ export const verifySession = async (): Promise<boolean> => {
}
};
/**
* Check if the server is running in a containerized (sandbox) environment.
* This endpoint is unauthenticated so it can be checked before login.
*/
export const checkSandboxEnvironment = async (): Promise<{
isContainerized: boolean;
error?: string;
}> => {
try {
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
method: 'GET',
});
if (!response.ok) {
console.warn('[HTTP Client] Failed to check sandbox environment');
return { isContainerized: false, error: 'Failed to check environment' };
}
const data = await response.json();
return { isContainerized: data.isContainerized ?? false };
} catch (error) {
console.error('[HTTP Client] Sandbox environment check failed:', error);
return { isContainerized: false, error: 'Network error' };
}
};
type EventType =
| 'agent:stream'
| 'auto-mode:event'
@@ -296,7 +378,17 @@ export class HttpApiClient implements ElectronAPI {
constructor() {
this.serverUrl = getServerUrl();
this.connectWebSocket();
// Wait for API key initialization before connecting WebSocket
// This prevents 401 errors on startup in Electron mode
waitForApiKeyInit()
.then(() => {
this.connectWebSocket();
})
.catch((error) => {
console.error('[HttpApiClient] API key initialization failed:', error);
// Still attempt WebSocket connection - it may work with cookie auth
this.connectWebSocket();
});
}
/**
@@ -389,8 +481,17 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log(
'[HttpApiClient] WebSocket message:',
data.type,
'hasPayload:',
!!data.payload,
'callbacksRegistered:',
this.eventCallbacks.has(data.type)
);
const callbacks = this.eventCallbacks.get(data.type);
if (callbacks) {
console.log('[HttpApiClient] Dispatching to', callbacks.size, 'callbacks');
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
@@ -460,39 +561,103 @@ export class HttpApiClient implements ElectronAPI {
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'POST',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If parsing JSON fails, use status text
}
throw new Error(errorMessage);
}
return response.json();
}
private async get<T>(endpoint: string): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If parsing JSON fails, use status text
}
throw new Error(errorMessage);
}
return response.json();
}
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'PUT',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If parsing JSON fails, use status text
}
throw new Error(errorMessage);
}
return response.json();
}
private async httpDelete<T>(endpoint: string): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If parsing JSON fails, use status text
}
throw new Error(errorMessage);
}
return response.json();
}
@@ -566,14 +731,15 @@ export class HttpApiClient implements ElectronAPI {
const result = await this.post<{
success: boolean;
path?: string;
isAllowed?: boolean;
error?: string;
}>('/api/fs/validate-path', { filePath: path });
if (result.success && result.path) {
if (result.success && result.path && result.isAllowed !== false) {
return { canceled: false, filePaths: [result.path] };
}
console.error('Invalid directory:', result.error);
console.error('Invalid directory:', result.error || 'Path not allowed');
return { canceled: true, filePaths: [] };
}
@@ -1707,3 +1873,10 @@ export function getHttpApiClient(): HttpApiClient {
}
return httpApiClientInstance;
}
// Start API key initialization immediately when this module is imported
// This ensures the init promise is created early, even before React components mount
// The actual async work happens in the background and won't block module loading
initApiKey().catch((error) => {
console.error('[HTTP Client] Failed to initialize API key:', error);
});

View File

@@ -3,15 +3,37 @@
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
*
* SECURITY: All file system access uses centralized methods from @automaker/platform.
*/
import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import fs from 'fs';
import crypto from 'crypto';
import http, { Server } from 'http';
import net from 'net';
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
import {
findNodeExecutable,
buildEnhancedPath,
initAllowedPaths,
isPathAllowed,
getAllowedRootDirectory,
// Electron userData operations
setElectronUserDataPath,
electronUserDataReadFileSync,
electronUserDataWriteFileSync,
electronUserDataExists,
// Electron app bundle operations
setElectronAppPaths,
electronAppExists,
electronAppReadFileSync,
electronAppStatSync,
electronAppStat,
electronAppReadFile,
// System path operations
systemPathExists,
} from '@automaker/platform';
// Development environment
const isDev = !app.isPackaged;
@@ -30,8 +52,51 @@ if (isDev) {
let mainWindow: BrowserWindow | null = null;
let serverProcess: ChildProcess | null = null;
let staticServer: Server | null = null;
const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
// When launched via root init.mjs we pass:
// - PORT (backend)
// - TEST_PORT (vite dev server / static)
const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
// Actual ports in use (set during startup)
let serverPort = DEFAULT_SERVER_PORT;
let staticPort = DEFAULT_STATIC_PORT;
/**
* Check if a port is available
*/
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => {
resolve(true);
});
});
// Use Node's default binding semantics (matches most dev servers)
// This avoids false-positives when a port is taken on IPv6/dual-stack.
server.listen(port);
});
}
/**
* Find an available port starting from the preferred port
* Tries up to 100 ports in sequence
*/
async function findAvailablePort(preferredPort: number): Promise<number> {
for (let offset = 0; offset < 100; offset++) {
const port = preferredPort + offset;
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`Could not find an available port starting from ${preferredPort}`);
}
// ============================================
// Window sizing constants for kanban layout
@@ -64,21 +129,19 @@ let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
let apiKey: string | null = null;
/**
* Get path to API key file in user data directory
* Get the relative path to API key file within userData
*/
function getApiKeyPath(): string {
return path.join(app.getPath('userData'), '.api-key');
}
const API_KEY_FILENAME = '.api-key';
/**
* Ensure an API key exists - load from file or generate new one.
* This key is passed to the server for CSRF protection.
* Uses centralized electronUserData methods for path validation.
*/
function ensureApiKey(): string {
const keyPath = getApiKeyPath();
try {
if (fs.existsSync(keyPath)) {
const key = fs.readFileSync(keyPath, 'utf-8').trim();
if (electronUserDataExists(API_KEY_FILENAME)) {
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
if (key) {
apiKey = key;
console.log('[Electron] Loaded existing API key');
@@ -92,7 +155,7 @@ function ensureApiKey(): string {
// Generate new key
apiKey = crypto.randomUUID();
try {
fs.writeFileSync(keyPath, apiKey, { encoding: 'utf-8', mode: 0o600 });
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
console.log('[Electron] Generated new API key');
} catch (error) {
console.error('[Electron] Failed to save API key:', error);
@@ -102,6 +165,7 @@ function ensureApiKey(): string {
/**
* Get icon path - works in both dev and production, cross-platform
* Uses centralized electronApp methods for path validation.
*/
function getIconPath(): string | null {
let iconFile: string;
@@ -117,8 +181,13 @@ function getIconPath(): string | null {
? path.join(__dirname, '../public', iconFile)
: path.join(__dirname, '../dist/public', iconFile);
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
try {
if (!electronAppExists(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
} catch (error) {
console.warn(`[Electron] Icon check failed: ${iconPath}`, error);
return null;
}
@@ -126,20 +195,18 @@ function getIconPath(): string | null {
}
/**
* Get path to window bounds settings file
* Relative path to window bounds settings file within userData
*/
function getWindowBoundsPath(): string {
return path.join(app.getPath('userData'), 'window-bounds.json');
}
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
/**
* Load saved window bounds from disk
* Uses centralized electronUserData methods for path validation.
*/
function loadWindowBounds(): WindowBounds | null {
try {
const boundsPath = getWindowBoundsPath();
if (fs.existsSync(boundsPath)) {
const data = fs.readFileSync(boundsPath, 'utf-8');
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
const bounds = JSON.parse(data) as WindowBounds;
// Validate the loaded data has required fields
if (
@@ -159,11 +226,11 @@ function loadWindowBounds(): WindowBounds | null {
/**
* Save window bounds to disk
* Uses centralized electronUserData methods for path validation.
*/
function saveWindowBounds(bounds: WindowBounds): void {
try {
const boundsPath = getWindowBoundsPath();
fs.writeFileSync(boundsPath, JSON.stringify(bounds, null, 2), 'utf-8');
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
console.log('[Electron] Window bounds saved');
} catch (error) {
console.warn('[Electron] Failed to save window bounds:', (error as Error).message);
@@ -241,6 +308,7 @@ function validateBounds(bounds: WindowBounds): WindowBounds {
/**
* Start static file server for production builds
* Uses centralized electronApp methods for serving static files from app bundle.
*/
async function startStaticServer(): Promise<void> {
const staticPath = path.join(__dirname, '../dist');
@@ -253,20 +321,24 @@ async function startStaticServer(): Promise<void> {
} else if (!path.extname(filePath)) {
// For client-side routing, serve index.html for paths without extensions
const possibleFile = filePath + '.html';
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
try {
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
filePath = path.join(staticPath, 'index.html');
} else if (electronAppExists(possibleFile)) {
filePath = possibleFile;
}
} catch {
filePath = path.join(staticPath, 'index.html');
} else if (fs.existsSync(possibleFile)) {
filePath = possibleFile;
}
}
fs.stat(filePath, (err, stats) => {
electronAppStat(filePath, (err, stats) => {
if (err || !stats?.isFile()) {
filePath = path.join(staticPath, 'index.html');
}
fs.readFile(filePath, (error, content) => {
if (error) {
electronAppReadFile(filePath, (error, content) => {
if (error || !content) {
response.writeHead(500);
response.end('Server Error');
return;
@@ -298,8 +370,8 @@ async function startStaticServer(): Promise<void> {
});
return new Promise((resolve, reject) => {
staticServer!.listen(STATIC_PORT, () => {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
staticServer!.listen(staticPort, () => {
console.log(`[Electron] Static server running at http://localhost:${staticPort}`);
resolve();
});
staticServer!.on('error', reject);
@@ -308,6 +380,7 @@ async function startStaticServer(): Promise<void> {
/**
* Start the backend server
* Uses centralized methods for path validation.
*/
async function startServer(): Promise<void> {
// Find Node.js executable (handles desktop launcher scenarios)
@@ -318,8 +391,20 @@ async function startServer(): Promise<void> {
const command = nodeResult.nodePath;
// Validate that the found Node executable actually exists
if (command !== 'node' && !fs.existsSync(command)) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
// systemPathExists is used because node-finder returns system paths
if (command !== 'node') {
let exists: boolean;
try {
exists = systemPathExists(command);
} catch (error) {
const originalError = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
);
}
if (!exists) {
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
}
}
let args: string[];
@@ -332,11 +417,22 @@ async function startServer(): Promise<void> {
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
let tsxCliPath: string;
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
} else {
// Check for tsx in app bundle paths
try {
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
} else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) {
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
} else {
try {
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
});
} catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
} catch {
try {
tsxCliPath = require.resolve('tsx/cli.mjs', {
paths: [path.join(__dirname, '../../server')],
@@ -351,7 +447,11 @@ async function startServer(): Promise<void> {
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
args = [serverPath];
if (!fs.existsSync(serverPath)) {
try {
if (!electronAppExists(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
} catch {
throw new Error(`Server not found at: ${serverPath}`);
}
}
@@ -360,6 +460,13 @@ async function startServer(): Promise<void> {
? path.join(process.resourcesPath, 'server', 'node_modules')
: path.join(__dirname, '../../server/node_modules');
// Server root directory - where .env file is located
// In dev: apps/server (not apps/server/src)
// In production: resources/server
const serverRoot = app.isPackaged
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
@@ -369,7 +476,7 @@ async function startServer(): Promise<void> {
const env = {
...process.env,
PATH: enhancedPath,
PORT: SERVER_PORT.toString(),
PORT: serverPort.toString(),
DATA_DIR: app.getPath('userData'),
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
@@ -381,12 +488,15 @@ async function startServer(): Promise<void> {
}),
};
console.log(`[Electron] Server will use port ${serverPort}`);
console.log('[Electron] Starting backend server...');
console.log('[Electron] Server path:', serverPath);
console.log('[Electron] Server root (cwd):', serverRoot);
console.log('[Electron] NODE_PATH:', serverNodeModules);
serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
cwd: serverRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -419,7 +529,7 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise<void>((resolve, reject) => {
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
const req = http.get(`http://localhost:${serverPort}/api/health`, (res) => {
if (res.statusCode === 200) {
resolve();
} else {
@@ -484,9 +594,9 @@ function createWindow(): void {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else if (isDev) {
// Fallback for dev without Vite server URL
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
mainWindow.loadURL(`http://localhost:${staticPort}`);
} else {
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
mainWindow.loadURL(`http://localhost:${staticPort}`);
}
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
@@ -541,6 +651,28 @@ app.whenReady().then(async () => {
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
}
// Initialize centralized path helpers for Electron
// This must be done before any file operations
setElectronUserDataPath(app.getPath('userData'));
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
// In production, only allow access to the built app directory and resources
if (isDev) {
// __dirname is apps/ui/dist-electron, so go up 3 levels to get project root
const projectRoot = path.join(__dirname, '../../..');
setElectronAppPaths([__dirname, projectRoot]);
} else {
setElectronAppPaths(__dirname, process.resourcesPath);
}
console.log('[Electron] Initialized path security helpers');
// Initialize security settings for path validation
// Set DATA_DIR before initializing so it's available for security checks
process.env.DATA_DIR = app.getPath('userData');
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
// (it will be passed to server process, but we also need it in main process for dialog validation)
initAllowedPaths();
if (process.platform === 'darwin' && app.dock) {
const iconPath = getIconPath();
if (iconPath) {
@@ -556,6 +688,21 @@ app.whenReady().then(async () => {
ensureApiKey();
try {
// Find available ports (prevents conflicts with other apps using same ports)
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
if (serverPort !== DEFAULT_SERVER_PORT) {
console.log(
`[Electron] Default server port ${DEFAULT_SERVER_PORT} in use, using port ${serverPort}`
);
}
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
if (staticPort !== DEFAULT_STATIC_PORT) {
console.log(
`[Electron] Default static port ${DEFAULT_STATIC_PORT} in use, using port ${staticPort}`
);
}
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
@@ -589,7 +736,29 @@ app.whenReady().then(async () => {
});
app.on('window-all-closed', () => {
// On macOS, keep the app and servers running when all windows are closed
// (standard macOS behavior). On other platforms, stop servers and quit.
if (process.platform !== 'darwin') {
if (serverProcess && serverProcess.pid) {
console.log('[Electron] All windows closed, stopping server...');
if (process.platform === 'win32') {
try {
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
} catch (error) {
console.error('[Electron] Failed to kill server process:', (error as Error).message);
}
} else {
serverProcess.kill('SIGTERM');
}
serverProcess = null;
}
if (staticServer) {
console.log('[Electron] Stopping static server...');
staticServer.close();
staticServer = null;
}
app.quit();
}
});
@@ -631,6 +800,22 @@ ipcMain.handle('dialog:openDirectory', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
});
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0];
if (!isPathAllowed(selectedPath)) {
const allowedRoot = getAllowedRootDirectory();
const errorMessage = allowedRoot
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
: 'The selected directory is not allowed.';
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
return { canceled: true, filePaths: [] };
}
}
return result;
});
@@ -720,7 +905,7 @@ ipcMain.handle('ping', async () => {
// Get server URL for HTTP client
ipcMain.handle('server:getUrl', async () => {
return `http://localhost:${SERVER_PORT}`;
return `http://localhost:${serverPort}`;
});
// Get API key for authentication
@@ -736,3 +921,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
// Always use the smaller minimum width - horizontal scrolling handles any overflow
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
});
// Quit the application (used when user denies sandbox risk confirmation)
ipcMain.handle('app:quit', () => {
console.log('[Electron] Quitting application via IPC request');
app.quit();
});

View File

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

View File

@@ -8,10 +8,22 @@ import {
} from '@/contexts/file-browser-context';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getElectronAPI } from '@/lib/electron';
import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import {
initApiKey,
isElectronMode,
verifySession,
checkSandboxEnvironment,
} from '@/lib/http-api-client';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
// Session storage key for sandbox risk acknowledgment
const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged';
const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied';
function RootLayoutContent() {
const location = useLocation();
@@ -27,6 +39,20 @@ function RootLayoutContent() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
// Check if user previously denied in this session
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
return 'denied';
}
// Check if user previously acknowledged in this session
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
return 'confirmed';
}
return 'pending';
});
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
const activeElement = document.activeElement;
@@ -73,6 +99,63 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// Check sandbox environment on mount
useEffect(() => {
// Skip if already decided
if (sandboxStatus !== 'pending') {
return;
}
const checkSandbox = async () => {
try {
const result = await checkSandboxEnvironment();
if (result.isContainerized) {
// Running in a container, no warning needed
setSandboxStatus('containerized');
} else {
// Not containerized, show warning dialog
setSandboxStatus('needs-confirmation');
}
} catch (error) {
console.error('[Sandbox] Failed to check environment:', error);
// On error, assume not containerized and show warning
setSandboxStatus('needs-confirmation');
}
};
checkSandbox();
}, [sandboxStatus]);
// Handle sandbox risk confirmation
const handleSandboxConfirm = useCallback(() => {
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
setSandboxStatus('confirmed');
}, []);
// Handle sandbox risk denial
const handleSandboxDeny = useCallback(async () => {
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
if (isElectron()) {
// In Electron mode, quit the application
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
try {
const electronAPI = window.electronAPI;
if (electronAPI?.quit) {
await electronAPI.quit();
} else {
console.error('[Sandbox] quit() not available on electronAPI');
}
} catch (error) {
console.error('[Sandbox] Failed to quit app:', error);
}
} else {
// In web mode, show rejection screen
setSandboxStatus('denied');
}
}, []);
// Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth)
// - Web mode: Uses HTTP-only session cookie
@@ -201,11 +284,31 @@ function RootLayoutContent() {
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Show rejection screen if user denied sandbox risk (web mode only)
if (sandboxStatus === 'denied' && !isElectron()) {
return <SandboxRejectionScreen />;
}
// Show loading while checking sandbox environment
if (sandboxStatus === 'pending') {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<div className="text-muted-foreground">Checking environment...</div>
</main>
);
}
// Show login page (full screen, no sidebar)
if (isLoginRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of login page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
@@ -228,12 +331,25 @@ function RootLayoutContent() {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of setup page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
{/* Full-width titlebar drag region for Electron window dragging */}
{isElectron() && (
<div
className={`fixed top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
aria-hidden="true"
/>
)}
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
@@ -249,6 +365,13 @@ function RootLayoutContent() {
}`}
/>
<Toaster richColors position="bottom-right" />
{/* Show sandbox dialog if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}

View File

@@ -1010,7 +1010,7 @@ const initialState: AppState = {
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
cursorDefaultModel: 'auto', // Default to auto selection
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur)
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
mcpServers: [], // No MCP servers configured by default
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled

View File

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

View File

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

View File

@@ -9,3 +9,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Global constants defined in vite.config.mts
declare const __APP_VERSION__: string;