Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/scripts/run-studio-permission-browser.sh
Original file line number Diff line number Diff line change
@@ -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}"
frontend_args=()
if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
frontend_args=(-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" "${frontend_args[@]}" \
>"$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 changes: 9 additions & 2 deletions .github/workflows/studio-mac-ui-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,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.
Expand All @@ -112,7 +112,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
Expand Down Expand Up @@ -244,6 +244,11 @@ 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
bash .github/scripts/run-studio-permission-browser.sh 18895 chromium chrome

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid Chrome-channel runs with the pinned Mac driver

In this macos-14 job, the install step pins Playwright to >=1.55,<1.58, but this new run launches the runner's branded Chrome via channel='chrome'. Playwright 1.57's release notes list Google Chrome 139 as the tested stable channel, while the current GitHub macos-14 runner image lists Chrome 149, so this check can fail or become flaky from a browser/protocol mismatch; use the bundled Chromium here or update/unpin Playwright before adding a Chrome-channel run.

Useful? React with 👍 / 👎.


- name: Reset auth + boot Studio for extra UI tests (port 18897)
run: |
unsloth studio reset-password
Expand Down Expand Up @@ -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
15 changes: 10 additions & 5 deletions .github/workflows/studio-ui-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,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 Studio
run: |
Expand Down Expand Up @@ -182,6 +179,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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the permission helper in UI CI triggers

This new step depends on .github/scripts/run-studio-permission-browser.sh, but the workflow's pull_request.paths list only includes the app/test directories and this YAML, not the new helper script. A PR that changes only the helper can therefore alter the command these browser runs execute without running Studio UI CI, so regressions in the helper won't be caught until another workflow trigger happens; add the helper path to the trigger list.

Useful? React with 👍 / 👎.

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 / Studio / Settings) needs a fresh Studio, so we boot a
Expand Down Expand Up @@ -297,6 +300,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
6 changes: 6 additions & 0 deletions .github/workflows/studio-windows-ui-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,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 Studio for extra UI tests (port 18897)
run: |
unsloth studio reset-password
Expand Down Expand Up @@ -402,5 +406,7 @@ jobs:
logs/studio_extra.log
logs/install.log
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
logs/studio-permissions-*.log
retention-days: 7
8 changes: 3 additions & 5 deletions studio/frontend/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1435,12 +1435,10 @@ const Composer: FC<{
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) +
Comment on lines +1438 to 1442

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The pillsCompact calculation in thread.tsx counts the RAG pill whenever ragEnabled is true. However, if the active model does not support tools or is an external model, the RAG pill is disabled and hidden (ragDisabled is true). To ensure consistency with shared-composer.tsx and prevent incorrect pill collapsing, we should retrieve ragDisabled using the useRagToolDisabled() hook and check !ragDisabled in the calculation.

Suggested change
// 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) +
const ragDisabled = useRagToolDisabled();
// More than 4 pills: collapse to icons only. Search, Code, and permissions
// always show; Images, RAG, Canvas and MCP are conditional.
const pillsCompact =
3 +
(ragEnabled && !ragDisabled ? 1 : 0) +

(supportsBuiltinImageGeneration ? 1 : 0) +
(artifactsEnabled ? 1 : 0) +
Expand Down
20 changes: 7 additions & 13 deletions studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
11 changes: 6 additions & 5 deletions studio/frontend/src/features/chat/chat-settings-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2041,13 +2041,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>
Expand All @@ -2068,11 +2069,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. */}
Expand Down
62 changes: 16 additions & 46 deletions studio/frontend/src/features/chat/permission-mode-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
CircleOff,
Hand,
ShieldCheck,
XIcon,
} from "lucide-react";
import { useState } from "react";

Expand Down Expand Up @@ -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;
Expand All @@ -61,21 +59,24 @@ 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",
description:
"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) ??
Expand All @@ -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);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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}>
Expand All @@ -281,32 +275,8 @@ export function PermissionModeComposerPill({
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. In compact
icon-only mode the glyph is the whole button, so clicks fall
through and open the menu instead. */}
<span
role="button"
aria-label="Turn off bypass permissions"
tabIndex={-1}
onPointerDown={(e) => {
if (e.currentTarget.closest('[data-pill-compact="true"]')) {
return;
}
e.stopPropagation();
}}
onClick={(e) => {
if (e.currentTarget.closest('[data-pill-compact="true"]')) {
return;
}
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
Expand Down
Loading
Loading