Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
Unsloth
b869eabe07 Use selected port for post-install browser watcher 2026-07-19 00:38:43 -07:00
Unsloth
b9f50ed00c Merge main and resolve installer conflicts 2026-07-18 22:53:06 -07:00
Unsloth
86200cb0be Address review feedback: reroute flags, curl EPIPE, post-install browser open
Three fixes from PR review and field testing:

- Forward an explicit --no-browser/--browser choice into the WSL Strix
  Halo reroute so the rerouted install honors the flag.
- Drain piped stdin before the --shortcuts-only early exit so
  curl | sh -s -- --shortcuts-only no longer dies with curl error 23.
- Open the browser after the installer's own foreground launch when the
  preference is on: a background watcher polls /api/health, verifies
  the per-install studio_root_id so a different Studio on the port is
  never opened, then opens the URL once. Mirrored in install.ps1 with a
  Start-Job watcher. When the preference is off nothing changes; the
  server already prints its URL.
2026-07-09 22:10:50 -07:00
Unsloth
b03142eac8 Silence SIGPIPE noise in the launcher no-browser test
grep -q exiting on first match SIGPIPEs the echo feeding it when the
haystack is the whole installer, spamming 'write error: Broken pipe'
in the CI job log. Feed grep from here-strings instead.
2026-07-09 03:04:26 -07:00
Unsloth
0d0425c8c4 Keep the saved browser preference when the reinstall prompt is accepted
An interactive reinstall over an install that had persisted
STUDIO_OPEN_BROWSER='0' would flip it back to 1 when the user pressed
Enter, because the prompt default was hardcoded to yes and the answer
then overrode the preserve logic. Seed the prompt default from the
existing preference (studio.conf on macOS/Linux/WSL, the value baked in
launch-studio.ps1 on Windows) and flip the hint to [y/N] accordingly.
Explicit y/n answers still override.
2026-07-09 00:23:56 -07:00
Unsloth
bf3a6f1a51 Run the launcher no-browser shell test in CI
The shell installer test step runs a hardcoded list, so the new
tests/sh/test_launcher_no_browser.sh was only bash -n parsed by lint
and never executed. Add it to the list; it only reads install.sh and
install.ps1 and writes to mktemp sandboxes, so it fits the step's
no-writable-tree constraint.
2026-07-08 23:25:52 -07:00
Unsloth
52acbcc250 Make UNSLOTH_STUDIO_NO_BROWSER falsy check case-insensitive in the shell launcher
Simulation testing caught that False or Off disabled the browser in
launch-studio.sh while the PowerShell launcher's -notin treats them as
falsy case-insensitively. Lowercase the value before matching so both
launchers agree, and pin the behavior in the launcher test.
2026-07-08 22:46:10 -07:00
Unsloth
c980658592 Add no-browser launch option for the Studio desktop launcher
The generated launchers (launch-studio.sh / launch-studio.ps1) always
opened the default browser once the server became healthy. Add a
--no-browser launcher flag, the UNSLOTH_STUDIO_NO_BROWSER env var, and
a persisted installer preference (studio.conf / baked into the ps1
launcher) with an interactive install prompt. When auto-open is off the
launcher still starts or attaches to the server and prints the URL, for
users who run Studio as a browser PWA or app window. The
--shortcuts-only refresh run by studio update preserves the choice.
2026-07-08 22:15:16 -07:00
5 changed files with 521 additions and 6 deletions

View file

@ -232,7 +232,8 @@ jobs:
tests/sh/test_torch_constraint.sh \
tests/sh/test_torch_flavor.sh \
tests/sh/test_with_llama_cpp_dir_flag.sh \
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
tests/sh/test_with_llama_cpp_dir_link_behavior.sh \
tests/sh/test_launcher_no_browser.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"

View file

@ -86,6 +86,8 @@ unsloth studio -p 8888
```
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally.
Launching from the terminal never opens a browser -- open the printed URL yourself. The desktop shortcut opens your default browser once the server is up; to launch the server without that (e.g. when running Studio as a browser PWA / app window), answer "n" at the installer's browser prompt, pass `--no-browser` to the launcher, or set `UNSLOTH_STUDIO_NO_BROWSER=1`.
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
#### Docker

View file

@ -102,6 +102,8 @@ function Install-UnslothStudio {
$SkipTorch = $false
$SkipAutostart = $false
$ShortcutsOnly = $false
# Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on).
$OpenBrowserPref = ""
$WithLlamaCppDir = ""
$argList = $args
for ($i = 0; $i -lt $argList.Count; $i++) {
@ -112,6 +114,8 @@ function Install-UnslothStudio {
"--verbose" { $script:UnslothVerbose = $true }
"-v" { $script:UnslothVerbose = $true }
"--shortcuts-only" { $ShortcutsOnly = $true }
"--no-browser" { $OpenBrowserPref = '0' }
"--browser" { $OpenBrowserPref = '1' }
"--package" {
$i++
if ($i -ge $argList.Count) {
@ -656,6 +660,20 @@ function Install-UnslothStudio {
"`$portFile = `$null`n`$mutexName = 'Local\UnslothStudioLauncher'`n"
}
# Browser auto-open: explicit installer choice wins; else keep the
# value baked into the existing launcher so `studio update`
# (--shortcuts-only) never resets it.
$_openBrowser = $OpenBrowserPref
if (-not $_openBrowser -and (Test-Path -LiteralPath $launcherPs1)) {
try {
$_prevLauncher = [System.IO.File]::ReadAllText($launcherPs1)
if ($_prevLauncher -match "(?m)^\`$openBrowserDefault = '([01])'") {
$_openBrowser = $Matches[1]
}
} catch {}
}
if ($_openBrowser -ne '0') { $_openBrowser = '1' }
$launcherContent = @"
$studioHomeExport`$ErrorActionPreference = 'Stop'
`$basePort = 8888
@ -663,6 +681,30 @@ $studioHomeExport`$ErrorActionPreference = 'Stop'
`$timeoutSec = 60
`$pollIntervalMs = 1000
`$_ExpectedStudioRootId = '$_studioRootId'
`$openBrowserDefault = '$_openBrowser'
# Browser auto-open: disabled by -NoBrowser/--no-browser, the
# UNSLOTH_STUDIO_NO_BROWSER env var, or the baked installer preference.
# When off the server still starts; the URL is printed instead (PWA use).
`$openBrowser = (`$openBrowserDefault -ne '0')
if (`$env:UNSLOTH_STUDIO_NO_BROWSER -and
(`$env:UNSLOTH_STUDIO_NO_BROWSER -notin @('0', 'false', 'no', 'off'))) {
`$openBrowser = `$false
}
foreach (`$_launchArg in `$args) {
if (`$_launchArg -in @('-NoBrowser', '--no-browser')) { `$openBrowser = `$false }
elseif (`$_launchArg -in @('-Browser', '--browser')) { `$openBrowser = `$true }
}
function Open-StudioUrl {
param([Parameter(Mandatory = `$true)][string]`$Url)
if (`$openBrowser) {
Start-Process `$Url
} else {
# Hidden-window launches have no console; never fail on the echo.
try { Write-Host "Unsloth Studio is running at: `$Url" } catch {}
}
}
function Test-StudioHealth {
param([Parameter(Mandatory = `$true)][int]`$Port)
@ -759,7 +801,7 @@ function Find-FreeLaunchPort {
# If Studio is already healthy on any expected port, just open it and exit.
`$existingPort = Find-HealthyStudioPort
if (`$existingPort) {
Start-Process "http://localhost:`$existingPort"
Open-StudioUrl "http://localhost:`$existingPort"
exit 0
}
@ -776,7 +818,7 @@ try {
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
while ((Get-Date) -lt `$deadline) {
`$port = Find-HealthyStudioPort
if (`$port) { Start-Process "http://localhost:`$port"; exit 0 }
if (`$port) { Open-StudioUrl "http://localhost:`$port"; exit 0 }
Start-Sleep -Milliseconds `$pollIntervalMs
}
exit 0
@ -825,7 +867,7 @@ try {
[System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n")
} catch {}
}
Start-Process "http://localhost:`$launchPort"
Open-StudioUrl "http://localhost:`$launchPort"
`$browserOpened = `$true
break
}
@ -2586,6 +2628,32 @@ exit 0
return
}
# Ask once (interactive installs only) whether the launcher should open
# the browser after the server is up. Skipped when --no-browser/--browser
# was passed or input is redirected; then an existing choice is kept.
# Enter keeps the choice baked into the existing launcher so a reinstall
# that accepts the defaults never flips a saved no-browser preference.
$_browserPromptOk = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
if (-not $OpenBrowserPref -and $_browserPromptOk) {
$_existingPref = ""
$_promptLauncher = if ($StudioDataDir) { Join-Path $StudioDataDir "launch-studio.ps1" } else { $null }
if ($_promptLauncher -and (Test-Path -LiteralPath $_promptLauncher)) {
try {
$_prevText = [System.IO.File]::ReadAllText($_promptLauncher)
if ($_prevText -match "(?m)^\`$openBrowserDefault = '([01])'") {
$_existingPref = $Matches[1]
}
} catch {}
}
$_browserHint = if ($_existingPref -eq '0') { '[y/N]' } else { '[Y/n]' }
Write-Host ""
$_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? $_browserHint"
$OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' }
elseif ($_browserReply -match '^[Yy]') { '1' }
elseif ($_existingPref) { $_existingPref }
else { '1' }
}
# New-StudioShortcuts gates the .lnk shortcuts on env-mode internally.
New-StudioShortcuts -UnslothExePath $UnslothExe
@ -2620,11 +2688,75 @@ exit 0
# caller explicitly disabled the post-install prompt.
# In non-interactive environments (CI, Docker) just print instructions.
$IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
# Select the same bounded free-port range as the generated desktop launcher.
# Passing the selected port to both the server and watcher prevents an
# existing Studio on 8888 from moving the backend while the watcher stays
# behind.
function Find-PostInstallStudioPort {
param([int]$BasePort = 8888, [int]$MaxPortOffset = 20)
$probes = @(
@{ Family = [System.Net.Sockets.AddressFamily]::InterNetwork; Host = '127.0.0.1' },
@{ Family = [System.Net.Sockets.AddressFamily]::InterNetworkV6; Host = '::1' }
)
for ($offset = 0; $offset -le $MaxPortOffset; $offset++) {
$candidate = $BasePort + $offset
$busy = $false
foreach ($probe in $probes) {
$client = $null
try {
$client = [System.Net.Sockets.TcpClient]::new($probe.Family)
$connect = $client.ConnectAsync($probe.Host, $candidate)
if ($connect.Wait(100) -and $client.Connected) {
$busy = $true
break
}
} catch {
} finally {
if ($client) { $client.Dispose() }
}
}
if (-not $busy) { return $candidate }
}
return $BasePort
}
# Background watcher for the foreground launch below: once the server is
# healthy on the selected port, open the browser per the persisted
# preference. Guarded by the per-install root id so a different Studio is
# never the one opened.
$_browserWatch = {
param($RootId, $Port)
$deadline = (Get-Date).AddSeconds(120)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 1 -Method Get
if ($r.service -eq 'Unsloth UI Backend' -and
((-not $RootId) -or $r.studio_root_id -eq $RootId)) {
Start-Process "http://localhost:$Port"
break
}
} catch {}
Start-Sleep -Seconds 1
}
}
if ($IsInteractive) {
Write-Host ""
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') {
& $UnslothExe studio -p 8888
$_launchPort = Find-PostInstallStudioPort
# Open the browser once the server is up, unless opted out. The
# server prints its own URL, so no watcher is needed when off.
if ($OpenBrowserPref -ne '0') {
$_watchRootId = ""
$_watchIdFile = Join-Path $StudioHome "share\studio_install_id"
if (Test-Path -LiteralPath $_watchIdFile) {
try { $_watchRootId = ([System.IO.File]::ReadAllText($_watchIdFile)).Trim() } catch {}
}
try {
$null = Start-Job -ScriptBlock $_browserWatch -ArgumentList @($_watchRootId, $_launchPort)
} catch {}
}
& $UnslothExe studio -p $_launchPort
} else {
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"

View file

@ -13,6 +13,7 @@
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
# Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch)
# ./install.sh --no-browser: launcher starts the server without opening the browser.
#
# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $HOME/.unsloth/studio
#
@ -53,6 +54,8 @@ _NO_TORCH_FLAG=false
_SKIP_AUTOSTART=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
# Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on).
_STUDIO_OPEN_BROWSER=""
_next_is_package=false
_next_is_python=false
_next_is_llama_cpp_dir=false
@ -84,6 +87,8 @@ for arg in "$@"; do
--no-torch) _NO_TORCH_FLAG=true ;;
--verbose|-v) _VERBOSE=true ;;
--shortcuts-only) _SHORTCUTS_ONLY=true ;;
--no-browser) _STUDIO_OPEN_BROWSER=0 ;;
--browser) _STUDIO_OPEN_BROWSER=1 ;;
--with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;;
esac
done
@ -656,6 +661,22 @@ if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then
exit 1
fi
# Browser auto-open. Priority: --no-browser/--browser arg, then
# UNSLOTH_STUDIO_NO_BROWSER env var, then studio.conf, default on.
# When off the server still starts; the URL is printed instead (PWA use).
OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}"
# Case-insensitive falsy check, matching the PowerShell launcher's -notin.
case "$(printf '%s' "${UNSLOTH_STUDIO_NO_BROWSER:-}" | tr '[:upper:]' '[:lower:]')" in
''|0|false|no|off) ;;
*) OPEN_BROWSER=0 ;;
esac
for _arg in "$@"; do
case "$_arg" in
--no-browser) OPEN_BROWSER=0 ;;
--browser) OPEN_BROWSER=1 ;;
esac
done
BASE_PORT=8888
MAX_PORT_OFFSET=20
TIMEOUT_SEC=60
@ -789,6 +810,10 @@ _find_launch_port() {
# ── Open browser ──
_open_browser() {
_url="$1"
if [ "$OPEN_BROWSER" = "0" ]; then
echo "Unsloth Studio is running at: $_url"
return 0
fi
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
open "$_url"
elif grep -qi microsoft /proc/version 2>/dev/null; then
@ -1020,11 +1045,21 @@ LAUNCHER_EOF
chmod +x "$_css_launcher"
# Browser auto-open: explicit installer choice wins; else keep the existing
# studio.conf value so `studio update` (--shortcuts-only) never resets it.
_css_open_browser="${_STUDIO_OPEN_BROWSER:-}"
if [ -z "$_css_open_browser" ] && [ -f "$_css_data_dir/studio.conf" ]; then
_css_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \
"$_css_data_dir/studio.conf" 2>/dev/null | head -n 1)
fi
[ "$_css_open_browser" = "0" ] || _css_open_browser=1
# studio.conf: exe path + (env-mode only) persisted env vars so fresh
# shells launch the right install without re-exporting.
_css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g")
{
printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'"
printf '%s\n' "STUDIO_OPEN_BROWSER='$_css_open_browser'"
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
# When an override resolves to the legacy default, llama.cpp
# still lives at ~/.unsloth/llama.cpp (one shared build).
@ -1407,6 +1442,10 @@ if [ "$_SHORTCUTS_ONLY" = true ]; then
fi
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
fi
# Drain piped stdin (curl | sh -s -- --shortcuts-only ...) before this
# early exit; otherwise curl dies with EPIPE, prints "curl: (23) Failure
# writing output to destination", and fails the whole pipeline.
[ ! -t 0 ] && cat > /dev/null 2>&1
exit 0
fi
@ -1640,6 +1679,10 @@ _maybe_reroute_strixhalo_to_2404() {
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
[ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose"
[ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri"
# Forward an explicit browser choice; "" (undecided) forwards nothing so
# the rerouted install keeps its own default.
[ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser"
[ "$_STUDIO_OPEN_BROWSER" = "1" ] && _rr_args="$_rr_args --browser"
if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then
_rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control
elif [ -n "$_rr_args" ]; then
@ -3170,6 +3213,31 @@ esac
# create_studio_shortcuts gates persistent menu shortcuts on env-mode;
# launcher + studio.conf + icon are always written.
if [ "$TAURI_MODE" != true ]; then
# Ask once (interactive installs only) whether the launcher should open
# the browser after the server is up. Skipped when --no-browser/--browser
# was passed or no TTY; then an existing choice is kept, defaulting to on.
# Enter keeps the choice persisted in studio.conf so a reinstall that
# accepts the defaults never flips a saved no-browser preference.
if [ -z "$_STUDIO_OPEN_BROWSER" ] && [ -t 1 ] && [ -r /dev/tty ]; then
_existing_open_browser=""
if [ -f "$DATA_DIR/studio.conf" ]; then
_existing_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \
"$DATA_DIR/studio.conf" 2>/dev/null | head -n 1)
fi
if [ "$_existing_open_browser" = "0" ]; then
_browser_hint="[y/N]"
else
_browser_hint="[Y/n]"
fi
echo ""
printf " Open Unsloth Studio in your default browser after launch? %s " "$_browser_hint"
read -r _browser_reply </dev/tty || _browser_reply=""
case "$_browser_reply" in
[nN]*) _STUDIO_OPEN_BROWSER=0 ;;
[yY]*) _STUDIO_OPEN_BROWSER=1 ;;
*) _STUDIO_OPEN_BROWSER="${_existing_open_browser:-1}" ;;
esac
fi
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
fi
@ -3227,6 +3295,87 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
echo ""
# Select the same bounded free-port range as the generated desktop launcher.
# Passing the selected port to both the server and watcher prevents an existing
# Studio on 8888 from making the backend move while the watcher stays behind.
_find_post_install_port() {
_pifp_base="${1:-8888}"
_pifp_max_offset="${2:-20}"
"$VENV_DIR/bin/python" - "$_pifp_base" "$_pifp_max_offset" <<'PY'
import socket
import sys
base = int(sys.argv[1])
max_offset = int(sys.argv[2])
def is_free(port):
endpoints = (
(socket.AF_INET, ("127.0.0.1", port)),
(socket.AF_INET6, ("::1", port, 0, 0)),
)
for family, address in endpoints:
try:
with socket.socket(family, socket.SOCK_STREAM) as probe:
probe.settimeout(0.1)
if probe.connect_ex(address) == 0:
return False
except OSError:
# IPv6 can be unavailable; match the backend's loopback probe.
continue
return True
for offset in range(max_offset + 1):
candidate = base + offset
if is_free(candidate):
print(candidate)
raise SystemExit(0)
raise SystemExit(1)
PY
}
# Background watcher for the post-install foreground launch below: once the
# server is healthy on the selected port, open the browser per the persisted
# preference. Guarded by the per-install root id so a different Studio is never
# the one opened.
_post_install_browser_watch() {
_pibw_port="$1"
_pibw_url="http://localhost:$_pibw_port"
_pibw_id=$(cat "$STUDIO_HOME/share/studio_install_id" 2>/dev/null || true)
(
_pibw_deadline=$(($(date +%s) + 120))
while [ "$(date +%s)" -lt "$_pibw_deadline" ]; do
_pibw_resp=$(curl -fsS --max-time 1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \
|| wget -qO- --timeout=1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \
|| true)
case "$_pibw_resp" in
*'"Unsloth UI Backend"'*)
if [ -n "$_pibw_id" ]; then
case "$_pibw_resp" in
*"\"studio_root_id\":\"$_pibw_id\""*|*"\"studio_root_id\": \"$_pibw_id\""*) ;;
*) sleep 1; continue ;;
esac
fi
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
open "$_pibw_url" 2>/dev/null
elif grep -qi microsoft /proc/version 2>/dev/null; then
if command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoProfile -Command "Start-Process '$_pibw_url'" >/dev/null 2>&1
elif command -v cmd.exe >/dev/null 2>&1; then
cmd.exe /c start "" "$_pibw_url" >/dev/null 2>&1
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$_pibw_url" >/dev/null 2>&1
fi
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$_pibw_url" >/dev/null 2>&1
fi
exit 0
;;
esac
sleep 1
done
) &
}
# In interactive terminals, ask the user before starting Studio unless the
# caller explicitly disabled the post-install prompt.
# In non-interactive environments (Docker, CI, cloud-init) just print instructions.
@ -3242,6 +3391,15 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
case "${_reply:-y}" in
[Yy]*|"")
step "launch" "starting Unsloth Studio..."
_post_install_port=$(_find_post_install_port 8888 20) || _post_install_port=8888
case "$_post_install_port" in
''|*[!0-9]*) _post_install_port=8888 ;;
esac
# Open the browser once the server is up, unless opted out. The
# server prints its own URL, so no watcher is needed when off.
if [ "${_STUDIO_OPEN_BROWSER:-1}" != "0" ]; then
_post_install_browser_watch "$_post_install_port"
fi
# Detach stdin from the `curl | sh` pipe: as a foreground server the
# studio would otherwise drain the rest of this piped script, leaving
# the shell to die parsing the now-truncated tail (`unexpected fi`).
@ -3250,7 +3408,7 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
trap '' INT
# `|| ...`: capture the exit code without set -e aborting first.
_LAUNCH_EXIT=0
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p 8888 </dev/null) || _LAUNCH_EXIT=$?
(trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p "$_post_install_port" </dev/null) || _LAUNCH_EXIT=$?
if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then
echo ""
echo "⚠️ Unsloth Studio failed to start after migration."

