Spaces:
Configuration error
Configuration error
| """ | |
| backend/main.py — FastAPI entry point (S354: split in APIRouter modules). | |
| Mappa dei router: | |
| api.conversations → /api/conversations/** | |
| api.agent_memory → /api/memory/agent/** | |
| api.files → /api/files/** | |
| api.agent → /api/agent/**, /api/reason/loop, /api/unified/loop, /run_loop | |
| api.exec → /api/exec, /api/execute-shell, /api/pip-install, /api/agent/fix | |
| api.search → /api/search, /api/fetch-page, /api/analyze-image | |
| api.providers → /health, /api/tools, /api/status, /api/ai/health, /api/providers/heartbeat | |
| api.vault → /api/vault/** | |
| api.webhook → /api/webhook/**, /api/public/chat | |
| api.terminal → /ws/terminal, GET /api/terminal/packages | |
| api.browser → /api/browser/** (pre-esistente) | |
| api.coding → /api/coding/** (pre-esistente) | |
| api.web → /web/** (pre-esistente) | |
| api.mcp → /api/mcp (P19-B3: MCP JSON-RPC 2.0 server) | |
| """ | |
| import os, time, logging, secrets as _secrets_mod | |
| from fastapi import FastAPI, Request | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import JSONResponse | |
| # Gap 2.2: Structured JSON logging — setup PRIMA di qualsiasi altro import | |
| from api.structured_log import setup_structured_logging as _setup_structured_log, router as _logs_router | |
| from api.integrity_manager import router as _integrity_router | |
| from api.state import get_env_secret # P41 | |
| _setup_structured_log() | |
| import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...') | |
| app = FastAPI(title='Agente AI', version='3.6.0') | |
| _logger = logging.getLogger('agente_ai') | |
| # S274-SEC3: INTERNAL_TOKEN — genera casuale al boot se non configurato. | |
| _GENERATED_TOKEN = _secrets_mod.token_hex(32) | |
| if not get_env_secret('INTERNAL_TOKEN'): | |
| os.environ['INTERNAL_TOKEN'] = _GENERATED_TOKEN | |
| _logger.critical('BOOT: INTERNAL_TOKEN not set — ephemeral token generato per questa sessione.') | |
| _logger.critical('BOOT: Ogni restart cambia il token → CF Worker riceve 401 finché il secret non è aggiornato!') | |
| _logger.critical('BOOT: session token (copia in CF Workers secret INTERNAL_TOKEN): %r', _GENERATED_TOKEN) | |
| _logger.critical('BOOT: Fix — imposta INTERNAL_TOKEN uguale su HF Spaces e CF Workers secrets.') | |
| else: | |
| _logger.info('BOOT: INTERNAL_TOKEN configurato OK') | |
| # ── CORS — env-driven, Safari-safe ─────────────────────────────────────────── | |
| # ALLOWED_ORIGINS: comma-separated list, e.g. "https://agente-ai.vercel.app,http://localhost:5173" | |
| # Supports wildcard suffix match (*.vercel.app, *.hf.space) for preview URLs. | |
| _ALLOWED_ORIGINS_ENV = os.getenv('ALLOWED_ORIGINS', '') | |
| # GAP-CORS-LOCALHOST-PROD: localhost vuoto in ENV=production | |
| _ALWAYS_ALLOWED = ( | |
| [] if os.getenv('ENV', 'dev') == 'production' else [ | |
| 'http://localhost:5173', | |
| 'http://localhost:4173', | |
| 'http://localhost:3000', | |
| ] | |
| ) | |
| _VERCEL_PATTERNS = ['.vercel.app', '.vercel.sh', '.pages.dev'] | |
| _HF_PATTERNS = ['.hf.space', '.huggingface.co'] | |
| def _build_origin_list() -> list[str]: | |
| origins = list(_ALWAYS_ALLOWED) | |
| if _ALLOWED_ORIGINS_ENV: | |
| for o in _ALLOWED_ORIGINS_ENV.split(','): | |
| o = o.strip() | |
| if o: | |
| origins.append(o) | |
| return origins | |
| _STATIC_ORIGINS = _build_origin_list() | |
| def _is_allowed_origin(origin: str | None) -> bool: | |
| if not origin: | |
| return False | |
| if origin in _STATIC_ORIGINS: | |
| _logger.info('CORS ALLOW (static): %s', origin) | |
| return True | |
| for pat in _VERCEL_PATTERNS + _HF_PATTERNS: | |
| if origin.endswith(pat): | |
| _logger.info('CORS ALLOW (pattern %s): %s', pat, origin) | |
| return True | |
| _logger.warning('CORS BLOCK: %s', origin) | |
| return False | |
| # CORS NOTE (HF Spaces 2026-05-27): HF proxy auto-injects ACAO on all responses. | |
| # This middleware handles preflight OPTIONS for Vercel frontend + documents allowed patterns. | |
| _CORS_HEADERS = { | |
| 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS,PATCH', | |
| 'Access-Control-Allow-Headers': '*', | |
| 'Access-Control-Allow-Credentials': 'true', | |
| 'Access-Control-Max-Age': '3600', | |
| 'Vary': 'Origin', | |
| } | |
| async def _cors_middleware(request: Request, call_next): | |
| origin = request.headers.get('origin') | |
| if request.method == 'OPTIONS': | |
| if _is_allowed_origin(origin): | |
| return JSONResponse(content={}, status_code=204, headers={ | |
| 'Access-Control-Allow-Origin': origin, | |
| **_CORS_HEADERS, | |
| }) | |
| return JSONResponse(content={}, status_code=204) | |
| response = await call_next(request) | |
| if _is_allowed_origin(origin): | |
| response.headers['Access-Control-Allow-Origin'] = origin | |
| for k, v in _CORS_HEADERS.items(): | |
| response.headers[k] = v | |
| return response | |
| async def _preflight_fallback(path: str, request: Request): | |
| origin = request.headers.get('origin', '') | |
| if not _is_allowed_origin(origin): | |
| return JSONResponse(content={}, status_code=204) | |
| return JSONResponse(content={}, status_code=204, headers={ | |
| 'Access-Control-Allow-Origin': origin, | |
| 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS,PATCH', | |
| 'Access-Control-Allow-Headers': '*', | |
| 'Access-Control-Allow-Credentials': 'true', | |
| 'Access-Control-Max-Age': '3600', | |
| 'Vary': 'Origin', | |
| }) | |
| # ── WARN-1 fix: body size hard limit — S292 ───────────────────────────────── | |
| _MAX_BODY_BYTES = 512_000 # 512 KB — increased for PDF/vision content (V006) | |
| async def _body_size_middleware(request: Request, call_next): | |
| cl = request.headers.get('content-length') | |
| if cl: | |
| try: | |
| if int(cl) > _MAX_BODY_BYTES: | |
| return JSONResponse( | |
| {'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES // 1024}KB. (R-S292)'}, | |
| status_code=413, | |
| ) | |
| except ValueError: | |
| pass | |
| return await call_next(request) | |
| # ── S477-SEC4: Rate limiting in-memory per IP ──────────────────────────────── | |
| # VITE_INTERNAL_TOKEN è visibile nel bundle JS → rate limiting come mitigazione | |
| # pratica all'abuso (CF Worker proxy è il fix definitivo, non ancora implementato). | |
| # 120 req/min globale per IP; OPTIONS exempt (preflight CORS non contano). | |
| # In-memory: si resetta a ogni restart HF Space — accettabile su free tier. | |
| _rl_store: dict[str, list[float]] = {} | |
| _RL_WINDOW = 60.0 | |
| _RL_LIMIT = 120 # req/minuto per IP | |
| async def _rate_limit_middleware(request: Request, call_next): | |
| if request.method == "OPTIONS": | |
| return await call_next(request) | |
| ip = (request.client.host if request.client else None) or "unknown" | |
| now = time.monotonic() | |
| hits = [t for t in _rl_store.get(ip, []) if now - t < _RL_WINDOW] | |
| if len(hits) >= _RL_LIMIT: | |
| return JSONResponse( | |
| {"detail": "Too many requests"}, | |
| status_code=429, | |
| headers={ | |
| "X-RateLimit-Limit": str(_RL_LIMIT), | |
| "X-RateLimit-Remaining": "0", | |
| "X-RateLimit-Reset": str(int(now + _RL_WINDOW)), | |
| "Retry-After": str(int(_RL_WINDOW)), | |
| }, | |
| ) | |
| hits.append(now) | |
| _rl_store[ip] = hits | |
| # S572: prune _rl_store ogni ~500 req — evita leak memoria con molti IP unici. | |
| # Rimuove IP con zero hit nella finestra (inattivi da > _RL_WINDOW secondi). | |
| if len(_rl_store) > 500: | |
| _cutoff = now - _RL_WINDOW | |
| _stale = [_k for _k, _v in list(_rl_store.items()) if not _v or _v[-1] < _cutoff] | |
| for _k in _stale: | |
| _rl_store.pop(_k, None) | |
| return await call_next(request) | |
| # ── Include routers (S354 split) ───────────────────────────────────────────── | |
| from api.conversations import router as _conv_router | |
| from api.agent_memory import router as _mem_router | |
| from api.files import router as _files_router | |
| from api.agent import router as _agent_router | |
| from api.exec import router as _exec_router | |
| from api.search import router as _search_router | |
| from api.providers import router as _providers_router | |
| from api.vault import router as _vault_router | |
| from api.webhook import router as _webhook_router | |
| from api.terminal import router as _terminal_router | |
| from api.browser import router as _browser_router | |
| from api.coding import router as _coding_router | |
| from api.web import router as _web_router | |
| from api.vision import router as _vision_router # V001: generate_image / analyze_image / search_images | |
| from api.gemini_vision import router as _gemini_vision_router # P48: Gemini 1.5 Flash Vision direct endpoint | |
| from api.email import router as _email_router # V002: send_email via Resend API | |
| from api.database import router as _db_router # V003: database_query PostgreSQL/SQLite | |
| from api.research import router as _research_router # V004: web_research multi-URL + Groq synthesis | |
| from api.deploy import router as _deploy_router # S750: CI status + deploy trigger | |
| from api.scheduler import router as _scheduler_router, start_scheduler as _start_scheduler # GAP-2.1: server-side persistent scheduler | |
| from api.benchmark import router as _benchmark_router # S-BENCH: self-test endpoint /api/debug/benchmark | |
| from api.telemetry import router as _telemetry_router # BG-3: timing metrics /api/telemetry | |
| from api.agent_telemetry import router as _agent_telemetry_router # Gap N4: verdetti cross-session | |
| from api.telegram_webhook import router as _tg_webhook_router # TG-BOT: riceve comandi bot + setup webhook | |
| # notify_bot rimosso — Telegram gestito dal daemon Node.js | |
| from api.incident_registry import router as _incident_router, start_incident_registry as _start_incident_reg # GAP-A1 | |
| from api.decision_memory import router as _decision_router, start_decision_memory as _start_decision_mem # GAP-A2 | |
| from api.llm_cache import router as _cache_router # Gap 2.3: /api/cache/stats | |
| from api.daemon_status import router as _daemon_status_router # DAEMON-STATUS: /api/daemon/status | |
| from api.auth_guard import AuthRole, require_role # GAP-A6: importa per uso nei router | |
| from api.blackboard import router as _blackboard_router # S-BB: shared blackboard cross-agent via Upstash | |
| from api.state_sync import router as _state_sync_router # S901: UltraVSS Beta Strategy | |
| from api.job_queue import router as _jq_router # S-DUAL-2: /api/jq/** Redis coordination | |
| from agents.skill_tracker import skill_router as _skill_tracker_router # P17-B2: POST /skill-record + DELETE /skill-stats | |
| from api.mcp import router as _mcp_router # P19-B3: MCP JSON-RPC 2.0 server | |
| from api.ads_manager import router as _ads_router # ADS-MANAGER: /api/ads/** | |
| try: | |
| from service_handlers.semantic_memory_routes import router as _sem_mem_router # S766-MEM | |
| _has_sem_mem = True | |
| except ImportError: | |
| _has_sem_mem = False | |
| from api.auth_managed import router as _auth_managed_router # P38: OAuth one-click connectors | |
| # Doc2-1b-FIX: memory/sync router non era montato — endpoint /api/memory/sync/* non raggiungibili | |
| # NOTA: create_memory_sync_router(memory) è una factory — richiede l'istanza MemoryManager. | |
| # GAP-5-FIX: memory/sync router montato in _on_startup() (vedi sotto) | |
| app.include_router(_auth_managed_router) # P38: OAuth one-click | |
| app.include_router(_conv_router) | |
| app.include_router(_mem_router) | |
| app.include_router(_files_router) | |
| app.include_router(_agent_router) | |
| app.include_router(_exec_router) | |
| app.include_router(_search_router) | |
| app.include_router(_providers_router) | |
| app.include_router(_vault_router) | |
| app.include_router(_webhook_router) | |
| app.include_router(_terminal_router) | |
| app.include_router(_browser_router) | |
| app.include_router(_coding_router) | |
| app.include_router(_web_router) | |
| app.include_router(_vision_router) | |
| app.include_router(_gemini_vision_router) # P48: /api/vision/gemini + /api/vision/screenshot_analyze | |
| app.include_router(_email_router) | |
| app.include_router(_db_router) | |
| app.include_router(_research_router) | |
| app.include_router(_deploy_router) | |
| app.include_router(_scheduler_router) # GAP-2.1 | |
| app.include_router(_benchmark_router) # S-BENCH: /api/debug/benchmark | |
| app.include_router(_tg_webhook_router) # TG-BOT: /api/telegram/webhook + /api/telegram/config/invalidate | |
| app.include_router(_telemetry_router) # BG-3: /api/telemetry | |
| app.include_router(_agent_telemetry_router) # Gap N4: /api/agent-telemetry/sync | |
| app.include_router(_logs_router) # Gap 2.2: /api/logs + /api/logs/frontend | |
| app.include_router(_incident_router) # GAP-A1: Incident Registry | |
| app.include_router(_decision_router) # GAP-A2: Decision Memory | |
| app.include_router(_cache_router) # Gap 2.3: /api/cache/stats | |
| app.include_router(_daemon_status_router) # DAEMON-STATUS: /api/daemon/status | |
| app.include_router(_blackboard_router) # S-BB: /api/blackboard/** | |
| app.include_router(_state_sync_router) # S901: /api/sync/** | |
| app.include_router(_jq_router) # S-DUAL-2: /api/jq/** | |
| app.include_router(_integrity_router) # P41: /api/integrity/** | |
| if _skill_tracker_router is not None: | |
| app.include_router(_skill_tracker_router) # P17-B2: /api/agent/skill-record + /api/agent/skill-stats (DELETE) | |
| app.include_router(_mcp_router) # P19-B3: /api/mcp — MCP JSON-RPC 2.0 | |
| app.include_router(_ads_router) # ADS-MANAGER: /api/ads/** | |
| if _has_sem_mem: | |
| app.include_router(_sem_mem_router) # S766-MEM: /api/semantic-memory/** | |
| # (memory/sync router montato in _on_startup) | |
| # ── Startup: heartbeat + warmup ──────────────────────────────────────────────── | |
| import asyncio as _asyncio_main | |
| def _log_task_exc(task: '_asyncio_main.Task[object]', name: str = '') -> None: | |
| """Done callback — loga eccezioni non gestite nei background task (P2).""" | |
| try: | |
| if not task.cancelled(): | |
| exc = task.exception() | |
| if exc: | |
| _logger.error('BG task %r crashed: %s', name or task.get_name(), exc, exc_info=exc) | |
| except Exception: | |
| pass | |
| async def _on_startup(): | |
| from api.providers import start_heartbeat | |
| start_heartbeat() | |
| _logger.info('BOOT: heartbeat started') | |
| # Master-Plan x5 — Fase 2.1: SPACE_ROLE gate | |
| # Solo SPACE_ROLE=brain (Space A arjanit98-terminal) avvia lo scheduler. | |
| # hands/memory saltano per evitare duplicati. | |
| _space_role = os.getenv('SPACE_ROLE', 'unknown') | |
| if _space_role in ('brain', 'unknown'): | |
| _start_scheduler() | |
| _logger.info('BOOT: scheduler avviato (SPACE_ROLE=%s)', _space_role) | |
| else: | |
| _logger.info('BOOT: scheduler SKIP — riservato a SPACE_ROLE=brain (nodo=%s)', _space_role) | |
| _start_incident_reg() # GAP-A1: Incident Registry | |
| _start_decision_mem() # GAP-A2: Decision Memory | |
| _logger.info('BOOT: core services (incident/decision) avviati') | |
| # S388: warmup TCP connection pools — inizializza i client Groq con 1 token | |
| # così la prima vera richiesta utente non paga il costo di handshake HTTP/TLS (~80ms per provider). | |
| # GAP-5-FIX: factory montata qui, DOPO _get_mem_manager_async() che garantisce | |
| # MemoryManager.init() completato prima che le richieste arrivino. | |
| try: | |
| from memory.sync import create_memory_sync_router as _create_sync_router | |
| from api.state import _get_mem_manager_async as _gmm_async | |
| _mem = await _gmm_async() | |
| if _mem is not None: | |
| _sync_router = _create_sync_router(_mem) | |
| app.include_router(_sync_router) | |
| _logger.info('BOOT: memory/sync router OK') | |
| else: | |
| _logger.info('BOOT: memory/sync router skip (manager None)') | |
| except Exception as _sync_err: | |
| _logger.warning('BOOT: memory/sync err — %s', _sync_err) | |
| import asyncio as _aio | |
| _t_wm = _aio.create_task(_startup_warmup()) | |
| _t_wm.add_done_callback(lambda t: _log_task_exc(t, 'startup_warmup')) | |
| # GAP-STATE: ripristina _agent_tasks da Supabase snapshot + avvia bg persist | |
| try: | |
| from api.state import restore_agent_tasks_from_snap as _restore_snap, persist_state_snapshot as _snap_bg | |
| await _restore_snap() | |
| # GAP-2: crash-recovery — task zombi RUNNING vengono resettati a pending | |
| try: | |
| from api.state import _agent_tasks as _agt_cr | |
| _requeued = 0 | |
| for _tid_cr, _td_cr in list(_agt_cr.items()): | |
| if _td_cr.get('_snap_restored') and _td_cr.get('status') in ('running', 'RUNNING'): | |
| _td_cr['status'] = 'pending' | |
| _td_cr['_crash_recovered'] = True | |
| _requeued += 1 | |
| if _requeued: | |
| _logger.info('BOOT GAP-2: %d task crash-recovered → status reset a pending', _requeued) | |
| except Exception as _cr_err: | |
| _logger.warning('BOOT GAP-2: crash-recover skip — %s', _cr_err) | |
| _t_snap = _aio.create_task(_snap_bg()) | |
| _t_snap.add_done_callback(lambda t: _log_task_exc(t, 'snap_bg')) | |
| _logger.info('BOOT: GAP-STATE snapshot bg avviato') | |
| except Exception as _gstate_err: | |
| _logger.warning('BOOT: GAP-STATE skip — %s', _gstate_err) | |
| # NOTA: heartbeat Telegram rimosso — notifiche gestite dal daemon Node.js | |
| # GAP-NEW-5: telemetry alert loop — campiona ogni 5min, alert Telegram su soglie | |
| try: | |
| from api.telemetry import telemetry_alert_loop as _tel_alert | |
| _t_tel = _aio.create_task(_tel_alert()) | |
| _t_tel.add_done_callback(lambda t: _log_task_exc(t, 'telemetry_alert_loop')) | |
| _logger.info('BOOT: telemetry alert loop avviato') | |
| except Exception as _tel_err: | |
| _logger.warning('BOOT: telemetry alert skip — %s', _tel_err) | |
| # S-DUAL-2: job queue consumer + load publisher | |
| try: | |
| from api.job_queue import start_job_queue_consumer as _start_jq | |
| _t_jq = _aio.create_task(_start_jq()) | |
| _t_jq.add_done_callback(lambda t: _log_task_exc(t, 'job_queue_consumer')) | |
| _logger.info('BOOT: job queue consumer/publisher avviato (SPACE_ROLE=%s)', os.getenv('SPACE_ROLE', 'unknown')) | |
| except Exception as _jq_err: | |
| _logger.warning('BOOT: job queue consumer skip — %s', _jq_err) | |
| # S766-GRID-5: HeartbeatMonitor daemon D↔B — solo su SPACE_ROLE=brain/unknown | |
| if _space_role in ('brain', 'unknown'): | |
| try: | |
| from api.self_healing import get_heartbeat_monitor as _get_hbm | |
| _hbm = _get_hbm() | |
| await _hbm.start() | |
| _logger.info('BOOT: HeartbeatMonitor avviato — daemon D/B sotto controllo (S766-GRID-5)') | |
| except Exception as _hbm_err: | |
| _logger.warning('BOOT: HeartbeatMonitor skip — %s', _hbm_err) | |
| async def _startup_warmup() -> None: | |
| """ | |
| S388: Warmup dei provider Groq al boot. | |
| Spara 1 token a ogni slot Groq in parallelo — preinizializza i connection pool HTTP. | |
| Non blocca il boot, fallback silenzioso su qualsiasi errore. | |
| Attende 1s per permettere a FastAPI di completare il setup. | |
| """ | |
| import asyncio as _aio | |
| await _aio.sleep(1) | |
| try: | |
| from api.state import _get_ai_client | |
| client = _get_ai_client() | |
| if not client or not client.providers: | |
| return | |
| # Warma solo gli slot Groq (veloci, <500ms) — non Gemini/OpenRouter | |
| groq_providers = [p for p in client.providers if p.name.startswith("groq")] | |
| if not groq_providers: | |
| return | |
| async def _warm_one(provider) -> None: | |
| try: | |
| c = client._client_for(provider) | |
| await _aio.wait_for( | |
| _aio.to_thread( | |
| c.chat.completions.create, | |
| model=provider.default_model, | |
| messages=[{"role": "user", "content": "hi"}], | |
| max_tokens=1, | |
| stream=False, | |
| ), | |
| timeout=5.0, | |
| ) | |
| _logger.info('BOOT: warmup OK — %s (%s)', provider.name, provider.default_model.split('/')[-1][:24]) | |
| except Exception as exc: | |
| _logger.warning('BOOT: warmup skip — %s: %s', provider.name, str(exc)[:60]) | |
| await _aio.gather(*[_warm_one(p) for p in groq_providers]) | |
| except Exception as exc: | |
| _logger.warning('BOOT: warmup failed: %s', exc) | |
| # P17-B4: pip pre-warm — importa i 20 moduli più usati dagli script sandbox | |
| # così la prima exec utente non paga il costo di import (~30-200ms/modulo). | |
| # Silenzioso: se non installato, skip. | |
| import importlib | |
| _PIP_PREWARM = [ | |
| "numpy", "pandas", "matplotlib", "requests", "httpx", | |
| "json", "re", "os", "sys", "math", | |
| "datetime", "pathlib", "itertools", "functools", "collections", | |
| "typing", "dataclasses", "io", "base64", "hashlib", | |
| ] | |
| for _pkg in _PIP_PREWARM: | |
| try: | |
| importlib.import_module(_pkg) | |
| except Exception: | |
| pass | |
| _logger.info("BOOT: pip pre-warm %d modules done", len(_PIP_PREWARM)) | |
| # ── Frontend static (SPA) ──────────────────────────────────��─────────────────── | |
| _STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static') | |
| if os.path.isdir(_STATIC_DIR): | |
| app.mount('/', StaticFiles(directory=_STATIC_DIR, html=True), name='spa') | |
| _logger.info('BOOT: serving frontend from %s', _STATIC_DIR) | |
| else: | |
| _logger.warning('BOOT: no frontend at %s', _STATIC_DIR) | |
| _logger.info('BOOT: main.py v%s ready — %s routes registered ✓', app.version, len(app.routes)) | |