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.
This commit is contained in:
Daniel Han-Chen 2026-05-13 14:31:53 +00:00
commit c7cbc39ff5
4 changed files with 31 additions and 19 deletions

View file

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

View file

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

View file

@ -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)

View file

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