Studio: clarify tool permission controls (#7181)
This commit is contained in:
parent
bdf51525ea
commit
2916e84499
13 changed files with 296 additions and 117 deletions
69
.github/scripts/run-studio-permission-browser.sh
vendored
Executable file
69
.github/scripts/run-studio-permission-browser.sh
vendored
Executable 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
|
||||
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/studio-ui-smoke.yml
vendored
16
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) >
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const PLUS_MENU_SETTINGS: {
|
|||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
label: "Bypass permissions",
|
||||
label: "Tool permissions",
|
||||
icon: (
|
||||
<HugeiconsIcon
|
||||
icon={ShieldBanIcon}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue