Spaces:
Running
Running
| import asyncio | |
| import logging | |
| import os | |
| import sys | |
| # ── No flashing console windows in the shipped app ──────────────────────────── | |
| # On Windows every subprocess started from a frozen (PyInstaller) build pops a | |
| # console window unless CREATE_NO_WINDOW is set. This backend shells out from | |
| # ~85 call sites, several on short timers (the USB monitor runs every 2s), so | |
| # the user sees a terminal flashing continuously while JARVIS is running. | |
| # Rather than annotate every call site (and silently regress on the next one), | |
| # force the flag globally for the packaged build. | |
| if sys.platform == "win32": | |
| import subprocess as _subprocess | |
| _CREATE_NO_WINDOW = 0x08000000 | |
| _orig_popen_init = _subprocess.Popen.__init__ | |
| def _no_window_popen_init(self, *args, **kwargs): | |
| # shell=True routes through cmd.exe, which is the loudest offender. | |
| if not kwargs.get("close_fds_is_dummy"): | |
| kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_NO_WINDOW | |
| return _orig_popen_init(self, *args, **kwargs) | |
| _subprocess.Popen.__init__ = _no_window_popen_init | |
| # --- resolve the app-data dir BEFORE any import that touches the database --- | |
| # JARVIS_APP_DATA_DIR used to be set only inside main(), i.e. after every module had | |
| # already been imported. backend/services/usb_vault.py initialises the vault at import | |
| # time (`MASTER_KEY = _init_vault_db()`), so on the exe it created vault_keys / | |
| # vault_secrets / vault_credentials in the FALLBACK database while every later request | |
| # resolved the real app-data database — two different SQLite files in one process. | |
| # | |
| # Verified on a fresh install in Part 26: the app-data memory.db had 17 tables and | |
| # neither vault table, so /security/vault/credentials and /sentinel/credentials/captured | |
| # answered 500 ("no such table: vault_credentials") and every secret decrypt logged | |
| # "no such table: vault_secrets". Setting the variable here, before the imports, points | |
| # both halves at the same file. | |
| _app_data = next( | |
| (sys.argv[i + 1] for i, a in enumerate(sys.argv) | |
| if a == '--app-data-dir' and i + 1 < len(sys.argv)), | |
| None | |
| ) | |
| if _app_data: | |
| os.environ.setdefault("JARVIS_APP_DATA_DIR", _app_data) | |
| # --- load .env early: token_manager.py executes at import time, before main() --- | |
| try: | |
| from dotenv import load_dotenv as _load_dotenv | |
| if getattr(sys, 'frozen', False): | |
| if _app_data: | |
| _load_dotenv(os.path.join(_app_data, '.env'), override=False) | |
| else: | |
| _load_dotenv() | |
| else: | |
| _load_dotenv() | |
| del _load_dotenv | |
| except ImportError: | |
| pass | |
| # ----------------------------------------------------------------------- | |
| # Insert root directory to sys.path to allow 'import modules.x' to work natively from Cloud | |
| parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| if parent_dir not in sys.path: | |
| sys.path.insert(0, parent_dir) | |
| os.environ["TYPEGUARD_DISABLE"] = "1" | |
| # Torch load monkey patch moved to backend/voice/vad.py for lazy-loading | |
| import inspect | |
| _orig_getsource = inspect.getsource | |
| def _safe_getsource(obj): | |
| try: | |
| return _orig_getsource(obj) | |
| except OSError: | |
| return "" | |
| inspect.getsource = _safe_getsource | |
| _orig_getsourcelines = inspect.getsourcelines | |
| def _safe_getsourcelines(obj): | |
| try: | |
| return _orig_getsourcelines(obj) | |
| except OSError: | |
| return ([""], 0) | |
| inspect.getsourcelines = _safe_getsourcelines | |
| import uvicorn | |
| # Ensure backend module is in sys.path when running from compiled PyInstaller executable | |
| if getattr(sys, 'frozen', False): | |
| sys.path.append(sys._MEIPASS) | |
| from backend.ws.agent_ws import ws_manager | |
| # --- STATIC IMPORTS FOR PYINSTALLER --- | |
| # ---------------------------------------- | |
| from backend.db.migrations import run_all_migrations | |
| from backend.routes.agent_routes import router as agent_router | |
| from backend.routes.memory_routes import router as memory_router | |
| from backend.routes.voice_routes import router as voice_router | |
| from backend.routes.system_routes import router as system_router | |
| from backend.routes.automation_routes import router as automation_router | |
| from backend.routes.security_routes import router as security_router | |
| from backend.routes.github_routes import router as github_router | |
| from backend.routes.xr_routes import router as xr_router | |
| from backend.routes.android_routes import router as android_router | |
| from backend.routes.config_routes import router as config_router | |
| from backend.routes.reliability_routes import router as reliability_router | |
| from backend.routes.easter_egg_routes import router as easter_egg_router | |
| from backend.routes.omega_routes import router as omega_router | |
| from backend.routes.persona_routes import router as persona_router | |
| from backend.routes.internet_routes import router as internet_router | |
| from backend.routes.sentinel_routes import router as sentinel_router | |
| from backend.routers.gaming_routes import router as gaming_router | |
| from backend.routes.model_proxy_routes import router as model_proxy_router # AR-FIX-SESSION | |
| from backend.routes.mobile_bridge_routes import router as mobile_bridge_router # S4: Guardian /api compat | |
| from backend.routes.distribution_routes import router as distribution_router # v21: real install + update path | |
| from backend.routers.family_device_routes import router as family_device_router # v21: was never mounted | |
| from backend.services.voice_service import start_wake_word_loop | |
| from backend.services.usb_monitor import start_usb_monitor | |
| from backend.services.system_monitor import start_system_stats_loop | |
| from backend.services.pc_mic_service import start_pc_mic_loop | |
| from backend.voice.audio_ws import run_in_thread as start_audio_ws | |
| # Single source of truth (backend/version.py). Six surfaces reported five different | |
| # version strings before this; nothing owned the value, so nothing reconciled it. | |
| from backend.version import VERSION as APP_VERSION | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request | |
| from backend.security.auth import verify_token | |
| try: | |
| from slowapi import Limiter, _rate_limit_exceeded_handler | |
| from slowapi.util import get_remote_address | |
| from slowapi.errors import RateLimitExceeded | |
| from slowapi.middleware import SlowAPIMiddleware | |
| SLOWAPI_AVAILABLE = True | |
| except ImportError: | |
| logging.warning("slowapi package not found. Rate limiting is disabled.") | |
| SLOWAPI_AVAILABLE = False | |
| try: | |
| import sentry_sdk | |
| sentry_sdk.init( | |
| dsn="https://c48f4234bb502ff74a2c5e518a3a65b2@o4511579717369856.ingest.us.sentry.io/4511579850407936", | |
| traces_sample_rate=1.0, | |
| send_default_pii=True, | |
| ) | |
| except Exception: | |
| pass | |
| print(r""" | |
| ____. _____ ____________________.__ _________ | |
| | | / _ \______ \____ \ \ \ \ \ ___/ | |
| | |/ /_\ \| _// | \ \ \ \ \____ \ | |
| /\__| / | \ | \ | \ \ \_\ \ \ \ | |
| \________\____|__ /____|_ /_______/___/\______/____/ | |
| \/ \/ | |
| Fast Booting... Lazy Loading Heavy ML Models... | |
| """) | |
| app = FastAPI(title="JARVIS / FRIDAY OS Backend Sidecar") | |
| if SLOWAPI_AVAILABLE: | |
| limiter = Limiter(key_func=get_remote_address, default_limits=["100/minute"]) | |
| app.state.limiter = limiter | |
| app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) | |
| app.add_middleware(SlowAPIMiddleware) | |
| app.include_router(agent_router, prefix="/agent", dependencies=[Depends(verify_token)]) | |
| app.include_router(memory_router, prefix="/memory", dependencies=[Depends(verify_token)]) | |
| app.include_router(voice_router, prefix="/voice", dependencies=[Depends(verify_token)]) | |
| app.include_router(system_router, prefix="/system", dependencies=[Depends(verify_token)]) | |
| # v22: AUTOMATION is a paid feature in the plan catalog (plus/pro only — see | |
| # billing/plans.py), but no automation route enforced it: a caller on the `free` plan got | |
| # 200 + real scheduler data from /automation/list and /automation/history. The catalog | |
| # declared the boundary and nothing collected on it. Gated at the mount so all ten routes | |
| # are covered, rather than per-route where the next new route would silently miss it. | |
| from backend.billing.gating import require_feature as _require_feature | |
| from backend.billing.plans import Feature as _Feature | |
| app.include_router(automation_router, prefix="/automation", | |
| dependencies=[Depends(verify_token), | |
| Depends(_require_feature(_Feature.AUTOMATION))]) | |
| app.include_router(security_router, prefix="/security", dependencies=[Depends(verify_token)]) | |
| app.include_router(github_router, prefix="/github", dependencies=[Depends(verify_token)]) | |
| app.include_router(xr_router, prefix="/xr", dependencies=[Depends(verify_token)]) | |
| app.include_router(android_router, prefix="/android", dependencies=[Depends(verify_token)]) | |
| app.include_router(config_router, prefix="/config", dependencies=[Depends(verify_token)]) | |
| # v6 additions: crash telemetry + user-data backup/export (owner-facing, local-only). | |
| app.include_router(reliability_router, prefix="/reliability", dependencies=[Depends(verify_token)]) | |
| # Subscription / billing. /billing/webhook stays OUT of the token gate — it is | |
| # authenticated by the payment processor's own signature (payments.verify_webhook), | |
| # not the operator token, exactly like any real payment webhook. | |
| from backend.billing.routes import router as billing_router | |
| app.include_router(billing_router, prefix="/billing") | |
| app.include_router(omega_router, prefix="") # Prefix defined in router as /omega | |
| app.include_router(easter_egg_router, prefix="/easter") # Exclude easter egg from auth | |
| # v21: the install/update path. Unauthenticated by design — gating the installer behind the app | |
| # you are trying to install is a bootstrap the product cannot survive. Entitlement is enforced on | |
| # features, server-side, not on installer bytes. | |
| app.include_router(distribution_router, prefix="") | |
| # v21: family_device_router existed with real routes but was never include_router'd, so the only | |
| # enrollment endpoint in the codebase was unreachable at runtime on every deployment. | |
| app.include_router(family_device_router, prefix="") | |
| app.include_router(persona_router, prefix="/persona", dependencies=[Depends(verify_token)]) | |
| app.include_router(internet_router, prefix="/internet", dependencies=[Depends(verify_token)]) | |
| app.include_router(sentinel_router, prefix="/sentinel", dependencies=[Depends(verify_token)]) | |
| app.include_router(gaming_router, prefix="", dependencies=[Depends(verify_token)]) # gaming routes already have /gaming prefix | |
| app.include_router(model_proxy_router, prefix="/api") # AR-FIX-SESSION: model provider proxy stubs (no auth — WebAR calls these from browser) | |
| # S4: the WebAR client's documented contract (README, service-worker, vite proxy) is | |
| # /api/ar_config, /api/ar_task, /api/ar_scene_patch — the backend only ever mounted | |
| # them under /xr, so every OMEGA link from the AR client 404'd. Serve both prefixes. | |
| app.include_router(xr_router, prefix="/api", dependencies=[Depends(verify_token)]) | |
| # S4: the JARVIS Mobile Guardian APK's remaining /api/* REST contract (status, caps, | |
| # link_info, pair_confirm, command, phone_observe, jarvis/mobile_event, | |
| # max_autonomy/task) had no cloud handler and 404'd against the Space. This compat | |
| # router serves exactly those, delegating to modules/max_autonomy + phone/crypto. | |
| app.include_router(mobile_bridge_router, prefix="/api", dependencies=[Depends(verify_token)]) | |
| # S4: serve generated 3D models. xr_tools.push_model_to_ar_scene has always broadcast | |
| # /static/models3d/<file> URLs, but nothing ever mounted them — every spawned GLB 404'd. | |
| from fastapi.staticfiles import StaticFiles | |
| _MODELS3D_DIR = os.path.join("storage", "models3d") | |
| os.makedirs(_MODELS3D_DIR, exist_ok=True) | |
| app.mount("/static/models3d", StaticFiles(directory=_MODELS3D_DIR), name="models3d") | |
| # S4: serve the WebAR client from the Space so mobile AR loads directly from | |
| # jarvis-cloud.hf.space/webar/ (previously only shipped in cloud_deployment + APK assets). | |
| for _webar_candidate in ( | |
| os.environ.get("JARVIS_WEBAR_DIR", ""), | |
| os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "webar"), | |
| os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cloud_deployment", "webar"), | |
| ): | |
| if _webar_candidate and os.path.isdir(_webar_candidate): | |
| app.mount("/webar", StaticFiles(directory=_webar_candidate, html=True), name="webar") | |
| logging.info(f"[S4] WebAR client mounted at /webar from {_webar_candidate}") | |
| break | |
| # Public front door of the Space. Everything on it is either static or rendered | |
| # server-side from real state (uptime) — nothing fabricated, nothing token-gated | |
| # leaks here. | |
| _ROOT_STATUS_PAGE = """<!doctype html> | |
| <html lang="en"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>JARVIS Cloud OS</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { --accent:#35e6ff; --amber:#ffb454; --text:#dff3fb; --dim:rgba(223,243,251,.55); } | |
| * { box-sizing:border-box; margin:0; } | |
| body { min-height:100vh; display:flex; align-items:center; justify-content:center; | |
| background:radial-gradient(circle at 50% 30%, #0a1620 0%, #05090e 65%, #030509 100%); | |
| color:var(--text); font-family:'Chakra Petch',sans-serif; padding:24px; } | |
| .card { width:min(560px,94vw); padding:36px 32px; border:1px solid rgba(120,220,255,.22); | |
| border-radius:20px; background:rgba(10,22,30,.55); backdrop-filter:blur(24px) saturate(1.4); | |
| box-shadow:0 18px 50px rgba(0,0,0,.55), inset 0 1px 0 rgba(180,240,255,.12); } | |
| .eyebrow { font:600 11px/1 'Chakra Petch'; letter-spacing:.34em; color:var(--dim); | |
| text-transform:uppercase; margin-bottom:14px; } | |
| h1 { font:700 30px/1.2 'Chakra Petch'; letter-spacing:.02em; margin-bottom:6px; } | |
| h1 em { font-style:normal; color:var(--accent); } | |
| .sub { color:var(--dim); font-size:14px; margin-bottom:26px; } | |
| .grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:26px; } | |
| .stat { border:1px solid rgba(120,220,255,.14); border-radius:12px; padding:12px 14px; } | |
| .stat span { display:block; font:400 10px/1 'Chakra Petch'; letter-spacing:.22em; | |
| color:var(--dim); text-transform:uppercase; margin-bottom:7px; } | |
| .stat strong { font:500 14px/1.3 'JetBrains Mono',monospace; color:var(--accent); | |
| text-transform:uppercase; } | |
| .stat strong.amber { color:var(--amber); } | |
| .pulse { display:inline-block; width:9px; height:9px; border-radius:50%; background:var(--accent); | |
| margin-right:8px; box-shadow:0 0 12px var(--accent); animation:pulse 2s ease-in-out infinite; } | |
| @keyframes pulse { 50% { opacity:.35; box-shadow:0 0 4px var(--accent); } } | |
| .links { display:flex; gap:10px; flex-wrap:wrap; } | |
| .links a { flex:1; min-width:140px; text-align:center; padding:13px 18px; border-radius:12px; | |
| text-decoration:none; font:600 13px/1 'Chakra Petch'; letter-spacing:.08em; text-transform:uppercase; | |
| color:var(--text); border:1px solid rgba(120,220,255,.3); transition:background .15s, box-shadow .15s; } | |
| .links a:hover { background:rgba(53,230,255,.12); box-shadow:0 0 22px rgba(53,230,255,.25); } | |
| .foot { margin-top:24px; color:var(--dim); font-size:11px; letter-spacing:.06em; } | |
| </style></head><body> | |
| <main class="card"> | |
| <p class="eyebrow">Stark Industries · Cloud Node</p> | |
| <h1><span class="pulse"></span>JARVIS <em>/</em> FRIDAY</h1> | |
| <p class="sub">OMEGA Cloud OS — all API surfaces are token-gated.</p> | |
| <div class="grid"> | |
| <div class="stat"><span>Status</span><strong>Online</strong></div> | |
| <div class="stat"><span>Uptime</span><strong>{{UPTIME}}</strong></div> | |
| <div class="stat"><span>Mode</span><strong class="amber">Cloud</strong></div> | |
| <div class="stat"><span>Assistant</span><strong>JARVIS</strong></div> | |
| </div> | |
| <div class="links"> | |
| <a href="/webar/">Enter WebAR</a> | |
| <a href="/ping">Ping</a> | |
| </div> | |
| <p class="foot">Authorized clients: JARVIS desktop, Guardian mobile, WebAR.</p> | |
| </main> | |
| </body></html>""" | |
| async def root_health_check(request: Request): | |
| # Browsers (the Space's public front door) get a real status page; | |
| # programmatic callers keep the JSON contract unchanged. | |
| if "text/html" in (request.headers.get("accept") or ""): | |
| from backend.routes.mobile_bridge_routes import _uptime_str | |
| from fastapi.responses import HTMLResponse | |
| return HTMLResponse(_ROOT_STATUS_PAGE.replace("{{UPTIME}}", _uptime_str())) | |
| return {"status": "JARVIS / FRIDAY Cloud OS Online", "message": "All systems nominal."} | |
| async def wake_server(): | |
| """Public endpoint — anyone can hit this from anywhere in the world to wake the cloud server. | |
| HF Spaces sleep after inactivity; this wakes them up instantly. | |
| Only claims what is actually true: the fact this handler is running means the | |
| Space process is awake. Per-database claims were previously hardcoded | |
| "online" without any check — that lie is gone.""" | |
| import time | |
| return { | |
| "status": "online", | |
| "message": "JARVIS / FRIDAY Cloud Brain is awake and operational.", | |
| "timestamp": time.time(), | |
| } | |
| async def ping(): | |
| """Ultra-lightweight public ping endpoint for keepalive from mobile app.""" | |
| return {"pong": True} | |
| from fastapi import Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from backend.dependencies.auth import verify_master_token | |
| # Configure CORS for production (Tauri and Android network) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["tauri://localhost", "http://localhost", "http://localhost:1420", "*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def global_exception_handler(request: Request, exc: Exception): | |
| logging.error(f"Global Error on {request.url.path}: {exc}") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"error": "Internal Server Error", "detail": str(exc)}, | |
| ) | |
| async def auth_middleware(request: Request, call_next): | |
| allowed_paths = ["/", "/health", "/wake", "/ping", "/android/pair", "/voice/upload_log"] | |
| # S4: read-only static surfaces the AR client fetches without headers — | |
| # GLTFLoader cannot attach Authorization, and the client app itself is public. | |
| # /static/models3d files are uuid-named GLBs; /webar is the shipped client bundle. | |
| # v21: /distribution is public by necessity, not by oversight. A subscriber standing at the | |
| # download page does not yet have the app that holds their token, and Tauri's updater sends | |
| # no Authorization header at all — so gating these behind auth means the install path and | |
| # every update check 401 for exactly the people they exist to serve. Verified: without this | |
| # prefix the live Space answered 401 (not 404) to /distribution/manifest. | |
| # Nothing sensitive is exposed: these serve public installer bytes and a version number. | |
| # Entitlement is still enforced server-side on features, per the standing plan-gating rule. | |
| allowed_prefixes = ("/static/models3d/", "/webar", "/distribution") | |
| if (request.url.path in allowed_paths | |
| or request.url.path.startswith(allowed_prefixes) | |
| or request.url.path.startswith("/ws") or request.url.path.endswith("/ws")): | |
| return await call_next(request) | |
| # Allow local connections without auth | |
| if request.client.host in ["127.0.0.1", "localhost", "::1"]: | |
| return await call_next(request) | |
| token = request.headers.get("Authorization") | |
| if not token or not verify_master_token(token): | |
| return JSONResponse(status_code=401, content={"error": "Unauthorized Access"}) | |
| return await call_next(request) | |
| IS_READY = False | |
| audio_ws_server = None | |
| BACKGROUND_TASKS = [] | |
| async def startup_event(): | |
| global IS_READY, audio_ws_server | |
| logging.info("Starting JARVIS / FRIDAY backend sidecar...") | |
| # Start Audio WS in background thread (no-op stub in cloud mode) | |
| audio_ws_server = start_audio_ws() | |
| # S4: start the AR scene bus (port 5050) in-process. On the Space nothing else | |
| # ever launched it — /scene/ws proxied into a dead port and every | |
| # push_model_to_ar_scene broadcast was silently lost. Desktop entry points | |
| # (tray, local_server) may have started it already; bind failure is fine then. | |
| try: | |
| from phone.ws_scene_bus import run_in_thread as start_scene_bus | |
| start_scene_bus(host="127.0.0.1", port=5050) | |
| logging.info("AR scene bus thread started on 127.0.0.1:5050") | |
| except Exception as scene_bus_error: | |
| logging.warning(f"AR scene bus not started: {scene_bus_error}") | |
| # Start automations scheduler | |
| from backend.services.automation_service import init_automations | |
| await init_automations() | |
| # Track all asyncio background loops | |
| # pc_mic_loop, wake_word_loop, usb, stats are local-only — skip in cloud (HF has no mic/usb) | |
| cloud_mode = os.environ.get("CLOUD_ENV", "false").lower() == "true" | |
| if not cloud_mode: | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_pc_mic_loop())) | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_wake_word_loop())) | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_usb_monitor())) | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_system_stats_loop())) | |
| # --- JARVIS 10X Universal Gaming Coach --- | |
| try: | |
| from backend.gaming.coach_engine import coach_engine | |
| from backend.gaming.overlay_renderer import run_overlay_in_background | |
| run_overlay_in_background() # Spawns PyQt5 window in a background OS thread | |
| BACKGROUND_TASKS.append(asyncio.create_task(coach_engine.start_loop())) | |
| except Exception as e: | |
| logging.warning(f"JARVIS 10X Coach not active (missing deps / Qt crash?): {e}") | |
| else: | |
| logging.info("[Startup] Cloud mode — skipping local hardware loops (mic, wake, usb, stats).") | |
| # 1. Models Loader Placeholder (Wait for models) | |
| # TTS model loading is offloaded to the Audio WS background thread to prevent blocking Uvicorn. | |
| # 2. mDNS Registration | |
| from backend.services.mdns_discovery import register_mdns_service | |
| port = int(os.environ.get("JARVIS_PORT_BOUND", os.environ.get("JARVIS_PORT", "7474"))) | |
| register_mdns_service(port) | |
| # 3. Auto-start PC Relay Client (Local Windows Only) | |
| cloud_env = os.environ.get("CLOUD_ENV", "false").lower() == "true" | |
| if not cloud_env: | |
| if getattr(sys, "frozen", False): | |
| # Frozen sidecar: pc_relay_client.py is not on disk (__file__ resolves into | |
| # the _MEIPASS extraction dir) and sys.executable is this exe itself, so the | |
| # subprocess path below would either silently no-op or relaunch a second | |
| # full sidecar. Run the relay in-process on the existing event loop instead. | |
| try: | |
| import pc_relay_client | |
| BACKGROUND_TASKS.append(asyncio.create_task(pc_relay_client.connect_to_cloud())) | |
| logging.info("Started PC relay in-process (frozen sidecar).") | |
| except Exception as e: | |
| logging.error(f"Could not start in-process PC relay: {e}") | |
| else: | |
| try: | |
| import subprocess | |
| parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| relay_script = os.path.join(parent_dir, "pc_relay_client.py") | |
| if os.path.exists(relay_script): | |
| si = subprocess.STARTUPINFO() | |
| si.dwFlags |= subprocess.STARTF_USESHOWWINDOW | |
| si.wShowWindow = 0 | |
| subprocess.Popen([sys.executable, relay_script], cwd=parent_dir, startupinfo=si, creationflags=0x08000000) | |
| logging.info("Auto-started pc_relay_client.py natively from main server.") | |
| except Exception as e: | |
| logging.error(f"Could not auto-start pc_relay_client.py: {e}") | |
| # 4. §2.3+2.4 — Start Continuous Research Mode (both environments) | |
| from backend.omega.research_engine import start_continuous_research_mode | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_continuous_research_mode("jarvis"))) | |
| logging.info("[Startup] Continuous Research Mode activated. JARVIS will monitor and queue proposals.") | |
| # 5. §Token Safety — Start Token Limit Checkpoint & Resume Monitor | |
| from backend.services.token_manager import start_token_refresh_monitor | |
| BACKGROUND_TASKS.append(asyncio.create_task(start_token_refresh_monitor())) | |
| logging.info("[Startup] Token Refresh Monitor activated. All Gemini 3.5 Flash tasks will auto-resume on 429.") | |
| # 6b. Billing/subscription store (additive tables in the same persistent DB) | |
| try: | |
| from backend.billing.store import init_db as _billing_init | |
| _billing_init() | |
| logging.info("[Startup] Billing/subscription tables verified.") | |
| except Exception as e: | |
| logging.error(f"[Startup] Billing init failed: {e}") | |
| # 6. Broadcast Ready (Health check passes) | |
| IS_READY = True | |
| async def shutdown_event(): | |
| logging.info("Shutting down JARVIS backend sidecar...") | |
| if audio_ws_server: | |
| audio_ws_server.stop() | |
| from backend.services.mdns_discovery import unregister_mdns_service | |
| unregister_mdns_service() | |
| from backend.services.automation_service import shutdown_automations | |
| await shutdown_automations() | |
| from backend.omega.research_engine import stop_continuous_research_mode | |
| stop_continuous_research_mode() | |
| from backend.services.token_manager import stop_token_refresh_monitor | |
| stop_token_refresh_monitor() | |
| # Gracefully cancel all background loops | |
| for task in BACKGROUND_TASKS: | |
| task.cancel() | |
| await asyncio.gather(*BACKGROUND_TASKS, return_exceptions=True) | |
| async def health(): | |
| if not IS_READY: | |
| from fastapi import HTTPException | |
| raise HTTPException(status_code=503, detail="Backend starting up") | |
| return {"status": "ok", "version": APP_VERSION} | |
| def _ws_authorized(websocket: WebSocket) -> bool: | |
| """S4: HTTP auth middleware never sees websocket scopes, so every WS endpoint | |
| was open to the internet on the public Space. Native clients send an | |
| Authorization header; browsers cannot, so they pass ?token= instead.""" | |
| try: | |
| if websocket.client and websocket.client.host in ("127.0.0.1", "localhost", "::1"): | |
| return True | |
| token = websocket.query_params.get("token") or websocket.headers.get("Authorization", "") | |
| return bool(token) and verify_master_token(token) | |
| except Exception: | |
| return False | |
| async def websocket_hub(websocket: WebSocket): | |
| if not _ws_authorized(websocket): | |
| await websocket.close(code=1008) | |
| return | |
| await ws_manager.connect(websocket) | |
| async def heartbeat(): | |
| while True: | |
| await asyncio.sleep(30) | |
| try: | |
| await websocket.send_json({"event": "ping"}) | |
| except Exception: | |
| logging.warning("Heartbeat failed, closing dead WS connection.") | |
| try: | |
| await websocket.close() | |
| except Exception as e: | |
| # NO `import logging` here: it would make `logging` local to | |
| # heartbeat() and turn the warning three lines above into an | |
| # UnboundLocalError — silently killing the WS heartbeat. | |
| logging.getLogger(__name__).error(f"Swallowed exception: {e}") | |
| break | |
| heartbeat_task = asyncio.create_task(heartbeat()) | |
| try: | |
| while True: | |
| data = await websocket.receive_json() | |
| await ws_manager.handle_client_event(websocket, data) | |
| except WebSocketDisconnect: | |
| pass | |
| finally: | |
| heartbeat_task.cancel() | |
| ws_manager.disconnect(websocket) | |
| async def agent_websocket_alias(websocket: WebSocket): | |
| await websocket_hub(websocket) | |
| async def voice_websocket_proxy(websocket: WebSocket): | |
| if not _ws_authorized(websocket): | |
| await websocket.close(code=1008) | |
| return | |
| await websocket.accept() | |
| import websockets | |
| try: | |
| async with websockets.connect("ws://127.0.0.1:8767") as target_ws: | |
| async def forward_to_target(): | |
| try: | |
| while True: | |
| msg = await websocket.receive() | |
| if "bytes" in msg: | |
| await target_ws.send(msg["bytes"]) | |
| elif "text" in msg: | |
| await target_ws.send(msg["text"]) | |
| except Exception as e: | |
| import logging; logging.getLogger(__name__).error(f"Swallowed exception: {e}") | |
| async def forward_to_client(): | |
| try: | |
| async for msg in target_ws: | |
| if isinstance(msg, bytes): | |
| await websocket.send_bytes(msg) | |
| else: | |
| await websocket.send_text(msg) | |
| except Exception as e: | |
| import logging; logging.getLogger(__name__).error(f"Swallowed exception: {e}") | |
| await asyncio.gather(forward_to_target(), forward_to_client()) | |
| except Exception as e: | |
| logging.error(f"Voice WS Proxy Error: {e}") | |
| async def scene_websocket_alias(websocket: WebSocket): | |
| # S4: the WebAR client dials /scene-ws (its documented contract); the backend | |
| # only ever exposed /scene/ws, so the cloud scene link never connected. | |
| await scene_websocket_proxy(websocket) | |
| async def scene_websocket_proxy(websocket: WebSocket): | |
| if not _ws_authorized(websocket): | |
| await websocket.close(code=1008) | |
| return | |
| await websocket.accept() | |
| import websockets | |
| try: | |
| async with websockets.connect("ws://127.0.0.1:5050") as target_ws: | |
| async def forward_to_target(): | |
| try: | |
| while True: | |
| msg = await websocket.receive() | |
| if "bytes" in msg: | |
| await target_ws.send(msg["bytes"]) | |
| elif "text" in msg: | |
| await target_ws.send(msg["text"]) | |
| except Exception as e: | |
| import logging; logging.getLogger(__name__).error(f"Swallowed exception: {e}") | |
| async def forward_to_client(): | |
| try: | |
| async for msg in target_ws: | |
| if isinstance(msg, bytes): | |
| await websocket.send_bytes(msg) | |
| else: | |
| await websocket.send_text(msg) | |
| except Exception as e: | |
| import logging; logging.getLogger(__name__).error(f"Swallowed exception: {e}") | |
| await asyncio.gather(forward_to_target(), forward_to_client()) | |
| except Exception as e: | |
| logging.error(f"Scene WS Proxy Error: {e}") | |
| def main(): | |
| import argparse | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--app-data-dir", default="") | |
| parser.add_argument("--resource-dir", default="") | |
| args, unknown = parser.parse_known_args() | |
| if args.app_data_dir: | |
| os.environ["JARVIS_APP_DATA_DIR"] = args.app_data_dir | |
| if args.resource_dir: | |
| os.environ["JARVIS_RESOURCE_DIR"] = args.resource_dir | |
| import re | |
| import json | |
| class JSONMaskingFormatter(logging.Formatter): | |
| key_patterns = [ | |
| re.compile(r'(sk-[a-zA-Z0-9]{20,})'), | |
| re.compile(r'(AIza[0-9A-Za-z-_]{30,})'), | |
| re.compile(r'\b([A-Za-z0-9-_]{32,})\b') | |
| ] | |
| def format(self, record): | |
| msg = record.getMessage() | |
| for pattern in self.key_patterns: | |
| def repl(m): | |
| s = m.group(1) | |
| if len(s) > 8: | |
| return '*' * (len(s) - 4) + s[-4:] | |
| return s | |
| msg = pattern.sub(repl, msg) | |
| log_record = { | |
| "timestamp": self.formatTime(record, self.datefmt), | |
| "level": record.levelname, | |
| "name": record.name, | |
| "message": msg, | |
| } | |
| if record.exc_info: | |
| log_record["exception"] = self.formatException(record.exc_info) | |
| return json.dumps(log_record) | |
| root_logger = logging.getLogger() | |
| root_logger.setLevel(logging.INFO) | |
| handler = logging.StreamHandler() | |
| handler.setFormatter(JSONMaskingFormatter()) | |
| root_logger.addHandler(handler) | |
| # Run migrations on the same DB path that routes query (get_db_path()) | |
| from backend.services.usb_monitor import get_db_path as _get_migration_db_path | |
| db_path = _get_migration_db_path() | |
| asyncio.run(run_all_migrations(db_path)) | |
| cloud_env = os.environ.get("CLOUD_ENV", "false").lower() == "true" | |
| host = "0.0.0.0" if cloud_env else "127.0.0.1" | |
| base_port = int(os.environ.get("PORT", "7860")) if cloud_env else int(os.environ.get("JARVIS_PORT", "7474")) | |
| for port in range(base_port, base_port + 11): | |
| try: | |
| os.environ["JARVIS_PORT_BOUND"] = str(port) | |
| print(f"JARVIS_PORT_BOUND={port}", flush=True) | |
| uvicorn.run(app, host=host, port=port, log_config=None, ws_ping_interval=20, ws_ping_timeout=10) | |
| break | |
| except OSError as e: | |
| if "WinError 10048" in str(e) or "address already in use" in str(e).lower(): | |
| logging.warning(f"Port {port} in use, trying next...") | |
| continue | |
| raise | |
| if __name__ == "__main__": | |
| main() | |