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:
parent
3ff6204aa7
commit
2fbbf8ade6
4 changed files with 62 additions and 26 deletions
|
|
@ -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 {}),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue