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