diff --git a/README.md b/README.md index 5f1630e2ba..ef45b91430 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Use the same command to update. ```bash unsloth studio -p 8888 ``` -For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. +For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally. 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). @@ -212,10 +212,23 @@ By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach i ```bash unsloth studio --secure -p 8888 ``` -- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. This also starts a public Cloudflare quick tunnel by default, which publishes an internet-reachable `https://*.trycloudflare.com` URL even behind a firewall. Both the raw port and the tunnel expose Studio beyond this machine, so only use this on a network you trust; pass `--no-cloudflare` to drop the public link while keeping the network bind. +- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust. ```bash unsloth studio -H 0.0.0.0 -p 8888 ``` +The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind. + +The first time Studio is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Studio shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI. + +For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`): + +```bash +unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history +UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var +printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin +``` + +A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process. Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio. diff --git a/install.ps1 b/install.ps1 index 0b32d7cb6c..4fa01bfa28 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2628,8 +2628,8 @@ exit 0 } else { step "launch" "to start later, run:" substep "unsloth studio -p 8888" - substep "(add -H 0.0.0.0 to allow network / cloud access)" - substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" Write-Host "" } } else { @@ -2649,8 +2649,8 @@ exit 0 substep "& $_actLiteral" substep "unsloth studio -p 8888" } - substep "(add -H 0.0.0.0 to allow network / cloud access)" - substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" Write-Host "" } } diff --git a/install.sh b/install.sh index 3bf6fd1855..f277d0cbfd 100755 --- a/install.sh +++ b/install.sh @@ -3266,8 +3266,8 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then *) step "launch" "to start later, run:" substep "unsloth studio -p 8888" - substep "(add -H 0.0.0.0 to allow network / cloud access)" - substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" echo "" ;; esac @@ -3288,7 +3288,7 @@ else substep "source $_li_act_q" substep "unsloth studio -p 8888" fi - substep "(add -H 0.0.0.0 to allow network / cloud access)" - substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" + substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" echo "" fi diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index a0da2b2096..9bb3ab5735 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -18,6 +18,10 @@ from utils.paths import auth_db_path, ensure_dir DB_PATH = auth_db_path() DEFAULT_ADMIN_USERNAME = "unsloth" +# Single source for the password policy; models/auth.py ChangePasswordRequest +# and the terminal prompt both enforce it. Keep the unsloth_cli mirror in sync. +MIN_PASSWORD_LENGTH = 8 + # Plaintext bootstrap password file beside auth.db, deleted on first password # change so the credential never lingers on disk. _BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password" @@ -79,11 +83,42 @@ def _load_bootstrap_password() -> Optional[str]: def clear_bootstrap_password() -> None: - """Delete the persisted bootstrap password file (called after password change).""" + """Delete the persisted bootstrap password file (after a password change). + + Best-effort: the new hash is already committed, so a locked/undeletable file + (Windows AV, read-only auth dir) must not fail the change. + """ global _bootstrap_password _bootstrap_password = None if _BOOTSTRAP_PW_PATH.is_file(): - _BOOTSTRAP_PW_PATH.unlink(missing_ok = True) + try: + _BOOTSTRAP_PW_PATH.unlink(missing_ok = True) + except OSError as e: + # Removal failed (Windows AV, read-only auth dir). The hash is already + # committed, so don't fail the change -- but truncate the file so its + # stale plaintext can't be re-seeded by generate_bootstrap_password() + # if a later reset-password deletes auth.db and re-validates it. + try: + _BOOTSTRAP_PW_PATH.write_text("") + cleared = True + except OSError: + cleared = False + import sys + + if cleared: + message = ( + f"Warning: could not delete {_BOOTSTRAP_PW_PATH.name} ({e}); " + "cleared its contents so the old bootstrap password cannot be reused." + ) + else: + # Neither removed nor truncated: stale plaintext is still on disk + # and would be reused if auth.db is reset. Don't claim otherwise. + message = ( + f"Warning: could not delete or clear {_BOOTSTRAP_PW_PATH.name} ({e}); " + "its old bootstrap password is still on disk. Remove it manually to " + "prevent reuse after a reset." + ) + print(message, file = sys.stderr, flush = True) def _hash_token(token: str) -> str: @@ -547,8 +582,18 @@ def ensure_default_admin() -> bool: return False -def update_password(username: str, new_password: str) -> bool: - """Update password, clear first-login requirement, rotate JWT secret.""" +def update_password( + username: str, + new_password: str, + *, + revoke_refresh_tokens: bool = False, +) -> bool: + """Update password, clear first-login requirement, rotate JWT secret. + + ``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME + transaction: a separate delete could fail after the password commit and + leave a pre-change token still able to mint access tokens. + """ from .hashing import hash_password salt, pwd_hash = hash_password(new_password) @@ -563,6 +608,8 @@ def update_password(username: str, new_password: str) -> bool: """, (salt, pwd_hash, jwt_secret, username), ) + if revoke_refresh_tokens and cursor.rowcount > 0: + conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) conn.commit() if cursor.rowcount > 0: clear_bootstrap_password() diff --git a/studio/backend/auth/terminal_prompt.py b/studio/backend/auth/terminal_prompt.py new file mode 100644 index 0000000000..8491019ae9 --- /dev/null +++ b/studio/backend/auth/terminal_prompt.py @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Interactive terminal prompt that forces a bootstrap password change before +Studio is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``). + +Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on +Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so +redirected stdout never swallows the prompt. + +Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI +cannot import the Studio backend package); keep the two in sync. +""" + +from __future__ import annotations + +import os +import sys +from typing import Callable, TextIO + +_CTRL_C = "\x03" +_CTRL_D = "\x04" +_CTRL_Z = "\x1a" +_BACKSPACES = ("\x7f", "\x08") +_SUBMITS = ("\r", "\n") + +# Env var that supplies the initial admin password non-interactively (mirror in +# unsloth_cli/commands/_password_prompt.py). Keep the name in sync. +SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD" + + +def _getch_windows() -> str: # pragma: no cover - exercised via fake on Linux CI + import msvcrt + + ch = msvcrt.getwch() + # Function/arrow keys arrive as a two-wchar \x00/\xe0 sequence; consume the + # second half and report a no-op control char. + if ch in ("\x00", "\xe0"): + msvcrt.getwch() + return "\x00" + return ch + + +class _RestoreTtyOnSignals: + """Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read. + + A finally block can't run when a signal terminates the process, leaving the + shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or + where the signals are absent. + """ + + def __init__(self, fd: int, old_attrs) -> None: + self._fd = fd + self._old_attrs = old_attrs + self._previous: list = [] + + def __enter__(self) -> "_RestoreTtyOnSignals": + import signal + import termios + + def _restore_and_reraise(signum, frame): + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + signal.signal(signum, signal.SIG_DFL) + signal.raise_signal(signum) + + for name in ("SIGTERM", "SIGHUP"): + sig = getattr(signal, name, None) + if sig is None: + continue + try: + self._previous.append((sig, signal.signal(sig, _restore_and_reraise))) + except (ValueError, OSError): # non-main thread / unsupported + pass + return self + + def __exit__(self, *exc) -> None: + import signal + for sig, previous in self._previous: + try: + signal.signal(sig, previous) + except (ValueError, OSError): + pass + + +class _prompt_raw_mode: + """Hold cbreak + cleared ISIG (no echo) on stdin for the WHOLE prompt line, + restoring when the line finishes (and on SIGTERM/SIGHUP). + + Echo must never re-enable mid-line: cbreak echoes on receipt, so a keystroke + arriving while echo is on would appear in cleartext. One cbreak block for the + whole line closes that window. No-op when stdin is not a real terminal, so + the _getch seam can be faked in tests. + """ + + def __enter__(self) -> "_prompt_raw_mode": + self._fd = None + self._old_attrs = None + self._signals = None + try: + import termios + import tty + except ImportError: # non-POSIX (Windows uses msvcrt, no mode to hold) + return self + try: + fd = sys.stdin.fileno() + old_attrs = termios.tcgetattr(fd) + except (AttributeError, ValueError, OSError, termios.error): + return self # redirected / captured stdin (tests): nothing to hold + self._fd = fd + self._old_attrs = old_attrs + self._signals = _RestoreTtyOnSignals(fd, old_attrs) + self._signals.__enter__() + # cbreak (not raw) keeps output post-processing while disabling echo/line + # buffering. It leaves ISIG on, so clear it and surface Ctrl-C as \x03 to + # the caller loop, which restores the tty itself. + tty.setcbreak(fd, termios.TCSADRAIN) + new_attrs = termios.tcgetattr(fd) + new_attrs[3] &= ~termios.ISIG + termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs) + return self + + def __exit__(self, *exc) -> None: + if self._old_attrs is None: + return + import termios + try: + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + finally: + if self._signals is not None: + self._signals.__exit__(*exc) + + +def _getch_posix() -> str: # pragma: no cover - needs a real tty + # Terminal already in cbreak+no-echo for the whole line (_prompt_raw_mode), + # so just read. Byte-at-a-time incremental decode so a multi-byte UTF-8 char + # straddling a read boundary isn't dropped. + import codecs + + fd = sys.stdin.fileno() + decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace") + while True: + b = os.read(fd, 1) + if not b: + return "" # stream EOF; caller raises EOFError + ch = decoder.decode(b) + if ch: + return ch + + +_getch: Callable[[], str] = _getch_windows if os.name == "nt" else _getch_posix + + +def _read_password(prompt: str, *, out: "TextIO | None" = None) -> str: + """Read one masked line: echo ``*`` per char, support backspace editing. + + Raises KeyboardInterrupt on Ctrl-C and EOFError on Ctrl-D/Ctrl-Z with an + empty buffer; the terminal is restored on every exit path. + """ + if out is None: + out = sys.stderr + out.write(prompt) + out.flush() + chars: list[str] = [] + with _prompt_raw_mode(): + while True: + key = _getch() + if key == "": # stream ended mid-line: abort, don't submit a partial + out.write("\n") + out.flush() + raise EOFError + for ch in key: # a paste can deliver several chars per read + if ch in _SUBMITS: + out.write("\n") + out.flush() + return "".join(chars) + if ch == _CTRL_C: + out.write("\n") + out.flush() + raise KeyboardInterrupt + if ch in (_CTRL_D, _CTRL_Z): + if not chars: + out.write("\n") + out.flush() + raise EOFError + continue # ignore mid-input + if ch in _BACKSPACES: + if chars: + chars.pop() + out.write("\b \b") + out.flush() + continue + if ch < " ": # other control characters (tab, escape, ...) + continue + chars.append(ch) + out.write("*") + out.flush() + + +def should_prompt_password_change( + *, tunnel_will_start: bool, requires_change: bool, stdin_isatty: bool, stderr_isatty: bool +) -> bool: + """Whether to block startup on an interactive terminal password change. + + True only when the tunnel is actually about to start, the admin still has + the seeded password, and both stdin and stderr are real terminals (headless + launches keep the bootstrap-timeout protection instead of hanging). + """ + return tunnel_will_start and requires_change and stdin_isatty and stderr_isatty + + +def prompt_for_password_change( + *, + min_length: int, + is_current_password: Callable[[str], bool], + apply_change: Callable[[str], None], + username: str = "unsloth", + out: "TextIO | None" = None, +) -> bool: + """Force a new admin password before public exposure; True on success. + + Loops until a valid, confirmed password is committed via ``apply_change``. + Ctrl-C / EOF returns False; the caller must then abort the launch. + """ + if out is None: + out = sys.stderr + out.write( + "\n" + "Unsloth Studio will be exposed on the public internet, so set a\n" + "password now. Ctrl+C to abort.\n\n" + ) + out.flush() + try: + while True: + new_password = _read_password("New password: ", out = out) + if len(new_password) < min_length: + out.write(f"Password must be at least {min_length} characters; try again.\n") + out.flush() + continue + if is_current_password(new_password): + out.write( + "New password must differ from the current bootstrap password; try again.\n" + ) + out.flush() + continue + confirmation = _read_password("Confirm new password: ", out = out) + if confirmation != new_password: + out.write("Passwords do not match; try again.\n") + out.flush() + continue + apply_change(new_password) + out.write(f"Password updated for '{username}'.\n") + out.flush() + return True + except (KeyboardInterrupt, EOFError): + out.write("Password change aborted; not exposing Studio.\n") + out.flush() + return False + + +def resolve_supplied_password(cli_value: "str | None", out: "TextIO | None" = None) -> "str | None": + """Resolve a non-interactive initial admin password, or None if unset. + + Precedence: an explicit ``--password`` (literal ``-`` reads a line from + stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off. + A literal argv value is visible in the process list, so a note points at the + env var or stdin instead. Mirror of the CLI helper -- keep the two in sync. + """ + if out is None: + out = sys.stderr + if cli_value == "-": + line = sys.stdin.readline() + if not line: + return None + return line.rstrip("\r\n") or None + if cli_value: + out.write( + "Note: --password is visible in the process list and shell history; " + f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n" + ) + out.flush() + return cli_value + return os.environ.get(SUPPLIED_PASSWORD_ENV) or None diff --git a/studio/backend/colab.py b/studio/backend/colab.py index dd274399bc..e04543b3aa 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -323,8 +323,8 @@ def start(port: int = 8888, *, cloudflare: bool = False): logger.info(" Starting server...") try: - # cloudflare=False: this helper owns the tunnel. run_server's default True - # would tunnel this 0.0.0.0 bind if Colab detection fails, breaking the opt-out. + # cloudflare=False: this helper owns the tunnel (Colab's own + # start(cloudflare=...) drives it), so pin it off explicitly. app = run_server( host = "0.0.0.0", port = port, diff --git a/studio/backend/main.py b/studio/backend/main.py index 8062b7b073..e64048dc00 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -560,8 +560,12 @@ async def lifespan(app: FastAPI): (_time.perf_counter() - _lifespan_started) * 1000, ) + # run_server's pre-bind gate sets suppress_bootstrap_injection when a public + # URL is about to serve with the default credential active: never (re)capture + # the bootstrap password into app.state, or the HTML would hand it out. + _suppress_bootstrap = getattr(app.state, "suppress_bootstrap_injection", False) if storage.ensure_default_admin(): - bootstrap_pw = storage.get_bootstrap_password() + bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password() app.state.bootstrap_password = bootstrap_pw bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password" @@ -572,7 +576,9 @@ async def lifespan(app: FastAPI): print(" Open the Studio UI to sign in and change it.") print("=" * 60 + "\n") else: - app.state.bootstrap_password = storage.get_bootstrap_password() + app.state.bootstrap_password = ( + None if _suppress_bootstrap else storage.get_bootstrap_password() + ) _lifespan_log.info( "lifespan startup completed in %.1fms", diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index f451f8d9dd..2283aa709f 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -7,6 +7,8 @@ from typing import Optional from pydantic import BaseModel, Field +from auth.storage import MIN_PASSWORD_LENGTH + class AuthLoginRequest(BaseModel): """Login payload: username/password to obtain a JWT.""" @@ -45,10 +47,14 @@ class ChangePasswordRequest(BaseModel): """Change the current user's password, typically on first login.""" current_password: str = Field( - ..., min_length = 8, description = "Existing password for the authenticated user" + ..., + min_length = MIN_PASSWORD_LENGTH, + description = "Existing password for the authenticated user", ) new_password: str = Field( - ..., min_length = 8, description = "Replacement password (minimum 8 characters)" + ..., + min_length = MIN_PASSWORD_LENGTH, + description = f"Replacement password (minimum {MIN_PASSWORD_LENGTH} characters)", ) diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index 92ecdbfb5b..c61c1a16e4 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -500,8 +500,9 @@ async def change_password( detail = "New password must be different from the current password", ) - storage.update_password(current_subject, payload.new_password) - storage.revoke_user_refresh_tokens(current_subject) + # Single transaction: a separate refresh-token purge could fail after the + # password commit, leaving pre-change tokens able to mint access tokens. + storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True) try: request.app.state.bootstrap_password = None except AttributeError: diff --git a/studio/backend/run.py b/studio/backend/run.py index 3efeac960e..56b9c78343 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -10,7 +10,7 @@ import os import sys import time from pathlib import Path -from typing import Optional +from typing import Optional, Tuple def _fix_torch_cuda_ld_path(): @@ -616,24 +616,28 @@ def _print_cloudflare_line(secure: bool = False, loopback_host: str = "127.0.0.1 f"bind {loopback_host} or close firewall access to keep Studio private.", warn, ) - elif not _cloudflare_flag: + elif _cloudflare_flag is False or _cloudflare_flag is None: + # None = off by default (no flag); False = explicit --no-cloudflare. + _reason = "default" if _cloudflare_flag is None else "--no-cloudflare" if _public_reachable is True: _emit( - " Cloudflare tunnel: OFF (--no-cloudflare). The raw port is still " + f" Cloudflare tunnel: OFF ({_reason}). The raw port is still " "reachable from the public internet (see the reachability check above): " - "--no-cloudflare disables only the Cloudflare link, not the public bind.", + "pass --cloudflare to also expose a public Cloudflare HTTPS link, or " + f"bind {loopback_host} to keep Studio private.", warn, ) elif _public_reachable is False: _emit( - " Cloudflare tunnel: OFF (--no-cloudflare). Studio is reachable on your " - "local network only. Omit --no-cloudflare to expose a public " + f" Cloudflare tunnel: OFF ({_reason}). Studio is reachable on your " + "local network only. Pass --cloudflare to expose a public " "Cloudflare HTTPS link." ) else: _emit( - " Cloudflare tunnel: OFF (--no-cloudflare). There is no Cloudflare " - "public link. Raw port reachability was not verified; " + f" Cloudflare tunnel: OFF ({_reason}). There is no Cloudflare " + "public link. Raw port reachability was not verified; pass --cloudflare " + "to expose a public Cloudflare HTTPS link, or " f"bind {loopback_host} or close firewall access to keep Studio private.", warn, ) @@ -874,7 +878,9 @@ _cloudflare_url = None _public_reachable = None _cloudflare_requested = False -_cloudflare_flag = True +# Opt-in tri-state (mirrors the CLI): None = off by default, True = on, +# False = explicit --no-cloudflare. run_server overwrites it before the banner. +_cloudflare_flag = None _DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist" @@ -1057,6 +1063,199 @@ def _cloudflare_tunnel_should_start( return host in ("0.0.0.0", "::") and not api_only +def _stream_isatty(stream) -> bool: + """isatty() that treats broken streams as non-interactive. + + isatty() can raise under service wrappers (closed stdin -> ValueError; + sys.stdin None in Windows GUI -> AttributeError); such a stream can't host a + prompt, which is a fallback, not an error. + """ + try: + return stream.isatty() + except (AttributeError, ValueError): + return False + + +def _terminal_password_gate( + *, + tunnel_will_start: bool, + host: str, + secure: bool, + api_only: bool, + frontend_served: bool, + is_colab: bool = False, +) -> Tuple[bool, bool]: + """Force a terminal password change before the public tunnel goes up. + + When the tunnel is about to publish Studio and the seeded admin password was + never changed, ask for a new one (masked, confirmed) before any public URL + exists. The CLI normally does this before re-exec'ing the backend; this is + the backstop for direct `python run.py` launches and older-CLI installs. + Must run BEFORE the uvicorn socket binds: on a wildcard bind the served HTML + injects the bootstrap credential, so a pre-gate listener would hand the + default password to anyone reaching the raw port while the operator types. + + Returns (proceed, drop_bootstrap_injection): + proceed False -> abort the launch (interactive refusal, or a headless + public launch nothing would protect); fail closed. + drop_bootstrap_injection True -> caller must null + app.state.bootstrap_password: the password just changed (stale), or a + public URL is about to serve the default credential and must not leak it. + + Without a usable terminal the prompt is skipped: proceed if the bootstrap + deadline (armed later) will protect the launch; if even that is disabled + (api-only, timeout 0) nothing protects it, so refuse. NOT wrapped in a broad + try/except: an auth storage failure must abort rather than expose the default. + """ + if not tunnel_will_start: + return True, False + + from auth import hashing as _auth_hashing + from auth import storage as _auth_storage + from auth.bootstrap_timeout import ( + bootstrap_timeout_seconds, + should_arm_bootstrap_timeout, + ) + from auth.terminal_prompt import ( + prompt_for_password_change, + should_prompt_password_change, + ) + + _admin = _auth_storage.DEFAULT_ADMIN_USERNAME + # Gate can run before lifespan: seed the admin row here (idempotent). + _auth_storage.ensure_default_admin() + requires_change = _auth_storage.requires_password_change(_admin) + if not requires_change: + return True, False + + if not should_prompt_password_change( + tunnel_will_start = tunnel_will_start, + requires_change = requires_change, + stdin_isatty = _stream_isatty(sys.stdin), + stderr_isatty = _stream_isatty(sys.stderr), + ): + # No terminal: only proceed if the bootstrap deadline will arm; api-only + # and TIMEOUT=0 never arm it, leaving the default credential public. + deadline_arms = should_arm_bootstrap_timeout( + host = host, + secure = secure, + api_only = api_only, + frontend_served = frontend_served, + is_colab = is_colab, + requires_change = True, + timeout_seconds = bootstrap_timeout_seconds(), + ) + if not deadline_arms: + print( + "Refusing to publish Studio on a public Cloudflare URL: the " + "default admin password was never changed, no terminal is " + "attached to change it here, and the bootstrap shutdown " + "deadline does not apply to this launch (api-only, or " + "UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0). Change the password " + "first (run `unsloth studio` locally and log in, or re-run " + "with a terminal attached), then retry.", + file = sys.stderr, + flush = True, + ) + return False, False + # The public page won't auto-fill the bootstrap credential (suppressed + # below) and the seeded file may already be gone, so point recovery at a + # terminal-attached run / reset-password instead of reading it from disk. + print( + " WARNING: the default admin password is still active while " + "Studio is about to be published on a public Cloudflare URL, and " + "no terminal is attached to change it here. The public page will " + "NOT auto-fill the bootstrap credential. Set a new password by " + "running `unsloth studio` locally with a terminal attached, or " + "`unsloth studio reset-password`. Studio shuts down after the " + "bootstrap deadline (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT, default 1h) " + "unless the password is changed.", + file = sys.stderr, + flush = True, + ) + # Never serve the default credential in HTML over a public URL. + return True, True + + def _is_current_password(candidate: str) -> bool: + record = _auth_storage.get_user_and_secret(_admin) + if record is None: + return False + salt, pwd_hash, _jwt_secret, _must_change = record + return _auth_hashing.verify_password(candidate, salt, pwd_hash) + + def _apply_change(new_password: str) -> None: + # Same effects as routes/auth.py change_password: rehash, rotate the JWT + # secret, revoke refresh tokens in the SAME transaction. + _auth_storage.update_password(_admin, new_password, revoke_refresh_tokens = True) + + changed = prompt_for_password_change( + min_length = _auth_storage.MIN_PASSWORD_LENGTH, + is_current_password = _is_current_password, + apply_change = _apply_change, + out = sys.stderr, + ) + return (True, True) if changed else (False, False) + + +def _apply_supplied_password(password_value: "Optional[str]") -> None: + """Non-interactively set the INITIAL admin password before the socket binds, + for a direct ``python run.py`` launch (the CLI does this in its own parent). + Value comes from --password / UNSLOTH_STUDIO_PASSWORD / stdin. + + Only ever sets the FIRST password: an already-set one is a hard error, an + invalid value fails closed. NOT wrapped in a broad try/except: an auth + storage failure must abort rather than expose the default credential. + """ + from auth import hashing as _auth_hashing + from auth import storage as _auth_storage + from auth.terminal_prompt import SUPPLIED_PASSWORD_ENV, resolve_supplied_password + + supplied = resolve_supplied_password(password_value) + # Strip the env var once read so child subprocesses (cloudflared, llama-server, + # code-exec tools) can't inherit the plaintext via /proc/PID/environ. Mirrors + # the CLI. Unconditional: strips a leftover value even when a literal --password won. + os.environ.pop(SUPPLIED_PASSWORD_ENV, None) + if not supplied: + return + + _admin = _auth_storage.DEFAULT_ADMIN_USERNAME + _auth_storage.ensure_default_admin() + if not _auth_storage.requires_password_change(_admin): + print( + "Error: a Studio admin password is already set; --password only sets " + "the initial password. Run `unsloth studio reset-password` first.", + file = sys.stderr, + flush = True, + ) + sys.exit(1) + + def _is_current_password(candidate: str) -> bool: + record = _auth_storage.get_user_and_secret(_admin) + if record is None: + return False + salt, pwd_hash, _jwt_secret, _must_change = record + return _auth_hashing.verify_password(candidate, salt, pwd_hash) + + if len(supplied) < _auth_storage.MIN_PASSWORD_LENGTH: + print( + f"Error: password must be at least {_auth_storage.MIN_PASSWORD_LENGTH} " + "characters; not starting.", + file = sys.stderr, + flush = True, + ) + sys.exit(1) + if _is_current_password(supplied): + print( + "Error: the new password must differ from the current bootstrap " + "password; not starting.", + file = sys.stderr, + flush = True, + ) + sys.exit(1) + _auth_storage.update_password(_admin, supplied, revoke_refresh_tokens = True) + print(f"Password updated for '{_admin}'.", file = sys.stderr, flush = True) + + def _apply_cli_tool_policy(enable_tools: "Optional[bool]") -> None: """Honor an explicit --enable-tools/--disable-tools; None leaves the policy unset (tools default on, per-request enable_tools honored). Host is never @@ -1075,9 +1274,10 @@ def run_server( silent: bool = False, api_only: bool = False, llama_parallel_slots: int = 1, - cloudflare: bool = True, + cloudflare: "Optional[bool]" = None, secure: bool = False, enable_tools: "Optional[bool]" = None, + password: "Optional[str]" = None, emit_tauri_port: bool = True, ): """ @@ -1090,6 +1290,9 @@ def run_server( silent: Suppress startup messages api_only: API server only, no frontend (for Tauri desktop app) llama_parallel_slots: parallel slots for llama-server + cloudflare: opt in to the public Cloudflare HTTPS tunnel for a wildcard + bind. Tri-state: None (unset) and False both mean off; True enables it. + --secure implies it (True) and rejects an explicit False. enable_tools: explicit --enable-tools/--disable-tools policy; None leaves the default (tools on, per-request enable_tools honored) emit_tauri_port: print the machine-readable TAURI_PORT line the desktop @@ -1111,13 +1314,16 @@ def run_server( initialize_parent_lifetime() - # --secure exposes only the Cloudflare link: force a loopback bind so the raw - # port is never public (even with -H 0.0.0.0), and reject the contradictory combo. - if secure and not cloudflare: - raise SystemExit( - "A secure Cloudflare link is not allowed, use --no-secure which provides a 0.0.0.0 link" - ) + # --secure exposes ONLY the Cloudflare link: reject --secure --no-cloudflare, + # then force a loopback bind so the raw port is never public (even -H 0.0.0.0). + # Otherwise keep the tri-state so the banner distinguishes "off by default" + # from an explicit --no-cloudflare. if secure: + if cloudflare is False: + raise SystemExit( + "--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare." + ) + cloudflare = True host = "127.0.0.1" # `unsloth studio run` installs its own resolved policy and passes None here. @@ -1202,7 +1408,7 @@ def run_server( print("=" * 50) if blocker: pid, name = blocker - print(f"Port {original_port} is already in use by " f"{name} (PID {pid}).") + print(f"Port {original_port} is already in use by {name} (PID {pid}).") else: print(f"Port {original_port} is already in use.") print(f"Unsloth Studio will use port {port} instead.") @@ -1315,6 +1521,44 @@ def run_server( app.state.trigger_shutdown = _trigger_shutdown + # A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial + # admin password before the gate and socket bind (direct `python run.py`; + # the CLI applies it in its own parent). + _apply_supplied_password(password) + + # Never publish with the seeded default password active: prompt first (or + # warn / fail closed headless; see _terminal_password_gate). Runs BEFORE the + # socket binds so a pre-gate listener can't hand out the injected credential. + _pw_proceed, _pw_drop_bootstrap = _terminal_password_gate( + tunnel_will_start = _cloudflare_tunnel_should_start( + cloudflare = cloudflare, + host = host, + secure = secure, + api_only = api_only, + is_colab = _IS_COLAB, + ), + host = host, + secure = secure, + api_only = api_only, + frontend_served = bool(frontend_path) and not api_only, + is_colab = _IS_COLAB, + ) + if not _pw_proceed: + print( + "Not starting Studio; set a new admin password first, or launch " + "without --secure/--cloudflare.", + file = sys.stderr, + flush = True, + ) + sys.exit(1) + if _pw_drop_bootstrap: + # Password just changed (stale) or a public URL is about to serve the + # default credential: don't leak it in the HTML. Lifespan runs AFTER this + # and re-reads the bootstrap password, so the flag (not a plain None) + # makes it skip that re-read. + app.state.suppress_bootstrap_injection = True + app.state.bootstrap_password = None + # Run server in a daemon thread with explicit new_event_loop() + # run_until_complete() (not asyncio.run) so nest_asyncio's patches don't # interfere when Colab/IPython already runs a loop on the main thread. @@ -1384,6 +1628,7 @@ def run_server( is_colab = _IS_COLAB, ) _cloudflare_requested = _cloudflare_enabled + if _cloudflare_enabled: try: # best-effort: any failure must not block startup from cloudflare_tunnel import start_studio_tunnel, stop_studio_tunnel @@ -1471,6 +1716,14 @@ def _build_arg_parser(): 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( + "--password", + default = None, + help = "Set the INITIAL admin password non-interactively (headless), only when " + "none is set yet. Also reads UNSLOTH_STUDIO_PASSWORD, or --password - for stdin. " + "A literal value is visible in the process list. Rotate later via " + "`unsloth studio reset-password`.", + ) parser.add_argument("--port", type = int, default = 8888, help = "Port to bind to") parser.add_argument( "--frontend", @@ -1487,11 +1740,13 @@ def _build_arg_parser(): parser.add_argument( "--cloudflare", action = argparse.BooleanOptionalAction, - default = True, - help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard " - "binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). " - "Pass --no-cloudflare to disable that Cloudflare URL; it does not change a " - "public wildcard bind. --api-only keeps it off unless paired with --secure.", + default = None, + help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS " + "tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; " + "pass --cloudflare to enable it (--secure implies it), --no-cloudflare to " + "force it off. It does not change a raw wildcard bind. If the admin " + "password was never changed, Studio asks for a new one in the terminal " + "before publishing the URL.", ) parser.add_argument( "--secure", @@ -1499,7 +1754,9 @@ def _build_arg_parser(): default = False, help = "Expose ONLY a Cloudflare HTTPS link: bind localhost and fail closed " "if the tunnel can't start. Without it, --no-secure also serves the raw " - "0.0.0.0 port, which is reachable from anywhere on the network", + "0.0.0.0 port, which is reachable from anywhere on the network. If the " + "admin password was never changed, Studio asks for a new one in the " + "terminal before publishing the URL.", ) # Back-compat: accept --not-secure as a hidden alias for --no-secure. parser.add_argument( @@ -1561,7 +1818,7 @@ if __name__ == "__main__": args = parser.parse_args() if not _PARALLEL_MIN <= args.parallel <= _PARALLEL_MAX: parser.error(f"--parallel must be between {_PARALLEL_MIN} and {_PARALLEL_MAX}") - if args.secure and not args.cloudflare: + if args.secure and args.cloudflare is False: parser.error( "--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare" ) @@ -1575,6 +1832,7 @@ if __name__ == "__main__": cloudflare = args.cloudflare, secure = args.secure, enable_tools = args.enable_tools, + password = args.password, ) if args.frontend is not None: kwargs["frontend_path"] = Path(args.frontend) diff --git a/studio/backend/tests/test_cloudflare_tunnel.py b/studio/backend/tests/test_cloudflare_tunnel.py index 7904c70a7b..bb51cabf76 100644 --- a/studio/backend/tests/test_cloudflare_tunnel.py +++ b/studio/backend/tests/test_cloudflare_tunnel.py @@ -691,13 +691,14 @@ def _argparse_default(source, option): return None -def test_run_server_cloudflare_default_true(): +def test_run_server_cloudflare_default_off(): defaults = _func_param_defaults(_RUN_PY.read_text(), "run_server") - assert defaults.get("cloudflare") is True + assert "cloudflare" in defaults + assert defaults["cloudflare"] is None -def test_argparse_cloudflare_default_true(): - assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is True +def test_argparse_cloudflare_default_off(): + assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is None def test_verify_global_reachability_marks_private_address_unreachable(): @@ -832,6 +833,31 @@ def test_cloudflare_line_states_disabled_when_off(monkeypatch): assert "local network only" in out +def test_cloudflare_line_labels_unset_as_default(monkeypatch): + # None = off by default (no flag) -> banner says "(default)", not "(--no-cloudflare)". + out = _run_print_cloudflare_line( + monkeypatch, + cloudflare_url = None, + public_reachable = False, + cloudflare_requested = False, + cloudflare_flag = None, + ) + assert "Cloudflare tunnel: OFF (default)" in out + assert "--no-cloudflare" not in out + + +def test_cloudflare_line_labels_explicit_no_cloudflare(monkeypatch): + # False = explicit --no-cloudflare -> banner says "(--no-cloudflare)". + out = _run_print_cloudflare_line( + monkeypatch, + cloudflare_url = None, + public_reachable = False, + cloudflare_requested = False, + cloudflare_flag = False, + ) + assert "Cloudflare tunnel: OFF (--no-cloudflare)" in out + + def test_cloudflare_line_states_failed_when_requested_but_no_url(monkeypatch): out = _run_print_cloudflare_line( monkeypatch, diff --git a/studio/backend/tests/test_password_prompt.py b/studio/backend/tests/test_password_prompt.py new file mode 100644 index 0000000000..372d6a2aa4 --- /dev/null +++ b/studio/backend/tests/test_password_prompt.py @@ -0,0 +1,324 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Masked terminal password prompt (auth/terminal_prompt.py): reader echo and +editing, the change loop's validation/re-prompt behavior, and the pure +should-prompt gate. Drives the reader through a scripted fake getch, so no +tty (and no msvcrt on Linux) is needed.""" + +from __future__ import annotations + +import io +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[1] +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from auth import terminal_prompt as tp # noqa: E402 + + +def _fake_getch(keys): + """Scripted keystroke source: yields one item per _getch() call. Items may + be multi-char strings to simulate a paste burst arriving in one read.""" + it = iter(keys) + + def getch(): + return next(it) + + return getch + + +def _read( + monkeypatch, + keys, + prompt = "P: ", +): + monkeypatch.setattr(tp, "_getch", _fake_getch(keys)) + out = io.StringIO() + value = tp._read_password(prompt, out = out) + return value, out.getvalue() + + +# ── _read_password ─────────────────────────────────────────────────── + + +def test_reader_echoes_one_star_per_char(monkeypatch): + value, out = _read(monkeypatch, list("secret") + ["\r"]) + assert value == "secret" + assert out.count("*") == 6 + assert "secret" not in out + + +def test_reader_backspace_edits_and_erases_star(monkeypatch): + value, out = _read(monkeypatch, list("abc") + ["\x7f"] + list("d") + ["\n"]) + assert value == "abd" + assert "\b \b" in out + # 4 stars were printed (a, b, c, d); one was erased. + assert out.count("*") == 4 + + +def test_reader_backspace_on_empty_buffer_is_noop(monkeypatch): + value, out = _read(monkeypatch, ["\x08", "\x7f"] + list("x") + ["\r"]) + assert value == "x" + assert "\b \b" not in out + + +def test_reader_paste_burst_delivers_all_chars(monkeypatch): + # A paste can arrive as one multi-char read; every char must count. + value, out = _read(monkeypatch, ["pasted-secret", "\r"]) + assert value == "pasted-secret" + assert out.count("*") == len("pasted-secret") + + +def test_reader_unicode_password(monkeypatch): + value, _ = _read(monkeypatch, list("pässwörd✓") + ["\r"]) + assert value == "pässwörd✓" + + +def test_reader_ignores_other_control_chars(monkeypatch): + value, _ = _read(monkeypatch, ["\t", "\x1b"] + list("ok") + ["\r"]) + assert value == "ok" + + +def test_reader_ctrl_c_raises_keyboard_interrupt(monkeypatch): + monkeypatch.setattr(tp, "_getch", _fake_getch(list("ab") + ["\x03"])) + with pytest.raises(KeyboardInterrupt): + tp._read_password("P: ", out = io.StringIO()) + + +def test_reader_ctrl_d_on_empty_raises_eof(monkeypatch): + monkeypatch.setattr(tp, "_getch", _fake_getch(["\x04"])) + with pytest.raises(EOFError): + tp._read_password("P: ", out = io.StringIO()) + + +def test_reader_ctrl_d_mid_input_is_ignored(monkeypatch): + value, _ = _read(monkeypatch, list("ab") + ["\x04"] + list("c") + ["\r"]) + assert value == "abc" + + +def test_reader_windows_key_prefix_is_ignored(monkeypatch): + # _getch_windows reports swallowed function-key sequences as "\x00". + value, _ = _read(monkeypatch, ["\x00"] + list("w") + ["\r"]) + assert value == "w" + + +def test_reader_holds_raw_mode_once_for_whole_line(monkeypatch): + # Regression: cbreak/no-echo must be held for the ENTIRE line, not toggled + # per keystroke. Re-enabling echo between reads opens a window where a + # keystroke arriving in the gap echoes the password in cleartext. Assert the + # raw-mode context wraps the whole read exactly once and every keystroke is + # read while it is active. + events = [] + + class _SpyRawMode: + def __enter__(self): + events.append("enter") + return self + + def __exit__(self, *exc): + events.append("exit") + return False + + monkeypatch.setattr(tp, "_prompt_raw_mode", _SpyRawMode) + + src = _fake_getch(list("s3cr3t!!") + ["\r"]) + + def _getch_recording(): + assert events and events[-1] == "enter", "keystroke read outside raw mode" + return src() + + monkeypatch.setattr(tp, "_getch", _getch_recording) + value = tp._read_password("P: ", out = io.StringIO()) + assert value == "s3cr3t!!" + assert events == ["enter", "exit"] + + +# ── prompt_for_password_change ─────────────────────────────────────── + + +def _run_loop( + monkeypatch, + keys, + *, + min_length = 8, + current = "bootstrap-pw", +): + monkeypatch.setattr(tp, "_getch", _fake_getch(keys)) + out = io.StringIO() + applied = [] + ok = tp.prompt_for_password_change( + min_length = min_length, + is_current_password = lambda pw: pw == current, + apply_change = applied.append, + out = out, + ) + return ok, applied, out.getvalue() + + +def _keys(*lines): + keys = [] + for line in lines: + keys.extend(list(line)) + keys.append("\r") + return keys + + +def test_loop_success_applies_once(monkeypatch): + ok, applied, out = _run_loop(monkeypatch, _keys("new-password", "new-password")) + assert ok is True + assert applied == ["new-password"] + assert "Password updated" in out + assert "new-password" not in out + + +def test_loop_short_password_reprompts(monkeypatch): + ok, applied, out = _run_loop(monkeypatch, _keys("short", "long-enough-pw", "long-enough-pw")) + assert ok is True + assert applied == ["long-enough-pw"] + assert "at least 8 characters" in out + + +def test_loop_rejects_current_password(monkeypatch): + ok, applied, out = _run_loop( + monkeypatch, _keys("bootstrap-pw", "fresh-password", "fresh-password") + ) + assert ok is True + assert applied == ["fresh-password"] + assert "must differ" in out + + +def test_loop_mismatch_reprompts_then_succeeds(monkeypatch): + ok, applied, out = _run_loop( + monkeypatch, + _keys("first-attempt", "typo-attempt", "second-attempt", "second-attempt"), + ) + assert ok is True + assert applied == ["second-attempt"] + assert "do not match" in out + + +def test_loop_ctrl_c_aborts_without_applying(monkeypatch): + ok, applied, out = _run_loop(monkeypatch, list("ab") + ["\x03"]) + assert ok is False + assert applied == [] + assert "aborted" in out + + +def test_loop_eof_aborts_without_applying(monkeypatch): + ok, applied, out = _run_loop(monkeypatch, ["\x04"]) + assert ok is False + assert applied == [] + assert "aborted" in out + + +def test_loop_ctrl_c_at_confirmation_aborts(monkeypatch): + ok, applied, _ = _run_loop(monkeypatch, _keys("valid-password") + ["\x03"]) + assert ok is False + assert applied == [] + + +def test_loop_min_length_counts_code_points(monkeypatch): + # 8 unicode code points must pass a min_length of 8. + pw = "pässwörd" + assert len(pw) == 8 + ok, applied, _ = _run_loop(monkeypatch, _keys(pw, pw)) + assert ok is True + assert applied == [pw] + + +# ── should_prompt_password_change ──────────────────────────────────── + + +@pytest.mark.parametrize( + "tunnel,requires,stdin_tty,stderr_tty,expected", + [ + (True, True, True, True, True), + (False, True, True, True, False), # tunnel not starting (loopback no-op) + (True, False, True, True, False), # password already changed + (True, True, False, True, False), # piped stdin (headless) + (True, True, True, False, False), # redirected stderr + (False, False, False, False, False), + ], +) +def test_should_prompt_matrix(tunnel, requires, stdin_tty, stderr_tty, expected): + assert ( + tp.should_prompt_password_change( + tunnel_will_start = tunnel, + requires_change = requires, + stdin_isatty = stdin_tty, + stderr_isatty = stderr_tty, + ) + is expected + ) + + +def test_stream_eof_aborts_instead_of_submitting(monkeypatch): + # A dead stream ("" from _getch, e.g. a closed pty) must abort the line, + # never silently submit the partial password typed so far. + import io + + err = io.StringIO() + monkeypatch.setattr(tp, "_getch", _fake_getch(list("abc") + [""])) + with pytest.raises(EOFError): + tp._read_password("New password: ", out = err) + + +# ── resolve_supplied_password: non-interactive --password / env / stdin ── + + +def test_resolve_supplied_password_literal_value_and_note(monkeypatch): + import io + + monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False) + out = io.StringIO() + assert tp.resolve_supplied_password("hunter2pw", out = out) == "hunter2pw" + # A literal value warns that it is visible in the process list / history. + assert "process list" in out.getvalue() + + +def test_resolve_supplied_password_stdin(monkeypatch): + import io + + monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False) + monkeypatch.setattr(sys, "stdin", io.StringIO("from-stdin-pw\n")) + assert tp.resolve_supplied_password("-") == "from-stdin-pw" + + +def test_resolve_supplied_password_stdin_empty_is_none(monkeypatch): + import io + + monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False) + monkeypatch.setattr(sys, "stdin", io.StringIO("")) + assert tp.resolve_supplied_password("-") is None + + +def test_resolve_supplied_password_env(monkeypatch): + monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw") + assert tp.resolve_supplied_password("") == "env-secret-pw" + assert tp.resolve_supplied_password(None) == "env-secret-pw" + + +def test_resolve_supplied_password_literal_beats_env(monkeypatch): + import io + monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw") + assert tp.resolve_supplied_password("cli-wins-pw", out = io.StringIO()) == "cli-wins-pw" + + +def test_resolve_supplied_password_stdin_beats_env(monkeypatch): + # `--password -` reads stdin and short-circuits, so a set env var does not win. + import io + + monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw") + monkeypatch.setattr(sys, "stdin", io.StringIO("stdin-wins-pw\n")) + assert tp.resolve_supplied_password("-") == "stdin-wins-pw" + + +def test_resolve_supplied_password_off_by_default(monkeypatch): + monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False) + assert tp.resolve_supplied_password("") is None + assert tp.resolve_supplied_password(None) is None diff --git a/studio/backend/tests/test_password_prompt_backstop.py b/studio/backend/tests/test_password_prompt_backstop.py new file mode 100644 index 0000000000..597eac1625 --- /dev/null +++ b/studio/backend/tests/test_password_prompt_backstop.py @@ -0,0 +1,405 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Pre-tunnel terminal password gate: never publish a public Cloudflare URL +while the seeded default admin password is active. Imports run.py directly, +so run under the Studio venv.""" + +from __future__ import annotations + +import io +import re +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parents[1] +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import run # noqa: E402 +from auth import storage as auth_storage # noqa: E402 +from auth import terminal_prompt # noqa: E402 +from auth.terminal_prompt import should_prompt_password_change # noqa: E402 + +_GATE_KWARGS = dict( + host = "127.0.0.1", + secure = True, + api_only = False, + frontend_served = True, +) + + +# ── pure decision matrix ───────────────────────────────────────────── + + +@pytest.mark.parametrize( + "tunnel_will_start,requires_change,stdin_isatty,stderr_isatty,expected", + [ + (True, True, True, True, True), + # Any missing precondition suppresses the prompt. + (False, True, True, True, False), + (True, False, True, True, False), + (True, True, False, True, False), + (True, True, True, False, False), + (False, False, False, False, False), + ], +) +def test_should_prompt_matrix( + tunnel_will_start, requires_change, stdin_isatty, stderr_isatty, expected +): + assert ( + should_prompt_password_change( + tunnel_will_start = tunnel_will_start, + requires_change = requires_change, + stdin_isatty = stdin_isatty, + stderr_isatty = stderr_isatty, + ) + is expected + ) + + +# ── _terminal_password_gate unit tests ─────────────────────────────── + + +class _Stream(io.StringIO): + def __init__(self, isatty: bool): + super().__init__() + self._isatty = isatty + + def isatty(self) -> bool: + return self._isatty + + +class _BrokenStream(io.StringIO): + """Service-wrapper stand-in whose isatty() raises (closed stdin).""" + + def isatty(self) -> bool: + raise ValueError("I/O operation on closed file") + + +def _patch_streams(monkeypatch, *, tty: bool) -> _Stream: + stderr = _Stream(isatty = tty) + monkeypatch.setattr(sys, "stdin", _Stream(isatty = tty)) + monkeypatch.setattr(sys, "stderr", stderr) + return stderr + + +def _patch_seeded_admin(monkeypatch, *, requires_change: bool) -> None: + # The gate seeds the admin row itself (it can run before lifespan startup); + # tests fake both the seeding no-op and the flag. + monkeypatch.setattr(auth_storage, "ensure_default_admin", lambda: False) + monkeypatch.setattr(auth_storage, "requires_password_change", lambda u: requires_change) + + +def test_gate_skips_when_tunnel_off(monkeypatch): + # Short-circuits before touching auth storage at all. + def _boom(*a, **k): + raise AssertionError("storage must not be consulted when the tunnel is off") + + monkeypatch.setattr(auth_storage, "requires_password_change", _boom) + monkeypatch.setattr(auth_storage, "ensure_default_admin", _boom) + assert run._terminal_password_gate(tunnel_will_start = False, **_GATE_KWARGS) == (True, False) + + +def test_gate_skips_when_password_already_changed(monkeypatch): + _patch_streams(monkeypatch, tty = True) + _patch_seeded_admin(monkeypatch, requires_change = False) + monkeypatch.setattr( + terminal_prompt, + "prompt_for_password_change", + lambda **k: pytest.fail("prompt must not run when no change is required"), + ) + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, False) + + +def test_gate_warns_and_proceeds_without_tty_when_deadline_arms(monkeypatch): + stderr = _patch_streams(monkeypatch, tty = False) + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False) + monkeypatch.setattr( + terminal_prompt, + "prompt_for_password_change", + lambda **k: pytest.fail("prompt must not run without a tty"), + ) + # Proceeds, but the public HTML must not auto-fill the default credential. + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True) + out = stderr.getvalue() + assert "default admin password is still active" in out + assert "UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT" in out + # The seeded file may already be gone (the CLI parent deletes it before + # re-exec), so the warning must point at the reset-password recovery path + # instead of promising a file to read. + assert "reset-password" in out + assert ".bootstrap_password" not in out + + +def test_gate_fails_closed_without_tty_when_deadline_cannot_arm(monkeypatch): + # api-only launches never arm the bootstrap deadline, so a headless public + # launch with the default password has NO safeguard: refuse to start. + stderr = _patch_streams(monkeypatch, tty = False) + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False) + kwargs = dict(_GATE_KWARGS) + kwargs["api_only"] = True + kwargs["frontend_served"] = False + assert run._terminal_password_gate(tunnel_will_start = True, **kwargs) == (False, False) + assert "Refusing to publish" in stderr.getvalue() + + +def test_gate_fails_closed_without_tty_when_deadline_disabled(monkeypatch): + stderr = _patch_streams(monkeypatch, tty = False) + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "0") + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (False, False) + assert "Refusing to publish" in stderr.getvalue() + + +def test_gate_treats_broken_streams_as_non_interactive(monkeypatch): + # A closed/None stdin must take the headless path, not blow up. + stderr = _Stream(isatty = False) + monkeypatch.setattr(sys, "stdin", _BrokenStream()) + monkeypatch.setattr(sys, "stderr", stderr) + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False) + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True) + + +def test_gate_refusal_fails_closed(monkeypatch): + _patch_streams(monkeypatch, tty = True) + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.setattr(terminal_prompt, "prompt_for_password_change", lambda **k: False) + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (False, False) + + +def test_gate_success_applies_route_equivalent_change(monkeypatch): + _patch_streams(monkeypatch, tty = True) + calls = [] + _patch_seeded_admin(monkeypatch, requires_change = True) + monkeypatch.setattr( + auth_storage, + "get_user_and_secret", + lambda u: ("salt", "hash", "jwt", True), + ) + monkeypatch.setattr( + auth_storage, + "update_password", + lambda u, p, **kw: calls.append(("update", u, p, kw)), + ) + + def _fake_prompt(*, min_length, is_current_password, apply_change, out): + # The gate wires the policy constant and route-equivalent apply hook. + assert min_length == auth_storage.MIN_PASSWORD_LENGTH + # Wired to the real hash comparison: a wrong guess is rejected. + assert is_current_password("wrong-guess") is False + apply_change("brand-new-password") + return True + + monkeypatch.setattr(terminal_prompt, "prompt_for_password_change", _fake_prompt) + assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True) + admin = auth_storage.DEFAULT_ADMIN_USERNAME + # One atomic call: refresh tokens revoked in the same transaction as the + # password commit (a separable follow-up delete can fail and leave a + # pre-change refresh token able to mint access tokens). + assert calls == [("update", admin, "brand-new-password", {"revoke_refresh_tokens": True})] + + +# ── ordering inside run_server (source-level, repo convention) ─────── + + +def test_gate_runs_before_server_bind_in_source(): + # The gate must run before the uvicorn socket binds: on a wildcard bind + # the served HTML injects the bootstrap credential for first login, so a + # pre-gate listener would hand out the default password mid-prompt. + src = (_BACKEND / "run.py").read_text(encoding = "utf-8") + gate_call = src.index("_pw_proceed, _pw_drop_bootstrap = _terminal_password_gate(") + thread_start = src.index("thread.start()") + tunnel_start = src.index("_cloudflare_url = start_studio_tunnel(port)") + assert gate_call < thread_start < tunnel_start + # The fail-closed branch exits before any server exists. + refusal = src[gate_call:thread_start] + assert "sys.exit(1)" in refusal + + +def test_min_password_length_single_source(): + # models/auth.py must reference the storage constant, not a literal. + models_src = (_BACKEND / "models" / "auth.py").read_text(encoding = "utf-8") + assert "MIN_PASSWORD_LENGTH" in models_src + assert not re.search(r"min_length\s*=\s*8\b", models_src) + assert auth_storage.MIN_PASSWORD_LENGTH == 8 + + +def test_lifespan_honors_bootstrap_suppression_in_source(): + # The lifespan runs AFTER the gate and re-reads the bootstrap password + # into app.state; without the suppress flag it would overwrite the gate's + # None and the public HTML would inject the default credential again. + main_src = (_BACKEND / "main.py").read_text(encoding = "utf-8") + assert "suppress_bootstrap_injection" in main_src + # Every lifespan capture of the bootstrap password must be flag-guarded. + for line in main_src.splitlines(): + if "storage.get_bootstrap_password()" in line and "=" in line: + assert "_suppress_bootstrap" in line, line + run_src = (_BACKEND / "run.py").read_text(encoding = "utf-8") + assert "app.state.suppress_bootstrap_injection = True" in run_src + + +def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path): + # If the file cannot be unlinked (Windows AV / read-only auth dir), clear must + # truncate it so its stale plaintext cannot be re-seeded by + # generate_bootstrap_password() after a later reset-password deletes auth.db, + # which would re-validate the revoked bootstrap password. + import pathlib + + pw_path = tmp_path / ".bootstrap_password" + pw_path.write_text("old-diceware-passphrase") + monkeypatch.setattr(auth_storage, "_BOOTSTRAP_PW_PATH", pw_path) + monkeypatch.setattr(auth_storage, "_bootstrap_password", "old-diceware-passphrase") + + _real_unlink = pathlib.Path.unlink + + def _boom(self, *a, **k): + if self == pw_path: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom) + + auth_storage.clear_bootstrap_password() + + assert pw_path.exists() # unlink failed + assert pw_path.read_text() == "" # but truncated -> no reusable plaintext + + # The stale value must not load back (empty file -> None), so a later re-seed + # generates fresh rather than resurrecting the revoked credential. + monkeypatch.setattr(auth_storage, "_bootstrap_password", None) + assert auth_storage._load_bootstrap_password() is None + + +def test_clear_bootstrap_password_warns_truthfully_when_not_cleared(monkeypatch, tmp_path, capsys): + # If the file can be neither unlinked NOR truncated, the stale plaintext stays + # on disk. The warning must NOT claim it was made unreusable (Codex 3571888584): + # it must say it could not be cleared and ask the user to remove it manually. + import pathlib + + pw_path = tmp_path / ".bootstrap_password" + pw_path.write_text("old-diceware-passphrase") + monkeypatch.setattr(auth_storage, "_BOOTSTRAP_PW_PATH", pw_path) + monkeypatch.setattr(auth_storage, "_bootstrap_password", "old-diceware-passphrase") + + _real_unlink = pathlib.Path.unlink + _real_write_text = pathlib.Path.write_text + + def _boom_unlink(self, *a, **k): + if self == pw_path: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + def _boom_write_text(self, *a, **k): + if self == pw_path: + raise OSError("read-only") + return _real_write_text(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + monkeypatch.setattr(pathlib.Path, "write_text", _boom_write_text) + + auth_storage.clear_bootstrap_password() + + # The stale plaintext survives untouched. + assert pw_path.read_text() == "old-diceware-passphrase" + warning = capsys.readouterr().err.lower() + assert "could not delete or clear" in warning + assert "still on disk" in warning + assert "remove it manually" in warning + # Must not falsely claim the contents were cleared (the bug being fixed). + assert "cleared its contents" not in warning + + +# ── _apply_supplied_password: non-interactive initial password (direct run.py) ── + + +def _seed_stub_admin( + monkeypatch, + *, + requires_change, + bootstrap_pw = "bootstrap-secret", +): + """Stub storage so _apply_supplied_password sees a seeded admin whose current + password is ``bootstrap_pw`` and whose must-change flag is ``requires_change``; + return the recorded update_password calls.""" + from auth import hashing + + salt, pwd_hash = hashing.hash_password(bootstrap_pw) + monkeypatch.setattr(auth_storage, "ensure_default_admin", lambda: False) + monkeypatch.setattr(auth_storage, "requires_password_change", lambda u: requires_change) + monkeypatch.setattr( + auth_storage, "get_user_and_secret", lambda u: (salt, pwd_hash, "jwt", requires_change) + ) + calls = [] + monkeypatch.setattr( + auth_storage, "update_password", lambda u, p, **kw: calls.append((u, p, kw)) + ) + return calls + + +def test_apply_supplied_password_sets_initial(monkeypatch): + calls = _seed_stub_admin(monkeypatch, requires_change = True) + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password") + run._apply_supplied_password(None) # resolves from the env var + admin = auth_storage.DEFAULT_ADMIN_USERNAME + assert calls == [(admin, "brand-new-password", {"revoke_refresh_tokens": True})] + + +def test_apply_supplied_password_off_is_noop(monkeypatch): + calls = _seed_stub_admin(monkeypatch, requires_change = True) + monkeypatch.delenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, raising = False) + run._apply_supplied_password(None) + run._apply_supplied_password("") + assert calls == [] + + +def test_apply_supplied_password_already_set_fails_closed(monkeypatch): + calls = _seed_stub_admin(monkeypatch, requires_change = False) + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password") + with pytest.raises(SystemExit) as exc: + run._apply_supplied_password(None) + assert exc.value.code == 1 + assert calls == [] # never overrides an existing password + + +def test_apply_supplied_password_too_short_fails_closed(monkeypatch): + calls = _seed_stub_admin(monkeypatch, requires_change = True) + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "short") + with pytest.raises(SystemExit) as exc: + run._apply_supplied_password(None) + assert exc.value.code == 1 + assert calls == [] + + +def test_apply_supplied_password_must_differ_fails_closed(monkeypatch): + calls = _seed_stub_admin(monkeypatch, requires_change = True, bootstrap_pw = "bootstrap-secret") + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "bootstrap-secret") + with pytest.raises(SystemExit) as exc: + run._apply_supplied_password(None) + assert exc.value.code == 1 + assert calls == [] + + +def test_apply_supplied_password_strips_env_from_subprocess_environment(monkeypatch): + # The plaintext password must not linger in os.environ: run_server later spawns + # cloudflared/llama-server/code-exec tools that would otherwise inherit it (also + # readable via /proc/PID/environ). The direct-run.py path pops it itself; the CLI + # pops it before re-exec. Assert the pop happens on the apply path... + _seed_stub_admin(monkeypatch, requires_change = True) + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password") + run._apply_supplied_password(None) + assert terminal_prompt.SUPPLIED_PASSWORD_ENV not in run.os.environ + + +def test_apply_supplied_password_strips_env_even_when_literal_wins(monkeypatch): + # A literal --password wins over the env var, but a stale env value would still + # leak to subprocesses; the unconditional pop must clear it regardless of source. + _seed_stub_admin(monkeypatch, requires_change = True) + monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "env-should-be-stripped") + run._apply_supplied_password("literal-new-password") + assert terminal_prompt.SUPPLIED_PASSWORD_ENV not in run.os.environ diff --git a/studio/backend/tests/test_secure_tunnel_gate.py b/studio/backend/tests/test_secure_tunnel_gate.py index 1f7608a4fc..2c13e13bbb 100644 --- a/studio/backend/tests/test_secure_tunnel_gate.py +++ b/studio/backend/tests/test_secure_tunnel_gate.py @@ -21,7 +21,7 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402 @pytest.mark.parametrize( "cloudflare,host,secure,api_only,is_colab,expected", [ - # Non-secure wildcard binds tunnel by default. + # Non-secure wildcard binds tunnel only when --cloudflare is passed (True). (True, "0.0.0.0", False, False, False, True), (True, "::", False, False, False, True), (True, "127.0.0.1", False, False, False, False), @@ -33,6 +33,10 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402 (False, "0.0.0.0", False, False, False, False), (False, "::", False, False, False, False), (False, "127.0.0.1", True, False, False, False), + # Unset (None, no flag) behaves as off for non-secure binds. + (None, "0.0.0.0", False, False, False, False), + (None, "::", False, False, False, False), + (None, "127.0.0.1", False, False, False, False), # Non-secure api-only never tunnels (Tauri). (True, "0.0.0.0", False, True, False, False), (True, "::", False, True, False, False), @@ -155,11 +159,12 @@ def test_startup_output_emits_disabled_notice(capsys, monkeypatch): def test_run_server_rejects_secure_without_cloudflare(): - # Direct backend callers (not just the CLI) must reject the contradictory combo. + # Direct backend callers (not just the CLI) must reject the contradictory + # combo: --secure asks for the tunnel, --no-cloudflare (cloudflare=False) forbids it. import run with pytest.raises(SystemExit) as exc: run.run_server(secure = True, cloudflare = False) - assert "A secure Cloudflare link is not allowed" in str(exc.value) + assert "do not combine it with --no-cloudflare" in str(exc.value) def test_failclosed_message_present_in_source(): diff --git a/studio/setup.ps1 b/studio/setup.ps1 index b19167c478..dab1e1e73f 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -4086,7 +4086,9 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) { } Write-Host " $Rule" -ForegroundColor DarkGray } -step "launch" "unsloth studio -H 0.0.0.0 -p 8888" +step "launch" "unsloth studio -p 8888" +substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" +substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" Write-Host "" # Match studio/setup.sh: exit non-zero for degraded llama.cpp when called diff --git a/studio/setup.sh b/studio/setup.sh index 64d4f852b5..3d67db3da7 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1975,8 +1975,8 @@ else else 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)" - printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)" + printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)" + printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)" fi echo "" diff --git a/unsloth_cli/commands/_password_prompt.py b/unsloth_cli/commands/_password_prompt.py new file mode 100644 index 0000000000..b6fd8ca34d --- /dev/null +++ b/unsloth_cli/commands/_password_prompt.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Masked terminal password prompt for the first-exposure password change. + +Mirror of ``studio/backend/auth/terminal_prompt.py`` -- keep the two in sync. +The CLI parent cannot import the backend package outside the studio venv, so the +reader is duplicated here (like the auth mirroring in ``commands/studio.py``). + +Input echoes one ``*`` per character (unlike ``getpass``). All output goes to +stderr so redirected stdout stays clean. +""" + +from __future__ import annotations + +import os +import sys +from typing import Callable, TextIO + +# Keep in sync with studio/backend/models/auth.py ChangePasswordRequest +# (new_password min_length) and studio/backend/auth/storage.py. +MIN_PASSWORD_LENGTH = 8 + +# Env var that supplies the initial admin password non-interactively (mirror in +# studio/backend/auth/terminal_prompt.py). Keep the name in sync. +SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD" + +_BACKSPACE_CHARS = ("\x7f", "\x08") +_SUBMIT_CHARS = ("\r", "\n") + + +class _RestoreTtyOnSignals: + """Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read. + + A finally block can't run when a signal terminates the process, leaving the + shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or + where the signals are absent. + """ + + def __init__(self, fd: int, old_attrs) -> None: + self._fd = fd + self._old_attrs = old_attrs + self._previous: list = [] + + def __enter__(self) -> "_RestoreTtyOnSignals": + import signal + import termios + + def _restore_and_reraise(signum, frame): + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs) + signal.signal(signum, signal.SIG_DFL) + signal.raise_signal(signum) + + for name in ("SIGTERM", "SIGHUP"): + sig = getattr(signal, name, None) + if sig is None: + continue + try: + self._previous.append((sig, signal.signal(sig, _restore_and_reraise))) + except (ValueError, OSError): # non-main thread / unsupported + pass + return self + + def __exit__(self, *exc) -> None: + import signal + for sig, previous in self._previous: + try: + signal.signal(sig, previous) + except (ValueError, OSError): + pass + + +def _read_masked_posix(prompt: str, out: TextIO) -> str: + import codecs + import termios + import tty + + fd = sys.stdin.fileno() + old_attrs = termios.tcgetattr(fd) + out.write(prompt) + out.flush() + chars: list[str] = [] + try: + with _RestoreTtyOnSignals(fd, old_attrs): + # cbreak + ISIG off (mirrors terminal_prompt.py): with ISIG on, + # Ctrl-Z would suspend mid-read and leave the shell no-echo before + # the finally restores it. Ctrl-C/Ctrl-Z arrive as \x03/\x1a here. + tty.setcbreak(fd) + new_attrs = termios.tcgetattr(fd) + new_attrs[3] &= ~termios.ISIG + termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs) + # Decode byte-at-a-time with errors="replace" (mirrors + # terminal_prompt.py): text-mode read(1) can raise UnicodeDecodeError + # on a pasted non-UTF-8 password or yield a lone surrogate that later + # crashes pbkdf2. os.read + incremental decoder maps bad bytes to + # U+FFFD and continues. + decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace") + submitted = False + while not submitted: + raw = os.read(fd, 1) + if not raw: # stream ended mid-line: abort, don't submit + raise EOFError + # One byte can complete >1 char, so iterate over the decoder's output. + for ch in decoder.decode(raw): + if ch in _SUBMIT_CHARS: + submitted = True + break + if ch == "\x03": # Ctrl-C (ISIG off: surfaces as a char) + raise KeyboardInterrupt + if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z + if not chars: + raise EOFError + continue + if ch in _BACKSPACE_CHARS: + if chars: + chars.pop() + out.write("\b \b") + out.flush() + continue + if ch < " ": # other control characters + continue + chars.append(ch) + out.write("*") + out.flush() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) + out.write("\n") + out.flush() + return "".join(chars) + + +def _read_masked_windows(prompt: str, out: TextIO) -> str: + import msvcrt + + out.write(prompt) + out.flush() + chars: list[str] = [] + try: + while True: + ch = msvcrt.getwch() + if ch in _SUBMIT_CHARS: + break + if ch == "\x03": # Ctrl-C: getwch swallows the signal, re-raise + raise KeyboardInterrupt + if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z + if not chars: + raise EOFError + continue + if ch in ("\x00", "\xe0"): # function/arrow key: swallow the code + msvcrt.getwch() + continue + if ch in _BACKSPACE_CHARS: + if chars: + chars.pop() + out.write("\b \b") + out.flush() + continue + if ch < " ": + continue + chars.append(ch) + out.write("*") + out.flush() + finally: + out.write("\n") + out.flush() + return "".join(chars) + + +def read_masked(prompt: str, out: TextIO | None = None) -> str: + """Read one line with ``*`` echo. Raises KeyboardInterrupt on Ctrl-C and + EOFError on Ctrl-D/Ctrl-Z at an empty prompt.""" + if out is None: + out = sys.stderr + if os.name == "nt": + return _read_masked_windows(prompt, out) + return _read_masked_posix(prompt, out) + + +def prompt_new_password(verify_current: Callable[[str], bool], out: TextIO | None = None) -> str: + """Prompt for a new admin password until a valid, confirmed one is given. + + ``verify_current`` returns True when the candidate equals the current stored + password; such candidates are rejected. KeyboardInterrupt/EOFError propagate + so the caller can abort the launch. + """ + if out is None: + out = sys.stderr + while True: + password = read_masked("New password: ", out) + if len(password) < MIN_PASSWORD_LENGTH: + out.write(f"Password must be at least {MIN_PASSWORD_LENGTH} characters. Try again.\n") + out.flush() + continue + if verify_current(password): + out.write("New password must differ from the current password. Try again.\n") + out.flush() + continue + confirmation = read_masked("Confirm new password: ", out) + if confirmation != password: + out.write("Passwords do not match. Try again.\n") + out.flush() + continue + return password + + +def resolve_supplied_password(cli_value: "str | None", out: TextIO | None = None) -> "str | None": + """Resolve a non-interactive initial admin password, or None if unset. + + Precedence: an explicit ``--password`` (literal ``-`` reads a line from + stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off. + A literal argv value is visible in the process list, so a note points at the + env var or stdin instead. Mirror of the backend helper -- keep the two in sync. + """ + if out is None: + out = sys.stderr + if cli_value == "-": + line = sys.stdin.readline() + if not line: + return None + return line.rstrip("\r\n") or None + if cli_value: + out.write( + "Note: --password is visible in the process list and shell history; " + f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n" + ) + out.flush() + return cli_value + return os.environ.get(SUPPLIED_PASSWORD_ENV) or None + + +def validate_new_password(candidate: str, verify_current: Callable[[str], bool]) -> "str | None": + """Error message if ``candidate`` is unacceptable (too short or equal to the + current password), else None. Same policy as the interactive loop.""" + if len(candidate) < MIN_PASSWORD_LENGTH: + return f"Password must be at least {MIN_PASSWORD_LENGTH} characters." + if verify_current(candidate): + return "New password must differ from the current password." + return None diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 80641a17c3..09355bd454 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -3,6 +3,7 @@ import importlib.util import hashlib +import hmac import json import os import platform @@ -21,6 +22,8 @@ from pathlib import Path from typing import List, Optional import typer +from unsloth_cli.commands import _password_prompt + studio_app = typer.Typer(help = "Unsloth Studio commands.") @@ -480,6 +483,14 @@ def _connect_auth_db() -> sqlite3.Connection: auth_dir = STUDIO_HOME / "auth" auth_dir.mkdir(parents = True, exist_ok = True) conn = sqlite3.connect(auth_dir / "auth.db") + # Mirror backend storage.get_connection: this path can create auth/ and + # auth.db (the pre-exposure gate writes here first), and sqlite3.connect + # makes the DB 0644 under a 022 umask. Keep both private. + for _path, _mode in ((auth_dir, 0o700), (auth_dir / "auth.db", 0o600)): + try: + os.chmod(_path, _mode) + except OSError: + pass conn.execute( """ CREATE TABLE IF NOT EXISTS auth_user ( @@ -627,6 +638,526 @@ def _create_desktop_secret_in_cli() -> str: conn.close() +def _should_prompt_password_change( + *, cloudflare: Optional[bool], host: str, secure: bool, api_only: bool +) -> bool: + """Whether this launch will expose Studio through the Cloudflare tunnel. + + CLI mirror of run.py's _cloudflare_tunnel_should_start, minus the Colab + case (Colab launches never come through this CLI path). --secure implies + the tunnel; --cloudflare only tunnels non-api-only wildcard binds. + """ + if secure: + return True + if cloudflare is not True: + return False + return host in ("0.0.0.0", "::") and not api_only + + +def _prompt_streams_interactive() -> bool: + """The prompt needs a real terminal for input and for the masked echo.""" + try: + return sys.stdin.isatty() and sys.stderr.isatty() + except (AttributeError, ValueError): + return False + + +def _bootstrap_deadline_active() -> bool: + """Whether the backend's bootstrap shutdown deadline will arm. + + Mirror of studio/backend/auth/bootstrap_timeout.py bootstrap_timeout_seconds: + unset/blank/malformed UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT falls back to the 1h + default (a typo must not remove protection); 0 or negative disables it. + """ + raw = os.environ.get("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "").strip() + if not raw: + return True + try: + return int(raw) > 0 + except ValueError: + return True + + +def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: str) -> None: + """CLI mirror of backend update_password + change-password route effects. + + One transaction: rehash, rotate the JWT secret, clear must_change_password, + revoke refresh tokens (PR #6651 finding), and drop the desktop secret. File + cleanup happens after commit; a failed unlink must not roll the change back. + """ + password_salt, password_hash = _hash_password(new_password) + with conn: + conn.execute( + """ + UPDATE auth_user + SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0 + WHERE username = ? + """, + (password_salt, password_hash, secrets.token_urlsafe(64), username), + ) + conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) + conn.execute( + "DELETE FROM app_secrets WHERE key IN (?, ?)", + (DESKTOP_SECRET_HASH_KEY, DESKTOP_SECRET_CREATED_AT_KEY), + ) + for stale in (BOOTSTRAP_PASSWORD_FILE, DESKTOP_SECRET_FILE): + stale_path = STUDIO_HOME / "auth" / stale + try: + stale_path.unlink(missing_ok = True) + except OSError as exc: + # The hash is already committed, so a failed unlink must NOT roll the + # change back. But a locked-yet-writable file (Windows AV, read-only + # auth dir) must be truncated: otherwise its stale plaintext survives + # and generate_bootstrap_password() would re-validate this revoked + # credential after a later reset-password deletes auth.db. Mirrors + # backend clear_bootstrap_password(). + try: + stale_path.write_text("") + cleared = True + except OSError: + cleared = False + if cleared: + typer.echo( + f"Warning: could not remove stale {stale} file ({exc}); cleared its " + "contents so the old credential cannot be reused.", + err = True, + ) + else: + typer.echo( + f"Warning: could not remove or clear stale {stale} file ({exc}); the " + "old credential is still on disk. Remove it manually to prevent reuse " + "after a reset.", + err = True, + ) + + +def _apply_supplied_password_before_launch(supplied_password: "str | None") -> None: + """Non-interactively set the INITIAL admin password (from --password / + UNSLOTH_STUDIO_PASSWORD / stdin) before the server binds, while the account + still has its auto-generated bootstrap password. + + Only ever sets the FIRST password: an already-set one is a hard error (an + override would be an auth bypass on a public launch), and an invalid value + fails closed. Runs in the parent before any re-exec so the secret never + crosses to the child argv. + """ + if not supplied_password: + return + try: + conn = _connect_auth_db() + except (OSError, sqlite3.Error) as exc: + typer.echo( + f"Error: --password could not open the Studio auth database ({exc}); not starting.", + err = True, + ) + raise typer.Exit(1) + try: + _ensure_cli_default_admin(conn) + conn.commit() + row = conn.execute( + "SELECT password_salt, password_hash, must_change_password " + "FROM auth_user WHERE username = ?", + (DEFAULT_ADMIN_USERNAME,), + ).fetchone() + if not row: + typer.echo( + "Error: --password could not initialize the admin account; not starting.", + err = True, + ) + raise typer.Exit(1) + if not row[2]: + typer.echo( + "Error: a Studio admin password is already set; --password only sets " + "the initial password. Run `unsloth studio reset-password` first " + "(or change it in the UI).", + err = True, + ) + raise typer.Exit(1) + password_salt, password_hash = row[0], row[1] + + def _is_current_password(candidate: str) -> bool: + return hmac.compare_digest( + _pbkdf2_hex(candidate, password_salt.encode("utf-8")), password_hash + ) + + problem = _password_prompt.validate_new_password(supplied_password, _is_current_password) + if problem is not None: + typer.echo(f"Error: {problem} Not starting.", err = True) + raise typer.Exit(1) + _cli_update_password(conn, DEFAULT_ADMIN_USERNAME, supplied_password) + typer.echo(f"Password updated for '{DEFAULT_ADMIN_USERNAME}'.", err = True) + except (OSError, sqlite3.Error) as exc: + # Any DB failure fails closed (typer.Exit is not caught here, so the + # deliberate Exit(1) branches above propagate unchanged). + typer.echo( + f"Error: --password could not update the Studio auth database ({exc}); not starting.", + err = True, + ) + raise typer.Exit(1) + finally: + conn.close() + + +def _strip_seeded_bootstrap_password_or_exit(*, context: str) -> None: + """Remove the seeded plaintext bootstrap password before a public re-exec. + + Version-independent protection: a re-exec'd child of ANY version (including an + old studio-venv predating the pre-bind gate) then reads None instead of + injecting the default credential into the public page. must_change_password + stays set, so the login page still forces a change and the timer still arms. + Removal IS the protection, so if it fails (locked file, read-only auth dir) + fail closed rather than publish it. + """ + bootstrap_file = STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE + try: + bootstrap_file.unlink(missing_ok = True) + except OSError as exc: + typer.echo( + "Error: refusing to publish Studio on a public Cloudflare URL: " + f"could not remove the seeded bootstrap password file ({exc}), so an " + f"older Studio child could still serve the default credential ({context}). " + "Delete it manually or change the admin password (run `unsloth studio` " + "locally with a terminal attached, or `unsloth studio reset-password`), " + "then retry.", + err = True, + ) + raise typer.Exit(1) + + +def _require_servable_frontend_or_exit( + *, frontend: Optional[Path], api_only: bool, cloudflare: Optional[bool], host: str, secure: bool +) -> Optional[Path]: + """Fail closed BEFORE the pre-exposure gate if a public UI launch has no + login page to change the seeded password. + + The gate strips the seeded .bootstrap_password on a headless public launch, + so if the child then cannot serve the login page the admin is locked out + (must_change_password=1, no file, no UI) until `unsloth studio reset-password`. + The login page is the ONLY in-band way to change the seeded password, so a + public non-api-only launch must have a servable dist before the strip. + + Returns the dist to serve: a user-supplied --frontend (validated to contain + index.html) or the auto-resolved built dist. Returns `frontend` unchanged for + non-public or --api-only launches (no login page needed). + """ + if api_only or not _should_prompt_password_change( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ): + return frontend + if frontend is not None: + # A user-supplied dist is not vetted by _find_frontend_dist, so verify it + # can serve the login page; else `--frontend /bad/path` bypasses the guard. + if (Path(frontend) / "index.html").is_file(): + return frontend + typer.echo( + "Error: --frontend points at a directory with no index.html, so a " + "public Studio launch would have no login page to change the seeded " + "admin password. Point --frontend at a built dist, rebuild it (re-run " + "install.sh), or use --api-only.", + err = True, + ) + raise typer.Exit(1) + # _find_frontend_dist only returns a path that already contains index.html. + resolved = _find_frontend_dist() + if resolved is not None: + return resolved + typer.echo( + "Error: the Studio frontend is not built, so a public launch would have " + "no login page to change the seeded admin password. Build it (re-run " + "install.sh), pass --frontend PATH to a built dist, or use --api-only.", + err = True, + ) + raise typer.Exit(1) + + +def _validate_inproc_backend_before_strip( + *, cloudflare: Optional[bool], host: str, secure: bool, api_only: bool +) -> None: + """In-venv (in-process) analogue of the re-exec launcher check. + + In-venv there is no re-exec, so the backend is imported in-process only AFTER + the gate. On the headless public path the gate strips the seeded + .bootstrap_password, so a broken venv that fails at import would leave + must_change_password=1 with no password to log in. Import the backend up front + on that path and exit cleanly if broken, before anything is stripped. + Headless-only so an interactive prompt is not delayed behind the import. + """ + if not _should_prompt_password_change( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ): + return + if _prompt_streams_interactive(): + return + try: + _load_run_module() + except Exception as exc: + typer.echo( + f"Error: the Studio backend could not be loaded ({exc}); refusing to " + "expose Studio publicly before it is confirmed runnable. Re-run: " + "unsloth studio setup", + err = True, + ) + raise typer.Exit(1) + + +def _tunnel_binary_confirmed_unavailable() -> bool: + """True only if cloudflared is provably unavailable (found nowhere on PATH or + in the Studio cache AND the download failed), so the tunnel cannot start. + + Used on the --secure path (loopback bind, so the tunnel is the ONLY public + exposure) to skip stripping the seeded recovery password before a public URL + that will never come up. Loads the stdlib-only cloudflare_tunnel helper by + file path so the check runs in the parent, before the strip. + + Returns False on ANY uncertainty: a possible credential leak outweighs a + recoverable lockout, so the caller keeps the strip unless the tunnel is + provably dead. + """ + run_py = _find_run_py() + if run_py is None: + return False + backend_dir = run_py.parent + tunnel_py = backend_dir / "cloudflare_tunnel.py" + if not tunnel_py.is_file(): + return False + # ensure_cloudflared() lazily imports utils.paths.storage_roots to resolve the + # Studio bin cache. The outer CLI hasn't added studio/backend to sys.path yet, + # so that import would fail and return None (a false "unavailable" that wrongly + # refuses --secure). Add the backend dir so the cache path resolves as in the child. + added_backend_path = False + try: + if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + added_backend_path = True + spec = importlib.util.spec_from_file_location("studio.backend.cloudflare_tunnel", tunnel_py) + if spec is None or spec.loader is None: + return False + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.ensure_cloudflared() is None + except Exception: + return False + finally: + if added_backend_path: + try: + sys.path.remove(str(backend_dir)) + except ValueError: + pass + + +def _child_self_suppresses(*, in_studio_venv: bool, child_run_py: Optional[Path]) -> bool: + """True when the child that will serve Studio is provably THIS install's + backend, whose pre-bind gate sets app.state.suppress_bootstrap_injection and + so never serves the seeded credential publicly -- even with .bootstrap_password + on disk. The parent-side strip is then unnecessary and can be skipped to avoid + a lockout if the tunnel never comes up, keeping the file for LOCAL recovery. + + True iff we run in-process here, or the re-exec target is the outer install's + own run.py (identity match). False on ANY doubt -- a studio-venv console script + or a venv run.py that may predate the gate -- so the strip stays in force + wherever an old child is possible. + """ + if in_studio_venv: + return True + if child_run_py is None: + return False + try: + outer_run_py = (_PACKAGE_ROOT / "studio" / "backend" / "run.py").resolve() + return child_run_py.resolve() == outer_run_py + except OSError: + return False + + +def _enforce_password_change_before_exposure( + *, + cloudflare: Optional[bool], + host: str, + secure: bool, + api_only: bool, + child_self_suppresses: bool = False, +) -> None: + """Force a terminal password change before the first public (tunnel) exposure. + + When the launch will start the tunnel and the admin still has its + auto-generated bootstrap password, ask for a new one in the terminal (masked, + confirmed) before any server or tunnel exists. Committing here, in the parent, + keeps the password off argv/env and an older studio-venv child sees it + immediately. Without a terminal, warn and fall back to the bootstrap shutdown + timer (~1h, UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT). + """ + if not _should_prompt_password_change( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ): + return + # Before public exposure we must PROVE the admin password is no longer the + # seeded default. If we cannot (auth DB won't open, or a fresh admin cannot be + # seeded + committed below), an old studio-venv child could regenerate a fresh + # bootstrap credential and serve it; stripping a file we can't vouch for cannot + # stop a regeneration. So those cases fail closed, as does a failure after the + # user typed a new password. + try: + conn = _connect_auth_db() + except (OSError, sqlite3.Error) as exc: + # Cannot open the auth DB, so cannot confirm a committed admin exists. + # Refuse rather than risk a child serving the default login; a transient + # lock clears on retry. + typer.echo( + "Error: refusing to publish Studio on a public Cloudflare URL: could " + f"not open the Studio auth database ({exc}) to confirm the admin " + "password was changed. Retry (a transient database lock clears), or " + "change the password first (run `unsloth studio` locally with a " + "terminal attached, or `unsloth studio reset-password`).", + err = True, + ) + raise typer.Exit(1) + try: + try: + _ensure_cli_default_admin(conn) + # Persist a freshly seeded admin before we might re-exec: the INSERT is + # otherwise uncommitted and rolls back on conn.close(). If the seed or + # commit fails, no admin is committed, so a re-exec'd OLD child finds + # none, regenerates a fresh bootstrap password + file, and serves THAT + # -- stripping cannot stop a regeneration. Can't prove a committed + # admin, so fail closed. + conn.commit() + except (OSError, sqlite3.Error) as exc: + # Best-effort remove any half-written seed file (its row rolled back); + # the launch is refused regardless. + try: + (STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE).unlink(missing_ok = True) + except OSError: + pass + typer.echo( + "Error: refusing to publish Studio on a public Cloudflare URL: could " + f"not initialize the admin account ({exc}), so a re-exec'd Studio " + "child could regenerate and serve a default credential. Retry (a " + "transient database lock clears), or change the password first (run " + "`unsloth studio` locally with a terminal attached, or `unsloth " + "studio reset-password`).", + err = True, + ) + raise typer.Exit(1) + try: + row = conn.execute( + "SELECT password_salt, password_hash, must_change_password " + "FROM auth_user WHERE username = ?", + (DEFAULT_ADMIN_USERNAME,), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + if child_self_suppresses: + # Could not read must_change back, but the child is this install's + # own backend and suppresses the injection, so nothing serves the + # seeded credential; proceed without stripping. + return + # The admin is committed above, so an old child finds it and won't + # regenerate; we just couldn't read must_change back. Strip the seeded + # file so nothing serves it, failing closed if the strip itself fails. + typer.echo( + f"Warning: could not read the Studio admin state back ({exc}); " + "removing the seeded bootstrap password before public exposure.", + err = True, + ) + _strip_seeded_bootstrap_password_or_exit(context = "auth DB row unreadable") + return + if not row or not row[2]: + return + if not _prompt_streams_interactive(): + # Only proceed headless if the bootstrap shutdown deadline will protect + # the launch: it never arms for api-only, and TIMEOUT=0 disables it. + if api_only or not _bootstrap_deadline_active(): + typer.echo( + "Error: refusing to publish Studio on a public Cloudflare " + "URL: the default admin password was never changed, no " + "terminal is attached to change it here, and the bootstrap " + "shutdown deadline does not apply to this launch (api-only, " + "or UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0). Change the " + "password first (run `unsloth studio` locally and log in, " + "or re-run with a terminal attached), then retry.", + err = True, + ) + raise typer.Exit(1) + if child_self_suppresses: + # The child is this install's own backend, whose pre-bind gate sets + # app.state.suppress_bootstrap_injection, so the seeded credential + # is never served publicly even with the file on disk. Skip the + # strip: unnecessary here, and it would lock the user out if the + # tunnel never comes up (e.g. a --secure loopback whose tunnel + # fails). Keep the file for LOCAL recovery; must_change stays set + # and the deadline arms. + typer.echo( + "Warning: Studio is being exposed publicly while the admin " + "account still uses its auto-generated bootstrap password. The " + "login page forces a change and the credential is never served " + "on the public page. Set a new password by running `unsloth " + "studio` locally with a terminal attached, or `unsloth studio " + "reset-password`; Studio shuts down after ~1h if the password " + "stays unchanged (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT).", + err = True, + ) + return + # The strip permanently removes the only plaintext recovery credential. + # On --secure the bind is loopback, so the tunnel is the ONLY public + # exposure: if cloudflared is provably unavailable no public URL can + # start, so stripping would just lock the user out. Refuse with the + # credential preserved. (A wildcard --cloudflare bind is public + # regardless of the tunnel, so it still strips below, as does any + # uncertainty.) + if secure and _tunnel_binary_confirmed_unavailable(): + typer.echo( + "Error: refusing to expose Studio: the Cloudflare tunnel binary " + "(cloudflared) is unavailable and could not be downloaded, so no " + "public URL can start. The seeded bootstrap password is preserved " + "for recovery; fix connectivity and retry, or change the password " + "first (`unsloth studio` locally, or `unsloth studio " + "reset-password`).", + err = True, + ) + raise typer.Exit(1) + # Mixed-version safety: an OLD studio-venv child (predating this gate) + # has no pre-bind suppression and would read the seeded credential back + # from disk and inject it into the public HTML until the deadline. + # Delete the file here, in the parent, so a fresh child of ANY version + # reads None. must_change_password stays set, so the login page still + # forces a change and the timer still arms; only the on-disk copy goes. + _strip_seeded_bootstrap_password_or_exit(context = "no terminal to change it") + typer.echo( + "Warning: Studio is being exposed publicly while the admin account " + "still uses its auto-generated bootstrap password. The seeded password " + "file has been removed so it is not served on the public page. Set a new " + "password by running `unsloth studio` locally with a terminal attached, " + "or `unsloth studio reset-password`; Studio shuts down after ~1h if the " + "password stays unchanged (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT).", + err = True, + ) + return + password_salt, password_hash = row[0], row[1] + + def _is_current_password(candidate: str) -> bool: + return hmac.compare_digest( + _pbkdf2_hex(candidate, password_salt.encode("utf-8")), password_hash + ) + + typer.echo( + "Unsloth Studio will be exposed on the public internet, so set a " + "password now. Ctrl+C to abort.", + err = True, + ) + try: + new_password = _password_prompt.prompt_new_password(_is_current_password) + except (KeyboardInterrupt, EOFError): + typer.echo( + "\nError: password change aborted; refusing to expose Studio " + "with the default admin password. Re-run and set a password, " + "or launch without --secure/--cloudflare.", + err = True, + ) + raise typer.Exit(1) + _cli_update_password(conn, DEFAULT_ADMIN_USERNAME, new_password) + typer.echo(f"Password updated for '{DEFAULT_ADMIN_USERNAME}'.", err = True) + finally: + conn.close() + + def _load_model_via_http( port: int, api_key: str, @@ -713,13 +1244,13 @@ def studio_default( f"defaults to {_PARALLEL_DEFAULT_RUN}." ), ), - cloudflare: bool = typer.Option( - True, + cloudflare: Optional[bool] = typer.Option( + None, "--cloudflare/--no-cloudflare", - help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard " - "binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). " - "Pass --no-cloudflare to disable that Cloudflare URL; it does not change a " - "public wildcard bind. --api-only keeps it off unless paired with --secure.", + help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS " + "tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; " + "pass --cloudflare to enable it (--secure implies it). --no-cloudflare forces " + "it off but does not change a raw wildcard bind.", ), secure: bool = typer.Option( False, @@ -747,6 +1278,14 @@ def studio_default( help = "Force server-side tools (web search, code execution) on or off for " "every request. Default: on for every bind, with the per-chat UI toggle honored.", ), + password: str = typer.Option( + "", + "--password", + help = "Set the INITIAL admin password non-interactively (headless setups), " + "only when none is set yet. Also reads the UNSLOTH_STUDIO_PASSWORD env var, or " + "`--password -` to read one line from stdin. A literal value is visible in the " + "process list and shell history. Rotate later with `unsloth studio reset-password`.", + ), ): """Launch the Unsloth Studio server.""" # Back-compat: --not-secure is a deprecated alias for --no-secure. @@ -766,13 +1305,14 @@ def studio_default( err = True, ) raise typer.Exit(2) - # Same for --no-cloudflare: it would not reach the subcommand. - if not cloudflare: + # Same for --cloudflare/--no-cloudflare: it would not reach the subcommand. + if cloudflare is not None: + _cf_flag = "--cloudflare" if cloudflare else "--no-cloudflare" typer.echo( - f"Error: --no-cloudflare on `unsloth studio` applies to the " + f"Error: {_cf_flag} on `unsloth studio` applies to the " f"plain-server path only. For `unsloth studio " f"{ctx.invoked_subcommand}`, put it after the subcommand: " - f"`unsloth studio {ctx.invoked_subcommand} --no-cloudflare ...`", + f"`unsloth studio {ctx.invoked_subcommand} {_cf_flag} ...`", err = True, ) raise typer.Exit(2) @@ -817,17 +1357,34 @@ def studio_default( err = True, ) raise typer.Exit(2) + # Same for --password: it applies to the plain-server path only. + if password: + typer.echo( + f"Error: --password on `unsloth studio` applies to the " + f"plain-server path only. For `unsloth studio " + f"{ctx.invoked_subcommand}`, put it after the subcommand: " + f"`unsloth studio {ctx.invoked_subcommand} --password ...`", + err = True, + ) + raise typer.Exit(2) return # --secure requires the tunnel; force a loopback bind. if secure: - if not cloudflare: + if cloudflare is False: typer.echo( "Error: --secure requires the Cloudflare tunnel; do not combine it " "with --no-cloudflare.", err = True, ) raise typer.Exit(2) + if host not in ("127.0.0.1", "localhost", "::1"): + typer.echo( + "Note: --secure ignores -H (it binds loopback and serves only " + "through the Cloudflare tunnel). Drop --secure to bind " + f"{host} directly, or keep --secure for a tunnel-only public link.", + err = True, + ) host = "127.0.0.1" # --verbose restores the per-request access logs that are suppressed by @@ -835,13 +1392,76 @@ def studio_default( if verbose: _enable_verbose_access_logs() - # Use the studio venv if it exists and we aren't already in it. + # Use the studio venv if present and not already in it. Resolve the child + # launcher BEFORE the gate: a headless gate strips the seeded + # .bootstrap_password, so aborting afterward (venv/run.py missing) would leave + # must_change_password=1 with no password to log in. studio_venv_dir = STUDIO_HOME / "unsloth_studio" in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) - + studio_python = run_py = None + resolved_frontend = frontend if not in_studio_venv: studio_python = _studio_venv_python() run_py = _find_run_py() + if not (studio_python and run_py): + typer.echo("Studio not set up. Run install.sh first.") + raise typer.Exit(1) + # A public UI launch must have a servable login page BEFORE the gate can + # strip the seeded .bootstrap_password, or the child has no way to change + # it. Also returns the resolved dist so the child serves a real build + # regardless of where its __file__ lands (fixes the shadowed silent 404). + resolved_frontend = _require_servable_frontend_or_exit( + frontend = resolved_frontend, + api_only = api_only, + cloudflare = cloudflare, + host = host, + secure = secure, + ) + # Non-public / api-only launches skip that validation but still forward an + # explicitly resolved dist for the same silent-404 reason. + if resolved_frontend is None and not api_only: + resolved_frontend = _find_frontend_dist() + else: + # Already in the studio venv: no re-exec, served in-process below. On the + # headless public path the gate strips the seeded .bootstrap_password, so + # validate BOTH FIRST -- else a bad dist or broken venv fails only after + # the strip (must_change_password=1, no password to log in). Frontend check + # first (cheap); the backend import is headless-only so an interactive + # prompt is not delayed behind it. + resolved_frontend = _require_servable_frontend_or_exit( + frontend = resolved_frontend, + api_only = api_only, + cloudflare = cloudflare, + host = host, + secure = secure, + ) + _validate_inproc_backend_before_strip( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ) + + # A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial + # admin password here in the parent, before the gate and any re-exec, so the + # secret never reaches the child argv; strip the env var so a re-exec'd child + # can't re-read it. The interactive gate below then no-ops. + _apply_supplied_password_before_launch(_password_prompt.resolve_supplied_password(password)) + os.environ.pop(_password_prompt.SUPPLIED_PASSWORD_ENV, None) + + # Public (tunnel) exposure with the seeded default password: force a terminal + # password change first, before any re-exec or server exists. The child is + # self-suppressing when we serve in-process or re-exec this install's own + # run.py (its pre-bind gate suppresses the injection), so the gate can skip + # the destructive strip. + _enforce_password_change_before_exposure( + cloudflare = cloudflare, + host = host, + secure = secure, + api_only = api_only, + child_self_suppresses = _child_self_suppresses( + in_studio_venv = in_studio_venv, child_run_py = run_py + ), + ) + + if not in_studio_venv: if studio_python and run_py: if not silent: typer.echo("Launching Unsloth Studio... Please wait...") @@ -855,20 +1475,22 @@ def studio_default( "--parallel", str(parallel), ] - # Resolve frontend explicitly so the spawned run.py uses a real - # built dist regardless of where its __file__ lands. Skip in - # --api-only (no UI served). - resolved_frontend = frontend - if resolved_frontend is None and not api_only: - resolved_frontend = _find_frontend_dist() + # Forward the frontend dist resolved before the gate (skipped in + # --api-only, which serves no UI). if resolved_frontend is not None: args.extend(["--frontend", str(resolved_frontend)]) if silent: args.append("--silent") if api_only: args.append("--api-only") - # Forward the explicit polarity (matches run.py's BooleanOptionalAction). - args.append("--cloudflare" if cloudflare else "--no-cloudflare") + # Forward polarity explicitly: _find_run_py can fall back to an older + # run.py (--cloudflare defaulted on), so an unset default must not let a + # mixed install silently re-enable the tunnel. --secure implies it, so + # forward nothing then. + if cloudflare is True: + args.append("--cloudflare") + elif not secure: + args.append("--no-cloudflare") args.append("--secure" if secure else "--no-secure") # Forward an explicit tool policy; None -> run.py leaves it unset (tools on). if enable_tools is True: @@ -920,8 +1542,10 @@ def studio_default( secure = secure, enable_tools = enable_tools, ) - if frontend is not None: - run_kwargs["frontend_path"] = frontend + # Forward the frontend validated before the gate (in-venv path), so the + # in-process server serves exactly the dist we vouched for. + if resolved_frontend is not None: + run_kwargs["frontend_path"] = resolved_frontend run_server(**run_kwargs) try: @@ -1106,13 +1730,13 @@ def run( f"{_PARALLEL_DEFAULT_RUN} (pre-PR hardcoded value)." ), ), - cloudflare: bool = typer.Option( - True, + cloudflare: Optional[bool] = typer.Option( + None, "--cloudflare/--no-cloudflare", - help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard " - "binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). " - "Pass --no-cloudflare to disable that Cloudflare URL; it does not change a " - "public wildcard bind. --api-only keeps it off unless paired with --secure.", + help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS " + "tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; " + "pass --cloudflare to enable it (--secure implies it). --no-cloudflare forces " + "it off but does not change a raw wildcard bind.", ), secure: bool = typer.Option( False, @@ -1136,6 +1760,14 @@ def run( "decode speed, MoE usually don't." ), ), + password: str = typer.Option( + "", + "--password", + help = "Set the INITIAL admin password non-interactively (headless setups), " + "only when none is set yet. Also reads the UNSLOTH_STUDIO_PASSWORD env var, or " + "`--password -` to read one line from stdin. A literal value is visible in the " + "process list and shell history. Rotate later with `unsloth studio reset-password`.", + ), ): """Start Studio, load a model, print an API key -- one-liner server. @@ -1207,13 +1839,20 @@ def run( # --secure requires the tunnel; force a loopback bind so the raw port is never public. if secure: - if not cloudflare: + if cloudflare is False: typer.echo( "Error: --secure requires the Cloudflare tunnel; do not combine it " "with --no-cloudflare.", err = True, ) raise typer.Exit(2) + if host not in ("127.0.0.1", "localhost", "::1"): + typer.echo( + "Note: --secure ignores -H (it binds loopback and serves only " + "through the Cloudflare tunnel). Drop --secure to bind " + f"{host} directly, or keep --secure for a tunnel-only public link.", + err = True, + ) host = "127.0.0.1" # Tool policy no longer depends on the bind: tools default on everywhere @@ -1228,10 +1867,14 @@ def run( silent = silent, ) - # 1. Re-exec into the studio venv (same pattern as studio_default). + # 1. Re-exec into the studio venv (same pattern as studio_default). Resolve + # the child launcher BEFORE the gate: a headless gate strips the seeded + # .bootstrap_password, so aborting afterward (venv/entry point missing) would + # leave must_change_password=1 with no password to log in. studio_venv_dir = STUDIO_HOME / "unsloth_studio" in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) - + studio_bin = None + resolved_frontend = frontend if not in_studio_venv: studio_python = _studio_venv_python() if not studio_python: @@ -1242,6 +1885,56 @@ def run( if not studio_bin.is_file(): typer.echo("Studio venv missing 'unsloth' entry point. Re-run: unsloth studio setup") raise typer.Exit(1) + # `run` serves the same Studio UI (unless --api-only); a public launch must + # have a servable login page BEFORE the gate strips the seeded password, or + # the child has no way to change it. Validate here and forward the resolved + # dist so a shadowed child that can't self-resolve one still serves it. + resolved_frontend = _require_servable_frontend_or_exit( + frontend = frontend, + api_only = api_only, + cloudflare = cloudflare, + host = host, + secure = secure, + ) + else: + # In-venv (in-process) run: validate the servable frontend and importable + # backend before the headless gate strips the seeded password. Frontend + # check first (cheap); backend import is headless-only so a prompt isn't + # delayed. + resolved_frontend = _require_servable_frontend_or_exit( + frontend = frontend, + api_only = api_only, + cloudflare = cloudflare, + host = host, + secure = secure, + ) + _validate_inproc_backend_before_strip( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ) + + # A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial + # admin password here in the parent, before the gate and any re-exec, so the + # secret never reaches the child argv; strip the env var so a re-exec'd child + # can't re-read it. The interactive gate below then no-ops. + _apply_supplied_password_before_launch(_password_prompt.resolve_supplied_password(password)) + os.environ.pop(_password_prompt.SUPPLIED_PASSWORD_ENV, None) + + # Public (tunnel) exposure with the seeded default password: force a terminal + # password change first, before any re-exec or server exists. The re-exec here + # runs the studio venv's `unsloth` console script (a possibly-OLD child), so it + # is NOT provably self-suppressing -- only the in-process case is, and the + # strip stays in force otherwise. + _enforce_password_change_before_exposure( + cloudflare = cloudflare, + host = host, + secure = secure, + api_only = api_only, + child_self_suppresses = _child_self_suppresses( + in_studio_venv = in_studio_venv, child_run_py = None + ), + ) + + if not in_studio_venv: args = [ str(studio_bin), "studio", @@ -1262,8 +1955,12 @@ def run( # Forward the explicit polarity; a future default flip on one # layer must not silently invert behaviour for the other. args.append("--load-in-4bit" if load_in_4bit else "--no-load-in-4bit") - if frontend: - args.extend(["--frontend", str(frontend)]) + # Forward the frontend resolved before the gate, not just a user-supplied + # one: the parent may have found a built dist the shadowed child cannot, + # and stripping without forwarding it would abort the child at frontend + # setup (lockout). + if resolved_frontend is not None: + args.extend(["--frontend", str(resolved_frontend)]) if api_only: args.append("--api-only") if silent: @@ -1279,8 +1976,13 @@ def run( # Typer claims --parallel outside ctx.args; without this the # child reverts to its default and silently drops the value. args.extend(["--parallel", str(parallel)]) - # Forward the explicit polarity (same rationale as --load-in-4bit above). - args.append("--cloudflare" if cloudflare else "--no-cloudflare") + # Always forward explicit polarity: a mixed-version studio venv whose old + # default was --cloudflare-on must not silently re-enable the tunnel. + # --secure implies it, so forward nothing then. + if cloudflare is True: + args.append("--cloudflare") + elif not secure: + args.append("--no-cloudflare") args.append("--secure" if secure else "--no-secure") args.append("--tensor-parallel" if tensor_parallel else "--no-tensor-parallel") if verbose: @@ -1322,8 +2024,9 @@ def run( # TAURI_PORT line would corrupt that machine-parseable output. emit_tauri_port = False, ) - if frontend is not None: - run_kwargs["frontend_path"] = frontend + # Forward the frontend validated before the gate (in-venv path). + if resolved_frontend is not None: + run_kwargs["frontend_path"] = resolved_frontend app = run_server(**run_kwargs) actual_port = getattr(app.state, "server_port", port) or port @@ -1943,9 +2646,44 @@ def reset_password(): ] had_db = db_file.exists() - db_file.unlink(missing_ok = True) + # Delete auth.db FIRST and prove it is gone before touching the seeded + # credential files. If it cannot be removed (a running Studio or Windows + # holds it open, or a read-only auth dir), abort with the credential files + # untouched: deleting them while an un-resettable DB (must_change_password=1) + # survives would lock a forgotten-password reset out of any recovery + # credential. Failing here leaves a consistent, still-recoverable state. + try: + db_file.unlink(missing_ok = True) + except OSError as exc: + typer.echo( + f"Error: could not delete the auth database ({exc}). Stop any running " + "Studio and retry; no credential files were changed.", + err = True, + ) + raise typer.Exit(1) + + # The DB is gone, so the next start re-seeds. Invalidate the seeded plaintext + # credential files so that re-seed generates a FRESH password instead of + # reusing a stale one: unlink only ignores FileNotFoundError, so a + # locked/undeletable file (Windows AV, read-only dir) would otherwise survive + # and generate_bootstrap_password() would read it back and re-validate the + # credential this reset revoked. Truncate on unlink failure; if a file can be + # neither removed nor truncated, fail closed -- the DB is already gone, so a + # surviving plaintext would be reused, and the user must remove it manually. for path in stale_files: - path.unlink(missing_ok = True) + try: + path.unlink(missing_ok = True) + except OSError: + try: + path.write_text("") + except OSError as exc: + typer.echo( + f"Error: could not remove or clear {path.name} ({exc}); delete " + "it manually before restarting Studio or the old password may " + "be reused.", + err = True, + ) + raise typer.Exit(1) if not had_db: typer.echo("No auth database found -- nothing to reset.") diff --git a/unsloth_cli/tests/test_studio_cloudflare_flag.py b/unsloth_cli/tests/test_studio_cloudflare_flag.py index c09ac8b998..fb57d7aaf4 100644 --- a/unsloth_cli/tests/test_studio_cloudflare_flag.py +++ b/unsloth_cli/tests/test_studio_cloudflare_flag.py @@ -3,8 +3,8 @@ """Tests for the `--cloudflare/--no-cloudflare` Studio flag. -Pins the typer Option (default on) on both `unsloth studio` and -`unsloth studio run`, and that the chosen polarity reaches the re-exec'd +Pins the typer Option (tri-state, default off / None) on both `unsloth studio` +and `unsloth studio run`, and that the chosen polarity reaches the re-exec'd child and run_server. Modeled on test_studio_run_parallel_flag.py. """ @@ -33,7 +33,7 @@ _BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"] # ── option registration ────────────────────────────────────────────── -def test_run_exposes_cloudflare_option_default_on(): +def test_run_exposes_cloudflare_option_default_off(): import inspect sig = inspect.signature(_studio().run) @@ -41,16 +41,16 @@ def test_run_exposes_cloudflare_option_default_on(): opt = sig.parameters["cloudflare"].default decls = set(getattr(opt, "param_decls", []) or []) assert "--cloudflare/--no-cloudflare" in decls - assert getattr(opt, "default", None) is True + assert getattr(opt, "default", "missing") is None -def test_studio_default_exposes_cloudflare_option_default_on(): +def test_studio_default_exposes_cloudflare_option_default_off(): import inspect sig = inspect.signature(_studio().studio_default) assert "cloudflare" in sig.parameters opt = sig.parameters["cloudflare"].default - assert getattr(opt, "default", None) is True + assert getattr(opt, "default", "missing") is None # ── re-exec forwarding: `unsloth studio run` ───────────────────────── @@ -69,6 +69,11 @@ def _install_run_reexec_capture(monkeypatch, *, platform = "linux"): monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") fake_venv = Path("/fake/studio/venv/unsloth_studio") monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + # A built frontend dist is present so the public-launch UI check passes + # deterministically (independent of whether the repo dist was built). + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) fake_bin = fake_venv / "bin" / "unsloth" real_is_file = Path.is_file monkeypatch.setattr( @@ -107,19 +112,23 @@ def _invoke_run(monkeypatch, args): @pytest.mark.parametrize( - "user_flag,expected,unexpected", + "extra_flags,expected,unexpected", [ - (None, "--cloudflare", "--no-cloudflare"), # default on - ("--cloudflare", "--cloudflare", "--no-cloudflare"), - ("--no-cloudflare", "--no-cloudflare", "--cloudflare"), + # Default (no flag) forwards --no-cloudflare explicitly so a mixed-version + # child venv (old default: --cloudflare on) can't re-enable the tunnel. + ([], "--no-cloudflare", "--cloudflare"), + (["--cloudflare"], "--cloudflare", "--no-cloudflare"), + (["--no-cloudflare"], "--no-cloudflare", "--cloudflare"), + # --secure implies the tunnel; never forward --no-cloudflare with it. + (["--secure"], None, "--no-cloudflare"), ], ) -def test_run_reexec_forwards_cloudflare_polarity(monkeypatch, user_flag, expected, unexpected): - extras = [user_flag] if user_flag else [] - captured = _invoke_run(monkeypatch, _BASE + extras) +def test_run_reexec_forwards_cloudflare_polarity(monkeypatch, extra_flags, expected, unexpected): + captured = _invoke_run(monkeypatch, _BASE + extra_flags) assert len(captured) == 1, captured argv = captured[0] - assert expected in argv, f"expected {expected} in child argv; got {argv}" + if expected is not None: + assert expected in argv, f"expected {expected} in child argv; got {argv}" assert unexpected not in argv, f"unexpected {unexpected} in child argv; got {argv}" @@ -142,7 +151,11 @@ def _invoke_studio_default( fake_venv = Path("/fake/studio/venv/unsloth_studio") monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) - monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) + # A built frontend dist is present so the public-launch UI check passes; this + # suite exercises flag forwarding, not the missing-dist lockout guard. + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) monkeypatch.setattr(sys, "platform", platform) def fake_execvp(file, argv): @@ -158,18 +171,24 @@ def _invoke_studio_default( @pytest.mark.parametrize( - "user_flag,expected,unexpected", + "extra_flags,expected,unexpected", [ - (None, "--cloudflare", "--no-cloudflare"), - ("--no-cloudflare", "--no-cloudflare", "--cloudflare"), + # Default (no flag) forwards --no-cloudflare explicitly: _find_run_py can fall + # back to an older studio-venv run.py (default on), so a mixed install must + # not re-enable the tunnel. + ([], "--no-cloudflare", "--cloudflare"), + (["--cloudflare"], "--cloudflare", "--no-cloudflare"), + (["--no-cloudflare"], "--no-cloudflare", "--cloudflare"), + # --secure implies the tunnel; never forward --no-cloudflare with it. + (["--secure"], None, "--no-cloudflare"), ], ) -def test_studio_default_reexec_forwards_cloudflare(monkeypatch, user_flag, expected, unexpected): - extras = [user_flag] if user_flag else [] - captured = _invoke_studio_default(monkeypatch, ["-H", "0.0.0.0"] + extras) +def test_studio_default_reexec_forwards_cloudflare(monkeypatch, extra_flags, expected, unexpected): + captured = _invoke_studio_default(monkeypatch, ["-H", "0.0.0.0"] + extra_flags) assert len(captured) == 1, captured argv = captured[0] - assert expected in argv, f"expected {expected}; got {argv}" + if expected is not None: + assert expected in argv, f"expected {expected}; got {argv}" assert unexpected not in argv, f"unexpected {unexpected}; got {argv}" @@ -182,7 +201,10 @@ class _RunServerCaptured(SystemExit): self.kwargs = dict(kwargs) -@pytest.mark.parametrize("user_flag,expected", [(None, True), ("--no-cloudflare", False)]) +@pytest.mark.parametrize( + "user_flag,expected", + [(None, None), ("--cloudflare", True), ("--no-cloudflare", False)], +) def test_run_in_venv_passes_cloudflare_to_run_server(monkeypatch, user_flag, expected): import types @@ -348,21 +370,22 @@ def test_run_silent_emits_cloudflare_notice_for_external_bind(monkeypatch): assert ("print", {"secure": False, "loopback_host": "127.0.0.1"}) in calls -# ── parent-level --no-cloudflare with a subcommand is rejected ─────── +# ── parent-level --cloudflare/--no-cloudflare with a subcommand is rejected ─ -def test_studio_default_rejects_no_cloudflare_with_subcommand(monkeypatch): - # `unsloth studio --no-cloudflare run ...` would not reach the subcommand, - # so it must error (mirrors --parallel) rather than silently still tunnel. +@pytest.mark.parametrize("flag", ["--cloudflare", "--no-cloudflare"]) +def test_studio_default_rejects_cloudflare_flag_with_subcommand(monkeypatch, flag): + # `unsloth studio --cloudflare run ...` (or --no-cloudflare) would not reach the + # subcommand, so it must error (mirrors --parallel) rather than silently drop it. import typer as _typer studio_mod = _studio() app = _typer.Typer() app.add_typer(studio_mod.studio_app, name = "studio") - result = CliRunner().invoke(app, ["studio", "--no-cloudflare", "run", "--model", "X"]) + result = CliRunner().invoke(app, ["studio", flag, "run", "--model", "X"]) assert result.exit_code == 2, result.output combined = (result.output or "") + (getattr(result, "stderr", "") or "") - assert "--no-cloudflare" in combined, combined + assert flag in combined, combined # ── run() tears the server + tunnel down if startup aborts ─────────── diff --git a/unsloth_cli/tests/test_studio_password_prompt.py b/unsloth_cli/tests/test_studio_password_prompt.py new file mode 100644 index 0000000000..7bbdfe3703 --- /dev/null +++ b/unsloth_cli/tests/test_studio_password_prompt.py @@ -0,0 +1,1397 @@ +# 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 for the forced terminal password change before public (tunnel) exposure. + +`unsloth studio --secure` / `--cloudflare` (wildcard bind) must, when the admin +account still has its seeded bootstrap password, prompt for a new password in +the terminal BEFORE any re-exec or server exists; without a terminal it warns +and falls back to the backend bootstrap timeout. Modeled on +test_studio_cloudflare_flag.py. +""" + +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +import pytest +from typer.testing import CliRunner + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + + +def _studio(): + from unsloth_cli.commands import studio as _studio_mod + return _studio_mod + + +_BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"] +_NEW_PW = "brand-new-password" + + +# ── pure trigger matrix ────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "cloudflare,host,secure,api_only,expected", + [ + # --secure always implies the tunnel (host already forced to loopback). + (None, "127.0.0.1", True, False, True), + (True, "127.0.0.1", True, False, True), + (None, "127.0.0.1", True, True, True), + # --cloudflare tunnels only non-api-only wildcard binds. + (True, "0.0.0.0", False, False, True), + (True, "::", False, False, True), + (True, "127.0.0.1", False, False, False), + (True, "0.0.0.0", False, True, False), + # Off/unset never prompts without --secure. + (None, "0.0.0.0", False, False, False), + (False, "0.0.0.0", False, False, False), + (None, "127.0.0.1", False, False, False), + ], +) +def test_should_prompt_password_change_matrix(cloudflare, host, secure, api_only, expected): + assert ( + _studio()._should_prompt_password_change( + cloudflare = cloudflare, host = host, secure = secure, api_only = api_only + ) + is expected + ) + + +# ── shared harness ─────────────────────────────────────────────────── + + +class _ExecCaptured(SystemExit): + def __init__(self, argv): + super().__init__(0) + self.argv = list(argv) + + +def _auth_db(studio_home: Path) -> Path: + return studio_home / "auth" / "auth.db" + + +def _seed_auth(studio_mod, *, must_change = True): + """Create the CLI-side default admin (must_change_password=1) plus one + refresh token, mirroring a fresh install that served a login.""" + conn = studio_mod._connect_auth_db() + try: + studio_mod._ensure_cli_default_admin(conn) + if not must_change: + conn.execute("UPDATE auth_user SET must_change_password = 0") + conn.execute( + "INSERT INTO refresh_tokens (token_hash, username, expires_at) VALUES (?, ?, ?)", + ("deadbeef", studio_mod.DEFAULT_ADMIN_USERNAME, "2099-01-01T00:00:00"), + ) + conn.commit() + row = conn.execute( + "SELECT password_hash, jwt_secret FROM auth_user WHERE username = ?", + (studio_mod.DEFAULT_ADMIN_USERNAME,), + ).fetchone() + return {"password_hash": row[0], "jwt_secret": row[1]} + finally: + conn.close() + + +def _auth_state(studio_mod): + conn = sqlite3.connect(_auth_db(studio_mod.STUDIO_HOME)) + try: + row = conn.execute( + "SELECT password_hash, jwt_secret, must_change_password FROM auth_user " + "WHERE username = ?", + (studio_mod.DEFAULT_ADMIN_USERNAME,), + ).fetchone() + n_refresh = conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] + return { + "password_hash": row[0], + "jwt_secret": row[1], + "must_change_password": row[2], + "n_refresh": n_refresh, + } + finally: + conn.close() + + +def _install_prompt_env( + monkeypatch, + tmp_path, + *, + interactive, + scripted = _NEW_PW, +): + """Tmp STUDIO_HOME + fake tty + scripted prompt. Returns the event log that + records prompt calls and re-exec argv in order.""" + studio_mod = _studio() + events = [] + + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + monkeypatch.setattr(studio_mod, "_prompt_streams_interactive", lambda: interactive) + # cloudflared is "available" by default so the headless --secure strip path + # proceeds without a real download; the unavailable-tunnel guard has its own + # dedicated test that overrides this. + monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: False) + + def fake_prompt(verify_current, out = None): + events.append(("prompt", verify_current)) + if isinstance(scripted, BaseException): + raise scripted + return scripted + + monkeypatch.setattr(studio_mod._password_prompt, "prompt_new_password", fake_prompt) + return events + + +def _install_studio_default_reexec(monkeypatch, events): + studio_mod = _studio() + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None) + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) + # A built frontend dist is present by default so the public-launch UI check + # passes; the no-dist lockout guard has its own dedicated test. + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) + monkeypatch.setattr(sys, "platform", "linux") + + def fake_execvp(file, argv): + events.append(("exec", list(argv))) + raise _ExecCaptured(argv) + + monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp) + + +def _install_run_reexec(monkeypatch, events): + studio_mod = _studio() + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + # A built frontend dist is present by default so the public-launch UI check + # passes deterministically (independent of whether the repo dist was built); + # the missing-dist lockout guard has its own dedicated test. + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) + fake_bin = fake_venv / "bin" / "unsloth" + real_is_file = Path.is_file + monkeypatch.setattr( + Path, + "is_file", + lambda self: True if str(self) == str(fake_bin) else real_is_file(self), + ) + from unsloth_cli import _tool_policy as _tp_mod + + monkeypatch.setattr( + _tp_mod, + "resolve_tool_policy", + lambda host, flag, yes, silent: False if flag is None else bool(flag), + ) + monkeypatch.setattr(sys, "platform", "linux") + + def fake_execvp(file, argv): + events.append(("exec", list(argv))) + raise _ExecCaptured(argv) + + monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp) + + +def _invoke_studio_default(monkeypatch, events, args): + import typer as _typer + + studio_mod = _studio() + _install_studio_default_reexec(monkeypatch, events) + app = _typer.Typer() + app.command()(studio_mod.studio_default) + return CliRunner().invoke(app, args, catch_exceptions = True) + + +def _invoke_run(monkeypatch, events, args): + import typer as _typer + + studio_mod = _studio() + _install_run_reexec(monkeypatch, events) + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(studio_mod.run) + return CliRunner().invoke(app, args, catch_exceptions = True) + + +# ── plain `unsloth studio` ─────────────────────────────────────────── + + +def test_studio_default_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + before = _seed_auth(studio_mod) + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["prompt", "exec"], events + + after = _auth_state(studio_mod) + assert after["must_change_password"] == 0 + assert after["password_hash"] != before["password_hash"] + assert after["jwt_secret"] != before["jwt_secret"] + assert after["n_refresh"] == 0 + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + + +def test_studio_default_prompt_rejects_current_password(monkeypatch, tmp_path): + # The verify_current callback handed to the prompt must recognize the + # seeded bootstrap password (hash compare with the stored salt). + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text() + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + verify_current = events[0][1] + assert verify_current(bootstrap_pw) is True + assert verify_current("something-else-entirely") is False + + +def test_studio_default_non_tty_warns_and_proceeds(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "bootstrap password" in combined + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +def test_studio_default_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path): + # Mixed-version safety: a headless public launch must delete the seeded + # plaintext credential before re-exec so a fresh child of ANY version reads + # None from disk and never injects it into the public HTML. The launch still + # proceeds (re-exec captured), and the DB flag stays set so the login page + # still forces a change and the bootstrap shutdown timer still arms. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + assert not bootstrap_file.exists() + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +def test_studio_default_reexec_outer_runpy_keeps_bootstrap_for_local_recovery( + monkeypatch, tmp_path +): + # Regression (Codex 3572165931): when the re-exec target is THIS install's own + # run.py, the child's pre-bind gate sets suppress_bootstrap_injection and never + # serves the seeded credential publicly, so the parent strip is unnecessary. + # Skipping it means a --secure launch whose tunnel later fails to connect does + # not lock the user out, and .bootstrap_password stays for local recovery. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _install_studio_default_reexec(monkeypatch, events) + # Re-exec target IS this install's outer run.py -> child self-suppresses. + outer_run_py = studio_mod._PACKAGE_ROOT / "studio" / "backend" / "run.py" + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: outer_run_py) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + # Strip skipped: file preserved, must_change still set, launch still re-execs. + assert bootstrap_file.exists(), result.output + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert "exec" in [k for k, _ in events], events + + +def test_studio_default_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path): + # Fresh STUDIO_HOME (no pre-seed): the gate's own _ensure_cli_default_admin + # does the INSERT. It must COMMIT that seed before re-exec, or conn.close() + # rolls it back and an OLD child would find no admin, regenerate a fresh + # bootstrap password + file, and inject THAT -- defeating the file deletion. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + # Deliberately NO _seed_auth(): exercise the gate seeding a fresh DB itself. + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + # The seeded admin persists (committed) so an old child sees it and does not + # regenerate; the bootstrap file stays deleted; the launch still re-execs. + state = _auth_state(studio_mod) + assert state["must_change_password"] == 1 + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + + +def test_studio_default_non_tty_fails_closed_when_bootstrap_removal_fails(monkeypatch, tmp_path): + # Removing .bootstrap_password IS the protection on this path. If unlink + # fails (locked file / read-only auth dir) the credential is still on disk + # for an old child to inject, so the launch must fail closed, not publish. + import pathlib + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _real_unlink = pathlib.Path.unlink + + def _boom_unlink(self, *a, **k): + if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert result.exit_code == 1, result.output + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "refusing to publish" in combined.lower() + # The file remains (removal failed) and the DB flag is untouched. + assert bootstrap_file.exists() + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +class _FailingSelectConn: + """Wrap a real auth connection but raise on the gate's must_change SELECT, + so seeding + commit still happen and only the read-back fails (a locked-DB + window that lands after _ensure_cli_default_admin already wrote the file).""" + + def __init__(self, inner): + self._inner = inner + + def execute(self, sql, *args, **kwargs): + if sql.lstrip().startswith("SELECT password_salt"): + raise sqlite3.OperationalError("database is locked") + return self._inner.execute(sql, *args, **kwargs) + + def __getattr__(self, name): + return getattr(self._inner, name) + + +class _FailingCommitConn: + """Wrap a real auth connection but raise on commit(), so a fresh install's + seeded admin INSERT rolls back on close() -- the seed-committed guarantee the + gate depends on is not met, even though _ensure_cli_default_admin already + wrote the .bootstrap_password file.""" + + def __init__(self, inner): + self._inner = inner + + def commit(self): + raise sqlite3.OperationalError("database is locked") + + def __getattr__(self, name): + return getattr(self._inner, name) + + +def test_studio_default_connect_failure_fails_closed(monkeypatch, tmp_path): + # If the auth DB cannot even be opened (transient lock / unwritable home) we + # cannot confirm a committed admin exists, so a re-exec'd old studio-venv child + # could find no admin, regenerate a fresh bootstrap credential, and serve it + # publicly -- stripping a file we cannot vouch for would not stop that. Refuse + # rather than publish; a transient lock clears on retry, and the existing + # credential file is left untouched so a retry can still prompt. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + monkeypatch.setattr( + studio_mod, + "_connect_auth_db", + lambda: (_ for _ in ()).throw(sqlite3.OperationalError("database is locked")), + ) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert result.exit_code == 1, result.output + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "refusing to publish" in combined.lower() + # Not stripped: a retry can still prompt/strip once the lock clears. + assert bootstrap_file.exists() + + +def test_studio_default_seed_commit_failure_fails_closed(monkeypatch, tmp_path): + # Fresh install: the gate's own _ensure_cli_default_admin does the INSERT and + # writes .bootstrap_password, but the commit fails (write lock held past + # busy_timeout). The uncommitted admin rolls back on close, so a re-exec'd old + # child would find no admin and regenerate + serve a fresh default credential; + # stripping cannot stop a regeneration. The gate must fail closed. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + # Deliberately NO _seed_auth(): the gate seeds the fresh DB itself, then commit fails. + real_connect = studio_mod._connect_auth_db + monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingCommitConn(real_connect())) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert result.exit_code == 1, result.output + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "refusing to publish" in combined.lower() + # The half-written seed file is stripped, and no admin row was committed. + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + verify = sqlite3.connect(_auth_db(tmp_path)) + try: + assert verify.execute("SELECT COUNT(*) FROM auth_user").fetchone()[0] == 0 + finally: + verify.close() + + +def test_studio_default_missing_venv_exits_before_stripping_bootstrap(monkeypatch, tmp_path): + # Regression: the venv/run.py launchability check must run BEFORE the headless + # gate strips .bootstrap_password. Otherwise a failed launch leaves the admin + # at must_change_password=1 with no password to log in (lockout until + # reset-password). With the venv missing, exit without stripping the file. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: None) # venv missing + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: None) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + # The seeded file survives: launchability failed BEFORE the gate could strip it. + assert bootstrap_file.exists() + assert _auth_state(studio_mod)["must_change_password"] == 1 + # The gate never ran (no prompt, no strip). + assert events == [], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "not set up" in combined.lower() + + +def test_studio_default_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path): + # Regression (item B): a public UI launch needs a built frontend dist -- the + # login page is the ONLY way to change the seeded password. Resolve it BEFORE + # the headless gate strips .bootstrap_password, so a missing dist aborts the + # launch without stripping (no lockout at must_change_password=1 with nothing + # left to log in with). + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + # Launcher present, but no built frontend dist. + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) + monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + # The seeded file survives: the frontend check failed BEFORE the gate stripped it. + assert bootstrap_file.exists() + assert _auth_state(studio_mod)["must_change_password"] == 1 + # The gate never ran (no prompt, no strip, no exec). + assert events == [], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "frontend is not built" in combined.lower() + + +def test_studio_default_bad_frontend_path_exits_before_stripping_bootstrap(monkeypatch, tmp_path): + # Regression (item B / reviewer finding): a user-supplied --frontend that does + # not contain index.html must NOT bypass the servable-UI guard. Otherwise the + # headless gate strips .bootstrap_password and the child serves no login page + # -> lockout. Validate the path BEFORE the gate and abort without stripping. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) + # Auto-resolution would find a dist, but the user forced an empty one (no + # index.html): the guard must reject it rather than trust it. + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) + empty_dir = tmp_path / "empty_frontend" + empty_dir.mkdir() + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke( + app, ["--secure", "--frontend", str(empty_dir)], catch_exceptions = True + ) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # not stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert events == [], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "index.html" in combined.lower() + + +def test_studio_default_missing_frontend_loopback_cloudflare_still_launches(monkeypatch, tmp_path): + # The dist guard is scoped to public exposure only. A loopback --cloudflare + # (default host) does not tunnel, so a missing dist must NOT abort it -- the + # launch proceeds exactly as before. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + + monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") + monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None) + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) + monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) + monkeypatch.setattr(sys, "platform", "linux") + + def fake_execvp(file, argv): + events.append(("exec", list(argv))) + raise _ExecCaptured(argv) + + monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--cloudflare"], catch_exceptions = True) + + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], (events, result.output) + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "frontend not built" not in combined.lower() + + +def test_studio_default_in_venv_broken_backend_exits_before_stripping_bootstrap( + monkeypatch, tmp_path +): + # Regression (item B / reviewer finding): the in-venv (in-process) path skips + # the re-exec launcher check, so a headless public launch would seed + strip + # the seeded .bootstrap_password in the gate before _load_run_module() later + # fails on a broken/partial venv -> lockout. Validate the backend is + # importable BEFORE the strip and abort without stripping. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + # Pretend we are already inside the studio venv, with a broken backend. + monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) + + def _boom(): + raise ImportError("cannot import backend run.py") + + monkeypatch.setattr(studio_mod, "_load_run_module", _boom) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # not stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert events == [], events # gate never stripped/prompted + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "backend could not be loaded" in combined.lower() + + +def test_studio_default_in_venv_missing_frontend_exits_before_stripping_bootstrap( + monkeypatch, tmp_path +): + # Regression (Codex): the in-venv (in-process) path validated the backend but + # not the frontend, so a headless public launch would strip the seeded + # password in the gate before run_server() aborted on a missing dist. Validate + # the servable frontend BEFORE the strip, same as the re-exec path. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv + monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist + monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # not stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert events == [], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "frontend is not built" in combined.lower() + + +def test_studio_default_secure_tunnel_unavailable_preserves_bootstrap(monkeypatch, tmp_path): + # Regression (Codex): a headless --secure launch strips the only plaintext + # recovery credential before the child proves the tunnel can start. If + # cloudflared is provably unavailable no public URL comes up (loopback bind), + # so the strip must be skipped and the launch refused, preserving recovery. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _install_studio_default_reexec(monkeypatch, events) + # cloudflared cannot be found or downloaded -> the --secure tunnel is dead. + monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # preserved for recovery, NOT stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert "exec" not in [k for k, _ in events], events # never re-exec'd + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "cloudflared" in combined.lower() + + +def test_studio_default_wildcard_cloudflare_strips_even_if_tunnel_unavailable( + monkeypatch, tmp_path +): + # The unavailable-tunnel skip is --secure-only: a wildcard --cloudflare binds + # 0.0.0.0 publicly regardless of the tunnel, so the seeded password must still + # be stripped even when cloudflared is unavailable. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _install_studio_default_reexec(monkeypatch, events) + monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True) + + app = _typer.Typer() + app.command()(studio_mod.studio_default) + result = CliRunner().invoke(app, ["-H", "0.0.0.0", "--cloudflare"], catch_exceptions = True) + + # Still strips (raw public bind) and re-execs. + assert not bootstrap_file.exists(), result.output + assert "exec" in [k for k, _ in events], events + + +def test_tunnel_probe_adds_backend_to_syspath(monkeypatch, tmp_path): + # Regression (Codex 3572165922): ensure_cloudflared -> _cache_path lazily + # imports utils.paths.storage_roots, which only resolves when studio/backend is + # on sys.path. From the outer CLI it is not, so the probe must add it or it + # false-reports "unavailable" and wrongly refuses --secure. Model that with a + # cloudflare_tunnel whose ensure_cloudflared resolves ONLY when backend is on + # sys.path. + studio_mod = _studio() + backend = tmp_path / "backend" + backend.mkdir() + (backend / "cloudflare_tunnel.py").write_text( + "import sys\n" + f"_BACKEND = {str(backend)!r}\n" + "def ensure_cloudflared():\n" + " # Resolvable (cached) ONLY when the backend dir is importable.\n" + " return '/fake/cloudflared' if _BACKEND in sys.path else None\n" + ) + monkeypatch.setattr(studio_mod, "_find_run_py", lambda: backend / "run.py") + assert str(backend) not in sys.path # precondition + + result = studio_mod._tunnel_binary_confirmed_unavailable() + + # ensure_cloudflared resolved (backend was on sys.path) -> available -> not + # "confirmed unavailable"; without the fix it would false-report True. + assert result is False + # The probe cleans up the sys.path entry it added. + assert str(backend) not in sys.path + + +def test_studio_default_query_failure_strips_bootstrap_file(monkeypatch, tmp_path): + # The DB opens and the admin is seeded + committed (so .bootstrap_password is + # on disk), but reading must_change_password back fails. Returning here would + # re-exec with the freshly seeded credential still on disk; strip it first. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + real_connect = studio_mod._connect_auth_db + monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingSelectConn(real_connect())) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + assert not bootstrap_file.exists() + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 1 + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "removing the seeded bootstrap password" in combined.lower() + + +def test_studio_default_loopback_cloudflare_never_prompts(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + + result = _invoke_studio_default(monkeypatch, events, ["--cloudflare"]) + + kinds = [kind for kind, _ in events] + assert "prompt" not in kinds, events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "bootstrap password" not in combined + + +def test_studio_default_changed_password_never_prompts(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod, must_change = False) + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + + +def test_studio_default_refusal_aborts_launch(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env( + monkeypatch, tmp_path, interactive = True, scripted = KeyboardInterrupt() + ) + _seed_auth(studio_mod) + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + assert result.exit_code == 1, result.output + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +def test_studio_default_wildcard_cloudflare_prompts(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + + _invoke_studio_default(monkeypatch, events, ["-H", "0.0.0.0", "--cloudflare"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["prompt", "exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 0 + + +# ── `unsloth studio run` ───────────────────────────────────────────── + + +def test_run_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + before = _seed_auth(studio_mod) + + _invoke_run(monkeypatch, events, _BASE + ["--secure"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["prompt", "exec"], events + + after = _auth_state(studio_mod) + assert after["must_change_password"] == 0 + assert after["password_hash"] != before["password_hash"] + assert after["n_refresh"] == 0 + + +def test_run_non_tty_warns_and_proceeds(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + + result = _invoke_run(monkeypatch, events, _BASE + ["--secure"]) + + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "bootstrap password" in combined + + +def test_run_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path): + # Same mixed-version safety for the `unsloth studio run` re-exec path (which + # cannot fail-close an old child via a CLI flag): the seeded credential file + # is deleted before re-exec, the launch still proceeds, and the DB flag holds. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _invoke_run(monkeypatch, events, _BASE + ["--secure"]) + + assert not bootstrap_file.exists() + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +def test_run_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path): + # Regression (item B / reviewer finding 4): `unsloth studio run` serves the + # same Studio UI and strips the seeded password on a headless public launch, + # so a missing frontend dist must abort BEFORE the strip -- the same lockout + # guard as `unsloth studio`, not just `studio run`'s model-load residual. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + _install_run_reexec(monkeypatch, events) + monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist + + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(studio_mod.run) + result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # not stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert events == [], events # no strip, no exec + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "frontend is not built" in combined.lower() + + +def test_run_in_venv_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path): + # Regression (Codex 3571888563): the in-venv `studio run` path validated only + # the backend, so a headless public launch would strip the seeded password + # before run_server() aborted on a missing dist. Validate the frontend first. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.exists() + + monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv + monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist + monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine + + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(studio_mod.run) + result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert bootstrap_file.exists() # not stripped + assert _auth_state(studio_mod)["must_change_password"] == 1 + assert events == [], events + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "frontend is not built" in combined.lower() + + +def test_run_reexec_forwards_resolved_frontend_on_public_launch(monkeypatch, tmp_path): + # Regression (Codex 3571888570): the run re-exec discarded the dist resolved + # by the pre-strip check and only forwarded a user-supplied --frontend. On a + # public launch it must forward the resolved dist so a shadowed child that + # cannot self-resolve one still serves it (no post-strip lockout). + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod, must_change = False) # gate is a no-op -> straight to re-exec + + # _install_run_reexec resolves _find_frontend_dist -> /fake/studio/frontend/dist. + _invoke_run(monkeypatch, events, _BASE + ["--secure"]) # no user --frontend + + exec_argv = [argv for kind, argv in events if kind == "exec"][0] + assert "--frontend" in exec_argv, exec_argv + assert exec_argv[exec_argv.index("--frontend") + 1] == "/fake/studio/frontend/dist", exec_argv + + +def test_run_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path): + # Fresh STUDIO_HOME on the `run` re-exec path: the seeded admin must be + # committed before re-exec so an old console-script child does not regenerate + # and inject a fresh bootstrap credential. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + + _invoke_run(monkeypatch, events, _BASE + ["--secure"]) + + state = _auth_state(studio_mod) + assert state["must_change_password"] == 1 + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + kinds = [kind for kind, _ in events] + assert kinds == ["exec"], events + + +def test_run_non_tty_api_only_fails_closed(monkeypatch, tmp_path): + # api-only serving never arms the bootstrap shutdown deadline, so a + # headless public launch with the default password has no safeguard at + # all: the CLI must refuse rather than promise a shutdown that never comes. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + + result = _invoke_run(monkeypatch, events, _BASE + ["--secure", "--api-only"]) + + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert result.exit_code == 1, result.output + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "refusing to publish" in combined.lower() + assert _auth_state(studio_mod)["must_change_password"] == 1 + + +def test_studio_default_non_tty_disabled_deadline_fails_closed(monkeypatch, tmp_path): + # UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables the deadline; headless + + # default password + public tunnel then has no protection -> refuse. + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "0") + + result = _invoke_studio_default(monkeypatch, events, ["--secure"]) + + kinds = [kind for kind, _ in events] + assert "exec" not in kinds, events + assert result.exit_code == 1, result.output + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "refusing to publish" in combined.lower() + + +@pytest.mark.parametrize( + "raw,expected", + [ + (None, True), # unset -> default 1h + ("", True), + ("garbage", True), # malformed must not remove protection + ("3600", True), + ("1", True), + ("0", False), + ("-5", False), + ], +) +def test_bootstrap_deadline_active_mirrors_backend_parsing(monkeypatch, raw, expected): + studio_mod = _studio() + if raw is None: + monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False) + else: + monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raw) + assert studio_mod._bootstrap_deadline_active() is expected + + +def test_reset_password_truncates_locked_bootstrap_after_db_delete(monkeypatch, tmp_path): + # reset-password deletes auth.db first, then invalidates the seeded credential + # files. A locked/undeletable .bootstrap_password must be truncated so its + # stale plaintext cannot be re-seeded (generate_bootstrap_password reuses a + # non-empty file), while the reset still succeeds. + import pathlib + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + auth_dir = tmp_path / "auth" + bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE + db_file = auth_dir / "auth.db" + assert bootstrap_file.exists() and db_file.exists() + assert bootstrap_file.read_text().strip() + + _real_unlink = pathlib.Path.unlink + + def _boom_unlink(self, *a, **k): + if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + + import typer as _typer + + app = _typer.Typer() + app.command()(studio_mod.reset_password) + result = CliRunner().invoke(app, [], catch_exceptions = True) + + assert result.exit_code == 0, result.output + assert not db_file.exists() + # The locked file survives, but truncated -- no reusable plaintext. + assert bootstrap_file.exists() + assert bootstrap_file.read_text() == "" + + +def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch, tmp_path): + # After a CLI/interactive password change the seeded .bootstrap_password is + # deleted. If it cannot be unlinked but is still writable (locked file / + # read-only dir), it must be TRUNCATED so its stale plaintext cannot be + # re-seeded by generate_bootstrap_password() after a later reset-password + # deletes auth.db. The change is already committed, so it must NOT roll back. + import pathlib + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE + assert bootstrap_file.read_text().strip() + + _real_unlink = pathlib.Path.unlink + + def _boom_unlink(self, *a, **k): + if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + + conn = studio_mod._connect_auth_db() + studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "fresh-new-pw-123") + conn.close() + + # The change committed (must_change cleared) AND the locked file is truncated. + assert _auth_state(studio_mod)["must_change_password"] == 0 + assert bootstrap_file.exists() + assert bootstrap_file.read_text() == "" + + +def test_reset_password_fails_closed_when_db_cannot_be_deleted(monkeypatch, tmp_path): + # If auth.db cannot be removed (running Studio / Windows lock, read-only dir), + # reset must abort BEFORE touching the credential files -- deleting them while + # an un-resettable must_change_password=1 DB survives would lock a + # forgotten-password reset out with no recovery credential. + import pathlib + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + auth_dir = tmp_path / "auth" + bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE + db_file = auth_dir / "auth.db" + assert bootstrap_file.exists() and db_file.exists() + + _real_unlink = pathlib.Path.unlink + + def _boom_unlink(self, *a, **k): + if self.name == "auth.db": + raise OSError("database is locked") + return _real_unlink(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + + import typer as _typer + + app = _typer.Typer() + app.command()(studio_mod.reset_password) + result = CliRunner().invoke(app, [], catch_exceptions = True) + + assert result.exit_code == 1, result.output + # DB still there; credential files untouched (no lockout, no half-done reset). + assert db_file.exists() + assert bootstrap_file.exists() + assert bootstrap_file.read_text().strip() + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "could not delete the auth database" in combined.lower() + + +def test_reset_password_fails_closed_when_credential_cannot_be_invalidated(monkeypatch, tmp_path): + # If a seeded credential file can be neither unlinked nor truncated, reset must + # fail closed: auth.db is already gone, so a surviving plaintext would be + # re-seeded and re-validate the revoked password. + import pathlib + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + auth_dir = tmp_path / "auth" + bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE + db_file = auth_dir / "auth.db" + assert bootstrap_file.exists() and db_file.exists() + + _real_unlink = pathlib.Path.unlink + _real_write_text = pathlib.Path.write_text + + def _boom_unlink(self, *a, **k): + if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: + raise OSError("locked") + return _real_unlink(self, *a, **k) + + def _boom_write_text(self, *a, **k): + if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE: + raise OSError("read-only") + return _real_write_text(self, *a, **k) + + monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink) + monkeypatch.setattr(pathlib.Path, "write_text", _boom_write_text) + + import typer as _typer + + app = _typer.Typer() + app.command()(studio_mod.reset_password) + result = CliRunner().invoke(app, [], catch_exceptions = True) + + assert result.exit_code == 1, result.output + # auth.db was deleted first; the un-invalidatable file is reported for manual removal. + assert not db_file.exists() + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "delete it manually" in combined.lower() + + +def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path): + # Fresh install: the CLI gate writes the password hash + JWT secret before + # the backend ever runs, so this path must apply the same 0700/0600 modes + # as backend storage.get_connection (sqlite3.connect creates 0644 files + # under a 022 umask). + import os as _os + import stat + + if _os.name == "nt": + pytest.skip("POSIX permission bits") + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + conn = studio_mod._connect_auth_db() + conn.close() + auth_dir = tmp_path / "auth" + assert stat.S_IMODE(auth_dir.stat().st_mode) == 0o700 + assert stat.S_IMODE((auth_dir / "auth.db").stat().st_mode) == 0o600 + + +# ── non-interactive --password / UNSLOTH_STUDIO_PASSWORD / stdin ────── + + +def _exec_argv(events): + return next(argv for kind, argv in events if kind == "exec") + + +def test_studio_default_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + before = _seed_auth(studio_mod) + + _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "cli-supplied-pw12"]) + + # No interactive prompt: --password applied in the parent, so the gate no-ops. + assert [kind for kind, _ in events] == ["exec"], events + after = _auth_state(studio_mod) + assert after["must_change_password"] == 0 + assert after["password_hash"] != before["password_hash"] + assert after["jwt_secret"] != before["jwt_secret"] + assert after["n_refresh"] == 0 + assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists() + # The secret never crosses to the child argv. + assert "--password" not in _exec_argv(events) + + +def test_studio_default_password_via_env_strips_child_env(monkeypatch, tmp_path): + import os + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12") + + _invoke_studio_default(monkeypatch, events, ["--secure"]) + + assert [kind for kind, _ in events] == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 0 + # Env var stripped so a re-exec'd child cannot re-read it. + assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ + + +def test_studio_default_password_via_stdin(monkeypatch, tmp_path): + # `--password -` reads one line from stdin. CliRunner owns stdin during + # invoke, so feed it via input= rather than patching sys.stdin. + import typer as _typer + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + _install_studio_default_reexec(monkeypatch, events) + app = _typer.Typer() + app.command()(studio_mod.studio_default) + CliRunner().invoke( + app, + ["--secure", "--password", "-"], + input = "stdin-supplied-pw12\n", + catch_exceptions = True, + ) + + assert [kind for kind, _ in events] == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 0 + + +def test_studio_default_password_too_short_fails_closed(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + + result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "short"]) + + assert result.exit_code == 1 + assert [kind for kind, _ in events] == [] # never reached the gate / re-exec + assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged + + +def test_studio_default_password_must_differ_fails_closed(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod) + bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text() + + result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", bootstrap_pw]) + + assert result.exit_code == 1 + assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged + + +def test_studio_default_password_already_set_fails_closed(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _seed_auth(studio_mod, must_change = False) # a password is already set + + result = _invoke_studio_default( + monkeypatch, events, ["--secure", "--password", "another-pw-12345"] + ) + + assert result.exit_code == 1 + assert [kind for kind, _ in events] == [] + + +def test_studio_default_password_before_subcommand_errors(monkeypatch, tmp_path): + # --password on `unsloth studio` (before a subcommand) is a plain-only option; + # like --secure/--cloudflare it must error, not be silently dropped. + import typer as _typer + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None) + app = _typer.Typer() + app.add_typer(studio_mod.studio_app, name = "studio") + result = CliRunner().invoke(app, ["studio", "--password", "x", "run", "--model", "X"]) + assert result.exit_code == 2 + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "--password" in combined + + +def test_run_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path): + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + before = _seed_auth(studio_mod) + + _invoke_run(monkeypatch, events, _BASE + ["--secure", "--password", "cli-supplied-pw12"]) + + assert [kind for kind, _ in events] == ["exec"], events + after = _auth_state(studio_mod) + assert after["must_change_password"] == 0 + assert after["password_hash"] != before["password_hash"] + assert "--password" not in _exec_argv(events) + + +def test_run_password_via_env_strips_child_env(monkeypatch, tmp_path): + # The `run` mirror must also strip UNSLOTH_STUDIO_PASSWORD before re-exec so a + # shadowed child cannot re-read the secret (parity with studio_default). + import os + + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = False) + _seed_auth(studio_mod) + monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12") + + _invoke_run(monkeypatch, events, _BASE + ["--secure"]) + + assert [kind for kind, _ in events] == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 0 + assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ + + +def test_studio_default_password_applies_on_headless_wildcard_no_tunnel(monkeypatch, tmp_path): + # The apply is scoped to "any launch", not just --secure/--cloudflare: a raw + # public wildcard bind (-H 0.0.0.0, no tunnel) must set the initial password + # before bind and re-exec, with the gate no-op'ing (must_change now 0). + studio_mod = _studio() + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + before = _seed_auth(studio_mod) + + _invoke_studio_default( + monkeypatch, events, ["-H", "0.0.0.0", "--password", "headless-set-pw12"] + ) + + assert [kind for kind, _ in events] == ["exec"], events + after = _auth_state(studio_mod) + assert after["must_change_password"] == 0 + assert after["password_hash"] != before["password_hash"] + assert "--password" not in _exec_argv(events) + + +def test_reset_password_then_password_roundtrip(monkeypatch, tmp_path): + # After reset-password wipes the DB, the next start re-seeds a fresh admin + # that again requires a change, so --password can set a new initial password. + import typer + + studio_mod = _studio() + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed_auth(studio_mod) + conn = studio_mod._connect_auth_db() + studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "first-password-1") + conn.close() + assert _auth_state(studio_mod)["must_change_password"] == 0 + + # reset-password deletes the auth DB + seeded credential files. + try: + studio_mod.reset_password() + except typer.Exit: + pass + assert not (tmp_path / "auth" / "auth.db").exists() + + # A restart re-seeds (ensure_default_admin, must_change=1); --password sets anew. + events = _install_prompt_env(monkeypatch, tmp_path, interactive = True) + _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "second-password-2"]) + assert [kind for kind, _ in events] == ["exec"], events + assert _auth_state(studio_mod)["must_change_password"] == 0 diff --git a/unsloth_cli/tests/test_studio_secure_flag.py b/unsloth_cli/tests/test_studio_secure_flag.py index 61b64ff65d..5e5895309c 100644 --- a/unsloth_cli/tests/test_studio_secure_flag.py +++ b/unsloth_cli/tests/test_studio_secure_flag.py @@ -74,6 +74,11 @@ def _install_run_reexec_capture(monkeypatch): monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") fake_venv = Path("/fake/studio/venv/unsloth_studio") monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + # A built frontend dist is present so the public-launch UI check passes + # deterministically (independent of whether the repo dist was built). + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) fake_bin = fake_venv / "bin" / "unsloth" real_is_file = Path.is_file monkeypatch.setattr( @@ -120,7 +125,11 @@ def _invoke_studio_default(monkeypatch, args): fake_venv = Path("/fake/studio/venv/unsloth_studio") monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py")) - monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) + # A built frontend dist is present so the public-launch UI check passes; this + # suite exercises flag forwarding, not the missing-dist lockout guard. + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) monkeypatch.setattr(sys, "platform", "linux") def fake_execvp(file, argv): @@ -172,6 +181,35 @@ def test_studio_default_reexec_forwards_secure(monkeypatch): assert argv[argv.index("--host") + 1] == "127.0.0.1", argv +def test_run_secure_warns_when_host_overridden(monkeypatch): + # -H 0.0.0.0 --secure forces the loopback bind; warn (not error) that -H is + # ignored so it does not silently read as "secure and on the network". + import typer as _typer + + _install_run_reexec_capture(monkeypatch) + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(_studio().run) + result = CliRunner().invoke(app, _BASE + ["-H", "0.0.0.0", "--secure"], catch_exceptions = True) + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "ignores -H" in combined, combined + + +def test_run_secure_no_warning_when_already_loopback(monkeypatch): + # --secure with an already-loopback -H must not warn about ignoring -H. + import typer as _typer + + _install_run_reexec_capture(monkeypatch) + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(_studio().run) + result = CliRunner().invoke(app, _BASE + ["-H", "127.0.0.1", "--secure"], catch_exceptions = True) + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "ignores -H" not in combined, combined + + def test_studio_default_not_secure_alias_forwards_no_secure(monkeypatch): # --not-secure on `unsloth studio` forwards the canonical --no-secure. captured = _invoke_studio_default(monkeypatch, ["--not-secure"]) @@ -206,13 +244,23 @@ class _RunServerCaptured(SystemExit): self.kwargs = dict(kwargs) -def test_run_in_venv_passes_secure_and_forces_host(monkeypatch): +def test_run_in_venv_passes_secure_and_forces_host(monkeypatch, tmp_path): import types studio_mod = _studio() - fake_venv = Path("/fake/studio/venv/unsloth_studio") + # Real STUDIO_HOME with an already-changed admin (must_change_password=0) so + # the pre-exposure gate is a no-op and the in-venv path reaches run_server. + # (The gate now fails closed if it cannot open the auth DB, so a fake path + # would refuse the launch before this assertion.) + monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path) + _seed = studio_mod._connect_auth_db() + studio_mod._ensure_cli_default_admin(_seed) + _seed.execute("UPDATE auth_user SET must_change_password = 0") + _seed.commit() + _seed.close() + + fake_venv = tmp_path / "unsloth_studio" monkeypatch.setattr(sys, "prefix", str(fake_venv)) - monkeypatch.setattr(studio_mod, "STUDIO_HOME", fake_venv.parent) from unsloth_cli import _tool_policy as _tp_mod @@ -284,6 +332,11 @@ def test_run_secure_resolves_tools_against_loopback(monkeypatch): monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv") fake_venv = Path("/fake/studio/venv/unsloth_studio") monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python") + # A built frontend dist is present so the public-launch UI check passes + # deterministically (independent of whether the repo dist was built). + monkeypatch.setattr( + studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist") + ) fake_bin = fake_venv / "bin" / "unsloth" real_is_file = Path.is_file monkeypatch.setattr(