* 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>
1397 lines
58 KiB
Python
1397 lines
58 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the forced terminal password change before public (tunnel) exposure.
|
|
|
|
`unsloth studio --secure` / `--cloudflare` (wildcard bind) must, when the admin
|
|
account still has its seeded bootstrap password, prompt for a new password in
|
|
the terminal BEFORE any re-exec or server exists; without a terminal it warns
|
|
and falls back to the backend bootstrap timeout. Modeled on
|
|
test_studio_cloudflare_flag.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
def _studio():
|
|
from unsloth_cli.commands import studio as _studio_mod
|
|
return _studio_mod
|
|
|
|
|
|
_BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"]
|
|
_NEW_PW = "brand-new-password"
|
|
|
|
|
|
# ── pure trigger matrix ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cloudflare,host,secure,api_only,expected",
|
|
[
|
|
# --secure always implies the tunnel (host already forced to loopback).
|
|
(None, "127.0.0.1", True, False, True),
|
|
(True, "127.0.0.1", True, False, True),
|
|
(None, "127.0.0.1", True, True, True),
|
|
# --cloudflare tunnels only non-api-only wildcard binds.
|
|
(True, "0.0.0.0", False, False, True),
|
|
(True, "::", False, False, True),
|
|
(True, "127.0.0.1", False, False, False),
|
|
(True, "0.0.0.0", False, True, False),
|
|
# Off/unset never prompts without --secure.
|
|
(None, "0.0.0.0", False, False, False),
|
|
(False, "0.0.0.0", False, False, False),
|
|
(None, "127.0.0.1", False, False, False),
|
|
],
|
|
)
|
|
def test_should_prompt_password_change_matrix(cloudflare, host, secure, api_only, expected):
|
|
assert (
|
|
_studio()._should_prompt_password_change(
|
|
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
|
)
|
|
is expected
|
|
)
|
|
|
|
|
|
# ── shared harness ───────────────────────────────────────────────────
|
|
|
|
|
|
class _ExecCaptured(SystemExit):
|
|
def __init__(self, argv):
|
|
super().__init__(0)
|
|
self.argv = list(argv)
|
|
|
|
|
|
def _auth_db(studio_home: Path) -> Path:
|
|
return studio_home / "auth" / "auth.db"
|
|
|
|
|
|
def _seed_auth(studio_mod, *, must_change = True):
|
|
"""Create the CLI-side default admin (must_change_password=1) plus one
|
|
refresh token, mirroring a fresh install that served a login."""
|
|
conn = studio_mod._connect_auth_db()
|
|
try:
|
|
studio_mod._ensure_cli_default_admin(conn)
|
|
if not must_change:
|
|
conn.execute("UPDATE auth_user SET must_change_password = 0")
|
|
conn.execute(
|
|
"INSERT INTO refresh_tokens (token_hash, username, expires_at) VALUES (?, ?, ?)",
|
|
("deadbeef", studio_mod.DEFAULT_ADMIN_USERNAME, "2099-01-01T00:00:00"),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute(
|
|
"SELECT password_hash, jwt_secret FROM auth_user WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
return {"password_hash": row[0], "jwt_secret": row[1]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _auth_state(studio_mod):
|
|
conn = sqlite3.connect(_auth_db(studio_mod.STUDIO_HOME))
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT password_hash, jwt_secret, must_change_password FROM auth_user "
|
|
"WHERE username = ?",
|
|
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
|
).fetchone()
|
|
n_refresh = conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0]
|
|
return {
|
|
"password_hash": row[0],
|
|
"jwt_secret": row[1],
|
|
"must_change_password": row[2],
|
|
"n_refresh": n_refresh,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _install_prompt_env(
|
|
monkeypatch,
|
|
tmp_path,
|
|
*,
|
|
interactive,
|
|
scripted = _NEW_PW,
|
|
):
|
|
"""Tmp STUDIO_HOME + fake tty + scripted prompt. Returns the event log that
|
|
records prompt calls and re-exec argv in order."""
|
|
studio_mod = _studio()
|
|
events = []
|
|
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
monkeypatch.setattr(studio_mod, "_prompt_streams_interactive", lambda: interactive)
|
|
# cloudflared is "available" by default so the headless --secure strip path
|
|
# proceeds without a real download; the unavailable-tunnel guard has its own
|
|
# dedicated test that overrides this.
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: False)
|
|
|
|
def fake_prompt(verify_current, out = None):
|
|
events.append(("prompt", verify_current))
|
|
if isinstance(scripted, BaseException):
|
|
raise scripted
|
|
return scripted
|
|
|
|
monkeypatch.setattr(studio_mod._password_prompt, "prompt_new_password", fake_prompt)
|
|
return events
|
|
|
|
|
|
def _install_studio_default_reexec(monkeypatch, events):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
# A built frontend dist is present by default so the public-launch UI check
|
|
# passes; the no-dist lockout guard has its own dedicated test.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
|
|
def _install_run_reexec(monkeypatch, events):
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
# A built frontend dist is present by default so the public-launch UI check
|
|
# passes deterministically (independent of whether the repo dist was built);
|
|
# the missing-dist lockout guard has its own dedicated test.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
fake_bin = fake_venv / "bin" / "unsloth"
|
|
real_is_file = Path.is_file
|
|
monkeypatch.setattr(
|
|
Path,
|
|
"is_file",
|
|
lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
|
|
)
|
|
from unsloth_cli import _tool_policy as _tp_mod
|
|
|
|
monkeypatch.setattr(
|
|
_tp_mod,
|
|
"resolve_tool_policy",
|
|
lambda host, flag, yes, silent: False if flag is None else bool(flag),
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
|
|
def _invoke_studio_default(monkeypatch, events, args):
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
return CliRunner().invoke(app, args, catch_exceptions = True)
|
|
|
|
|
|
def _invoke_run(monkeypatch, events, args):
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
_install_run_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
return CliRunner().invoke(app, args, catch_exceptions = True)
|
|
|
|
|
|
# ── plain `unsloth studio` ───────────────────────────────────────────
|
|
|
|
|
|
def test_studio_default_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["jwt_secret"] != before["jwt_secret"]
|
|
assert after["n_refresh"] == 0
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
|
|
|
|
def test_studio_default_prompt_rejects_current_password(monkeypatch, tmp_path):
|
|
# The verify_current callback handed to the prompt must recognize the
|
|
# seeded bootstrap password (hash compare with the stored salt).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text()
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
verify_current = events[0][1]
|
|
assert verify_current(bootstrap_pw) is True
|
|
assert verify_current("something-else-entirely") is False
|
|
|
|
|
|
def test_studio_default_non_tty_warns_and_proceeds(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" in combined
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path):
|
|
# Mixed-version safety: a headless public launch must delete the seeded
|
|
# plaintext credential before re-exec so a fresh child of ANY version reads
|
|
# None from disk and never injects it into the public HTML. The launch still
|
|
# proceeds (re-exec captured), and the DB flag stays set so the login page
|
|
# still forces a change and the bootstrap shutdown timer still arms.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_reexec_outer_runpy_keeps_bootstrap_for_local_recovery(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (Codex 3572165931): when the re-exec target is THIS install's own
|
|
# run.py, the child's pre-bind gate sets suppress_bootstrap_injection and never
|
|
# serves the seeded credential publicly, so the parent strip is unnecessary.
|
|
# Skipping it means a --secure launch whose tunnel later fails to connect does
|
|
# not lock the user out, and .bootstrap_password stays for local recovery.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
# Re-exec target IS this install's outer run.py -> child self-suppresses.
|
|
outer_run_py = studio_mod._PACKAGE_ROOT / "studio" / "backend" / "run.py"
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: outer_run_py)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
# Strip skipped: file preserved, must_change still set, launch still re-execs.
|
|
assert bootstrap_file.exists(), result.output
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert "exec" in [k for k, _ in events], events
|
|
|
|
|
|
def test_studio_default_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path):
|
|
# Fresh STUDIO_HOME (no pre-seed): the gate's own _ensure_cli_default_admin
|
|
# does the INSERT. It must COMMIT that seed before re-exec, or conn.close()
|
|
# rolls it back and an OLD child would find no admin, regenerate a fresh
|
|
# bootstrap password + file, and inject THAT -- defeating the file deletion.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
# Deliberately NO _seed_auth(): exercise the gate seeding a fresh DB itself.
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
# The seeded admin persists (committed) so an old child sees it and does not
|
|
# regenerate; the bootstrap file stays deleted; the launch still re-execs.
|
|
state = _auth_state(studio_mod)
|
|
assert state["must_change_password"] == 1
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_studio_default_non_tty_fails_closed_when_bootstrap_removal_fails(monkeypatch, tmp_path):
|
|
# Removing .bootstrap_password IS the protection on this path. If unlink
|
|
# fails (locked file / read-only auth dir) the credential is still on disk
|
|
# for an old child to inject, so the launch must fail closed, not publish.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# The file remains (removal failed) and the DB flag is untouched.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
class _FailingSelectConn:
|
|
"""Wrap a real auth connection but raise on the gate's must_change SELECT,
|
|
so seeding + commit still happen and only the read-back fails (a locked-DB
|
|
window that lands after _ensure_cli_default_admin already wrote the file)."""
|
|
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
def execute(self, sql, *args, **kwargs):
|
|
if sql.lstrip().startswith("SELECT password_salt"):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
return self._inner.execute(sql, *args, **kwargs)
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
class _FailingCommitConn:
|
|
"""Wrap a real auth connection but raise on commit(), so a fresh install's
|
|
seeded admin INSERT rolls back on close() -- the seed-committed guarantee the
|
|
gate depends on is not met, even though _ensure_cli_default_admin already
|
|
wrote the .bootstrap_password file."""
|
|
|
|
def __init__(self, inner):
|
|
self._inner = inner
|
|
|
|
def commit(self):
|
|
raise sqlite3.OperationalError("database is locked")
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._inner, name)
|
|
|
|
|
|
def test_studio_default_connect_failure_fails_closed(monkeypatch, tmp_path):
|
|
# If the auth DB cannot even be opened (transient lock / unwritable home) we
|
|
# cannot confirm a committed admin exists, so a re-exec'd old studio-venv child
|
|
# could find no admin, regenerate a fresh bootstrap credential, and serve it
|
|
# publicly -- stripping a file we cannot vouch for would not stop that. Refuse
|
|
# rather than publish; a transient lock clears on retry, and the existing
|
|
# credential file is left untouched so a retry can still prompt.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(
|
|
studio_mod,
|
|
"_connect_auth_db",
|
|
lambda: (_ for _ in ()).throw(sqlite3.OperationalError("database is locked")),
|
|
)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# Not stripped: a retry can still prompt/strip once the lock clears.
|
|
assert bootstrap_file.exists()
|
|
|
|
|
|
def test_studio_default_seed_commit_failure_fails_closed(monkeypatch, tmp_path):
|
|
# Fresh install: the gate's own _ensure_cli_default_admin does the INSERT and
|
|
# writes .bootstrap_password, but the commit fails (write lock held past
|
|
# busy_timeout). The uncommitted admin rolls back on close, so a re-exec'd old
|
|
# child would find no admin and regenerate + serve a fresh default credential;
|
|
# stripping cannot stop a regeneration. The gate must fail closed.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
# Deliberately NO _seed_auth(): the gate seeds the fresh DB itself, then commit fails.
|
|
real_connect = studio_mod._connect_auth_db
|
|
monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingCommitConn(real_connect()))
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
# The half-written seed file is stripped, and no admin row was committed.
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
verify = sqlite3.connect(_auth_db(tmp_path))
|
|
try:
|
|
assert verify.execute("SELECT COUNT(*) FROM auth_user").fetchone()[0] == 0
|
|
finally:
|
|
verify.close()
|
|
|
|
|
|
def test_studio_default_missing_venv_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression: the venv/run.py launchability check must run BEFORE the headless
|
|
# gate strips .bootstrap_password. Otherwise a failed launch leaves the admin
|
|
# at must_change_password=1 with no password to log in (lockout until
|
|
# reset-password). With the venv missing, exit without stripping the file.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: None) # venv missing
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: None)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# The seeded file survives: launchability failed BEFORE the gate could strip it.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
# The gate never ran (no prompt, no strip).
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "not set up" in combined.lower()
|
|
|
|
|
|
def test_studio_default_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B): a public UI launch needs a built frontend dist -- the
|
|
# login page is the ONLY way to change the seeded password. Resolve it BEFORE
|
|
# the headless gate strips .bootstrap_password, so a missing dist aborts the
|
|
# launch without stripping (no lockout at must_change_password=1 with nothing
|
|
# left to log in with).
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
# Launcher present, but no built frontend dist.
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# The seeded file survives: the frontend check failed BEFORE the gate stripped it.
|
|
assert bootstrap_file.exists()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
# The gate never ran (no prompt, no strip, no exec).
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_studio_default_bad_frontend_path_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B / reviewer finding): a user-supplied --frontend that does
|
|
# not contain index.html must NOT bypass the servable-UI guard. Otherwise the
|
|
# headless gate strips .bootstrap_password and the child serves no login page
|
|
# -> lockout. Validate the path BEFORE the gate and abort without stripping.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
# Auto-resolution would find a dist, but the user forced an empty one (no
|
|
# index.html): the guard must reject it rather than trust it.
|
|
monkeypatch.setattr(
|
|
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
|
)
|
|
empty_dir = tmp_path / "empty_frontend"
|
|
empty_dir.mkdir()
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(
|
|
app, ["--secure", "--frontend", str(empty_dir)], catch_exceptions = True
|
|
)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "index.html" in combined.lower()
|
|
|
|
|
|
def test_studio_default_missing_frontend_loopback_cloudflare_still_launches(monkeypatch, tmp_path):
|
|
# The dist guard is scoped to public exposure only. A loopback --cloudflare
|
|
# (default host) does not tunnel, so a missing dist must NOT abort it -- the
|
|
# launch proceeds exactly as before.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
events.append(("exec", list(argv)))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--cloudflare"], catch_exceptions = True)
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], (events, result.output)
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend not built" not in combined.lower()
|
|
|
|
|
|
def test_studio_default_in_venv_broken_backend_exits_before_stripping_bootstrap(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (item B / reviewer finding): the in-venv (in-process) path skips
|
|
# the re-exec launcher check, so a headless public launch would seed + strip
|
|
# the seeded .bootstrap_password in the gate before _load_run_module() later
|
|
# fails on a broken/partial venv -> lockout. Validate the backend is
|
|
# importable BEFORE the strip and abort without stripping.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
# Pretend we are already inside the studio venv, with a broken backend.
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio"))
|
|
|
|
def _boom():
|
|
raise ImportError("cannot import backend run.py")
|
|
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", _boom)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events # gate never stripped/prompted
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "backend could not be loaded" in combined.lower()
|
|
|
|
|
|
def test_studio_default_in_venv_missing_frontend_exits_before_stripping_bootstrap(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Regression (Codex): the in-venv (in-process) path validated the backend but
|
|
# not the frontend, so a headless public launch would strip the seeded
|
|
# password in the gate before run_server() aborted on a missing dist. Validate
|
|
# the servable frontend BEFORE the strip, same as the re-exec path.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_studio_default_secure_tunnel_unavailable_preserves_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (Codex): a headless --secure launch strips the only plaintext
|
|
# recovery credential before the child proves the tunnel can start. If
|
|
# cloudflared is provably unavailable no public URL comes up (loopback bind),
|
|
# so the strip must be skipped and the launch refused, preserving recovery.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
# cloudflared cannot be found or downloaded -> the --secure tunnel is dead.
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # preserved for recovery, NOT stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert "exec" not in [k for k, _ in events], events # never re-exec'd
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "cloudflared" in combined.lower()
|
|
|
|
|
|
def test_studio_default_wildcard_cloudflare_strips_even_if_tunnel_unavailable(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# The unavailable-tunnel skip is --secure-only: a wildcard --cloudflare binds
|
|
# 0.0.0.0 publicly regardless of the tunnel, so the seeded password must still
|
|
# be stripped even when cloudflared is unavailable.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
monkeypatch.setattr(studio_mod, "_tunnel_binary_confirmed_unavailable", lambda: True)
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
result = CliRunner().invoke(app, ["-H", "0.0.0.0", "--cloudflare"], catch_exceptions = True)
|
|
|
|
# Still strips (raw public bind) and re-execs.
|
|
assert not bootstrap_file.exists(), result.output
|
|
assert "exec" in [k for k, _ in events], events
|
|
|
|
|
|
def test_tunnel_probe_adds_backend_to_syspath(monkeypatch, tmp_path):
|
|
# Regression (Codex 3572165922): ensure_cloudflared -> _cache_path lazily
|
|
# imports utils.paths.storage_roots, which only resolves when studio/backend is
|
|
# on sys.path. From the outer CLI it is not, so the probe must add it or it
|
|
# false-reports "unavailable" and wrongly refuses --secure. Model that with a
|
|
# cloudflare_tunnel whose ensure_cloudflared resolves ONLY when backend is on
|
|
# sys.path.
|
|
studio_mod = _studio()
|
|
backend = tmp_path / "backend"
|
|
backend.mkdir()
|
|
(backend / "cloudflare_tunnel.py").write_text(
|
|
"import sys\n"
|
|
f"_BACKEND = {str(backend)!r}\n"
|
|
"def ensure_cloudflared():\n"
|
|
" # Resolvable (cached) ONLY when the backend dir is importable.\n"
|
|
" return '/fake/cloudflared' if _BACKEND in sys.path else None\n"
|
|
)
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: backend / "run.py")
|
|
assert str(backend) not in sys.path # precondition
|
|
|
|
result = studio_mod._tunnel_binary_confirmed_unavailable()
|
|
|
|
# ensure_cloudflared resolved (backend was on sys.path) -> available -> not
|
|
# "confirmed unavailable"; without the fix it would false-report True.
|
|
assert result is False
|
|
# The probe cleans up the sys.path entry it added.
|
|
assert str(backend) not in sys.path
|
|
|
|
|
|
def test_studio_default_query_failure_strips_bootstrap_file(monkeypatch, tmp_path):
|
|
# The DB opens and the admin is seeded + committed (so .bootstrap_password is
|
|
# on disk), but reading must_change_password back fails. Returning here would
|
|
# re-exec with the freshly seeded credential still on disk; strip it first.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
real_connect = studio_mod._connect_auth_db
|
|
monkeypatch.setattr(studio_mod, "_connect_auth_db", lambda: _FailingSelectConn(real_connect()))
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "removing the seeded bootstrap password" in combined.lower()
|
|
|
|
|
|
def test_studio_default_loopback_cloudflare_never_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--cloudflare"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "prompt" not in kinds, events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" not in combined
|
|
|
|
|
|
def test_studio_default_changed_password_never_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_studio_default_refusal_aborts_launch(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(
|
|
monkeypatch, tmp_path, interactive = True, scripted = KeyboardInterrupt()
|
|
)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert result.exit_code == 1, result.output
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_wildcard_cloudflare_prompts(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["-H", "0.0.0.0", "--cloudflare"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
|
|
|
|
# ── `unsloth studio run` ─────────────────────────────────────────────
|
|
|
|
|
|
def test_run_secure_prompts_and_updates_before_reexec(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["prompt", "exec"], events
|
|
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["n_refresh"] == 0
|
|
|
|
|
|
def test_run_non_tty_warns_and_proceeds(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "bootstrap password" in combined
|
|
|
|
|
|
def test_run_non_tty_deletes_bootstrap_password_file(monkeypatch, tmp_path):
|
|
# Same mixed-version safety for the `unsloth studio run` re-exec path (which
|
|
# cannot fail-close an old child via a CLI flag): the seeded credential file
|
|
# is deleted before re-exec, the launch still proceeds, and the DB flag holds.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
assert not bootstrap_file.exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_run_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (item B / reviewer finding 4): `unsloth studio run` serves the
|
|
# same Studio UI and strips the seeded password on a headless public launch,
|
|
# so a missing frontend dist must abort BEFORE the strip -- the same lockout
|
|
# guard as `unsloth studio`, not just `studio run`'s model-load residual.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
_install_run_reexec(monkeypatch, events)
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events # no strip, no exec
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_run_in_venv_missing_frontend_exits_before_stripping_bootstrap(monkeypatch, tmp_path):
|
|
# Regression (Codex 3571888563): the in-venv `studio run` path validated only
|
|
# the backend, so a headless public launch would strip the seeded password
|
|
# before run_server() aborted on a missing dist. Validate the frontend first.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.exists()
|
|
|
|
monkeypatch.setattr(sys, "prefix", str(tmp_path / "unsloth_studio")) # in-venv
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None) # no built dist
|
|
monkeypatch.setattr(studio_mod, "_load_run_module", lambda: None) # backend fine
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
result = CliRunner().invoke(app, _BASE + ["--secure"], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
assert bootstrap_file.exists() # not stripped
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
assert events == [], events
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "frontend is not built" in combined.lower()
|
|
|
|
|
|
def test_run_reexec_forwards_resolved_frontend_on_public_launch(monkeypatch, tmp_path):
|
|
# Regression (Codex 3571888570): the run re-exec discarded the dist resolved
|
|
# by the pre-strip check and only forwarded a user-supplied --frontend. On a
|
|
# public launch it must forward the resolved dist so a shadowed child that
|
|
# cannot self-resolve one still serves it (no post-strip lockout).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False) # gate is a no-op -> straight to re-exec
|
|
|
|
# _install_run_reexec resolves _find_frontend_dist -> /fake/studio/frontend/dist.
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"]) # no user --frontend
|
|
|
|
exec_argv = [argv for kind, argv in events if kind == "exec"][0]
|
|
assert "--frontend" in exec_argv, exec_argv
|
|
assert exec_argv[exec_argv.index("--frontend") + 1] == "/fake/studio/frontend/dist", exec_argv
|
|
|
|
|
|
def test_run_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path):
|
|
# Fresh STUDIO_HOME on the `run` re-exec path: the seeded admin must be
|
|
# committed before re-exec so an old console-script child does not regenerate
|
|
# and inject a fresh bootstrap credential.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
state = _auth_state(studio_mod)
|
|
assert state["must_change_password"] == 1
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
kinds = [kind for kind, _ in events]
|
|
assert kinds == ["exec"], events
|
|
|
|
|
|
def test_run_non_tty_api_only_fails_closed(monkeypatch, tmp_path):
|
|
# api-only serving never arms the bootstrap shutdown deadline, so a
|
|
# headless public launch with the default password has no safeguard at
|
|
# all: the CLI must refuse rather than promise a shutdown that never comes.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_run(monkeypatch, events, _BASE + ["--secure", "--api-only"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1
|
|
|
|
|
|
def test_studio_default_non_tty_disabled_deadline_fails_closed(monkeypatch, tmp_path):
|
|
# UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables the deadline; headless +
|
|
# default password + public tunnel then has no protection -> refuse.
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "0")
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
kinds = [kind for kind, _ in events]
|
|
assert "exec" not in kinds, events
|
|
assert result.exit_code == 1, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "refusing to publish" in combined.lower()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
(None, True), # unset -> default 1h
|
|
("", True),
|
|
("garbage", True), # malformed must not remove protection
|
|
("3600", True),
|
|
("1", True),
|
|
("0", False),
|
|
("-5", False),
|
|
],
|
|
)
|
|
def test_bootstrap_deadline_active_mirrors_backend_parsing(monkeypatch, raw, expected):
|
|
studio_mod = _studio()
|
|
if raw is None:
|
|
monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False)
|
|
else:
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raw)
|
|
assert studio_mod._bootstrap_deadline_active() is expected
|
|
|
|
|
|
def test_reset_password_truncates_locked_bootstrap_after_db_delete(monkeypatch, tmp_path):
|
|
# reset-password deletes auth.db first, then invalidates the seeded credential
|
|
# files. A locked/undeletable .bootstrap_password must be truncated so its
|
|
# stale plaintext cannot be re-seeded (generate_bootstrap_password reuses a
|
|
# non-empty file), while the reset still succeeds.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
auth_dir = tmp_path / "auth"
|
|
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
db_file = auth_dir / "auth.db"
|
|
assert bootstrap_file.exists() and db_file.exists()
|
|
assert bootstrap_file.read_text().strip()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.reset_password)
|
|
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert not db_file.exists()
|
|
# The locked file survives, but truncated -- no reusable plaintext.
|
|
assert bootstrap_file.exists()
|
|
assert bootstrap_file.read_text() == ""
|
|
|
|
|
|
def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch, tmp_path):
|
|
# After a CLI/interactive password change the seeded .bootstrap_password is
|
|
# deleted. If it cannot be unlinked but is still writable (locked file /
|
|
# read-only dir), it must be TRUNCATED so its stale plaintext cannot be
|
|
# re-seeded by generate_bootstrap_password() after a later reset-password
|
|
# deletes auth.db. The change is already committed, so it must NOT roll back.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_file = tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
assert bootstrap_file.read_text().strip()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
conn = studio_mod._connect_auth_db()
|
|
studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "fresh-new-pw-123")
|
|
conn.close()
|
|
|
|
# The change committed (must_change cleared) AND the locked file is truncated.
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
assert bootstrap_file.exists()
|
|
assert bootstrap_file.read_text() == ""
|
|
|
|
|
|
def test_reset_password_fails_closed_when_db_cannot_be_deleted(monkeypatch, tmp_path):
|
|
# If auth.db cannot be removed (running Studio / Windows lock, read-only dir),
|
|
# reset must abort BEFORE touching the credential files -- deleting them while
|
|
# an un-resettable must_change_password=1 DB survives would lock a
|
|
# forgotten-password reset out with no recovery credential.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
auth_dir = tmp_path / "auth"
|
|
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
db_file = auth_dir / "auth.db"
|
|
assert bootstrap_file.exists() and db_file.exists()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == "auth.db":
|
|
raise OSError("database is locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.reset_password)
|
|
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# DB still there; credential files untouched (no lockout, no half-done reset).
|
|
assert db_file.exists()
|
|
assert bootstrap_file.exists()
|
|
assert bootstrap_file.read_text().strip()
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "could not delete the auth database" in combined.lower()
|
|
|
|
|
|
def test_reset_password_fails_closed_when_credential_cannot_be_invalidated(monkeypatch, tmp_path):
|
|
# If a seeded credential file can be neither unlinked nor truncated, reset must
|
|
# fail closed: auth.db is already gone, so a surviving plaintext would be
|
|
# re-seeded and re-validate the revoked password.
|
|
import pathlib
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
auth_dir = tmp_path / "auth"
|
|
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
|
db_file = auth_dir / "auth.db"
|
|
assert bootstrap_file.exists() and db_file.exists()
|
|
|
|
_real_unlink = pathlib.Path.unlink
|
|
_real_write_text = pathlib.Path.write_text
|
|
|
|
def _boom_unlink(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("locked")
|
|
return _real_unlink(self, *a, **k)
|
|
|
|
def _boom_write_text(self, *a, **k):
|
|
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
|
raise OSError("read-only")
|
|
return _real_write_text(self, *a, **k)
|
|
|
|
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
|
monkeypatch.setattr(pathlib.Path, "write_text", _boom_write_text)
|
|
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.reset_password)
|
|
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
|
|
|
assert result.exit_code == 1, result.output
|
|
# auth.db was deleted first; the un-invalidatable file is reported for manual removal.
|
|
assert not db_file.exists()
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "delete it manually" in combined.lower()
|
|
|
|
|
|
def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path):
|
|
# Fresh install: the CLI gate writes the password hash + JWT secret before
|
|
# the backend ever runs, so this path must apply the same 0700/0600 modes
|
|
# as backend storage.get_connection (sqlite3.connect creates 0644 files
|
|
# under a 022 umask).
|
|
import os as _os
|
|
import stat
|
|
|
|
if _os.name == "nt":
|
|
pytest.skip("POSIX permission bits")
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
conn = studio_mod._connect_auth_db()
|
|
conn.close()
|
|
auth_dir = tmp_path / "auth"
|
|
assert stat.S_IMODE(auth_dir.stat().st_mode) == 0o700
|
|
assert stat.S_IMODE((auth_dir / "auth.db").stat().st_mode) == 0o600
|
|
|
|
|
|
# ── non-interactive --password / UNSLOTH_STUDIO_PASSWORD / stdin ──────
|
|
|
|
|
|
def _exec_argv(events):
|
|
return next(argv for kind, argv in events if kind == "exec")
|
|
|
|
|
|
def test_studio_default_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure", "--password", "cli-supplied-pw12"])
|
|
|
|
# No interactive prompt: --password applied in the parent, so the gate no-ops.
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert after["jwt_secret"] != before["jwt_secret"]
|
|
assert after["n_refresh"] == 0
|
|
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
|
# The secret never crosses to the child argv.
|
|
assert "--password" not in _exec_argv(events)
|
|
|
|
|
|
def test_studio_default_password_via_env_strips_child_env(monkeypatch, tmp_path):
|
|
import os
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12")
|
|
|
|
_invoke_studio_default(monkeypatch, events, ["--secure"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
# Env var stripped so a re-exec'd child cannot re-read it.
|
|
assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ
|
|
|
|
|
|
def test_studio_default_password_via_stdin(monkeypatch, tmp_path):
|
|
# `--password -` reads one line from stdin. CliRunner owns stdin during
|
|
# invoke, so feed it via input= rather than patching sys.stdin.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
_install_studio_default_reexec(monkeypatch, events)
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
CliRunner().invoke(
|
|
app,
|
|
["--secure", "--password", "-"],
|
|
input = "stdin-supplied-pw12\n",
|
|
catch_exceptions = True,
|
|
)
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
|
|
|
|
def test_studio_default_password_too_short_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", "short"])
|
|
|
|
assert result.exit_code == 1
|
|
assert [kind for kind, _ in events] == [] # never reached the gate / re-exec
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged
|
|
|
|
|
|
def test_studio_default_password_must_differ_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod)
|
|
bootstrap_pw = (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).read_text()
|
|
|
|
result = _invoke_studio_default(monkeypatch, events, ["--secure", "--password", bootstrap_pw])
|
|
|
|
assert result.exit_code == 1
|
|
assert _auth_state(studio_mod)["must_change_password"] == 1 # unchanged
|
|
|
|
|
|
def test_studio_default_password_already_set_fails_closed(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_seed_auth(studio_mod, must_change = False) # a password is already set
|
|
|
|
result = _invoke_studio_default(
|
|
monkeypatch, events, ["--secure", "--password", "another-pw-12345"]
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert [kind for kind, _ in events] == []
|
|
|
|
|
|
def test_studio_default_password_before_subcommand_errors(monkeypatch, tmp_path):
|
|
# --password on `unsloth studio` (before a subcommand) is a plain-only option;
|
|
# like --secure/--cloudflare it must error, not be silently dropped.
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
app = _typer.Typer()
|
|
app.add_typer(studio_mod.studio_app, name = "studio")
|
|
result = CliRunner().invoke(app, ["studio", "--password", "x", "run", "--model", "X"])
|
|
assert result.exit_code == 2
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "--password" in combined
|
|
|
|
|
|
def test_run_password_sets_initial_no_prompt_no_forward(monkeypatch, tmp_path):
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure", "--password", "cli-supplied-pw12"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert "--password" not in _exec_argv(events)
|
|
|
|
|
|
def test_run_password_via_env_strips_child_env(monkeypatch, tmp_path):
|
|
# The `run` mirror must also strip UNSLOTH_STUDIO_PASSWORD before re-exec so a
|
|
# shadowed child cannot re-read the secret (parity with studio_default).
|
|
import os
|
|
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = False)
|
|
_seed_auth(studio_mod)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_PASSWORD", "env-supplied-pw12")
|
|
|
|
_invoke_run(monkeypatch, events, _BASE + ["--secure"])
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
assert "UNSLOTH_STUDIO_PASSWORD" not in os.environ
|
|
|
|
|
|
def test_studio_default_password_applies_on_headless_wildcard_no_tunnel(monkeypatch, tmp_path):
|
|
# The apply is scoped to "any launch", not just --secure/--cloudflare: a raw
|
|
# public wildcard bind (-H 0.0.0.0, no tunnel) must set the initial password
|
|
# before bind and re-exec, with the gate no-op'ing (must_change now 0).
|
|
studio_mod = _studio()
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
before = _seed_auth(studio_mod)
|
|
|
|
_invoke_studio_default(
|
|
monkeypatch, events, ["-H", "0.0.0.0", "--password", "headless-set-pw12"]
|
|
)
|
|
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
after = _auth_state(studio_mod)
|
|
assert after["must_change_password"] == 0
|
|
assert after["password_hash"] != before["password_hash"]
|
|
assert "--password" not in _exec_argv(events)
|
|
|
|
|
|
def test_reset_password_then_password_roundtrip(monkeypatch, tmp_path):
|
|
# After reset-password wipes the DB, the next start re-seeds a fresh admin
|
|
# that again requires a change, so --password can set a new initial password.
|
|
import typer
|
|
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
|
_seed_auth(studio_mod)
|
|
conn = studio_mod._connect_auth_db()
|
|
studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "first-password-1")
|
|
conn.close()
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|
|
|
|
# reset-password deletes the auth DB + seeded credential files.
|
|
try:
|
|
studio_mod.reset_password()
|
|
except typer.Exit:
|
|
pass
|
|
assert not (tmp_path / "auth" / "auth.db").exists()
|
|
|
|
# A restart re-seeds (ensure_default_admin, must_change=1); --password sets anew.
|
|
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
|
_invoke_studio_default(monkeypatch, events, ["--secure", "--password", "second-password-2"])
|
|
assert [kind for kind, _ in events] == ["exec"], events
|
|
assert _auth_state(studio_mod)["must_change_password"] == 0
|