unsloth/unsloth_cli/tests/test_studio_password_prompt.py
Leo Borcherding 91a0df9514
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>
2026-07-15 06:13:25 -07:00

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