diff --git a/studio/backend/main.py b/studio/backend/main.py index c1c9ed1d90..b60ad48218 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -494,14 +494,32 @@ app.include_router( @app.get("/api/health") async def health_check(request: Request): - """Liveness only; full diagnostic dict gated on a valid bearer.""" - minimal = { + """Liveness plus launcher capability bits; install fingerprint gated on a valid bearer. + + Unauthenticated callers (Tauri watchdog, frontend bootstrap polls) need + ``service`` / ``studio_root_id`` / ``chat_only`` / ``desktop_*`` / ``native_path_leases_supported`` + to (a) re-adopt a sibling backend across restarts and (b) gate UI surfaces + before any token is available. None of those leak install path or version. + ``version`` / ``studio_version`` / ``device_type`` still require a bearer + because they fingerprint the host. + """ + base = { "status": "healthy", "timestamp": datetime.now().isoformat(), + "service": "Unsloth UI Backend", + "chat_only": _hw_module.CHAT_ONLY, + "desktop_protocol_version": 1, + "desktop_manageability_version": 1, + "supports_desktop_auth": True, + "supports_desktop_backend_ownership": True, + # Opaque per-install id; launchers reject sibling Studios on the same port. + "studio_root_id": _studio_root_id(), + "native_path_leases_supported": native_path_leases_supported(), + **({"desktop_owner": owner} if (owner := _desktop_owner()) else {}), } auth = request.headers.get("authorization", "") if not auth.lower().startswith("bearer "): - return minimal + return base try: from auth.authentication import get_current_subject as _gcs from fastapi.security import HTTPAuthorizationCredentials @@ -512,29 +530,19 @@ async def health_check(request: Request): # Must await: a bare coroutine is truthy and would skip the auth check. subject = await _gcs(creds) except HTTPException: - return minimal + return base except Exception: - return minimal + return base if not subject: - return minimal + return base platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"} device_type = platform_map.get(sys.platform, sys.platform) return { - **minimal, - "service": "Unsloth UI Backend", + **base, "version": UNSLOTH_VERSION, "studio_version": STUDIO_VERSION, "device_type": device_type, - "chat_only": _hw_module.CHAT_ONLY, - "desktop_protocol_version": 1, - "desktop_manageability_version": 1, - "supports_desktop_auth": True, - "supports_desktop_backend_ownership": True, - # Hex digest of the install path; launchers reject sibling Studios on the same port. - "studio_root_id": _studio_root_id(), - "native_path_leases_supported": native_path_leases_supported(), - **({"desktop_owner": owner} if (owner := _desktop_owner()) else {}), } diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index bdf8e6d5a5..4e396db9c5 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -228,17 +228,35 @@ def health_app(tmp_path, monkeypatch): class TestHealthAuthGate: - def test_no_auth_returns_minimal_payload(self, health_app): + # Launcher / frontend bootstrap fields are available unauth so the Tauri + # watchdog can re-adopt a sibling backend and the SPA can detect chat-only + # mode before any token exists. Version / device_type still require a bearer. + LAUNCHER_BITS = ( + "service", + "studio_root_id", + "chat_only", + "desktop_protocol_version", + "desktop_manageability_version", + "supports_desktop_auth", + "supports_desktop_backend_ownership", + "native_path_leases_supported", + ) + FINGERPRINT_FIELDS = ("version", "studio_version", "device_type") + + def test_no_auth_exposes_launcher_bits(self, health_app): c = TestClient(health_app) r = c.get("/api/health") assert r.status_code == 200 body = r.json() assert body["status"] == "healthy" assert "timestamp" in body - for forbidden in ("version", "device_type", "studio_root_id"): + for field in self.LAUNCHER_BITS: + assert field in body, f"missing launcher bit: {field}" + assert body["service"] == "Unsloth UI Backend" + for forbidden in self.FINGERPRINT_FIELDS: assert forbidden not in body - def test_invalid_bearer_returns_minimal_payload(self, health_app): + def test_invalid_bearer_returns_launcher_bits_only(self, health_app): # Regression: calling the async dep without await made any Bearer header pass. c = TestClient(health_app) r = c.get( @@ -248,7 +266,9 @@ class TestHealthAuthGate: assert r.status_code == 200 body = r.json() assert body["status"] == "healthy" - for forbidden in ("version", "device_type", "studio_root_id"): + for field in self.LAUNCHER_BITS: + assert field in body + for forbidden in self.FINGERPRINT_FIELDS: assert forbidden not in body def test_valid_bearer_returns_full_payload(self, health_app): @@ -264,6 +284,5 @@ class TestHealthAuthGate: assert r.status_code == 200 body = r.json() assert body["status"] == "healthy" - assert "version" in body - assert "device_type" in body - assert "studio_root_id" in body + for field in self.LAUNCHER_BITS + self.FINGERPRINT_FIELDS: + assert field in body, f"missing: {field}" diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx index 68d82a804a..97f1a0b1d7 100644 --- a/studio/frontend/src/features/settings/tabs/about-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx @@ -48,7 +48,10 @@ async function fetchStudioVersions(): Promise<{ studioVersion: string | null; }> { try { - const res = await fetch(apiUrl("/api/health")); + const token = getAuthToken(); + const headers = new Headers(); + if (token) headers.set("Authorization", `Bearer ${token}`); + const res = await fetch(apiUrl("/api/health"), { headers }); if (!res.ok) { return { packageVersion: null, studioVersion: null }; } diff --git a/studio/src-tauri/src/preflight/backend.rs b/studio/src-tauri/src/preflight/backend.rs index 987adc3892..0277ef5149 100644 --- a/studio/src-tauri/src/preflight/backend.rs +++ b/studio/src-tauri/src/preflight/backend.rs @@ -143,7 +143,13 @@ fn backend_capability_stale_reason(health: &BackendHealth) -> Option { if health.supports_desktop_backend_ownership != Some(true) { return Some("desktop_backend_ownership_unsupported".to_string()); } - backend_version_stale_reason(health.version.as_deref()) + // Unauthenticated /api/health gates `version` behind a bearer; capability bits + // (protocol/manageability/auth/ownership) above are only set by backends >= + // MIN_DESKTOP_BACKEND_VERSION, so missing version means "auth-gated", not "old". + match health.version.as_deref() { + Some(version) if !version.is_empty() => backend_version_stale_reason(Some(version)), + _ => None, + } } #[derive(Serialize)]