Studio: clarify tool permission controls (#7181)

This commit is contained in:
Michael Han 2026-07-20 05:55:39 -07:00 committed by GitHub
commit 2916e84499
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 296 additions and 117 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<{
<ToolStatusDisplay />
<div
className="unsloth-composer-line"
data-expanded={composerExpanded ? "true" : "false"}
// The permission pill is always visible, so keep the two-row layout
// expanded and leave the primary tool toggles accessible in every mode.
data-expanded="true"
>
<div
className="unsloth-composer-left"
data-pill-compact={pillsCompact ? "true" : undefined}
>
<ComposerToolsMenu side={effectiveMenuSide} />
{/* 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. */}
<PermissionModeComposerPill side={effectiveMenuSide} />
{composerExpanded ? (
<>
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<KnowledgeBaseComposerButton side={effectiveMenuSide} />
{artifactsEnabled ? <ArtifactsToggle /> : null}
{mcpEnabledForChat ? (
<McpComposerButton side={effectiveMenuSide} />
) : null}
</>
<WebSearchToggle />
<CodeToolsToggle />
<ImagesToggle />
<KnowledgeBaseComposerButton side={effectiveMenuSide} />
{artifactsEnabled ? <ArtifactsToggle /> : null}
{mcpEnabledForChat ? (
<McpComposerButton side={effectiveMenuSide} />
) : null}
</div>
<ComposerPrimitive.Input

View file

@ -20,16 +20,12 @@ import {
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import { PermissionModeMenuItems } from "./permission-mode-select";
import {
FULL_ACCESS_WARNING,
PermissionModeMenuItems,
} from "./permission-mode-select";
// "Bypass permissions" entry for the composer "+" -> 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() {
}
>
<HugeiconsIcon icon={ShieldBanIcon} strokeWidth={2} />
Bypass permissions
Tool permissions
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="unsloth-plus-menu w-[300px]">
<PermissionModeMenuItems
@ -75,9 +71,7 @@ export function BypassPermissionsConfirmDialog() {
<AlertDialogHeader>
<AlertDialogTitle>Enable Full access?</AlertDialogTitle>
<AlertDialogDescription>
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}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View file

@ -2432,13 +2432,14 @@ function ConfirmToolCallsToggle() {
<InfoHint>
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.
</InfoHint>
</div>
{permissionMode === "full" ? (
<span className="text-[11px] text-muted-foreground">
Overridden by Full access (Bypass permissions)
Overridden by Full access
</span>
) : null}
</div>
@ -2459,11 +2460,11 @@ function BypassPermissionsToggle() {
<div className="flex flex-col gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span className="whitespace-nowrap text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
Bypass permissions
Tool permissions
</span>
<InfoHint>
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.
</InfoHint>
</div>
{/* Full width, styled like the panel selects/preset input. */}

View file

@ -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({
<DropdownMenuItem
key={option.value}
onSelect={() => {
// 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({
<AlertDialogHeader>
<AlertDialogTitle>Enable Full access?</AlertDialogTitle>
<AlertDialogDescription>
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}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
@ -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. */}
<span
role="button"
aria-label="Turn off bypass permissions"
tabIndex={-1}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
setPermissionMode("off");
}}
className="composer-pill-glyph cursor-pointer"
>
<span className="composer-pill-glyph">
<ActiveIcon className="size-[15px]" strokeWidth={2} />
<XIcon className="composer-pill-x" />
</span>
<span>{active.label}</span>
<HugeiconsIcon

View file

@ -619,7 +619,6 @@ export function SharedComposer({
const artifactsEnabled = useChatRuntimeStore((s) => 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) >

View file

@ -110,7 +110,7 @@ const PLUS_MENU_SETTINGS: {
},
{
id: "bypassPermissions",
label: "Bypass permissions",
label: "Tool permissions",
icon: (
<HugeiconsIcon
icon={ShieldBanIcon}

View file

@ -258,7 +258,7 @@ export const en = {
},
permissions: {
sectionTitle: "Permissions",
bypassLabel: "Bypass permissions",
bypassLabel: "Tool permissions",
bypassDescription:
"How Unsloth approves chat tool calls (terminal, python, web, MCP) before they run. Full access disables approvals and the code sandbox.",
},

View file

@ -1532,12 +1532,12 @@ html[data-chat-font] .aui-root {
}
/* Hovering an active pill swaps the icon for an X (click to turn off). */
.composer-pill-btn[data-active="true"]:hover .composer-pill-glyph > :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;
}

View file

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