# 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 any(ch.isspace() for ch in password): out.write("Password cannot contain spaces. 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 any(ch.isspace() for ch in candidate): return "Password cannot contain spaces." if verify_current(candidate): return "New password must differ from the current password." return None