View file

@ -0,0 +1,222 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# The generated desktop launchers must support suppressing the automatic
# default-browser open after the server becomes healthy (PWA users run Studio
# in a browser-app window, not the OS default browser). Covered surface:
# - launch-studio.sh: --no-browser flag, UNSLOTH_STUDIO_NO_BROWSER env var,
# persisted STUDIO_OPEN_BROWSER from studio.conf; prints the URL when off.
# - install.sh: --no-browser flag, interactive prompt, studio.conf persistence
# that survives the --shortcuts-only refresh run by `unsloth studio update`.
# - install.ps1: same feature baked into launch-studio.ps1 (Open-StudioUrl).
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
INSTALL_PS1="$SCRIPT_DIR/../../install.ps1"
PASS=0
FAIL=0
# Here-strings, not `echo | grep -q`: grep exiting on first match SIGPIPEs
# the echo on large haystacks, spamming "write error: Broken pipe" in CI logs.
assert_contains() {
_label="$1"; _haystack="$2"; _needle="$3"
if grep -qF -- "$_needle" <<< "$_haystack"; then
echo " PASS: $_label"
PASS=$((PASS + 1))
else
echo " FAIL: $_label (expected to find '$_needle')"
FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
_label="$1"; _haystack="$2"; _needle="$3"
if grep -qF -- "$_needle" <<< "$_haystack"; then
echo " FAIL: $_label (found '$_needle' but should not)"
FAIL=$((FAIL + 1))
else
echo " PASS: $_label"
PASS=$((PASS + 1))
fi
}
echo ""
echo "=== install.sh launcher template ==="
# Extract the heredoc that generates ~/.local/share/unsloth/launch-studio.sh.
_launcher=$(awk '/cat > "\$_css_launcher"/{found=1} found{print} /^LAUNCHER_EOF$/{found=0}' "$INSTALL_SH")
assert_contains \
"launcher template: --no-browser argument handled" \
"$_launcher" "--no-browser) OPEN_BROWSER=0"
assert_contains \
"launcher template: UNSLOTH_STUDIO_NO_BROWSER env var handled" \
"$_launcher" "UNSLOTH_STUDIO_NO_BROWSER"
# Mixed-case falsy values (False, Off) must not disable, matching the
# PowerShell launcher's case-insensitive -notin.
assert_contains \
"launcher template: env var check is case-insensitive" \
"$_launcher" "tr '[:upper:]' '[:lower:]'"
assert_contains \
"launcher template: studio.conf preference is the default" \
"$_launcher" 'OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}"'
assert_contains \
"launcher template: _open_browser is gated on OPEN_BROWSER" \
"$_launcher" '[ "$OPEN_BROWSER" = "0" ]'
assert_contains \
"launcher template: URL still printed when auto-open is off" \
"$_launcher" "Unsloth Studio is running at:"
echo ""
echo "=== install.sh installer plumbing ==="
_installer=$(cat "$INSTALL_SH")
assert_contains \
"install.sh: --no-browser flag parsed" \
"$_installer" "--no-browser) _STUDIO_OPEN_BROWSER=0"
assert_contains \
"install.sh: preference persisted into studio.conf" \
"$_installer" "STUDIO_OPEN_BROWSER='\$_css_open_browser'"
assert_contains \
"install.sh: existing studio.conf choice preserved on refresh" \
"$_installer" "s/^STUDIO_OPEN_BROWSER="
assert_contains \
"install.sh: interactive prompt asks about browser auto-open" \
"$_installer" "Open Unsloth Studio in your default browser after launch?"
# A reinstall that accepts the prompt default must keep the saved choice.
assert_contains \
"install.sh: prompt Enter keeps the persisted preference" \
"$_installer" '*) _STUDIO_OPEN_BROWSER="${_existing_open_browser:-1}"'
# The WSL Strix Halo reroute must forward an explicit browser choice.
assert_contains \
"install.sh: reroute forwards --no-browser" \
"$_installer" '[ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser"'
# The post-install foreground launch honors the preference too.
assert_contains \
"install.sh: post-install launch opens browser via gated watcher" \
"$_installer" '_post_install_browser_watch "$_post_install_port"'
assert_contains \
"install.sh: post-install launch selects a free port" \
"$_installer" "_find_post_install_port()"
assert_contains \
"install.sh: server uses the watcher-selected port" \
"$_installer" 'studio -p "$_post_install_port"'
# The --shortcuts-only early exit must not EPIPE a curl | sh pipeline.
assert_contains \
"install.sh: shortcuts-only exit drains piped stdin" \
"$_installer" '[ ! -t 0 ] && cat > /dev/null'
echo ""
echo "=== install.sh _open_browser gating (functional) ==="
# Extract the _open_browser function from the (column-0) launcher heredoc and
# drive it with stubbed browser openers on PATH.
_fn=$(printf '%s\n' "$_launcher" | awk '/^_open_browser\(\) \{/{found=1} found{print} found && /^\}/{exit}')
if [ -z "$_fn" ]; then
echo " FAIL: could not extract _open_browser from launcher template"
FAIL=$((FAIL + 1))
else
_tmp=$(mktemp -d)
trap 'rm -rf "$_tmp"' EXIT
for _stub in open xdg-open; do
printf '#!/bin/sh\necho "BROWSER_OPENED:$1" >> "$RECORD"\n' > "$_tmp/$_stub"
chmod +x "$_tmp/$_stub"
done
# Off: no browser process, URL echoed instead.
_out=$(RECORD="$_tmp/record_off" PATH="$_tmp:$PATH" bash -c \
"OPEN_BROWSER=0; $_fn; _open_browser http://localhost:9999")
assert_contains \
"OPEN_BROWSER=0 prints the URL" \
"$_out" "Unsloth Studio is running at: http://localhost:9999"
if [ -f "$_tmp/record_off" ]; then
echo " FAIL: OPEN_BROWSER=0 still invoked a browser opener"
FAIL=$((FAIL + 1))
else
echo " PASS: OPEN_BROWSER=0 does not invoke a browser opener"
PASS=$((PASS + 1))
fi
# On (default): browser opener invoked with the URL.
RECORD="$_tmp/record_on" PATH="$_tmp:$PATH" bash -c \
"OPEN_BROWSER=1; $_fn; _open_browser http://localhost:9999" > /dev/null
# xdg-open is backgrounded inside _open_browser; give the stub a moment.
_i=0
while [ ! -s "$_tmp/record_on" ] && [ "$_i" -lt 20 ]; do
sleep 0.1
_i=$((_i + 1))
done
assert_contains \
"OPEN_BROWSER=1 invokes a browser opener with the URL" \
"$(cat "$_tmp/record_on" 2>/dev/null)" "BROWSER_OPENED:http://localhost:9999"
fi
echo ""
echo "=== install.ps1 launcher template ==="
# grep the file directly: piping the whole installer into grep -q trips
# SIGPIPE noise from echo once grep exits on first match.
assert_file_contains() {
_label="$1"; _file="$2"; _needle="$3"
if grep -qF -- "$_needle" "$_file"; then
echo " PASS: $_label"
PASS=$((PASS + 1))
else
echo " FAIL: $_label (expected to find '$_needle')"
FAIL=$((FAIL + 1))
fi
}
assert_file_contains \
"install.ps1: --no-browser flag parsed" \
"$INSTALL_PS1" "\"--no-browser\" { \$OpenBrowserPref = '0' }"
assert_file_contains \
"install.ps1: preference baked into launch-studio.ps1" \
"$INSTALL_PS1" "openBrowserDefault = '\$_openBrowser'"
assert_file_contains \
"install.ps1: launcher honors UNSLOTH_STUDIO_NO_BROWSER" \
"$INSTALL_PS1" "UNSLOTH_STUDIO_NO_BROWSER"
assert_file_contains \
"install.ps1: gated helper defined" \
"$INSTALL_PS1" "function Open-StudioUrl {"
assert_file_contains \
"install.ps1: interactive prompt asks about browser auto-open" \
"$INSTALL_PS1" "Open Unsloth Studio in your default browser after launch?"
assert_file_contains \
"install.ps1: prompt Enter keeps the baked preference" \
"$INSTALL_PS1" 'elseif ($_existingPref) { $_existingPref }'
assert_file_contains \
"install.ps1: post-install launch opens browser via gated watcher" \
"$INSTALL_PS1" 'Start-Job -ScriptBlock $_browserWatch'
assert_file_contains \
"install.ps1: post-install launch selects a free port" \
"$INSTALL_PS1" 'function Find-PostInstallStudioPort {'
assert_file_contains \
"install.ps1: watcher receives the selected port" \
"$INSTALL_PS1" 'ArgumentList @($_watchRootId, $_launchPort)'
assert_file_contains \
"install.ps1: server uses the watcher-selected port" \
"$INSTALL_PS1" '& $UnslothExe studio -p $_launchPort'
# All launcher URL opens must route through the gated helper. The one
# allowed direct call is inside the post-install $_browserWatch scriptblock,
# whose Start-Job call site is itself gated on the preference.
_ps1_direct_open=$(grep -cE 'Start-Process "http://localhost:' "$INSTALL_PS1" || true)
_ps1_watch_open=$(awk '/\$_browserWatch = \{/{f=1} f && /^ \}$/{exit} f' "$INSTALL_PS1" \
| grep -cE 'Start-Process "http://localhost:' || true)
if [ "$_ps1_direct_open" -eq 1 ] && [ "$_ps1_watch_open" -eq 1 ]; then
echo " PASS: only the gated browser watcher opens a URL directly"
PASS=$((PASS + 1))
else
echo " FAIL: found $_ps1_direct_open direct URL opens ($_ps1_watch_open in the watcher); all others must route through Open-StudioUrl"
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== Results ==="
echo " PASS: $PASS"
echo " FAIL: $FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED"
exit 1
fi
echo "ALL PASSED"