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 <token> 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
This commit is contained in:
Daniel Han 2026-05-17 21:29:24 -07:00 committed by GitHub
commit 2fbbf8ade6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 62 additions and 26 deletions

View file

@ -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 {}),
}

View file

@ -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}"

View file

@ -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 };
}

View file

@ -143,7 +143,13 @@ fn backend_capability_stale_reason(health: &BackendHealth) -> Option<String> {
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)]