From b4cae48a0af424ae95a1ca5a823ca46c4bc556df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 16:13:59 +0000 Subject: [PATCH 1/2] security: default Studio host to 127.0.0.1 and prompt before auto-start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4684. Previously Unsloth Studio bound to 0.0.0.0 (all interfaces) by default and the installer silently auto-started a server at the end of install, contradicting the documented "privacy first / 100% offline and locally" guarantee and exposing the service on the network without user consent. Changes: - studio/backend/run.py: change run_server() default host to 127.0.0.1; extract argparse setup into _make_argument_parser() for testability - unsloth_cli/commands/studio.py: change typer.Option default to 127.0.0.1 - install.sh: remove -H 0.0.0.0 from generated launch-studio.sh launcher template; replace silent auto-start with a [Y/n] prompt - install.ps1: replace silent auto-start with a Read-Host [Y/n] prompt - README.md: simplify launch examples to `unsloth studio` (no -H flag); note that -H 0.0.0.0 is available for cloud/network use Users who need all-interface binding (cloud VMs, LAN sharing) can still pass -H 0.0.0.0 explicitly. No other logic was changed: _is_port_free(), startup_banner, and health-check paths all already handle 127.0.0.1 correctly. Tests (TDD — written before implementation): - studio/backend/tests/test_host_defaults.py: AST inspection of run_server() parameter default and argparse --host default - tests/studio/test_cli_studio_defaults.py: AST inspection of typer.Option default for studio_default() --host parameter - tests/sh/test_install_host_defaults.sh: static analysis of installer scripts and README launch section https://claude.ai/code/session_012umxRmBdeDV5U7Xhm1utu6 --- README.md | 15 +-- install.ps1 | 18 +++- install.sh | 54 +++++++---- studio/backend/run.py | 41 +++++--- studio/backend/tests/test_host_defaults.py | 103 ++++++++++++++++++++ tests/sh/test_install_host_defaults.sh | 98 +++++++++++++++++++ tests/studio/test_cli_studio_defaults.py | 104 +++++++++++++++++++++ unsloth_cli/commands/studio.py | 2 +- 8 files changed, 389 insertions(+), 46 deletions(-) create mode 100644 studio/backend/tests/test_host_defaults.py create mode 100755 tests/sh/test_install_host_defaults.sh create mode 100644 tests/studio/test_cli_studio_defaults.py diff --git a/README.md b/README.md index 7046a2af7c..5dbe099344 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ irm https://unsloth.ai/install.ps1 | iex #### Launch ```bash -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` +> For cloud VMs or LAN access, add `-H 0.0.0.0` to bind on all interfaces. #### Update To update, use the same install commands as above. Or run (does not work on Windows): @@ -151,7 +152,7 @@ The below advanced instructions are for Unsloth Studio. For Unsloth Core advance git clone https://github.com/unslothai/unsloth cd unsloth ./install.sh --local -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` Then to update : ```bash @@ -164,7 +165,7 @@ git clone https://github.com/unslothai/unsloth.git cd unsloth Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` Then to update : ```bash @@ -177,11 +178,11 @@ git clone https://github.com/unslothai/unsloth cd unsloth git checkout nightly ./install.sh --local -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` Then to launch every time: ```bash -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` #### Nightly: Windows: @@ -192,11 +193,11 @@ cd unsloth git checkout nightly Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` Then to launch every time: ```bash -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio ``` #### Uninstall diff --git a/install.ps1 b/install.ps1 index 5ddb42ea7e..de3946f8df 100644 --- a/install.ps1 +++ b/install.ps1 @@ -958,15 +958,25 @@ shell.Run cmd, 0, False step "path" "added unsloth to PATH" } - # Launch studio automatically in interactive terminals; - # in non-interactive environments (CI, Docker) just print instructions. + # 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) if ($IsInteractive) { - & $UnslothExe studio -H 0.0.0.0 -p 8888 + Write-Host "" + $reply = Read-Host " Start Unsloth Studio now? [Y/n]" + if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') { + & $UnslothExe studio -p 8888 + } else { + step "launch" "to start later, run:" + substep "unsloth studio" + substep "(add -H 0.0.0.0 to allow network / cloud access)" + Write-Host "" + } } else { step "launch" "manual commands:" substep "& `"$VenvDir\Scripts\Activate.ps1`"" - substep "unsloth studio -H 0.0.0.0 -p 8888" + substep "unsloth studio" + substep "(add -H 0.0.0.0 to allow network / cloud access)" Write-Host "" } } diff --git a/install.sh b/install.sh index 053f334d2b..28b05d4a95 100755 --- a/install.sh +++ b/install.sh @@ -447,11 +447,11 @@ if [ -t 1 ]; then ) & # Clear traps so exec does not trigger _release_lock (the subshell owns it) trap - EXIT INT TERM - exec "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port" + exec "$UNSLOTH_EXE" studio -p "$_launch_port" else # ── Background mode (no TTY) ── # Used by macOS .app and headless invocations. - _launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port") + _launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -p "$_launch_port") _launch_cmd=${_launch_cmd% } _spawn_terminal "$_launch_cmd" @@ -1211,28 +1211,42 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!" printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" -# Launch studio automatically in interactive terminals; -# in non-interactive environments (Docker, CI, cloud-init) just print instructions. +# In interactive terminals, ask the user before starting Studio. +# In non-interactive environments (Docker, CI, cloud-init) just print instructions. if [ -t 1 ]; then - step "launch" "starting Unsloth Studio..." - "$VENV_DIR/bin/unsloth" studio -H 0.0.0.0 -p 8888 - _LAUNCH_EXIT=$? - if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then - echo "" - echo "⚠️ Unsloth Studio failed to start after migration." - echo " Your migrated environment may be incompatible." - echo " To fix, remove the environment and reinstall:" - echo "" - echo " rm -rf $VENV_DIR" - echo " curl -fsSL https://unsloth.ai/install.sh | sh" - echo "" - fi - exit "$_LAUNCH_EXIT" + echo "" + printf " Start Unsloth Studio now? [Y/n] " + read -r _reply "argparse.ArgumentParser": + """Build and return the ArgumentParser for standalone run.py execution. - # Ensure stderr can handle Unicode on Windows (tracebacks with non-ASCII paths) - if sys.platform == "win32" and hasattr(sys.stderr, "reconfigure"): - try: - sys.stderr.reconfigure(encoding = "utf-8", errors = "replace") - except Exception: - pass + Extracted from ``__main__`` so it can be imported and tested without + executing the full startup sequence. + """ + import argparse parser = argparse.ArgumentParser(description = "Run Unsloth UI Backend server") - parser.add_argument("--host", default = "0.0.0.0", help = "Host to bind to") + parser.add_argument( + "--host", + default = "127.0.0.1", + help = "Host to bind to (default: 127.0.0.1; use 0.0.0.0 for network/cloud access)", + ) parser.add_argument("--port", type = int, default = 8888, help = "Port to bind to") parser.add_argument( "--frontend", @@ -380,8 +379,22 @@ def _trigger_shutdown(): help = "Path to frontend build", ) parser.add_argument("--silent", action = "store_true", help = "Suppress output") + return parser + + +# For direct execution (also invoked by CLI via os.execvp / subprocess) +if __name__ == "__main__": + import signal + import traceback + + # Ensure stderr can handle Unicode on Windows (tracebacks with non-ASCII paths) + if sys.platform == "win32" and hasattr(sys.stderr, "reconfigure"): + try: + sys.stderr.reconfigure(encoding = "utf-8", errors = "replace") + except Exception: + pass - args = parser.parse_args() + args = _make_argument_parser().parse_args() kwargs = dict(host = args.host, port = args.port, silent = args.silent) if args.frontend is not None: diff --git a/studio/backend/tests/test_host_defaults.py b/studio/backend/tests/test_host_defaults.py new file mode 100644 index 0000000000..92d16d3464 --- /dev/null +++ b/studio/backend/tests/test_host_defaults.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests that Unsloth Studio defaults to 127.0.0.1 (loopback) not 0.0.0.0. + +TDD: these tests should FAIL before the host default changes are applied, +and PASS afterwards. + +Uses AST parsing to inspect source-level defaults without requiring the +full studio venv (run.py has heavy dependencies like structlog/uvicorn). + +Run with: + cd studio/backend + python -m pytest tests/test_host_defaults.py -v +""" + +import ast +from pathlib import Path + +import pytest + +_RUN_PY = Path(__file__).resolve().parent.parent / "run.py" + + +def _parse_function_param_defaults(source: str, func_name: str) -> dict: + """Return {param_name: default_value} for a named function in *source*. + + Only handles ast.Constant defaults (strings, ints, bools). + """ + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name: + result = {} + all_args = node.args.args + defaults = node.args.defaults + # Defaults are right-aligned against the args list + offset = len(all_args) - len(defaults) + for i, default in enumerate(defaults): + arg_name = all_args[offset + i].arg + if isinstance(default, ast.Constant): + result[arg_name] = default.value + return result + return {} + + +def _parse_argparse_add_argument_default(source: str, option_name: str) -> "str | None": + """Return the 'default' kwarg value for add_argument(option_name, ...) in *source*. + + Only handles ast.Constant defaults. + """ + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + # Match: parser.add_argument("--host", ...) + func = node.func + if not (isinstance(func, ast.Attribute) and func.attr == "add_argument"): + continue + if not node.args: + continue + first_arg = node.args[0] + if not (isinstance(first_arg, ast.Constant) and first_arg.value == option_name): + continue + for kw in node.keywords: + if kw.arg == "default" and isinstance(kw.value, ast.Constant): + return kw.value.value + return None + + +def test_run_server_default_host_is_loopback(): + """run_server() parameter default for 'host' must be 127.0.0.1, not 0.0.0.0. + + Binding to 0.0.0.0 by default exposes the service on all network + interfaces, contradicting the documented "privacy first / 100% local" + guarantee. Loopback (127.0.0.1) is the least-permissive default; + users who need network access can pass -H 0.0.0.0 explicitly. + """ + source = _RUN_PY.read_text() + defaults = _parse_function_param_defaults(source, "run_server") + assert "host" in defaults, "run_server() must have a 'host' parameter with a default" + host_default = defaults["host"] + assert host_default == "127.0.0.1", ( + f"run_server() host default must be '127.0.0.1' (loopback) " + f"but got '{host_default}'. Binding to '{host_default}' by default " + f"exposes the service beyond localhost." + ) + + +def test_argparse_default_host_is_loopback(): + """_make_argument_parser() --host add_argument default must be 127.0.0.1. + + When run.py is invoked directly (python run.py), the argparse default + should match the function default so direct execution is equally safe. + """ + source = _RUN_PY.read_text() + host_default = _parse_argparse_add_argument_default(source, "--host") + assert host_default is not None, ( + "Could not find add_argument('--host', ...) in run.py; " + "ensure _make_argument_parser() contains it." + ) + assert host_default == "127.0.0.1", ( + f"run.py argparse --host default must be '127.0.0.1', got '{host_default}'" + ) diff --git a/tests/sh/test_install_host_defaults.sh b/tests/sh/test_install_host_defaults.sh new file mode 100755 index 0000000000..6f5aa2cc04 --- /dev/null +++ b/tests/sh/test_install_host_defaults.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Static analysis: installer scripts and README must not hard-code 0.0.0.0 as default launch command. +# TDD: fails against current code, passes after host-default changes are applied. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +INSTALL_PS1="$SCRIPT_DIR/../../install.ps1" +README="$SCRIPT_DIR/../../README.md" +PASS=0 +FAIL=0 + +assert_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF -- "$_needle"; 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 echo "$_haystack" | grep -qF -- "$_needle"; 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 block that generates launch-studio.sh. +# The block runs from the line containing 'cat > "$_css_launcher"' +# to the LAUNCHER_EOF terminator. +_launcher=$(awk '/cat > "\$_css_launcher"/{found=1} found{print} /^LAUNCHER_EOF$/{found=0}' "$INSTALL_SH") +# Verify the extraction worked (block must contain the shebang line) +assert_contains \ + "launcher template: extraction found the heredoc content" \ + "$_launcher" "#!/usr/bin/env bash" +# The generated launcher is only useful for local (desktop) use, so it must +# not hard-code 0.0.0.0 — the default 127.0.0.1 should be used instead. +assert_not_contains \ + "launcher template: no hardcoded 'studio -H 0.0.0.0'" \ + "$_launcher" "studio -H 0.0.0.0" + +echo "" +echo "=== install.sh end-of-install block ===" + +_end=$(tail -40 "$INSTALL_SH") +# The interactive path must prompt the user before starting Studio. +assert_contains \ + "install.sh: interactive block prompts user (bash read)" \ + "$_end" "read" +# The actual launch command and manual-instructions hint must not include +# 'studio -H 0.0.0.0' as the primary invocation. +assert_not_contains \ + "install.sh: no 'studio -H 0.0.0.0' in end-of-install commands" \ + "$_end" "studio -H 0.0.0.0" + +echo "" +echo "=== install.ps1 end-of-install block ===" + +_ps1_end=$(tail -20 "$INSTALL_PS1") +assert_contains \ + "install.ps1: interactive block prompts user (Read-Host)" \ + "$_ps1_end" "Read-Host" +assert_not_contains \ + "install.ps1: no 'studio -H 0.0.0.0' in end-of-install commands" \ + "$_ps1_end" "studio -H 0.0.0.0" + +echo "" +echo "=== README.md Launch section ===" + +# The primary Launch example must show 'unsloth studio' without -H 0.0.0.0 +# (0.0.0.0 may appear later as an opt-in note, but not in the code block). +_readme_launch=$(awk '/#### Launch/{found=1} found{print} /^####/{if(!/#### Launch/)found=0}' "$README" | head -10) +assert_contains \ + "README: Launch section exists" \ + "$_readme_launch" "unsloth studio" +assert_not_contains \ + "README: Launch section primary command has no -H 0.0.0.0" \ + "$_readme_launch" "studio -H 0.0.0.0" + +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED" diff --git a/tests/studio/test_cli_studio_defaults.py b/tests/studio/test_cli_studio_defaults.py new file mode 100644 index 0000000000..d4f063575a --- /dev/null +++ b/tests/studio/test_cli_studio_defaults.py @@ -0,0 +1,104 @@ +"""Tests that the 'unsloth studio' CLI defaults to 127.0.0.1. + +TDD: these tests should FAIL before the CLI host default is changed, +and PASS afterwards. + +Uses AST parsing to inspect source-level defaults without requiring the +full unsloth_cli dependencies (pydantic, etc. are not available in unit +test environments without the full venv). + +Run with: + python -m pytest tests/studio/test_cli_studio_defaults.py -v +""" + +import ast +from pathlib import Path + +import pytest + +_STUDIO_CMD_PY = Path(__file__).resolve().parents[2] / "unsloth_cli" / "commands" / "studio.py" + + +def _find_typer_option_default(source: str, func_name: str, long_option: str): + """Return the default value of a typer.Option() parameter in *func_name*. + + Matches by the long option name (e.g. '--host') among the positional args + of the typer.Option() call and returns the first arg (the default value). + Only handles ast.Constant defaults (strings, ints, bools). + """ + tree = ast.parse(source) + for func_node in ast.walk(tree): + if not isinstance(func_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if func_node.name != func_name: + continue + args = func_node.args.args + defaults = func_node.args.defaults + offset = len(args) - len(defaults) + for i, default in enumerate(defaults): + if not isinstance(default, ast.Call): + continue + # Identify this Option by the presence of the long option name string + has_long_option = any( + isinstance(a, ast.Constant) and a.value == long_option + for a in default.args + ) + if not has_long_option: + continue + # First positional arg is the default value + if default.args and isinstance(default.args[0], ast.Constant): + return default.args[0].value + return None + + +def test_studio_cli_default_host_is_loopback(): + """'unsloth studio' with no -H flag must default to 127.0.0.1, not 0.0.0.0. + + The typer.Option default is the value forwarded as --host when the user + invokes 'unsloth studio' without an explicit -H flag. Defaulting to + 0.0.0.0 exposes the service on all interfaces without the user opting in. + """ + source = _STUDIO_CMD_PY.read_text() + host_default = _find_typer_option_default(source, "studio_default", "--host") + assert host_default is not None, ( + "Could not find typer.Option('...', '--host', ...) in studio_default(); " + "check that the parameter exists with a long-form '--host' option." + ) + assert host_default == "127.0.0.1", ( + f"studio_default() host typer.Option default must be '127.0.0.1', " + f"got '{host_default}'. This is the value forwarded to run.py when " + f"the user invokes 'unsloth studio' without -H." + ) + + +def test_studio_cli_option_accepts_explicit_override(): + """The --host option must still accept arbitrary values like 0.0.0.0. + + Verify that the option is not locked to 127.0.0.1 — the default should + be loopback but users must be able to opt in to all-interfaces binding. + This is confirmed by the option having a configurable typer.Option (not + a hard-coded constant), identified by the '--host'/'-H' option names. + """ + source = _STUDIO_CMD_PY.read_text() + # Confirm the option exists (is a typer.Option call, not a plain default) + tree = ast.parse(source) + found_option_call = False + for func_node in ast.walk(tree): + if not isinstance(func_node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if func_node.name != "studio_default": + continue + for default in func_node.args.defaults: + if not isinstance(default, ast.Call): + continue + has_host_option = any( + isinstance(a, ast.Constant) and a.value in ("--host", "-H") + for a in default.args + ) + if has_host_option: + found_option_call = True + break + assert found_option_call, ( + "studio_default() 'host' parameter must be a typer.Option() call with " + "'--host'/'-H' flags so users can override it; plain default found instead." + ) diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 2fecb9d6b1..6ebc6e7325 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -76,7 +76,7 @@ def _find_setup_script() -> Optional[Path]: def studio_default( ctx: typer.Context, port: int = typer.Option(8888, "--port", "-p"), - host: str = typer.Option("0.0.0.0", "--host", "-H"), + host: str = typer.Option("127.0.0.1", "--host", "-H"), frontend: Optional[Path] = typer.Option(None, "--frontend", "-f"), silent: bool = typer.Option(False, "--silent", "-q"), ): From e445b0d6c27755895a5e794348f01d45e3d78a58 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 02:59:05 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_host_defaults.py | 15 ++++++++++----- tests/studio/test_cli_studio_defaults.py | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/studio/backend/tests/test_host_defaults.py b/studio/backend/tests/test_host_defaults.py index 92d16d3464..38242bbbf2 100644 --- a/studio/backend/tests/test_host_defaults.py +++ b/studio/backend/tests/test_host_defaults.py @@ -29,7 +29,10 @@ def _parse_function_param_defaults(source: str, func_name: str) -> dict: """ tree = ast.parse(source) for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == func_name: + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == func_name + ): result = {} all_args = node.args.args defaults = node.args.defaults @@ -77,7 +80,9 @@ def test_run_server_default_host_is_loopback(): """ source = _RUN_PY.read_text() defaults = _parse_function_param_defaults(source, "run_server") - assert "host" in defaults, "run_server() must have a 'host' parameter with a default" + assert ( + "host" in defaults + ), "run_server() must have a 'host' parameter with a default" host_default = defaults["host"] assert host_default == "127.0.0.1", ( f"run_server() host default must be '127.0.0.1' (loopback) " @@ -98,6 +103,6 @@ def test_argparse_default_host_is_loopback(): "Could not find add_argument('--host', ...) in run.py; " "ensure _make_argument_parser() contains it." ) - assert host_default == "127.0.0.1", ( - f"run.py argparse --host default must be '127.0.0.1', got '{host_default}'" - ) + assert ( + host_default == "127.0.0.1" + ), f"run.py argparse --host default must be '127.0.0.1', got '{host_default}'" diff --git a/tests/studio/test_cli_studio_defaults.py b/tests/studio/test_cli_studio_defaults.py index d4f063575a..899e51a11d 100644 --- a/tests/studio/test_cli_studio_defaults.py +++ b/tests/studio/test_cli_studio_defaults.py @@ -16,7 +16,9 @@ import pytest -_STUDIO_CMD_PY = Path(__file__).resolve().parents[2] / "unsloth_cli" / "commands" / "studio.py" +_STUDIO_CMD_PY = ( + Path(__file__).resolve().parents[2] / "unsloth_cli" / "commands" / "studio.py" +) def _find_typer_option_default(source: str, func_name: str, long_option: str):