diff --git a/README.md b/README.md index e1b7e448ef..6c12b28096 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 -p 8888 ``` +> 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 -p 8888 ``` 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 -p 8888 ``` 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 -p 8888 ``` Then to launch every time: ```bash -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio -p 8888 ``` #### 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 -p 8888 ``` Then to launch every time: ```bash -unsloth studio -H 0.0.0.0 -p 8888 +unsloth studio -p 8888 ``` #### Uninstall diff --git a/install.ps1 b/install.ps1 index ab43bcdc52..7dc5a50250 100644 --- a/install.ps1 +++ b/install.ps1 @@ -552,7 +552,7 @@ try { } catch {} exit 1 } - `$studioCommand = '& "' + `$studioExe + '" studio -H 0.0.0.0 -p ' + `$launchPort + `$studioCommand = '& "' + `$studioExe + '" studio -p ' + `$launchPort `$launchArgs = @( '-NoExit', '-NoProfile', @@ -1344,15 +1344,25 @@ shell.Run cmd, 0, False New-StudioShortcuts -UnslothExePath $UnslothExe - # 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 -p 8888" + 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 -p 8888" + substep "(add -H 0.0.0.0 to allow network / cloud access)" Write-Host "" } } diff --git a/install.sh b/install.sh index 4f17ea80cb..7948170043 100755 --- a/install.sh +++ b/install.sh @@ -622,11 +622,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" @@ -1847,28 +1847,46 @@ 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 "" + echo "" + printf " Start Unsloth Studio now? [Y/n] " + if [ -r /dev/tty ]; then + read -r _reply 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): + """Return the 'default' kwarg value for add_argument(option_name, ...) in *source*. + + Walks the entire module so the call can live in __main__ or in a helper + function — only handles ast.Constant defaults. + """ + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + 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(): + """argparse --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" + 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/studio/setup.sh b/studio/setup.sh index 39fb566f1b..3e875eed30 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1087,10 +1087,11 @@ else fi printf " ${C_DIM}%s${C_RST}\n" "$RULE" if [ "$_LLAMA_CPP_DEGRADED" = true ]; then - printf " ${C_DIM}%-15s${C_WARN}%s${C_RST}\n" "launch" "unsloth studio -H 0.0.0.0 -p 8888" + printf " ${C_DIM}%-15s${C_WARN}%s${C_RST}\n" "launch" "unsloth studio -p 8888" else - printf " ${C_DIM}%-15s${C_OK}%s${C_RST}\n" "launch" "unsloth studio -H 0.0.0.0 -p 8888" + printf " ${C_DIM}%-15s${C_OK}%s${C_RST}\n" "launch" "unsloth studio -p 8888" fi + printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 to allow network / cloud access)" fi echo "" diff --git a/tests/sh/test_install_host_defaults.sh b/tests/sh/test_install_host_defaults.sh new file mode 100755 index 0000000000..7b01eaa716 --- /dev/null +++ b/tests/sh/test_install_host_defaults.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Static analysis: installer scripts and README must not hard-code 0.0.0.0 +# in any user-visible default launch command. The dynamic-port launcher +# templates and post-install hints should rely on the new loopback default. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +INSTALL_PS1="$SCRIPT_DIR/../../install.ps1" +SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh" +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 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: extraction found the heredoc content" \ + "$_launcher" "#!/usr/bin/env bash" +# The desktop launcher should rely on the new 127.0.0.1 default. +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 -50 "$INSTALL_SH") +assert_contains \ + "install.sh: interactive block prompts user (read)" \ + "$_end" "read" +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 -25 "$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 "=== studio/setup.sh launch hint ===" + +_setup_tail=$(tail -30 "$SETUP_SH") +assert_not_contains \ + "studio/setup.sh: launch hint has no '-H 0.0.0.0'" \ + "$_setup_tail" "studio -H 0.0.0.0" + +echo "" +echo "=== README.md Launch section ===" + +# The primary Launch example must not include -H 0.0.0.0; the LAN/cloud +# note appears as an opt-in line outside the code block. +_readme_launch=$(awk '/^#### Launch$/{found=1} found{print} /^#### Update$/{found=0}' "$README") +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" +assert_contains \ + "README: Launch section documents -H 0.0.0.0 opt-in" \ + "$_readme_launch" "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..4d6f28f075 --- /dev/null +++ b/tests/studio/test_cli_studio_defaults.py @@ -0,0 +1,87 @@ +"""Tests that the 'unsloth studio' CLI defaults to 127.0.0.1. + +Uses AST parsing to inspect source-level defaults without requiring the +full unsloth_cli dependencies (typer/pydantic) at test-collection time. +""" + +import ast +from pathlib import Path + +_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 positional arg (the + default value). Only handles ast.Constant defaults. + """ + 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 + # Walk both regular args and kwonly args, each paired with its default. + all_args = func_node.args.args + func_node.args.kwonlyargs + all_defaults = func_node.args.defaults + [ + d for d in func_node.args.kw_defaults if d is not None + ] + # ast pads defaults right-aligned against args (ignoring kwonly). We + # iterate calls directly, which is simpler and robust. + for default in all_defaults: + if not isinstance(default, ast.Call): + continue + call_func = default.func + is_typer_option = ( + isinstance(call_func, ast.Attribute) + and call_func.attr == "Option" + and isinstance(call_func.value, ast.Name) + and call_func.value.id == "typer" + ) + if not is_typer_option: + continue + # First positional is the default value; remaining positionals are + # option flags like "--host", "-H". + if not default.args: + continue + flags = [ + a.value + for a in default.args[1:] + if isinstance(a, ast.Constant) and isinstance(a.value, str) + ] + if long_option not in flags: + continue + first = default.args[0] + if isinstance(first, ast.Constant): + return first.value + return None + + +def test_studio_default_host_is_loopback(): + """`unsloth studio` (studio_default) --host typer Option default must be 127.0.0.1.""" + 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 --host typer.Option default in studio_default()" + assert host_default == "127.0.0.1", ( + f"studio_default() --host default must be '127.0.0.1' (loopback) " + f"but got '{host_default}'." + ) + + +def test_studio_run_host_is_loopback(): + """`unsloth studio run` --host typer Option default must be 127.0.0.1.""" + source = _STUDIO_CMD_PY.read_text() + host_default = _find_typer_option_default(source, "run", "--host") + assert ( + host_default is not None + ), "Could not find --host typer.Option default in run()" + assert host_default == "127.0.0.1", ( + f"`unsloth studio run` --host default must be '127.0.0.1' (loopback) " + f"but got '{host_default}'." + ) diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index b74f42674d..ac25c8805d 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -414,7 +414,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"), api_only: bool = typer.Option( @@ -528,7 +528,7 @@ def run( "cli", "--api-key-name", help = "Label for the auto-generated API key" ), 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"), ):