Studio: print the first-boot password when nothing else will show it (#11118)

* Print the first-boot password when nothing else will show it

The banner for a newly created admin account names the file and never the
value:

    DEFAULT ADMIN ACCOUNT CREATED
        username: unsloth
        password saved to: /opt/unsloth-studio/auth/.bootstrap_password

That is right for a normal launch. _inject_bootstrap puts the credentials
into the login page while the change is still pending, so the form arrives
filled in and nobody opens the file, and a password nothing needed has no
business in a log that gets pasted into a bug report.

Two launches never get that injection, and there the file is the only copy.
A public URL suppresses it on purpose rather than hand the default
credential to whoever loads the page, and --api-only serves no HTML to
inject into. Those are also the launches least likely to have a terminal
next to the file, so a remote operator's only route to their own password
was an SSH session to cat it. Print the value in those two cases.

Verified on a real lifespan against the shipped image, twice, on a fresh
Studio home each time:

    UNSLOTH_API_ONLY=1   password: ShrewdlyMonumentCampsiteTasty
                         also saved to: /fresh/auth/.bootstrap_password
    local                password saved to: /fresh/auth/.bootstrap_password

The decision moves into bootstrap_banner_lines() so it is testable without
standing up the app. A missing password degrades to the old path-only line
rather than printing "password: None". Nothing in the repo parses this
banner; the only other reference is a comment in studiobench.

One visible consequence in Docker: under UNSLOTH_STUDIO_SECURE=1 the
password now appears twice in the log, once here and once from
studio_password.sh, which already read the file and printed it.

DOCKERHUB.md told the reader that the public page will not hand out the
first-boot password "so set one first". That is now only one of the two
options, so it points at the log as well.

* Print the banner when the pre-bind gate seeded the account

The guard was `if storage.ensure_default_admin():`, which is True only when
that call created the row. run_server calls _terminal_password_gate before
the lifespan and the gate seeds the admin itself (run.py:2049), so on a
tunnel launch the lifespan's call returns False and the whole banner block
was skipped. That is exactly the launch with no autofill to fall back on,
so the password went nowhere and the DOCKERHUB.md line pointing at the log
was wrong outside the image's own helper script.

Reproduced at the real ordering, gate first then lifespan, on a fresh
Studio home:

    before   [gate] ensure_default_admin() -> True
             (nothing)
    after    [gate] ensure_default_admin() -> True
             DEFAULT ADMIN ACCOUNT CREATED
                 username: unsloth
                 password: ScoopDaybedHumiliateImpromptu

storage records whether this process seeded the account, so the answer no
longer depends on who called first. The guard also requires the bootstrap
credential to still be pending: the gate can take a new password at its
prompt, which retires it, and the banner would otherwise print a dead one.
The else branch went away with it, since app.state.bootstrap_password is
now assigned once on both paths.

The new AST test asserts the guard references admin_created_this_process,
which is the check that would have caught this. 109 tests pass across the
five auth suites.

Also the opening comment pass: 23 comment lines to 8, verified code
unchanged against 232fcd7c before these edits.

* Decide the banner from the launch, not from sticky process state

run_server sets UNSLOTH_API_ONLY=1 and never clears it (run.py:2393, no else
branch), and an embedded host may call run_server() more than once in one
process: llama_cpp._begin_server_lifecycle says so and scopes its own state
to a lifecycle for that reason. So an embedder that starts api-only and then
restarts with the UI carried the variable into the second launch, and the
banner would have printed the password into a log while _inject_bootstrap
was able to fill the form. That is the leak this change exists to avoid.

app.state.suppress_bootstrap_injection had the same shape and nobody raised
it: run.py:2687 sets it True and never resets it, so a stale True from an
earlier tunnel launch would print the credential on the next ordinary one.
Fixing one and leaving the other in the same expression is not worth the
smaller diff, so both are now set per launch, before the gate.

banner_autofill_available() reads app.state first and falls back to the
environment only for a direct uvicorn launch that never went through
run_server.

171 tests pass across the six auth and bind suites. The new AST test asserts
run_server performs both resets.
This commit is contained in:
Daniel Han 2026-09-16 07:13:55 -07:00 • committed by GitHub
parent d709671b5f
commit cf335fa470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 246 additions and 14 deletions

View file

@ -21,7 +21,7 @@ That starts the container and follows its startup, which ends after about a minu
Those ports publish on every interface, which is fine on a laptop and not on a cloud host. Three ways to close that, in order of least work:
- `-e UNSLOTH_STUDIO_SECURE=1`, with `-p 8000:8000` dropped and 8888 changed to `-p 127.0.0.1:8888:8888`: Studio is served only over a Cloudflare HTTPS link, printed in the log, and binds to loopback inside the container so no raw port exists. It fails closed, so no tunnel means no link rather than serving in the clear. Move JupyterLab too, or it stays published on every interface over plain HTTP, which is a shell on your GPU box with its password in the clear. On a new volume the public page will not hand out the generated first-boot password, so set one first (see below). `UNSLOTH_STUDIO_CLOUDFLARE=1` keeps the local port as well.
- `-e UNSLOTH_STUDIO_SECURE=1`, with `-p 8000:8000` dropped and 8888 changed to `-p 127.0.0.1:8888:8888`: Studio is served only over a Cloudflare HTTPS link, printed in the log, and binds to loopback inside the container so no raw port exists. It fails closed, so no tunnel means no link rather than serving in the clear. Move JupyterLab too, or it stays published on every interface over plain HTTP, which is a shell on your GPU box with its password in the clear. On a new volume the public page will not hand out the generated first-boot password, on purpose; read it from the log, where it is printed for exactly this case, or set one yourself with `UNSLOTH_STUDIO_PASSWORD`. `UNSLOTH_STUDIO_CLOUDFLARE=1` keeps the local port as well.
- Publish to `127.0.0.1` and tunnel: `-p 127.0.0.1:8000:8000 -p 127.0.0.1:8888:8888`, then `ssh -L 8000:localhost:8000 -L 8888:localhost:8888 user@your-host`.
- Keep the ports and set real passwords with `-e UNSLOTH_STUDIO_PASSWORD=...` and `-e JUPYTER_PASSWORD=...`.

View file

@ -1226,9 +1226,24 @@ def load_jwt_secret() -> str:
conn.close()
_admin_created_this_process = False
def admin_created_this_process() -> bool:
"""Whether this process seeded the admin account, whoever called first.
run_server's pre-bind password gate calls ensure_default_admin() itself on a tunnel
launch, so by the time the lifespan calls it the answer is already False and a
first-boot check keyed on that return value silently does nothing.
"""
return _admin_created_this_process
def ensure_default_admin() -> bool:
"""Seed the default admin account on first startup, using a randomly generated diceware passphrase
as the bootstrap password. Returns True when it was created in this call."""
global _admin_created_this_process
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is not None:
_load_bootstrap_password()
return False
@ -1241,6 +1256,7 @@ def ensure_default_admin() -> bool:
jwt_secret = secrets.token_urlsafe(64),
must_change_password = True,
)
_admin_created_this_process = True
return True
except sqlite3.IntegrityError:
return False

View file

@ -634,6 +634,46 @@ def clear_compiled_cache_unless_shared(app: FastAPI) -> None:
_clear_compiled_cache_unless_shared(getattr(app.state, "live_sibling_backend", None))
def banner_autofill_available(app_state, environ) -> bool:
"""Whether _inject_bootstrap will hand the login page the credential.
Read from the launch, not the environment: run_server sets UNSLOTH_API_ONLY and never
clears it, and an embedded host may call run_server() again in the same process with
different flags, so the variable outlives the launch that set it. The environment is
only the fallback for a direct uvicorn launch that never went through run_server.
"""
if getattr(app_state, "suppress_bootstrap_injection", False):
return False
api_only = getattr(app_state, "api_only", None)
if api_only is None:
api_only = environ.get("UNSLOTH_API_ONLY") == "1"
return not api_only
def bootstrap_banner_lines(
username: str,
bootstrap_path,
password: Optional[str],
*,
autofill_available: bool,
) -> "list[str]":
"""The first-boot banner for a freshly created admin account.
Printing the password is the exception, not the rule: _inject_bootstrap fills the
login form in, so a launch that gets the injection must keep the credential out of
a log that ends up in a bug report.
"""
lines = ["=" * 60, "DEFAULT ADMIN ACCOUNT CREATED", f" username: {username}"]
if autofill_available or not password:
lines.append(f" password saved to: {bootstrap_path}")
else:
lines.append(f" password: {password}")
lines.append(f" also saved to: {bootstrap_path}")
lines.append(" Open the Unsloth UI to sign in and change it.")
lines.append("=" * 60)
return lines
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
@ -777,20 +817,30 @@ async def lifespan(app: FastAPI):
# run_server's pre-bind gate sets suppress_bootstrap_injection when a public URL is about
# to serve with the default credential: never capture the bootstrap password into app.state.
_suppress_bootstrap = getattr(app.state, "suppress_bootstrap_injection", False)
if storage.ensure_default_admin():
bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
_created = storage.ensure_default_admin()
app.state.bootstrap_password = (
None if _suppress_bootstrap else storage.get_bootstrap_password()
)
# A tunnel launch runs the pre-bind gate first and that gate seeds the account, so
# _created is False there and the whole banner would be skipped on exactly the launch
# that needs it. requires_password_change: the gate may also have taken a new password
# at its prompt, which retires the bootstrap one.
if (_created or storage.admin_created_this_process()) and storage.requires_password_change(
storage.DEFAULT_ADMIN_USERNAME
):
bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
print("\n" + "=" * 60)
print("DEFAULT ADMIN ACCOUNT CREATED")
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
print(f" password saved to: {bootstrap_path}")
print(" Open the Unsloth UI to sign in and change it.")
print("=" * 60 + "\n")
else:
app.state.bootstrap_password = (
None if _suppress_bootstrap else storage.get_bootstrap_password()
_autofill = banner_autofill_available(app.state, os.environ)
print(
"\n"
+ "\n".join(
bootstrap_banner_lines(
storage.DEFAULT_ADMIN_USERNAME,
bootstrap_path,
storage.get_bootstrap_password(),
autofill_available = _autofill,
)
)
+ "\n"
)
# Last, so it never contends for the GIL: the socket binds as soon as this returns, so the login

View file

@ -2672,6 +2672,11 @@ def run_server(
# gate and socket bind (direct `python run.py`; the CLI applies it in its own parent).
_apply_supplied_password(password)
# Per launch, not per process: an embedded host may call run_server() again with different
# flags, and UNSLOTH_API_ONLY above is never cleared once set.
app.state.api_only = api_only
app.state.suppress_bootstrap_injection = False
# Never publish with the seeded default password active: prompt first (or warn / fail closed headless; see
# _terminal_password_gate). Runs BEFORE the socket binds so a pre-gate listener cannot hand out the
# injected credential.

View file

@ -0,0 +1,161 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""The first-boot banner prints the generated password only when nothing else will."""
PASSWORD = "CorrectHorseBatteryStaple"
PATH = "/opt/unsloth-studio/auth/.bootstrap_password"
def _lines(**kwargs):
from main import bootstrap_banner_lines
return bootstrap_banner_lines("unsloth", PATH, PASSWORD, **kwargs)
def test_a_local_launch_names_the_file_and_never_the_password():
body = "\n".join(_lines(autofill_available = True))
assert PASSWORD not in body, "the login page fills this in; the log does not need it"
assert f"password saved to: {PATH}" in body
def test_a_launch_without_autofill_prints_the_password():
body = "\n".join(_lines(autofill_available = False))
assert f"password: {PASSWORD}" in body
# Still say where it lives: the operator may come back after the log has scrolled.
assert f"also saved to: {PATH}" in body
def test_a_missing_password_falls_back_to_the_path():
from main import bootstrap_banner_lines
body = "\n".join(
bootstrap_banner_lines("unsloth", PATH, None, autofill_available = False)
)
assert "password: None" not in body
assert f"password saved to: {PATH}" in body
def test_every_banner_names_the_account_and_what_to_do_next():
for autofill in (True, False):
body = "\n".join(_lines(autofill_available = autofill))
assert "DEFAULT ADMIN ACCOUNT CREATED" in body
assert "username: unsloth" in body
assert "Open the Unsloth UI to sign in and change it." in body
def _banner_guard():
"""The `if` in the lifespan whose body prints the banner."""
import ast
from pathlib import Path
import main as main_mod
tree = ast.parse(Path(main_mod.__file__).read_text(encoding = "utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.If) and "bootstrap_banner_lines" in ast.unparse(node):
return ast.unparse(node.test)
raise AssertionError("nothing in the lifespan prints the banner any more")
def test_the_banner_guard_does_not_key_only_on_this_call_creating_the_admin():
"""run_server's pre-bind gate calls ensure_default_admin() before the lifespan on a
tunnel launch, so its return value is False there and a guard keyed on it alone skips
the banner on exactly the launch that has no autofill to fall back on."""
guard = _banner_guard()
assert "admin_created_this_process" in guard
assert "requires_password_change" in guard, (
"the gate can take a new password at its prompt, which retires the bootstrap one"
)
def test_the_creation_flag_survives_a_later_call_that_creates_nothing(monkeypatch):
from auth import storage
monkeypatch.setattr(storage, "_admin_created_this_process", False)
monkeypatch.setattr(storage, "get_user_and_secret", lambda *a, **k: None)
monkeypatch.setattr(storage, "generate_bootstrap_password", lambda: PASSWORD)
monkeypatch.setattr(storage, "create_initial_user", lambda **kw: None)
assert storage.ensure_default_admin() is True
assert storage.admin_created_this_process() is True
# The gate's call is first; the lifespan's is this one, and it creates nothing.
monkeypatch.setattr(storage, "get_user_and_secret", lambda *a, **k: object())
monkeypatch.setattr(storage, "_load_bootstrap_password", lambda: PASSWORD)
assert storage.ensure_default_admin() is False
assert storage.admin_created_this_process() is True
class _State:
"""Stands in for app.state, which is a plain namespace."""
def test_the_launch_wins_over_a_stale_api_only_environment_variable():
"""run_server sets UNSLOTH_API_ONLY and never clears it, and an embedded host may call
run_server() again in the same process. Reading the variable would treat that second,
normal UI launch as having no autofill and print the password into its log."""
from main import banner_autofill_available
state = _State()
state.api_only = False
assert banner_autofill_available(state, {"UNSLOTH_API_ONLY": "1"}) is True
def test_an_api_only_launch_has_no_autofill():
from main import banner_autofill_available
state = _State()
state.api_only = True
assert banner_autofill_available(state, {}) is False
def test_a_suppressed_injection_has_no_autofill():
from main import banner_autofill_available
state = _State()
state.api_only = False
state.suppress_bootstrap_injection = True
assert banner_autofill_available(state, {}) is False
def test_a_direct_uvicorn_launch_falls_back_to_the_environment():
"""Nothing set app.state here, so the variable is all there is."""
from main import banner_autofill_available
assert banner_autofill_available(_State(), {"UNSLOTH_API_ONLY": "1"}) is False
assert banner_autofill_available(_State(), {}) is True
def test_run_server_resets_the_per_launch_flags_before_the_gate():
import ast
from pathlib import Path
import run as run_mod
source = Path(run_mod.__file__).read_text(encoding = "utf-8")
tree = ast.parse(source)
fn = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name == "run_server"
)
assigns = [
ast.unparse(node)
for node in ast.walk(fn)
if isinstance(node, ast.Assign) and "app.state" in ast.unparse(node)
]
assert "app.state.api_only = api_only" in assigns
assert "app.state.suppress_bootstrap_injection = False" in assigns, (
"a sticky True from an earlier launch withholds the autofill that is available"
)