diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 688fdbc5..083a8703 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -1,12 +1,13 @@ /** * Health check routes * - * NOTE: Only the basic health check (/) is unauthenticated. + * NOTE: Only the basic health check (/) and environment check are unauthenticated. * The /detailed endpoint requires authentication. */ import { Router } from 'express'; import { createIndexHandler } from './routes/index.js'; +import { createEnvironmentHandler } from './routes/environment.js'; /** * Create unauthenticated health routes (basic check only) @@ -18,6 +19,10 @@ export function createHealthRoutes(): Router { // Basic health check - no sensitive info router.get('/', createIndexHandler()); + // Environment info including containerization status + // This is unauthenticated so the UI can check on startup + router.get('/environment', createEnvironmentHandler()); + return router; } diff --git a/apps/server/src/routes/health/routes/environment.ts b/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 00000000..ee5f7d53 --- /dev/null +++ b/apps/server/src/routes/health/routes/environment.ts @@ -0,0 +1,20 @@ +/** + * GET /environment endpoint - Environment information including containerization status + * + * This endpoint is unauthenticated so the UI can check it on startup + * before login to determine if sandbox risk warnings should be shown. + */ + +import type { Request, Response } from 'express'; + +export interface EnvironmentResponse { + isContainerized: boolean; +} + +export function createEnvironmentHandler() { + return (_req: Request, res: Response): void => { + res.json({ + isContainerized: process.env.IS_CONTAINERIZED === 'true', + } satisfies EnvironmentResponse); + }; +} diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index 4cadb26d..dd2597f5 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -3,4 +3,6 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions- export { DeleteSessionDialog } from './delete-session-dialog'; export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; +export { SandboxRejectionScreen } from './sandbox-rejection-screen'; +export { SandboxRiskDialog } from './sandbox-risk-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx new file mode 100644 index 00000000..32be56d4 --- /dev/null +++ b/apps/ui/src/components/dialogs/sandbox-rejection-screen.tsx @@ -0,0 +1,90 @@ +/** + * Sandbox Rejection Screen + * + * Shown in web mode when user denies the sandbox risk confirmation. + * Prompts them to either restart the app in a container or reload to try again. + */ + +import { useState } from 'react'; +import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const DOCKER_COMMAND = 'npm run dev:docker'; + +export function SandboxRejectionScreen() { + const [copied, setCopied] = useState(false); + + const handleReload = () => { + // Clear the rejection state and reload + sessionStorage.removeItem('automaker-sandbox-denied'); + window.location.reload(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(DOCKER_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( +
+
+
+
+ +
+
+ +
+

Access Denied

+

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

+
+ +
+
+ +
+

Run in Docker (Recommended)

+

+ Run Automaker in a containerized sandbox environment: +

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

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

+ +
+

Potential Risks:

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

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

+
+ {DOCKER_COMMAND} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5b3abeab..0a17eae1 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -432,6 +432,7 @@ export interface SaveImageResult { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; openDirectory: () => Promise; openFile: (options?: object) => Promise; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1c9d0533..a0455c98 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -294,6 +294,32 @@ export const verifySession = async (): Promise => { } }; +/** + * Check if the server is running in a containerized (sandbox) environment. + * This endpoint is unauthenticated so it can be checked before login. + */ +export const checkSandboxEnvironment = async (): Promise<{ + isContainerized: boolean; + error?: string; +}> => { + try { + const response = await fetch(`${getServerUrl()}/api/health/environment`, { + method: 'GET', + }); + + if (!response.ok) { + console.warn('[HTTP Client] Failed to check sandbox environment'); + return { isContainerized: false, error: 'Failed to check environment' }; + } + + const data = await response.json(); + return { isContainerized: data.isContainerized ?? false }; + } catch (error) { + console.error('[HTTP Client] Sandbox environment check failed:', error); + return { isContainerized: false, error: 'Network error' }; + } +}; + type EventType = | 'agent:stream' | 'auto-mode:event' diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 09fe21a9..e56a2583 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -838,3 +838,9 @@ ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => { // Always use the smaller minimum width - horizontal scrolling handles any overflow mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT); }); + +// Quit the application (used when user denies sandbox risk confirmation) +ipcMain.handle('app:quit', () => { + console.log('[Electron] Quitting application via IPC request'); + app.quit(); +}); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index 4a1aa6f1..0955ab1b 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -50,6 +50,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // Window management updateMinWidth: (sidebarExpanded: boolean): Promise => ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded), + + // App control + quit: (): Promise => ipcRenderer.invoke('app:quit'), }); console.log('[Preload] Electron API exposed (TypeScript)'); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 23a4fa30..79262add 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -8,10 +8,21 @@ import { } from '@/contexts/file-browser-context'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import { getElectronAPI } from '@/lib/electron'; -import { initApiKey, isElectronMode, verifySession } from '@/lib/http-api-client'; +import { getElectronAPI, isElectron } from '@/lib/electron'; +import { + initApiKey, + isElectronMode, + verifySession, + checkSandboxEnvironment, +} from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; +import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; +import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; + +// Session storage key for sandbox risk acknowledgment +const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; +const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied'; function RootLayoutContent() { const location = useLocation(); @@ -27,6 +38,20 @@ function RootLayoutContent() { const [isAuthenticated, setIsAuthenticated] = useState(false); const { openFileBrowser } = useFileBrowser(); + // Sandbox environment check state + type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; + const [sandboxStatus, setSandboxStatus] = useState(() => { + // Check if user previously denied in this session + if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) { + return 'denied'; + } + // Check if user previously acknowledged in this session + if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) { + return 'confirmed'; + } + return 'pending'; + }); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -73,6 +98,63 @@ function RootLayoutContent() { setIsMounted(true); }, []); + // Check sandbox environment on mount + useEffect(() => { + // Skip if already decided + if (sandboxStatus !== 'pending') { + return; + } + + const checkSandbox = async () => { + try { + const result = await checkSandboxEnvironment(); + + if (result.isContainerized) { + // Running in a container, no warning needed + setSandboxStatus('containerized'); + } else { + // Not containerized, show warning dialog + setSandboxStatus('needs-confirmation'); + } + } catch (error) { + console.error('[Sandbox] Failed to check environment:', error); + // On error, assume not containerized and show warning + setSandboxStatus('needs-confirmation'); + } + }; + + checkSandbox(); + }, [sandboxStatus]); + + // Handle sandbox risk confirmation + const handleSandboxConfirm = useCallback(() => { + sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true'); + setSandboxStatus('confirmed'); + }, []); + + // Handle sandbox risk denial + const handleSandboxDeny = useCallback(async () => { + sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true'); + + if (isElectron()) { + // In Electron mode, quit the application + // Use window.electronAPI directly since getElectronAPI() returns the HTTP client + try { + const electronAPI = window.electronAPI; + if (electronAPI?.quit) { + await electronAPI.quit(); + } else { + console.error('[Sandbox] quit() not available on electronAPI'); + } + } catch (error) { + console.error('[Sandbox] Failed to quit app:', error); + } + } else { + // In web mode, show rejection screen + setSandboxStatus('denied'); + } + }, []); + // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie @@ -201,11 +283,31 @@ function RootLayoutContent() { const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; + // Show rejection screen if user denied sandbox risk (web mode only) + if (sandboxStatus === 'denied' && !isElectron()) { + return ; + } + + // Show loading while checking sandbox environment + if (sandboxStatus === 'pending') { + return ( +
+
Checking environment...
+
+ ); + } + // Show login page (full screen, no sidebar) if (isLoginRoute) { return (
+ {/* Show sandbox dialog on top of login page if needed */} +
); } @@ -228,6 +330,12 @@ function RootLayoutContent() { return (
+ {/* Show sandbox dialog on top of setup page if needed */} +
); } @@ -249,6 +357,13 @@ function RootLayoutContent() { }`} /> + + {/* Show sandbox dialog if needed */} + ); } diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 44985d6b..15c61f8c 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -465,6 +465,7 @@ export interface AutoModeAPI { export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; + quit?: () => Promise; openExternalLink: (url: string) => Promise<{ success: boolean; error?: string }>; // Dialog APIs diff --git a/docker-compose.yml b/docker-compose.yml index 8bbf2e84..2026ff0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,10 @@ services: # Optional - CORS origin (default allows all) - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} + + # Internal - indicates the API is running in a containerized sandbox environment + # This is used by the UI to determine if sandbox risk warnings should be shown + - IS_CONTAINERIZED=true volumes: # ONLY named volumes - these are isolated from your host filesystem # This volume persists data between restarts but is container-managed