jarvis-cloud / backend /main.py
Jarvis2345's picture
deploy(S4): Blender headless pipeline + WebAR client + backend fixes
270c887 verified
Raw
History Blame Contribute Delete
34.6 kB
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 &middot; Cloud Node</p>
<h1><span class="pulse"></span>JARVIS <em>/</em> FRIDAY</h1>
<p class="sub">OMEGA Cloud OS &mdash; 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>"""
@app.get("/")
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."}
@app.get("/wake")
@app.post("/wake")
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(),
}
@app.get("/ping")
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=["*"],
)
@app.exception_handler(Exception)
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)},
)
@app.middleware("http")
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 = []
@app.on_event("startup")
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
@app.on_event("shutdown")
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)
@app.get("/health")
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
@app.websocket("/ws")
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)
@app.websocket("/agent/ws")
async def agent_websocket_alias(websocket: WebSocket):
await websocket_hub(websocket)
@app.websocket("/voice/ws")
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}")
@app.websocket("/scene-ws")
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)
@app.websocket("/scene/ws")
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()