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:
parent
a03a119469
commit
c7cbc39ff5
4 changed files with 31 additions and 19 deletions
|
|
@ -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 {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue