Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
18 changes: 14 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The manual command hint correctly suggests adding -H 0.0.0.0 for network access. However, there is an inconsistency in the New-StudioShortcuts function (specifically the $launcherContent here-string at line 381), which still has -H 0.0.0.0 hardcoded in the $studioCommand variable. This should be updated to match the new security defaults so that the desktop shortcut also binds to 127.0.0.1 by default, ensuring consistency with the changes made to the install.sh launcher template.

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 ""
}
}
Expand Down
54 changes: 34 additions & 20 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 </dev/tty || _reply="y"
case "${_reply:-y}" in
[Yy]*|"")
step "launch" "starting Unsloth Studio..."
"$VENV_DIR/bin/unsloth" studio -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"
;;
*)
step "launch" "to start later, run:"
substep "unsloth studio"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
echo ""
;;
esac
else
step "launch" "manual commands:"
substep "unsloth studio -H 0.0.0.0 -p 8888"
substep "unsloth studio"
substep "or activate env first:"
substep "source ${VENV_DIR}/bin/activate"
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)"
echo ""
fi
41 changes: 27 additions & 14 deletions studio/backend/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def _graceful_shutdown(server = None):


def run_server(
host: str = "0.0.0.0",
host: str = "127.0.0.1",
port: int = 8888,
frontend_path: Path = Path(__file__).resolve().parent.parent / "frontend" / "dist",
silent: bool = False,
Expand Down Expand Up @@ -368,21 +368,20 @@ def _trigger_shutdown():
return app


# For direct execution (also invoked by CLI via os.execvp / subprocess)
if __name__ == "__main__":
import argparse
import signal
import traceback
def _make_argument_parser() -> "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",
Expand All @@ -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:
Expand Down
108 changes: 108 additions & 0 deletions studio/backend/tests/test_host_defaults.py
Original file line number Diff line number Diff line change
@@ -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}'"
Loading