Studio: make the Cloudflare tunnel opt-in (off by default) (#7046)
* Studio: make the Cloudflare tunnel opt-in (off by default) A wildcard bind (`-H 0.0.0.0`) auto-started a public trycloudflare.com tunnel, so exposing Studio on the LAN also published it to the public internet. Flip the default so the tunnel is opt-in. - `--cloudflare` is now tri-state (Optional[bool], default None = off), mirroring the existing --enable-tools/--disable-tools handling. Pass --cloudflare to expose a public HTTPS link for a wildcard bind; --secure still implies the tunnel. - --secure + --no-cloudflare is still rejected as a contradiction. - Update the parent-command guard, re-exec forwarding, startup-banner wording, the colab comment, README, and tests. * Studio: update installer/setup launch hints for opt-in Cloudflare The post-install launch hints only mentioned --secure for a public link. Now that the tunnel is opt-in, clarify that -H 0.0.0.0 exposes the raw port on the LAN (not a public URL), and surface --cloudflare as the explicit opt-in for a public HTTPS link (--secure keeps the raw port private). Applied to install.ps1, install.sh, and studio/setup.sh. * Studio: address review - keep cloudflare tri-state + harden run re-exec Two review points from the bots: - Gemini: keep `cloudflare` as Optional[bool] in run_server instead of casting None -> False, so the startup banner can distinguish "OFF (default)" (unset) from "OFF (--no-cloudflare)" (explicit). `_cloudflare_flag` and the banner branch now carry the tri-state. - Codex (P1): `unsloth studio run` re-execs the studio venv's console script, which can be an older build whose --cloudflare defaulted on; omitting the flag let it re-enable the tunnel. That path now forwards the default polarity explicitly (--no-cloudflare, or nothing under --secure since --secure implies the tunnel). The plain `unsloth studio` path runs the same-version in-tree run.py (resolved via _find_run_py), so it keeps forwarding only an explicit polarity and still shows the accurate "(default)" banner. Tests updated for the tri-state banner labels, the None gate cases, and the new re-exec forwarding. * Studio: forward --no-cloudflare on plain re-exec too (mixed install) Codex follow-up: _find_run_py falls back to STUDIO_HOME/.../studio/backend/ run.py when the package copy is absent, so the plain `unsloth studio` re-exec can land on an older run.py whose --cloudflare defaults on. Forward the default polarity explicitly there too (--no-cloudflare, or nothing under --secure), matching the run subcommand. The common in-venv launch skips the re-exec and still shows the tri-state "(default)" banner. * Studio: fix launch hint - --cloudflare needs the wildcard bind Codex P3: the launch hint listed --cloudflare next to the loopback `unsloth studio -p 8888` command, but the tunnel only starts for wildcard binds, so `--cloudflare` alone on 127.0.0.1 does nothing. Show `-H 0.0.0.0 --cloudflare` in the hints (install.ps1, install.sh, studio/setup.sh) and clarify the same in the README. * Studio: cross-platform masked terminal password prompt helper Per-keystroke '*' echo (POSIX termios cbreak / Windows msvcrt.getwch), backspace editing, Ctrl-C abort, EOF handling, confirmation loop with re-prompt on mismatch or policy failure. Pure should_prompt gate for the --secure/--cloudflare exposure paths. * Studio CLI: force a terminal password change before public tunnel exposure When a launch will start the Cloudflare tunnel (--secure, or --cloudflare on a non-api-only wildcard bind) and the admin account still has its seeded bootstrap password, prompt for a new password in the terminal (masked with '*', confirmed, re-prompting until valid) before any re-exec or server exists. The change is committed in the parent so it never crosses argv or the environment and older studio-venv children see it immediately. Without a terminal, warn and fall back to the backend bootstrap shutdown timer. Mirrors backend update_password semantics in one transaction: rehash, rotate the JWT secret, clear must_change_password, revoke refresh tokens, drop the desktop secret, then remove the stale credential files. * Studio: terminal password gate before the public tunnel (backend backstop) Never publish a trycloudflare URL while the seeded admin password is active: run_server now runs a terminal password-change gate after the tunnel decision and strictly before start_studio_tunnel. Interactive refusal fails closed (shutdown + exit 1, mirroring the secure gate); without a tty it warns and keeps the bootstrap deadline. Success applies the same effects as the change-password route (update_password + revoke_user_refresh_tokens) and drops the stale app.state.bootstrap_password. MIN_PASSWORD_LENGTH centralised in auth/storage.py and referenced by the HTTP schema. terminal_prompt.py carries the pure gate helper (interactive loop stubbed; supplied by the masked-input module). Also migrates the studio/setup.ps1 launch footer that still showed the bare wildcard hint. * README: reconcile remote-access section with opt-in Cloudflare tunnel * Studio: harden the terminal password gate after review - run.py: run the gate BEFORE the uvicorn socket binds. On a wildcard --cloudflare launch the served HTML injects the bootstrap credential for first login, so a pre-gate listener would hand the default password to anyone who reaches the raw port while the operator is still typing. The gate now also seeds the admin row itself (it can run before lifespan startup). - Headless launches that nothing would protect now fail closed: the bootstrap deadline never arms for api-only serving and UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables it, so warn-and-proceed would have promised a shutdown that never comes. Both the CLI and the backend refuse to publish in that case; the ordinary headless path still warns and relies on the 1h deadline, and no longer auto-fills the default credential into HTML served on a public URL. - storage.update_password gains revoke_refresh_tokens to delete the user's refresh tokens in the SAME transaction as the password commit; the change-password route and the backend gate use it (a separable follow-up delete could fail after the commit and leave a stale refresh token able to mint access tokens under the rotated secret). - clear_bootstrap_password is best-effort: a locked/undeletable file must not surface as a failed password change. - CLI masked reader: disable ISIG like the backend so Ctrl-Z cannot suspend the process with the shared terminal stuck in no-echo mode; handle Ctrl-C/Ctrl-Z as characters; treat stream EOF mid-line as an abort instead of submitting a partial password. Both readers restore terminal attrs from a SIGTERM/SIGHUP handler since a finally block cannot run when a default-disposition signal terminates the process. - Backend reader: decode byte-at-a-time through an incremental UTF-8 decoder so multi-byte characters split across read boundaries are no longer dropped; isatty checks tolerate closed/None streams. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: persist bootstrap suppression through lifespan startup The pre-bind password gate nulled app.state.bootstrap_password, but the FastAPI lifespan runs after it and re-reads the bootstrap password into app.state on both admin paths, so a headless public launch could still serve the injected credential in HTML. Carry a persistent suppress_bootstrap_injection flag that the lifespan honors instead. Also drop the quoted Tuple annotation on _terminal_password_gate that tripped the import-hoist lint (the typing import looked unused). * Studio CLI: keep the pre-exec auth DB private (0700 dir, 0600 db) On a fresh install the pre-exposure password gate creates auth/ and auth.db through the CLI before the backend ever runs, and sqlite3.connect leaves the DB 0644 under a 022 umask. Mirror backend storage.get_connection's chmod so the committed password hash and JWT secret are never world-readable, even if the launch aborts before the backend applies its own modes. * Tighten pre-exposure password gate comments * Studio: delete seeded bootstrap password before headless public re-exec The headless warn-and-proceed path returns with the default admin password still active, then re-execs a child Studio process. An old studio-venv child (mixed-version install) predates the pre-bind gate and its injection-suppress flag, so its lifespan reads .bootstrap_password and injects the seeded credential into the public HTML for up to the bootstrap deadline. A CLI-flag handshake cannot fix this uniformly: the studio run path uses ignore_unknown_options and an old in-venv child runs in-process, so it would never reject the flag. Delete the seeded .bootstrap_password file in the parent before re-exec so a fresh child of any version reads None and never serves it. This covers both re-exec paths and both child versions. must_change_password stays set, so the login page still forces a change and the bootstrap shutdown timer still arms; only the plaintext-on-disk copy is removed. Recovery is via a terminal-attached run or reset-password. Backend gate and CLI warnings updated to match. * Studio: commit the seeded admin before headless public re-exec The headless-warn path deletes the seeded .bootstrap_password so a re-exec'd child cannot inject it, but _ensure_cli_default_admin's INSERT was never committed and rolled back on conn.close(). On a fresh STUDIO_HOME an old studio-venv child then found no admin, regenerated a fresh bootstrap password + file, and injected THAT into the public page, defeating the deletion. Commit the seeded admin right after _ensure_cli_default_admin so any re-exec'd child sees the existing account and does not regenerate. Regression tests cover both re-exec paths on a fresh (unseeded) DB. * Studio: fail closed when the bootstrap password file cannot be removed On the headless public path, deleting .bootstrap_password is the protection against an old re-exec'd child injecting the seeded credential. If unlink fails (locked file, read-only auth dir) the file is still on disk, so warning and proceeding would still leak it for the bootstrap-timeout window. Abort with a clear error instead. Regression test covers the unlink-failure fail-closed path. * Studio: hold no-echo for the whole password line, not per keystroke The POSIX masked reader set cbreak/no-echo inside _getch_posix and restored the terminal to echo-on in a finally after every single keystroke, because _read_password calls _getch once per character. Between one char returning and the next call re-entering cbreak, ECHO was on, so a keystroke arriving in that window echoed the password in cleartext. Move the terminal mode into a _prompt_raw_mode context that _read_password holds around the entire line (mirroring unsloth_cli/commands/_password_prompt.py, which already did this), restoring once when the line completes or aborts. _getch_posix now only reads, since the mode is held by the caller. The context is a no-op when stdin is not a real terminal, keeping the _getch test seam. Add a regression test asserting the raw-mode context wraps the read exactly once and every keystroke is read while it is active. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: strip the seeded bootstrap password when the auth DB check fails The pre-exposure gate returned early on two auth-DB inspection failures and proceeded to re-exec without removing the seeded .bootstrap_password: - _connect_auth_db() failure: a seeded credential from a prior run may still be on disk. - the must_change_password read-back failure: worse, _ensure_cli_default_admin had already seeded the admin and the code committed it (writing .bootstrap_password) right before the failing SELECT. In the mixed-version case (a new outer CLI re-execing an old studio-venv child that predates the pre-bind gate), that child would read the file back and inject the default admin credential into the public Cloudflare page. The sibling headless branch already deletes the file for exactly this reason, so these returns were an inconsistent gap. Factor the delete-or-fail-closed logic into _strip_seeded_bootstrap_password_or_exit and call it on both inspection failures (and reuse it in the headless branch): strip the seeded file first (version-independent protection), failing closed if the removal itself fails. must_change_password stays set, so the login page still forces a change and the bootstrap shutdown timer still arms. Add tests for both new paths (connect failure and post-commit read-back failure strip the file and proceed; a failed strip fails closed). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fail closed when the seeded admin cannot be committed before exposure The pre-exposure gate wrapped _ensure_cli_default_admin (the INSERT), its conn.commit(), and the must_change_password read-back in one try, and the except recovered by stripping .bootstrap_password and proceeding to re-exec on the assumption the admin was already committed. That assumption only holds when the failing statement is the SELECT. When the INSERT or the commit itself fails (e.g. a write lock held past the busy timeout on a fresh install), no admin row is committed: it rolls back on conn.close(), and a re-exec'd old studio-venv child (no pre-bind gate) then finds no admin, regenerates a fresh bootstrap password + file, and serves that default credential on the public Cloudflare page. Stripping the file cannot stop a regeneration. Split the seed+commit into its own try that fails closed (refuse the public launch, best-effort removing any half-written seed file) since we cannot prove a committed admin; keep the separate read-back failure on the strip-and-proceed path, where the admin is committed so an old child finds it and will not regenerate. Add a test for the seed-commit-failure path. * Studio: decode the CLI masked password reader with errors="replace" The CLI reader read keystrokes with text-mode sys.stdin.read(1), which raises UnicodeDecodeError on a pasted non-UTF-8 password (e.g. Latin-1 bytes), or under PYTHONUTF8 yields a lone surrogate that later crashes the pbkdf2 encode -- either aborts the launch with a traceback. The backend mirror (terminal_prompt.py) already reads raw bytes through an incremental decoder with errors="replace". Mirror that here: read with os.read and an incremental decoder so invalid bytes map to U+FFFD, iterating over each emitted char (one byte can complete a replacement plus the next char). * Studio: resolve the child launcher before the pre-exposure gate The gate strips the seeded .bootstrap_password on a headless public launch, and it ran before the re-exec launchability check (studio venv / run.py / console script present). So a headless launch with an incomplete studio setup would seed the admin, delete the bootstrap password, then abort because the child could not be found, leaving the admin at must_change_password=1 with no password ever shown or injectable: locked out until `unsloth studio reset-password`. Resolve and validate the child launcher first, in both `studio` (studio_default) and `studio run`, and only then run the gate, so an unlaunchable setup exits before anything is stripped. Add a regression test that a missing venv exits without removing the seeded file. * Studio: fail closed when the auth DB cannot be opened before exposure The connect-failure branch of the pre-exposure gate stripped .bootstrap_password and proceeded, on the assumption a committed admin from a prior run made an old child find it and not regenerate. But on a fresh public launch whose _connect_auth_db() itself fails (transient lock during the schema/seed step, or an unwritable home), no admin is committed, so a mixed-version re-exec child that predates the backend gate can find no user, generate a fresh bootstrap password, and serve it on the public Cloudflare page. Stripping a file we cannot vouch for cannot stop a regeneration. Make this branch fail closed like the seed/commit failure path: we only continue past the DB inspection once a committed admin is confirmed. The existing file is left untouched so a retry (after a transient lock clears) can still prompt. Update the connect-failure test to assert fail-closed, and give the in-venv --secure flag test a real STUDIO_HOME with an already-changed admin so the gate is a no-op rather than relying on a DB-open failure. * Studio: invalidate seeded bootstrap files before deleting auth.db on reset reset-password deleted auth.db first, then best-effort unlinked the seeded .bootstrap_password and desktop secret. unlink() only ignores FileNotFoundError, so a locked or read-only file (Windows AV, read-only auth dir) survived while auth.db was gone. The next server start then re-seeded from that stale plaintext and re-validated the exact credential the reset was meant to revoke. Invalidate the credential files first, truncating any that cannot be unlinked, then delete the DB, so a surviving file can never carry a reusable secret. clear_bootstrap_password now truncates on unlink failure for the same reason, and its warning says the contents were cleared rather than claiming the stale password is already invalid. * Studio: require a servable frontend before the pre-exposure gate can strip the seeded password A headless public launch strips the seeded .bootstrap_password before the re-exec'd child starts. If the child then cannot serve the login page (the only in-band way to change the seeded password) the admin is locked out (must_change_password=1, no file, no UI) until reset-password. Add _require_servable_frontend_or_exit and call it before the gate on both `unsloth studio` and `unsloth studio run` public launches: fail closed if a non-api-only public launch has no built frontend dist, before anything is stripped. A user-supplied --frontend is validated to contain index.html so a bad path cannot silently bypass the check; an auto-resolved dist is trusted (_find_frontend_dist already requires index.html) and forwarded to the child. Model-load aborts on `studio run` remain a residual: the parent must strip for mixed-version safety (an old studio-venv child has no pre-bind gate) and model loadability cannot be proven before exec, so that path stays recoverable via reset-password. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: harden reset-password ordering and validate the in-venv backend before the strip Three follow-ups to the pre-exposure hardening: reset-password now deletes auth.db FIRST and proves it is gone before touching the seeded credential files. If the DB cannot be removed (a running Studio or Windows holds it open, or a read-only auth dir) it aborts with the credential files untouched, so a forgotten-password reset is not left half-done with the recovery credentials deleted while an un-resettable must_change_password=1 DB survives. After the DB is gone it invalidates the stale credential files (unlink, else truncate) and fails closed if a file can be neither removed nor truncated, since a surviving plaintext would be re-seeded by generate_bootstrap_password() and re-validate the revoked password. The in-venv (in-process) launch path had no analogue of the re-exec launcher check: a headless public launch would seed the admin and strip the seeded .bootstrap_password in the gate before _load_run_module() later failed on a broken/partial venv, leaving must_change_password=1 with no password to log in. Add _validate_inproc_backend_before_strip, called on the in-venv path (both `unsloth studio` and `unsloth studio run`) before the gate on the headless public path, so a broken backend fails cleanly before anything is stripped. It is scoped to the headless path so an interactive prompt is not delayed behind a full backend import. * Studio: validate the frontend and tunnel before the strip on every public path Five follow-ups closing the remaining pre-exposure-strip lockouts: The in-venv (in-process) paths of both `unsloth studio` and `unsloth studio run` validated the backend but not the frontend before the gate, so a headless public launch with a missing/bad dist would strip the seeded .bootstrap_password and then abort in run_server() during frontend setup, leaving must_change_password=1 with no login page. Both now validate a servable frontend before the strip (cheap check first, backend import after) and serve the resolved dist in-process. The `studio run` re-exec discarded the dist that satisfied the pre-strip check and only forwarded a user-supplied --frontend. In a shadowed install where the parent finds a built dist the child cannot, it stripped and exec'd without the path, and the child aborted during frontend setup. It now forwards the resolved dist, matching `unsloth studio`. On a headless --secure launch the bind is loopback, so the Cloudflare tunnel is the only public exposure. If cloudflared is provably unavailable (found nowhere and undownloadable) the tunnel cannot start, so stripping the recovery credential would just lock the user out with no public URL ever served. Add _tunnel_binary_confirmed_unavailable and, on --secure only, refuse the launch with the credential preserved rather than strip. Wildcard --cloudflare binds 0.0.0.0 publicly regardless of the tunnel, so it still strips; any uncertainty (helper not loadable) also still strips, since a possible credential leak outweighs a recoverable lockout. clear_bootstrap_password no longer claims it cleared the file's contents when both unlink and truncate failed; it now reports the stale password is still on disk and asks the user to remove it manually. * Studio: fix cloudflared probe path and skip the bootstrap strip for a self-suppressing child Two follow-ups to the --secure pre-exposure hardening: The cloudflared availability probe loaded cloudflare_tunnel by file path but not its backend deps: 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 saw ensure_cloudflared() return None (cache unresolvable) and wrongly treated the tunnel as unavailable, refusing --secure even when cloudflared was cached or downloadable. Add the backend dir to sys.path for the probe (and remove it after) so the cache path resolves as it will in the child. A headless --secure launch stripped the seeded .bootstrap_password before the child proved the tunnel could actually connect, so a cloudflared that is present but cannot establish the tunnel (blocked connectivity, Cloudflare outage) left must_change_password=1 with no recovery credential. But the strip is only needed when the re-exec'd child is an OLD studio-venv backend with no pre-bind suppression: this install's own run.py sets app.state.suppress_bootstrap_injection before binding and never serves the seeded credential publicly. Add _child_self_suppresses (true in-process, or when the re-exec target is this install's own run.py by path identity) and skip the strip in that case, keeping .bootstrap_password as a local recovery credential; the strip stays fully in force for the studio-venv console-script path and any venv-fallback run.py, where an old child is actually possible. * Studio: reword the pre-exposure terminal password prompt * Studio: warn when -H is overridden by --secure; align pre-exposure prompt wording - --secure/--secure run: emit a Note (not an error) when -H is a non-loopback host, since --secure forces the loopback bind and would otherwise discard -H silently. - Reword the pre-exposure terminal prompt to 'exposed on the public internet' in both the backend gate and the CLI mirror. - Align the CLI success line with the backend ("Password updated for '<user>'."). - Tests for the new -H warning (present when overridden, absent on loopback). * Studio: add non-interactive --password to set the initial admin password Headless hosts (CI, containers, systemd units) have no TTY, so the forced first-exposure password change could not be completed unattended. Add a non-interactive way to set the INITIAL admin password before the server binds: - --password <value>, the UNSLOTH_STUDIO_PASSWORD env var, or --password - (read one line from stdin). Off by default; unset falls back to the normal interactive terminal prompt / browser setup. - Applies on any launch (public --secure/--cloudflare or a headless -H 0.0.0.0 bind), only when the account still has its seeded bootstrap password. An already-set password is a hard error, never an override; an invalid value (too short, or equal to the bootstrap) fails closed before bind. - The CLI applies the change in the parent, never forwards --password to the re-exec child, and strips UNSLOTH_STUDIO_PASSWORD from the child env so the secret never crosses to the child. run.py does the same on the direct path and strips the env var so spawned subprocesses (cloudflared, llama-server, tools) cannot inherit it. Mirrors resolve_supplied_password across the CLI and backend, documents the option in the README (including the argv-visibility caveat), and covers all flows (env/stdin/literal, fail-closed cases, no-forward, env-strip, reset-password roundtrip) in the CLI, backend, and unit suites. * Studio: truncate the stale bootstrap file when unlink fails on a CLI password change The post-change cleanup in _cli_update_password only warned when .bootstrap_password could not be unlinked but was still writable (locked file, read-only auth dir), leaving the old plaintext on disk. If auth.db is later reset or removed, generate_bootstrap_password() reads that file back and re-validates the revoked bootstrap password. Truncate the file on unlink failure so its stale plaintext cannot be re-seeded, mirroring the backend clear_bootstrap_password(); the password change is already committed, so this never rolls it back. The warning now states truthfully whether the contents were cleared or the file must be removed manually. * Studio: tighten comments --------- Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
e1e38419df
commit
91a0df9514
21 changed files with 3953 additions and 129 deletions
238
unsloth_cli/commands/_password_prompt.py
Normal file
238
unsloth_cli/commands/_password_prompt.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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 ───────────
|
||||
|
|
|
|||
1397
unsloth_cli/tests/test_studio_password_prompt.py
Normal file
1397
unsloth_cli/tests/test_studio_password_prompt.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue