From c7cbc39ff58faec443292dc530303dc27cf0b983 Mon Sep 17 00:00:00 2001 From: Daniel Han-Chen Date: Wed, 13 May 2026 14:31:53 +0000 Subject: [PATCH] studio: expose chat_only in unauth /api/health for SPA first-load routing PR #5406's launcher contract withheld chat_only because the diagnostic dict gated it behind a valid bearer. The SPA's first-load router needs chat_only *before* any bearer is available (to decide whether /studio and /export redirect to /chat); the Windows + Linux UI smokes' chat_only inference fallback was incorrectly classifying non-chat-only Studios as chat-only when the bearered probe came back without the field (post-bootstrap tokens still carry must_change_password=True). chat_only is a hardware-shape capability bit (training vs inference-only) -- the same category as supports_desktop_auth and desktop_manageability_version which already ship unauth -- so promoting it is consistent with the existing contract. The truly sensitive diagnostic stays gated: version, studio_version, device_type, native_path_leases_supported, desktop_owner. Tests + the playwright_extra_ui chat_only probe updated to match the new contract. --- studio/backend/main.py | 18 ++++++++++--- .../tests/test_health_unauth_contract.py | 2 +- studio/backend/tests/test_middleware.py | 3 ++- tests/studio/playwright_extra_ui.py | 27 +++++++++---------- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/studio/backend/main.py b/studio/backend/main.py index dc93b62064..86990c1b71 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -528,9 +528,20 @@ async def health_check(request: Request): "timestamp": datetime.now().isoformat(), # Launcher / preflight contract: stable identity + capability bits. # Safe to expose unauthenticated -- studio_root_id is a hex digest - # of the install path, the desktop flags are non-sensitive booleans. + # of the install path, the desktop flags and chat_only are + # non-sensitive feature-shape booleans (same category as + # ``supports_desktop_auth``). + # + # chat_only is part of the contract because the SPA's first-load + # router needs it to decide whether to redirect /studio + /export + # to /chat *before* any bearer is available; the Playwright UI + # tests rely on the same signal so they don't have to maintain a + # heuristic ("did the URL change after goto?"). Withholding it + # broke the Windows + Linux UI smokes and the change-password + # bootstrap flow. "service": "Unsloth UI Backend", "studio_root_id": _studio_root_id(), + "chat_only": _hw_module.CHAT_ONLY, "desktop_protocol_version": 1, "desktop_manageability_version": 1, "supports_desktop_auth": True, @@ -561,13 +572,14 @@ async def health_check(request: Request): **minimal, # Sensitive diagnostic fields. Gated on a valid bearer because: # - version / studio_version reveal patch-level CVE exposure; - # - device_type / chat_only reveal training-vs-inference shape; + # - device_type reveals the training-vs-inference shape; # - desktop_owner reveals which UID/process owns the desktop lease; # - native_path_leases_supported reveals filesystem capability. + # chat_only is intentionally NOT gated; see the comment on the + # ``minimal`` dict above. "version": UNSLOTH_VERSION, "studio_version": STUDIO_VERSION, "device_type": device_type, - "chat_only": _hw_module.CHAT_ONLY, "native_path_leases_supported": native_path_leases_supported(), **({"desktop_owner": owner} if (owner := _desktop_owner()) else {}), } diff --git a/studio/backend/tests/test_health_unauth_contract.py b/studio/backend/tests/test_health_unauth_contract.py index 1aab32b81f..ec7aed0f16 100644 --- a/studio/backend/tests/test_health_unauth_contract.py +++ b/studio/backend/tests/test_health_unauth_contract.py @@ -41,6 +41,7 @@ LAUNCHER_KEYS = { "timestamp", "service", "studio_root_id", + "chat_only", "desktop_protocol_version", "desktop_manageability_version", "supports_desktop_auth", @@ -51,7 +52,6 @@ GATED_KEYS = { "version", "studio_version", "device_type", - "chat_only", "native_path_leases_supported", } diff --git a/studio/backend/tests/test_middleware.py b/studio/backend/tests/test_middleware.py index 37cf24ca8e..93d5b6189d 100644 --- a/studio/backend/tests/test_middleware.py +++ b/studio/backend/tests/test_middleware.py @@ -245,12 +245,13 @@ class TestHealthAuthGate: "timestamp", "service", "studio_root_id", + "chat_only", "desktop_protocol_version", "desktop_manageability_version", "supports_desktop_auth", "supports_desktop_backend_ownership", } - GATED_KEYS = ("version", "device_type", "chat_only", "native_path_leases_supported") + GATED_KEYS = ("version", "device_type", "native_path_leases_supported") def test_no_auth_returns_launcher_payload(self, health_app): c = TestClient(health_app) diff --git a/tests/studio/playwright_extra_ui.py b/tests/studio/playwright_extra_ui.py index 17fb4c80d4..5ae2d2b72e 100644 --- a/tests/studio/playwright_extra_ui.py +++ b/tests/studio/playwright_extra_ui.py @@ -298,16 +298,14 @@ with sync_playwright() as p: composer = page.locator('textarea[aria-label="Message input"]') composer.wait_for(state = "visible", timeout = 60_000) - # Detect chat-only mode: /api/health.chat_only is the source of truth, - # but the field is gated behind a valid bearer (along with version / - # device_type / desktop_owner) -- unauthenticated callers only see the - # launcher contract (status, service, studio_root_id, desktop_*). - # Pass the access token we already minted above so the test always - # observes chat_only directly rather than guessing from URL behavior. + # Detect chat-only mode: /api/health.chat_only is the source of truth. + # The field is part of the unauthenticated launcher contract so the + # SPA's first-load router can decide whether to redirect /studio + + # /export to /chat *before* any bearer exists. Bearered or not, the + # field is always present on a healthy backend. health_resp = evaluate_fetch( page, f"{BASE}/api/health", - headers = {"Authorization": f"Bearer {token}"}, timeout_ms = FETCH_TIMEOUT_MS, ) if health_resp.get("error"): @@ -315,13 +313,14 @@ with sync_playwright() as p: sys.exit(1) health = health_resp.get("body") or {} if "chat_only" not in health: - # Older Studio builds (pre-auth gating) returned chat_only in the - # minimal payload. Tolerate that by probing /studio: a 3xx to /chat - # is the equivalent runtime signal. - page.goto(f"{BASE}/studio") - page.wait_for_timeout(500) - chat_only = "/studio" not in page.url - info(f"chat_only inferred from /studio URL: {chat_only}") + # Defensive: an older Studio build without the launcher-contract + # patch may omit chat_only when called unauthenticated. Default + # to non-chat-only and log so the probe is not silently wrong. + chat_only = False + info( + "WARN /api/health did not return chat_only; defaulting to " + "chat_only=False (older Studio build)" + ) else: chat_only = bool(health.get("chat_only")) info(f"chat_only mode: {chat_only}")