From 2fbbf8ade663367601c7bb7315d3328752831cb8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 17 May 2026 21:29:24 -0700 Subject: [PATCH] studio: expose launcher capability bits on unauth /api/health (#5486) * studio: expose launcher capability bits on unauth /api/health PR #5375 reduced the unauthenticated /api/health response to {status, timestamp} only, on the theory that the rest of the payload was useful fingerprinting. That was too aggressive: the Tauri watchdog reads `service == "Unsloth UI Backend"` and `studio_root_id` to re-adopt its own backend across restarts (src-tauri/src/desktop_backend_owner.rs and commands.rs), and the SPA bootstrap fetches the same payload unauth to detect chat-only mode and native path lease support before any token is available (frontend src/config/env.ts and features/native-intents/use-native-readiness.ts). With the post-#5375 shape, the watchdog kills its own healthy backend, the SPA never flips out of "full Studio" mode on chat-only Linux/Windows, and the About tab shows "dev" in place of the real version. The actual fingerprint-ish fields are `version` / `studio_version` / `device_type` (and to a lesser extent the hostname inside `device_type`). `service`, `studio_root_id` (already a hex digest of the install path, not the raw path), `chat_only`, the desktop_* capability flags, and `native_path_leases_supported` do not leak the install path or version. This patch keeps the auth gate but rebalances which fields sit on each side of it: unauth service, studio_root_id, chat_only, desktop_protocol_version, desktop_manageability_version, supports_desktop_auth, supports_desktop_backend_ownership, native_path_leases_supported, desktop_owner (when present) authed + version, studio_version, device_type Existing must-change-password sessions still fall through to the base payload because get_current_subject (strict) rejects them; that matches prior behaviour. test_middleware.py is updated to pin the new contract: launcher bits present unauth, fingerprint fields present only with a valid bearer. * studio: complete launcher-bits health unauth contract on Tauri + About tab Reviewer follow-ups to the unauth /api/health launcher bits split. Tauri preflight: backend_capability_stale_reason() fell through to backend_version_stale_reason(health.version.as_deref()) when capability bits were present but version was absent. With the unauth payload now exposing service + studio_root_id + desktop_* bits but gating version behind a bearer, the desktop watchdog was reading the new payload, parsing all capability bits, then classifying the same-root backend as desktop_backend_version_missing and refusing to adopt it. A backend that exposes desktop_protocol_version=1, desktop_manageability_version>=1, supports_desktop_auth=true and supports_desktop_backend_ownership=true was introduced together with MIN_DESKTOP_BACKEND_VERSION=2026.5.3 in #5341, so a present capability bitset is itself a version-compatibility signal. Skip the version sub-check when version is None/empty; keep it for non-empty values so genuinely-too-old backends that do echo a version still get desktop_backend_version_too_old. About tab: fetchStudioVersions() did a bare fetch(apiUrl("/api/health")), which the unauth payload no longer carries version/studio_version for, so Settings -> About kept rendering "dev"/"dev" for any logged-in user. Attach Authorization: Bearer when getAuthToken() returns one; fall back to bare fetch (still 200, just truncated payload) for the not-logged-in case. No new endpoint. Comment: studio_root_id is no longer a hex digest of the install path; it is an opaque per-install id written by the launcher. Updated the inline comment to match. Test: - python -m pytest studio/backend/tests/test_middleware.py::TestHealthAuthGate studio/backend/tests/test_desktop_auth.py -q -> 29 passed - npm run typecheck clean, npm run build produces fresh dist * Trigger CI rerun for flaky Mac Chat UI step --- studio/backend/main.py | 42 +++++++++++-------- studio/backend/tests/test_middleware.py | 33 +++++++++++---- .../src/features/settings/tabs/about-tab.tsx | 5 ++- studio/src-tauri/src/preflight/backend.rs | 8 +++- 4 files changed, 62 insertions(+), 26 deletions(-) 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)]