From 2916e8449900ed97b8462c2108aeaa8c7bb3ca40 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:55:39 -0700 Subject: [PATCH] Studio: clarify tool permission controls (#7181) --- .../scripts/run-studio-permission-browser.sh | 69 ++++++++ .github/workflows/studio-mac-ui-smoke.yml | 11 +- .github/workflows/studio-ui-smoke.yml | 16 +- .github/workflows/studio-windows-ui-smoke.yml | 7 + .../src/components/assistant-ui/thread.tsx | 49 ++---- .../chat/bypass-permissions-menu-item.tsx | 20 +-- .../src/features/chat/chat-settings-sheet.tsx | 11 +- .../features/chat/permission-mode-select.tsx | 57 ++----- .../src/features/chat/shared-composer.tsx | 12 +- .../src/features/settings/tabs/chat-tab.tsx | 2 +- studio/frontend/src/i18n/locales/en.ts | 2 +- studio/frontend/src/index.css | 4 +- tests/studio/playwright_chat_ui.py | 153 +++++++++++++++++- 13 files changed, 296 insertions(+), 117 deletions(-) create mode 100755 .github/scripts/run-studio-permission-browser.sh diff --git a/.github/scripts/run-studio-permission-browser.sh b/.github/scripts/run-studio-permission-browser.sh new file mode 100755 index 0000000000..2007789035 --- /dev/null +++ b/.github/scripts/run-studio-permission-browser.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +set -euo pipefail + +port="${1:?usage: $0 PORT BROWSER [CHANNEL]}" +browser="${2:?usage: $0 PORT BROWSER [CHANNEL]}" +channel="${3:-}" +slug="$browser${channel:+-$channel}" +artifact_dir="logs/playwright-permissions-$slug" +server_log="logs/studio-permissions-$slug.log" +studio_home="${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth/studio}" +set -- +if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then + set -- -f "$STUDIO_PERMISSION_FRONTEND" +fi + +mkdir -p "$artifact_dir" +unsloth studio reset-password +UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \ + >"$server_log" 2>&1 & +studio_pid=$! + +cleanup() { + kill "$studio_pid" 2>/dev/null || true + wait "$studio_pid" 2>/dev/null || true +} +trap cleanup EXIT + +healthy=0 +for _ in $(seq 1 180); do + if curl -fs "http://127.0.0.1:$port/api/health" >/dev/null; then + healthy=1 + break + fi + if ! kill -0 "$studio_pid" 2>/dev/null; then + tail -100 "$server_log" || true + exit 1 + fi + sleep 1 +done +if [ "$healthy" -ne 1 ]; then + tail -100 "$server_log" || true + exit 1 +fi + +old_password=$(cat "$studio_home/auth/.bootstrap_password") +new_password="CIPerm-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')" +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::add-mask::$old_password" + echo "::add-mask::$new_password" +fi + +export BASE_URL="http://127.0.0.1:$port" +export STUDIO_OLD_PW="$old_password" +export STUDIO_NEW_PW="$new_password" +export STUDIO_UI_STRICT=1 +export STUDIO_UI_PERMISSION_ONLY=1 +export STUDIO_UI_WALL_TIMEOUT_S=240 +export STUDIO_PLAYWRIGHT_BROWSER="$browser" +export PW_ART_DIR="$artifact_dir" +if [ -n "$channel" ]; then + export STUDIO_PLAYWRIGHT_CHANNEL="$channel" +else + unset STUDIO_PLAYWRIGHT_CHANNEL || true +fi + +python tests/studio/playwright_chat_ui.py diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index 378e8ee5a6..7375e9bcbf 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -19,6 +19,7 @@ on: - 'install.sh' - 'pyproject.toml' - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-mac-ui-smoke.yml' push: branches: [main, pip] @@ -96,7 +97,7 @@ jobs: - name: Assert llama.cpp loads on this macOS run: bash .github/scripts/assert-llama-loads.sh - - name: Install Playwright + Chromium + - name: Install Playwright browsers # No --with-deps on Mac: that flag installs Linux apt packages. # GitHub-hosted macos-14 ships the system frameworks Chromium # needs already. @@ -112,7 +113,7 @@ jobs: # in-script retry recover from any residual flakes. run: | pip install 'playwright>=1.55,<1.58' - python -m playwright install chromium + python -m playwright install chromium webkit - name: Patch Playwright pipeTransport.js to tolerate malformed JSON # In Playwright 1.55-1.58, pipeTransport.js does @@ -244,6 +245,10 @@ jobs: kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 + - name: Cross-browser permission controls + run: | + bash .github/scripts/run-studio-permission-browser.sh 18895 webkit + - name: Reset auth + boot Unsloth for extra UI tests (port 18897) run: | unsloth studio reset-password @@ -343,5 +348,7 @@ jobs: logs/studio_extra.log logs/install.log logs/playwright + logs/playwright-permissions-* logs/playwright_extra + logs/studio-permissions-*.log retention-days: 7 diff --git a/.github/workflows/studio-ui-smoke.yml b/.github/workflows/studio-ui-smoke.yml index b6d6d7d6e2..30280c281e 100644 --- a/.github/workflows/studio-ui-smoke.yml +++ b/.github/workflows/studio-ui-smoke.yml @@ -27,6 +27,7 @@ on: # The Playwright test files themselves -- a PR that ONLY edits # the test must still trigger UI CI. - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-ui-smoke.yml' push: branches: [main, pip] @@ -107,13 +108,10 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Install Playwright + Chromium + - name: Install Playwright browsers run: | pip install 'playwright>=1.45' - # --with-deps installs the OS-level runtime libs Chromium - # needs (libnss3, libxkbcommon, etc.). About 30 s on a - # warm runner. - python -m playwright install --with-deps chromium + python -m playwright install --with-deps chromium firefox webkit - name: Reset auth + boot Unsloth run: | @@ -182,6 +180,12 @@ jobs: kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 + - name: Cross-browser permission controls + run: | + bash .github/scripts/run-studio-permission-browser.sh 18893 firefox + bash .github/scripts/run-studio-permission-browser.sh 18893 webkit + bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome + # The chat UI test ends by clicking the Shutdown menuitem, which # leaves the server dead. The extra UI test (Compare / Recipes / # Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a @@ -297,6 +301,8 @@ jobs: logs/install.log logs/server-logs/ logs/playwright + logs/playwright-permissions-* logs/playwright_extra logs/playwright_ime + logs/studio-permissions-*.log retention-days: 7 diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index 12d7475b53..f401f7be44 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -19,6 +19,7 @@ on: - 'install.ps1' - 'pyproject.toml' - 'tests/studio/**' + - '.github/scripts/run-studio-permission-browser.sh' - '.github/workflows/studio-windows-ui-smoke.yml' push: branches: [main, pip] @@ -345,6 +346,10 @@ jobs: kill "${STUDIO_PID}" 2>/dev/null || true sleep 2 + - name: Edge permission controls + run: | + bash .github/scripts/run-studio-permission-browser.sh 18895 chromium msedge + - name: Reset auth + boot Unsloth for extra UI tests (port 18897) run: | unsloth studio reset-password @@ -402,5 +407,7 @@ jobs: logs/studio_extra.log logs/install.log logs/playwright + logs/playwright-permissions-* logs/playwright_extra + logs/studio-permissions-*.log retention-days: 7 diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 235dcb3c3d..32fbd61e09 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1434,13 +1434,10 @@ const Composer: FC<{ const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled); const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled); - const permissionMode = useChatRuntimeStore((s) => s.permissionMode); - // More than 4 pills: collapse to icons only. Search and Code always show; the - // permission pill shows in every mode except "off" (it renders null there); - // Images, RAG, Canvas and MCP are conditional. + // More than 4 pills: collapse to icons only. Search, Code, and permissions + // always show; Images, RAG, Canvas and MCP are conditional. const pillsCompact = - 2 + - (permissionMode !== "off" ? 1 : 0) + + 3 + (ragEnabled ? 1 : 0) + (supportsBuiltinImageGeneration ? 1 : 0) + (artifactsEnabled ? 1 : 0) + @@ -1556,20 +1553,6 @@ const Composer: FC<{ const t = setTimeout(() => writeComposerDraft(draftKey, composerText), 300); return () => clearTimeout(t); }, [composerText, draftKey]); - // Two-row layout shows once the input wraps or a tool is on. Tools can - // pre-select before a model loads, so an active toggle expands it either way. - // Keep the composer expanded whenever the permission pill is visible. - const composerExpanded = - isMultiline || - hasAttachments || - hasPendingAudio || - toolsEnabled || - codeToolsEnabled || - imageToolsEnabled || - ragEnabled || - artifactsEnabled || - mcpEnabledForChat || - permissionMode !== "off"; // react-textarea-autosize re-measures only on value change or window resize, // not on the width swap from expanding, so it keeps the taller height and // leaves a stray blank row. Nudge a resize whenever input width changes. @@ -1856,27 +1839,25 @@ const Composer: FC<{
- {/* Permission-level pill: always visible, even while the pill row - is collapsed; opens the permission level dropdown. */} + {/* Permission-level pill: always visible and opens the permission + level dropdown. */} - {composerExpanded ? ( - <> - - - - - {artifactsEnabled ? : null} - {mcpEnabledForChat ? ( - - ) : null} - + + + + + {artifactsEnabled ? : null} + {mcpEnabledForChat ? ( + ) : null}
More menu. Like the MCP -// pill, it opens a submenu where the user picks the permission level (Ask for -// approval / Approve for me / Full access). Picking Full access demands the -// danger warning; the other levels apply immediately. The menu closes normally -// on select (no preventDefault) -- the warning dialog lives outside the menu -// (BypassPermissionsConfirmDialog, mounted once at the chat-page root and -// driven by the store), so it survives the menu unmounting and the "+"/More -// popovers don't stay frozen. +// Tool permissions entry for the composer "+" menu. export function BypassPermissionsMenuItem() { const permissionMode = useChatRuntimeStore((s) => s.permissionMode); const setBypassConfirmOpen = useChatRuntimeStore( @@ -44,7 +40,7 @@ export function BypassPermissionsMenuItem() { } > - Bypass permissions + Tool permissions Enable Full access? - Full access (Bypass permissions) is dangerous since the AI model - might delete, corrupt your machine, and or cause real world damage - to you or the world - only accept if you are certain + {FULL_ACCESS_WARNING} diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index b368a811fa..ecb6707a04 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -2432,13 +2432,14 @@ function ConfirmToolCallsToggle() { When on, every local Unsloth tool call pauses for your approval before it runs (the "Ask for approval" level). When off, tool calls - run without prompts inside the sandbox (the "Off" level). + run without prompts inside the sandbox (the "Run automatically" + level). Provider-hosted tools are not gated here.
{permissionMode === "full" ? ( - Overridden by Full access (Bypass permissions) + Overridden by Full access ) : null} @@ -2459,11 +2460,11 @@ function BypassPermissionsToggle() {
- Bypass permissions + Tool permissions - How Unsloth approves tool calls before they run. Full access is - dangerous: it disables confirmations and the code sandbox. + Choose how Unsloth approves tool calls before they run. Full access + disables confirmations and the code sandbox.
{/* Full width, styled like the panel selects/preset input. */} diff --git a/studio/frontend/src/features/chat/permission-mode-select.tsx b/studio/frontend/src/features/chat/permission-mode-select.tsx index a9cb8ce5d1..e6c89cf54a 100644 --- a/studio/frontend/src/features/chat/permission-mode-select.tsx +++ b/studio/frontend/src/features/chat/permission-mode-select.tsx @@ -7,7 +7,6 @@ import { CircleOff, Hand, ShieldCheck, - XIcon, } from "lucide-react"; import { useState } from "react"; @@ -39,9 +38,8 @@ import { } from "./stores/chat-runtime-store"; /** - * Permission levels for the Bypass permissions dropdowns (General settings, - * chat settings sheet, composer "+" menu). Off sits last as the toggle that - * turns the feature off entirely. + * Permission levels for tool calls. Full access stays last because it disables + * both approval prompts and the code sandbox. */ export const PERMISSION_MODE_OPTIONS: readonly { value: PermissionMode; @@ -61,6 +59,12 @@ export const PERMISSION_MODE_OPTIONS: readonly { description: "Only ask for actions detected as potentially unsafe", icon: ShieldCheck, }, + { + value: "off", + label: "Run automatically", + description: "Run tool calls without approval prompts inside the sandbox", + icon: CircleOff, + }, { value: "full", label: "Full access", @@ -68,14 +72,11 @@ export const PERMISSION_MODE_OPTIONS: readonly { "Unrestricted: no approval prompts and the code sandbox is disabled", icon: CircleAlert, }, - { - value: "off", - label: "Off", - description: "Turn off bypass permissions", - icon: CircleOff, - }, ] as const; +export const FULL_ACCESS_WARNING = + "Full access lets tool calls run without approval prompts or the code sandbox. They can modify or delete files, run commands, and make network requests. Enable it only when you trust the current task."; + export function permissionModeOption(mode: PermissionMode) { return ( PERMISSION_MODE_OPTIONS.find((option) => option.value === mode) ?? @@ -100,10 +101,10 @@ export function PermissionModeMenuItems({ { - // Reselecting the active level toggles the feature off. if (option.value === permissionMode) { - setPermissionMode("off"); - } else if (option.value === "full") { + return; + } + if (option.value === "full") { onRequestFullAccess(); } else { setPermissionMode(option.value); @@ -154,9 +155,7 @@ export function FullAccessConfirmDialog({ Enable Full access? - Full access (Bypass permissions) is dangerous since the AI model - might delete, corrupt your machine, and or cause real world damage - to you or the world - only accept if you are certain + {FULL_ACCESS_WARNING} @@ -260,15 +259,10 @@ export function PermissionModeComposerPill({ const setBypassConfirmOpen = useChatRuntimeStore( (s) => s.setBypassConfirmOpen, ); - const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode); const active = permissionModeOption(permissionMode); const ActiveIcon = active.icon; const fullAccess = permissionMode === "full"; - // Off means the feature is off: no pill (re-enable via the "+" menu or - // settings, like the pre-levels bypass badge). - if (permissionMode === "off") return null; - return ( @@ -278,30 +272,11 @@ export function PermissionModeComposerPill({ data-pill-label={active.label} data-active={fullAccess ? "true" : "false"} data-variant={fullAccess ? "danger" : undefined} - data-keep-label="true" aria-label="Permission level for tool calls" title={`${active.label}: ${active.description}`} > - {/* The icon doubles as an off switch (mirrors the MCP pill): hover - swaps it to an X; clicking it turns bypass permissions Off (no - prompts, sandbox on) without opening the menu. data-keep-label - exempts this pill from compact icon-only mode, so the off switch - stays clickable even while the other pills are collapsed. */} - { - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - setPermissionMode("off"); - }} - className="composer-pill-glyph cursor-pointer" - > + - {active.label} s.artifactsEnabled); const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled); const showCanvasMenuItem = useChatRuntimeStore((s) => s.showCanvasMenuItem); - const permissionMode = useChatRuntimeStore((s) => s.permissionMode); const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); const setMcpEnabledForChat = useChatRuntimeStore( (s) => s.setMcpEnabledForChat, @@ -790,15 +789,12 @@ export function SharedComposer({ // can still be pre-selected, matching Web search/Code/MCP. const ragDisabled = modelLoaded && (isExternalModel || !supportsTools); const showRagPill = !isExternalModel; - // Above 4 pills, collapse to icons only to cut clutter. Compare, Search and - // Code always show; the permission pill shows in every mode except "off" - // (it renders null there); the rest are conditional. - const permissionPillVisible = permissionMode !== "off"; + // Above 4 pills, collapse to icons only. Compare, Search, Code, and + // permissions always show; the rest are conditional. const pillsCompact = - 3 + - (permissionPillVisible ? 1 : 0) + + 4 + (showImagePill ? 1 : 0) + - (showRagPill && ragEnabled && !ragDisabled ? 1 : 0) + + (showRagPill && ragEnabled ? 1 : 0) + (showWebFetchPill ? 1 : 0) + (artifactsEnabled ? 1 : 0) + (mcpEnabledForChat ? 1 : 0) > diff --git a/studio/frontend/src/features/settings/tabs/chat-tab.tsx b/studio/frontend/src/features/settings/tabs/chat-tab.tsx index 3e419af78d..f7f3bccad6 100644 --- a/studio/frontend/src/features/settings/tabs/chat-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/chat-tab.tsx @@ -110,7 +110,7 @@ const PLUS_MENU_SETTINGS: { }, { id: "bypassPermissions", - label: "Bypass permissions", + label: "Tool permissions", icon: ( :not(.composer-pill-x), + .composer-pill-btn[data-active="true"]:has(.composer-pill-x):hover .composer-pill-glyph > :not(.composer-pill-x), .unsloth-thinking-pill[data-active="true"]:hover .composer-pill-glyph > :not(.composer-pill-x) { @apply opacity-0; } - .composer-pill-btn[data-active="true"]:hover .composer-pill-x, + .composer-pill-btn[data-active="true"]:has(.composer-pill-x):hover .composer-pill-x, .unsloth-thinking-pill[data-active="true"]:hover .composer-pill-x { @apply opacity-100; } diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 35b18756ff..b00b45f97a 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -52,6 +52,14 @@ TURN_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_TURN_TIMEOUT_MS", "180000")) # Wall-clock cap for the whole script (healthy run is 5-9 min). WALL_TIMEOUT_S = float(os.environ.get("STUDIO_UI_WALL_TIMEOUT_S", "720")) +# Run only bootstrap plus permission controls for fast cross-browser checks. +PERMISSION_ONLY = os.environ.get("STUDIO_UI_PERMISSION_ONLY", "0") == "1" + +# Default stays Chromium for CI. Local runs can select firefox/webkit or a +# Chromium channel such as chrome/msedge. +PLAYWRIGHT_BROWSER = os.environ.get("STUDIO_PLAYWRIGHT_BROWSER", "chromium").lower() +PLAYWRIGHT_CHANNEL = os.environ.get("STUDIO_PLAYWRIGHT_CHANNEL") or None + # Per-fetch budget; /api/inference/load is the slowest (cold-cache GGUF load). FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_FETCH_TIMEOUT_MS", "30000")) LOAD_FETCH_TIMEOUT_MS = int(os.environ.get("STUDIO_UI_LOAD_TIMEOUT_MS", "180000")) @@ -116,6 +124,126 @@ def soft_fail(m): info(f"WARN (strict-off): {m}") +def exercise_permission_mode_controls(page, shoot): + """Exercise labels, migration, persistence, confirmation, and focus.""" + step("permission levels: labels, persistence, confirmation, and focus") + pill = page.locator('button[aria-label="Permission level for tool calls"]:visible').first + expect(pill).to_be_visible() + + def expect_mode(label): + expect(pill).to_have_attribute("data-pill-label", label) + expect(pill).to_contain_text(label) + + def open_menu(): + pill.click() + menu = page.get_by_role("menu").last + expect(menu).to_be_visible() + return menu + + def choose(label): + menu = open_menu() + item = menu.get_by_role("menuitem").filter(has_text = label).first + expect(item).to_be_visible() + item.click() + + # Fresh profiles default to Approve for me. + expect_mode("Approve for me") + menu = open_menu() + for label in ( + "Ask for approval", + "Approve for me", + "Run automatically", + "Full access", + ): + expect(menu.get_by_role("menuitem").filter(has_text = label).first).to_be_visible() + if menu.get_by_text("Off", exact = True).count() != 0: + fail("legacy Off label is still visible") + if menu.locator('[role="menuitem"] button, [role="menuitem"] [role="button"]').count(): + fail("permission menu contains nested interactive controls") + page.keyboard.press("Escape") + expect(pill).to_be_focused() + + # The active row is a no-op and must not open the Full access dialog. + choose("Approve for me") + expect_mode("Approve for me") + expect(page.get_by_role("alertdialog")).to_have_count(0) + + # Pointer and compact-layout coverage. + page.set_viewport_size({"width": 390, "height": 844}) + expect(pill).to_be_visible() + box = pill.bounding_box() + if box is None or box["x"] < 0 or box["x"] + box["width"] > 390: + fail(f"permission pill is clipped in compact layout: {box!r}") + page.set_viewport_size({"width": 1280, "height": 900}) + + # Legacy setting migration: true -> ask, false -> off, absent -> auto. + migration_cases = ( + ("true", "Ask for approval"), + ("false", "Run automatically"), + (None, "Approve for me"), + ) + for legacy_value, expected_label in migration_cases: + page.evaluate( + """(legacyValue) => { + localStorage.removeItem("unsloth_chat_permission_mode"); + if (legacyValue === null) { + localStorage.removeItem("unsloth_chat_confirm_tool_calls"); + } else { + localStorage.setItem( + "unsloth_chat_confirm_tool_calls", + legacyValue, + ); + } + }""", + legacy_value, + ) + page.reload(wait_until = "domcontentloaded") + expect(pill).to_be_visible() + expect_mode(expected_label) + + choose("Run automatically") + expect_mode("Run automatically") + expect(page.locator('button[data-pill-label="Search"]:visible').first).to_be_visible() + expect(page.locator('button[data-pill-label="Code"]:visible').first).to_be_visible() + stored = page.evaluate("() => localStorage.getItem('unsloth_chat_permission_mode')") + if stored != "off": + fail(f"Run automatically persisted {stored!r}, expected 'off'") + + # Full access requires explicit consent and never overwrites persistence. + choose("Full access") + dialog = page.get_by_role("alertdialog") + expect(dialog).to_be_visible() + expect(dialog.get_by_role("heading", name = "Enable Full access?")).to_be_visible() + expect(dialog).to_contain_text("the code sandbox") + dialog.get_by_role("button", name = "Cancel").click() + expect(dialog).to_be_hidden() + expect_mode("Run automatically") + + choose("Full access") + expect(dialog).to_be_visible() + dialog.get_by_role("button", name = "I understand").click() + expect_mode("Full access") + expect(pill).to_have_attribute("data-variant", "danger") + active_icon = pill.locator(".composer-pill-glyph > :first-child") + pill.hover() + page.wait_for_timeout(200) + icon_opacity = float(active_icon.evaluate("el => getComputedStyle(el).opacity")) + if icon_opacity < 0.5: + fail(f"Full access icon disappeared on hover (opacity={icon_opacity})") + stored = page.evaluate("() => localStorage.getItem('unsloth_chat_permission_mode')") + if stored != "off": + fail(f"Full access overwrote persisted mode with {stored!r}") + + page.reload(wait_until = "domcontentloaded") + expect(pill).to_be_visible() + expect_mode("Run automatically") + + # Leave the full chat smoke in the fresh-install default. + choose("Approve for me") + expect_mode("Approve for me") + shoot("04-permission-levels") + + def login_via_api(pw): req = urllib.request.Request( f"{BASE}/api/auth/login", @@ -145,11 +273,17 @@ with sync_playwright() as p: # DB is still migrating; this 30s probe catches that gap before we # sink 60s into a change-password timeout. Diagnostic only. wait_for_health(BASE, timeout = 30.0, info = info) - # Chromium launch args: see `tests/studio/_playwright_robust.py`. - browser = p.chromium.launch( - headless = True, - args = chromium_launch_args(), - ) + if PLAYWRIGHT_BROWSER not in ("chromium", "firefox", "webkit"): + fail(f"unsupported STUDIO_PLAYWRIGHT_BROWSER={PLAYWRIGHT_BROWSER!r}") + browser_type = getattr(p, PLAYWRIGHT_BROWSER) + launch_kwargs = {"headless": True} + if PLAYWRIGHT_BROWSER == "chromium": + launch_kwargs["args"] = chromium_launch_args() + if PLAYWRIGHT_CHANNEL: + launch_kwargs["channel"] = PLAYWRIGHT_CHANNEL + elif PLAYWRIGHT_CHANNEL: + fail("STUDIO_PLAYWRIGHT_CHANNEL requires chromium") + browser = browser_type.launch(**launch_kwargs) ctx = browser.new_context( viewport = {"width": 1280, "height": 900}, # Reduce motion so view-transition animations don't intercept @@ -364,6 +498,15 @@ with sync_playwright() as p: raise last_err shoot("03-chat-loaded") + exercise_permission_mode_controls(page, shoot) + if PERMISSION_ONLY: + info( + "permission-only run passed " + f"(browser={PLAYWRIGHT_BROWSER}, channel={PLAYWRIGHT_CHANNEL or 'bundled'})" + ) + browser.close() + sys.exit(0) + # /api/models/list and /api/inference/load need a bearer; the # frontend stores it under "unsloth_auth_token" (auth/session.ts). token = robust_evaluate(