From c980658592fd2a9e40f8b2acc75ac289a32d178b Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 8 Jul 2026 22:15:16 -0700 Subject: [PATCH 1/7] 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. --- README.md | 2 + install.ps1 | 58 ++++++++- install.sh | 46 +++++++ tests/sh/test_launcher_no_browser.sh | 174 +++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 3 deletions(-) create mode 100755 tests/sh/test_launcher_no_browser.sh diff --git a/README.md b/README.md index e3fd4e6980..3116f608cc 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ unsloth studio -p 8888 ``` For cloud or global access, add `-H 0.0.0.0`. 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 diff --git a/install.ps1 b/install.ps1 index 696f4e613a..a8c52a7b4b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -99,6 +99,8 @@ function Install-UnslothStudio { $TauriMode = $false $SkipTorch = $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++) { @@ -109,6 +111,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) { @@ -640,6 +644,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 @@ -647,6 +665,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) @@ -743,7 +785,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 } @@ -760,7 +802,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 @@ -809,7 +851,7 @@ try { [System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n") } catch {} } - Start-Process "http://localhost:`$launchPort" + Open-StudioUrl "http://localhost:`$launchPort" `$browserOpened = `$true break } @@ -2570,6 +2612,16 @@ 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. + $_browserPromptOk = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + if (-not $OpenBrowserPref -and $_browserPromptOk) { + Write-Host "" + $_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? [Y/n]" + $OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' } else { '1' } + } + # New-StudioShortcuts gates the .lnk shortcuts on env-mode internally. New-StudioShortcuts -UnslothExePath $UnslothExe diff --git a/install.sh b/install.sh index 0acc9ec0be..74e003035a 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,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 # @@ -51,6 +52,8 @@ _USER_PYTHON="" _NO_TORCH_FLAG=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 @@ -82,6 +85,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 @@ -647,6 +652,21 @@ 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 "${UNSLOTH_STUDIO_NO_BROWSER:-}" in + ''|0|false|FALSE|no|NO|off|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 @@ -780,6 +800,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 @@ -1011,11 +1035,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). @@ -3160,6 +3194,18 @@ 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. + if [ -z "$_STUDIO_OPEN_BROWSER" ] && [ -t 1 ] && [ -r /dev/tty ]; then + echo "" + printf " Open Unsloth Studio in your default browser after launch? [Y/n] " + read -r _browser_reply "\$_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" +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?" + +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?" +# All launcher URL opens must route through the gated helper. +_ps1_direct_open=$(grep -cF 'Start-Process "http://localhost:' "$INSTALL_PS1" || true) +if [ "$_ps1_direct_open" -eq 0 ]; then + echo " PASS: no ungated Start-Process http://localhost calls remain" + PASS=$((PASS + 1)) +else + echo " FAIL: $_ps1_direct_open ungated Start-Process http://localhost call(s) remain" + 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" From 52acbcc25067f01b9af39952e7a9e89f5db371a1 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 8 Jul 2026 22:46:10 -0700 Subject: [PATCH 2/7] 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. --- install.sh | 5 +++-- tests/sh/test_launcher_no_browser.sh | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 74e003035a..38625c43c5 100755 --- a/install.sh +++ b/install.sh @@ -656,8 +656,9 @@ fi # 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 "${UNSLOTH_STUDIO_NO_BROWSER:-}" in - ''|0|false|FALSE|no|NO|off|OFF) ;; +# 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 diff --git a/tests/sh/test_launcher_no_browser.sh b/tests/sh/test_launcher_no_browser.sh index 8928ebc097..4ea4875671 100755 --- a/tests/sh/test_launcher_no_browser.sh +++ b/tests/sh/test_launcher_no_browser.sh @@ -50,6 +50,11 @@ assert_contains \ 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}"' From bf3a6f1a51982c53cd55821c844e6d851ad1b88c Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 8 Jul 2026 23:25:52 -0700 Subject: [PATCH 3/7] 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. --- .github/workflows/studio-backend-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 3022127a2b..e4ef484371 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -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::" From 0d0425c8c49dbbc88a444944af51b59782734e37 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Thu, 9 Jul 2026 00:23:56 -0700 Subject: [PATCH 4/7] 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. --- install.ps1 | 20 ++++++++++++++++++-- install.sh | 19 ++++++++++++++++--- tests/sh/test_launcher_no_browser.sh | 7 +++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index a8c52a7b4b..62d55989ea 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2615,11 +2615,27 @@ exit 0 # 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? [Y/n]" - $OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' } else { '1' } + $_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. diff --git a/install.sh b/install.sh index 38625c43c5..10bc6b8863 100755 --- a/install.sh +++ b/install.sh @@ -3198,13 +3198,26 @@ 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? [Y/n] " - read -r _browser_reply Date: Thu, 9 Jul 2026 03:04:26 -0700 Subject: [PATCH 5/7] 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. --- tests/sh/test_launcher_no_browser.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sh/test_launcher_no_browser.sh b/tests/sh/test_launcher_no_browser.sh index 073168d0d0..ee9147a277 100755 --- a/tests/sh/test_launcher_no_browser.sh +++ b/tests/sh/test_launcher_no_browser.sh @@ -17,9 +17,11 @@ 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 echo "$_haystack" | grep -qF -- "$_needle"; then + if grep -qF -- "$_needle" <<< "$_haystack"; then echo " PASS: $_label" PASS=$((PASS + 1)) else @@ -30,7 +32,7 @@ assert_contains() { assert_not_contains() { _label="$1"; _haystack="$2"; _needle="$3" - if echo "$_haystack" | grep -qF -- "$_needle"; then + if grep -qF -- "$_needle" <<< "$_haystack"; then echo " FAIL: $_label (found '$_needle' but should not)" FAIL=$((FAIL + 1)) else From 86200cb0bef9fc9ce55fc37b78048cc70a83ef4a Mon Sep 17 00:00:00 2001 From: Unsloth Date: Thu, 9 Jul 2026 22:10:50 -0700 Subject: [PATCH 6/7] 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. --- install.ps1 | 31 +++++++++++++++ install.sh | 56 ++++++++++++++++++++++++++++ tests/sh/test_launcher_no_browser.sh | 29 +++++++++++--- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 62d55989ea..3e945b0a39 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2671,10 +2671,41 @@ exit 0 # In interactive terminals, ask the user before starting Studio. # In non-interactive environments (CI, Docker) just print instructions. $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + # Background watcher for the foreground launch below: once the server is + # healthy, open the browser per the persisted preference (mirrors the + # desktop launcher). Guarded by the per-install root id so a different + # Studio already on the port 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]') { + # 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, 8888) + } catch {} + } & $UnslothExe studio -p 8888 } else { step "launch" "to start later, run:" diff --git a/install.sh b/install.sh index 10bc6b8863..3d5b599b78 100755 --- a/install.sh +++ b/install.sh @@ -1433,6 +1433,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 @@ -1665,6 +1669,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 @@ -3277,6 +3285,49 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!" printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" +# Background watcher for the post-install foreground launch below: once the +# server is healthy, open the browser per the persisted preference (mirrors +# the desktop launcher). Guarded by the per-install root id so a different +# Studio already on the port 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. # In non-interactive environments (Docker, CI, cloud-init) just print instructions. if [ -t 1 ]; then @@ -3291,6 +3342,11 @@ if [ -t 1 ]; then case "${_reply:-y}" in [Yy]*|"") step "launch" "starting Unsloth Studio..." + # 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 8888 + 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`). diff --git a/tests/sh/test_launcher_no_browser.sh b/tests/sh/test_launcher_no_browser.sh index ee9147a277..795280ee3a 100755 --- a/tests/sh/test_launcher_no_browser.sh +++ b/tests/sh/test_launcher_no_browser.sh @@ -87,6 +87,18 @@ assert_contains \ 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 8888" +# 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) ===" @@ -167,13 +179,20 @@ assert_file_contains \ assert_file_contains \ "install.ps1: prompt Enter keeps the baked preference" \ "$INSTALL_PS1" 'elseif ($_existingPref) { $_existingPref }' -# All launcher URL opens must route through the gated helper. -_ps1_direct_open=$(grep -cF 'Start-Process "http://localhost:' "$INSTALL_PS1" || true) -if [ "$_ps1_direct_open" -eq 0 ]; then - echo " PASS: no ungated Start-Process http://localhost calls remain" +assert_file_contains \ + "install.ps1: post-install launch opens browser via gated watcher" \ + "$INSTALL_PS1" 'Start-Job -ScriptBlock $_browserWatch' +# 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: $_ps1_direct_open ungated Start-Process http://localhost call(s) remain" + 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 From b869eabe07f40ae25254de0479a6ad93c2f78196 Mon Sep 17 00:00:00 2001 From: Unsloth Date: Sun, 19 Jul 2026 00:38:43 -0700 Subject: [PATCH 7/7] Use selected port for post-install browser watcher --- install.ps1 | 43 ++++++++++++++++++++--- install.sh | 52 +++++++++++++++++++++++++--- tests/sh/test_launcher_no_browser.sh | 17 ++++++++- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/install.ps1 b/install.ps1 index e5fcd1e609..623c252029 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2688,10 +2688,42 @@ 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, open the browser per the persisted preference (mirrors the - # desktop launcher). Guarded by the per-install root id so a different - # Studio already on the port is never the one opened. + # 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) @@ -2711,6 +2743,7 @@ exit 0 Write-Host "" $reply = Read-Host " Start Unsloth Studio now? [Y/n]" if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') { + $_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') { @@ -2720,10 +2753,10 @@ exit 0 try { $_watchRootId = ([System.IO.File]::ReadAllText($_watchIdFile)).Trim() } catch {} } try { - $null = Start-Job -ScriptBlock $_browserWatch -ArgumentList @($_watchRootId, 8888) + $null = Start-Job -ScriptBlock $_browserWatch -ArgumentList @($_watchRootId, $_launchPort) } catch {} } - & $UnslothExe studio -p 8888 + & $UnslothExe studio -p $_launchPort } else { step "launch" "to start later, run:" substep "unsloth studio -p 8888" diff --git a/install.sh b/install.sh index fd7ca805c0..2a36b07ee3 100755 --- a/install.sh +++ b/install.sh @@ -3295,10 +3295,48 @@ 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, open the browser per the persisted preference (mirrors -# the desktop launcher). Guarded by the per-install root id so a different -# Studio already on the port is never the one opened. +# 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" @@ -3353,10 +3391,14 @@ 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 8888 + _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 @@ -3366,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