-
Notifications
You must be signed in to change notification settings - Fork 416
Expand file tree
/
Copy pathmain.py
More file actions
274 lines (222 loc) · 8.89 KB
/
main.py
File metadata and controls
274 lines (222 loc) · 8.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""
FastAPI Main Application
========================
Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""
import asyncio
import logging
import os
import shutil
import sys
from contextlib import asynccontextmanager
from pathlib import Path
# Fix for Windows subprocess support in asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
from dotenv import load_dotenv
# Load environment variables from .env file if present
load_dotenv()
from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .routers import (
agent_router,
assistant_chat_router,
devserver_router,
expand_project_router,
features_router,
filesystem_router,
projects_router,
scaffold_router,
schedules_router,
settings_router,
spec_creation_router,
terminal_router,
)
from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.chat_constants import ROOT_DIR
from .services.dev_server_manager import (
cleanup_all_devservers,
cleanup_orphaned_devserver_locks,
)
from .services.expand_chat_session import cleanup_all_expand_sessions
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
from .services.scheduler_service import cleanup_scheduler, get_scheduler
from .services.terminal_manager import cleanup_all_terminals
from .websocket import project_websocket
# Paths
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown."""
# Startup - clean up stale temp files (Playwright profiles, .node cache, etc.)
try:
from temp_cleanup import cleanup_stale_temp
stats = cleanup_stale_temp()
if stats["dirs_deleted"] > 0 or stats["files_deleted"] > 0:
mb_freed = stats["bytes_freed"] / (1024 * 1024)
logger.info("Startup temp cleanup: %d dirs, %d files, %.1f MB freed",
stats["dirs_deleted"], stats["files_deleted"], mb_freed)
except Exception as e:
logger.warning("Startup temp cleanup failed (non-fatal): %s", e)
# Startup - clean up orphaned lock files from previous runs
cleanup_orphaned_locks()
cleanup_orphaned_devserver_locks()
# Start the scheduler service
scheduler = get_scheduler()
await scheduler.start()
yield
# Shutdown - cleanup scheduler first to stop triggering new starts
await cleanup_scheduler()
# Then cleanup all running agents, sessions, terminals, and dev servers
await cleanup_all_managers()
await cleanup_assistant_sessions()
await cleanup_all_expand_sessions()
await cleanup_all_terminals()
await cleanup_all_devservers()
# Create FastAPI app
app = FastAPI(
title="Autonomous Coding UI",
description="Web UI for the Autonomous Coding Agent",
version="1.0.0",
lifespan=lifespan,
)
# Module logger
logger = logging.getLogger(__name__)
# Check if remote access is enabled via environment variable
# Set by start_ui.py when --host is not 127.0.0.1
ALLOW_REMOTE = os.environ.get("AUTOFORGE_ALLOW_REMOTE", "").lower() in ("1", "true", "yes")
if ALLOW_REMOTE:
logger.warning(
"ALLOW_REMOTE is enabled. Terminal WebSocket is exposed without sandboxing. "
"Only use this in trusted network environments."
)
# CORS - allow all origins when remote access is enabled, otherwise localhost only
if ALLOW_REMOTE:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for remote access
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
else:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173", # Vite dev server
"http://127.0.0.1:5173",
"http://localhost:8888", # Production
"http://127.0.0.1:8888",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# Security Middleware
# ============================================================================
if not ALLOW_REMOTE:
@app.middleware("http")
async def require_localhost(request: Request, call_next):
"""Only allow requests from localhost (disabled when AUTOFORGE_ALLOW_REMOTE=1)."""
client_host = request.client.host if request.client else None
# Allow localhost connections
if client_host not in ("127.0.0.1", "::1", "localhost", None):
raise HTTPException(status_code=403, detail="Localhost access only")
return await call_next(request)
# ============================================================================
# Include Routers
# ============================================================================
app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(schedules_router)
app.include_router(devserver_router)
app.include_router(spec_creation_router)
app.include_router(expand_project_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(settings_router)
app.include_router(terminal_router)
app.include_router(scaffold_router)
# ============================================================================
# WebSocket Endpoint
# ============================================================================
@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
"""WebSocket endpoint for real-time project updates."""
await project_websocket(websocket, project_name)
# ============================================================================
# Setup & Health Endpoints
# ============================================================================
@app.get("/api/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
"""Check system setup status."""
# Check for Claude CLI
claude_cli = shutil.which("claude") is not None
# Check for CLI configuration directory
# Note: CLI no longer stores credentials in ~/.claude/.credentials.json
# The existence of ~/.claude indicates the CLI has been configured
claude_dir = Path.home() / ".claude"
has_claude_config = claude_dir.exists() and claude_dir.is_dir()
# If GLM mode is configured via .env, we have alternative credentials
glm_configured = bool(os.getenv("ANTHROPIC_BASE_URL") and os.getenv("ANTHROPIC_AUTH_TOKEN"))
credentials = has_claude_config or glm_configured
# Check for Node.js and npm
node = shutil.which("node") is not None
npm = shutil.which("npm") is not None
return SetupStatus(
claude_cli=claude_cli,
credentials=credentials,
node=node,
npm=npm,
)
# ============================================================================
# Static File Serving (Production)
# ============================================================================
# Serve React build files if they exist
if UI_DIST_DIR.exists():
# Mount static assets
app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
@app.get("/")
async def serve_index():
"""Serve the React app index.html."""
return FileResponse(UI_DIST_DIR / "index.html")
@app.get("/{path:path}")
async def serve_spa(path: str):
"""
Serve static files or fall back to index.html for SPA routing.
"""
# Check if the path is an API route (shouldn't hit this due to router ordering)
if path.startswith("api/") or path.startswith("ws/"):
raise HTTPException(status_code=404)
# Try to serve the file directly
file_path = (UI_DIST_DIR / path).resolve()
# Ensure resolved path is within UI_DIST_DIR (prevent path traversal)
try:
file_path.relative_to(UI_DIST_DIR.resolve())
except ValueError:
raise HTTPException(status_code=404)
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fall back to index.html for SPA routing
return FileResponse(UI_DIST_DIR / "index.html")
# ============================================================================
# Main Entry Point
# ============================================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"server.main:app",
host="127.0.0.1", # Localhost only for security
port=8888,
reload=True,
)