diff --git a/README.md b/README.md index e1b7e448ef..c85e308c2a 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,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): @@ -167,7 +168,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 @@ -180,7 +181,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 @@ -193,11 +194,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: @@ -208,11 +209,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 3fc9ac4690..55f5539659 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1109,15 +1109,25 @@ shell.Run cmd, 0, False } Refresh-SessionPath # sync current session with registry - # 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 6915893ddf..c2e3125bcd 100755 --- a/install.sh +++ b/install.sh @@ -486,11 +486,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" @@ -1644,28 +1644,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", @@ -391,8 +390,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..38242bbbf2 --- /dev/null +++ b/studio/backend/tests/test_host_defaults.py @@ -0,0 +1,108 @@ +# 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..899e51a11d --- /dev/null +++ b/tests/studio/test_cli_studio_defaults.py @@ -0,0 +1,106 @@ +"""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 a3c0840be1..3f927f5fb0 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -150,7 +150,7 @@ def _load_model_via_http( 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"), ):