* Studio: make the startup public-IP lookup opt-in (#7307 P8)
Startup resolved the machine's external IP by asking ifconfig.me whenever
Studio bound to 0.0.0.0 or ::. That tells whoever runs that service this
host is running Unsloth, which the user never agreed to.
The lookup is now gated behind UNSLOTH_STUDIO_PUBLIC_IP_PROBE, off by
default, and logs plainly what it sends and where when enabled. Only
1/true/yes/on enable it, so a typo leaves the private default in place.
The other two steps stay unconditional because neither discloses
anything: the GCE metadata server is link-local, and the UDP connect only
asks the kernel which local address routes to 8.8.8.8 without putting a
packet on the wire. Disabling the probe therefore still yields a usable
LAN address for the access banner.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: gate the check-host.net reachability probe too, and keep the cloud address
Problem 8 of #7307 is the check-host.net probe in _verify_global_reachability: it hands this machine's address and port to a third party and asks its nodes to connect back. That was still unconditional, so on GCE and on any host whose routing address is already public the reported behaviour was unchanged. It is now behind the same UNSLOTH_STUDIO_PUBLIC_IP_PROBE opt-in.
A private interface address is not proof the port is unreachable, since a NAT or cloud firewall can forward it. The private-address path no longer sets _public_reachable = False, so the banner keeps its warning instead of claiming local network only.
To stop the privacy default from costing cloud users their shareable address, step 1 now reads AWS IMDSv2 and Azure IMDS alongside GCE on link-local 169.254.169.254.
Also drops the redundant function-local import os and documents the variable in the README remote access section.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: switch to a single UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK opt-out
Replaces the opt-in variable and the cloud metadata work from the previous commit. Both third-party startup lookups stay on by default, so nothing changes for existing users, and one variable turns both off for lab and privacy-sensitive deployments. That is the fallback the reporter offered in #7307 Problem 8, and it keeps the firewall diagnostic that the reachability check exists to provide.
The ifconfig.me lookup and the check-host.net probe are now both guarded by public_check_disabled(). Parsing matches the nearest existing switch, _trust_forwarded_for in utils/client_ip.py. The reachability guard sits after the private-address branch, which makes no network call, so a LAN user who opts out still gets the address note.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
* fix(studio): report Vulkan GPUs in system UI
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): separate Vulkan inference GPU reporting
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* fix(studio): keep retrying Vulkan probe refreshes
* fix(studio): preserve known zero GPU budgets
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Bring the local-subagent support onto main. The original change (#7316) merged
into the stacked pr/daniel-unsloth-start-audit branch rather than main, and #7313
reached main via squash, so these files never landed on main.
Adds --as-subagent for claude, codex, opencode and pi: the parent agent keeps its
own cloud model while a locally served GGUF is registered as a delegated subagent,
using ephemeral per-session config that never touches the user's real agent config.
* Replace standalone Studio wording with Unsloth
Replace the single word Studio with Unsloth wherever it is used as
shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n
locales, workflow display names, comments and docstrings.
Kept unchanged: the full name Unsloth Studio, third party product
names (LM Studio, Visual Studio, Mac Studio), feature names
(Recipe Studio, Fine-tuning Studio and its translations), and all
identifiers such as env vars, commands, paths and filenames.
* Address review feedback on the Studio wording rename
Use "an" before Unsloth where the rename left the article as "a".
Restore the split brand where Unsloth and Studio render as two halves
of the full product name: the onboarding sidebar subtitle and the
IPv6 localhost warning. Scope two messages to the full name Unsloth
Studio where plain Unsloth was misleading: the AMD README bullet and
the CLI studio setup error.
* 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>
* Clarify in README that -H 0.0.0.0 starts a public Cloudflare tunnel
* Hedge tunnel URL wording and restore trusted-network caution
* Tighten the 0.0.0.0 tunnel note
* Drop trust-the-network caution from tunnel note
* Restore trusted-network note on the raw-bind sentence
* Use Cloudflare's quick tunnel terminology and consolidate the trust warning
* Studio: UNSLOTH_NPM_REGISTRY opt-in for corporate npm mirrors (#6491)
studio/frontend/.npmrc pins registry=https://registry.npmjs.org/ as a
supply-chain lock. A project-level pin takes precedence over a user's
~/.npmrc, so behind a corporate firewall that blocks npmjs.org the
frontend bun/npm install hit npmjs.org directly and failed with 403.
Add an opt-in UNSLOTH_NPM_REGISTRY env var (off by default). When set it
is threaded as --registry into every registry-touching install in
setup.sh, setup.ps1 and build.sh (bun bootstrap, bun install + retry, npm
fallback, OXC validator runtime). --registry is the highest-precedence
override for both bun and npm and leaves min-release-age and save-exact
in force, so the default lock is unchanged for everyone else.
On an install failure that looks like a blocked registry, print guidance
pointing at UNSLOTH_NPM_REGISTRY and auto-suggest the mirror already set
in the user's npm config. Registries are never switched automatically.
Also correct the .npmrc comment: the pin does not block an ambient
NPM_CONFIG_REGISTRY env var (npm and bun honor that at higher precedence);
it only guards against a lower-precedence stale ~/.npmrc.
* Studio: make the registry hint reachable under set -e; clean temp log (#6491)
run_quiet_no_exit returns non-zero on failure, which under `set -euo
pipefail` exits the script at the call site before the exit code is
captured, so the new UNSLOTH_NPM_REGISTRY hint never printed on the npm
fallback and OXC validator paths. Guard both with `|| _rc=$?` (the same
idiom every other run_quiet_no_exit caller already uses) so the failure
branch runs, and remove the _FRONTEND_INSTALL_LOG temp file on the
early-exit path.
* Studio: detect the user's mirror outside the pinned frontend dir (#6491)
_suggest_npm_registry / Show-NpmRegistryHint run while the cwd is still
studio/frontend, whose .npmrc pins registry=https://registry.npmjs.org/.
So `npm config get registry` returned that pin instead of the user's
~/.npmrc mirror, and the "Detected a registry" branch was skipped for the
main corporate case (mirror set in ~/.npmrc). Run the lookup from a
directory with no project .npmrc (/ in bash, the temp dir in PowerShell)
so the user/global mirror is surfaced. The NPM_CONFIG_REGISTRY env check
is unchanged and still takes precedence.
The Launch-section blurb described --secure as a secure HTTPS link
instead of a raw port and stressed that the raw port is never exposed,
which reads as the more private option. The Cloudflare tunnel actually
publishes Studio at a public trycloudflare.com URL, and server-side
tools let anyone with the API key run code. Reword to state the public
exposure and the code-execution caveat, matching the installer hints and
the Remote access section.
* Added Windows RTX 50-series troubleshooting guide
* Update README.md to link Blackwell Windows Troubleshooting Guide
Added a troubleshooting guide for Windows installation issues with RTX 50-Series.
* Fix install path, troubleshooting accuracy and markdown for PR #6286
* Condense Windows Blackwell tips into README and drop standalone guide for PR #6286
---------
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Add copyable per-platform examples for UNSLOTH_NO_TORCH, UNSLOTH_PYTHON and
UNSLOTH_STUDIO_HOME (curl | sh after the pipe; $env: before irm | iex), and
move the UNSLOTH_CPU_THREADS note to the end of the section.
* Move uninstall scripts into scripts/ and fix all references
Relocates `uninstall.sh` and `uninstall.ps1` from the repo root into
the existing `scripts/` directory, alongside the other helper scripts.
Reference fixes:
* `README.md`: Studio uninstall instructions now point at the raw
GitHub URLs under `scripts/`. The previous `unsloth.ai/uninstall.*`
short URLs currently 404 (unlike `unsloth.ai/install.sh`, which
301s to the raw github URL), so the raw URL is the working entry
point until that redirect is configured.
* `scripts/uninstall.sh` header `Usage:` example updated to the new
raw GitHub path.
* `scripts/uninstall.ps1` header `Usage:` example updated to the new
raw GitHub path.
* `.github/workflows/studio-update-smoke.yml`: `paths:` trigger and
round-trip exec/exists checks now use `scripts/uninstall.sh`.
* `.github/workflows/studio-mac-update-smoke.yml`: same.
* `.github/workflows/studio-windows-update-smoke.yml`: `paths:`
trigger and round-trip exec/exists checks now use
`scripts/uninstall.ps1`.
The in-script help hints (e.g. `sh uninstall.sh`, `.\uninstall.ps1`)
are left unchanged because they are user-facing examples shown after
the user already has the file locally, and the basename form works
regardless of which directory the user downloaded the script into.
Follow-up note for unsloth.ai: once this lands, please add the
`unsloth.ai/uninstall.sh` and `unsloth.ai/uninstall.ps1` short-URL
redirects to `raw.githubusercontent.com/unslothai/unsloth/main/scripts/...`
(matching the existing `unsloth.ai/install.sh` redirect pattern).
* Update remaining uninstall script help hints for new scripts/ path
Three user-facing strings inside the uninstall scripts still showed
the old basename form, which became misleading after the move:
* `scripts/uninstall.ps1` header `# Local:` example: now references
`.\scripts\uninstall.ps1` (the actual path from the cloned repo
root).
* `scripts/uninstall.sh` env-var re-run hint: now shows the canonical
curl-pipe form documented in README, since callers who came via
`curl -fsSL ... | sh` never had a local `uninstall.sh` to invoke.
* `scripts/uninstall.ps1` env-var re-run hint: same, switched to the
`irm ... | iex` form documented in README.
Pure string changes, no behavior change.
---------
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
* studio: add uninstall.ps1 and document it in README for Windows
The previous Windows uninstall guidance was Remove-Item -Recurse -Force on
$HOME\.unsloth\studio, which only deletes the install dir and leaves
behind:
* %LOCALAPPDATA%\Unsloth Studio (data dir)
* Desktop\Unsloth Studio.lnk (Desktop shortcut)
* %APPDATA%\Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk
* Custom UNSLOTH_STUDIO_HOME / STUDIO_HOME roots
* Running unsloth_studio venv processes
* User PATH entry under .unsloth\studio
* HKCU\Software\Unsloth\PathBackup
This script mirrors uninstall.sh for Windows. It stops listening backends
by reading the port from share\studio.port (with a Win32_Process sweep
anchored on \unsloth_studio\ as a fallback), removes the install dir,
data dir, both shortcuts, the Studio PATH entry, and the PathBackup
registry key. Custom roots discovered from env vars or share\studio.conf
are accepted only if they contain a Studio sentinel (share\studio.conf,
unsloth_studio\.unsloth-studio-owned, or bin\unsloth.exe) and are not
on a hard deny list (drive root, %USERPROFILE%, parent of %USERPROFILE%,
or top-level system paths).
README now points Windows users at the script.
* Scope port-file kill and PATH cleanup to known Studio roots for PR #5513
Three findings from the reviewer round:
1. _StopByPortFile killed whatever owned the recorded port without proving
the PID belonged to this Studio install. A stale studio.port pointing
at a port a different local service later bound would force-kill that
service. New _PidUnderKnownRoot checks the listening PID's exe path
against the same $KnownRoots that _StopStudioProcesses already uses.
2. The netstat.exe fallback matched ":$port " anywhere in the line, so a
stale port file with 443 (or any common port) could match an
ESTABLISHED row whose remote endpoint was that port, killing an
unrelated process (browser, IDE). Now requires the row contain
LISTENING, and applies the same _PidUnderKnownRoot ownership check.
3. PATH cleanup removed any entry whose expanded path contained
\unsloth_studio\, which would also clobber an unrelated user virtualenv
that shared the name. Now only removes entries that resolve inside a
known Studio root (default %USERPROFILE%\.unsloth\studio plus any
custom roots discovered from UNSLOTH_STUDIO_HOME / STUDIO_HOME /
share\studio.conf).
* Expand tilde and honor UNSLOTH_STUDIO_HOME precedence for PR #5513
Two findings from the latest review round:
1. install.ps1 (lines 152-154) expands ~ and ~\path to $env:USERPROFILE
before resolving the install root, but uninstall.ps1 was passing the
raw env value to [System.IO.Path]::GetFullPath. That resolved ~\foo
relative to the current directory rather than the user profile, so a
user who installed with UNSLOTH_STUDIO_HOME='~\custom' could not
uninstall through the same variable. New _ExpandTilde helper matches
install.ps1's behavior.
2. Mirror install.ps1's env-var precedence: UNSLOTH_STUDIO_HOME wins,
STUDIO_HOME is ignored when both are set. Otherwise uninstalling
install A could also touch install B if the user has a stale
STUDIO_HOME pointing at B.
---------
Co-authored-by: Daniel Han <info@unsloth.ai>
* studio: add uninstall.sh and document it in README
The current uninstall guidance in README.md is `rm -rf ~/.unsloth/studio`,
which leaves behind everything that lives outside that path:
- ~/.local/share/unsloth/ (launcher script, studio.conf, studio.log,
icon assets)
- ~/Applications/Unsloth Studio.app (macOS bundle, orphaned and
pointing nowhere on next reinstall)
- ~/Desktop/Unsloth Studio (broken symlink after the bundle is gone)
- ~/Desktop/unsloth-studio.desktop (Linux)
- ~/.local/share/applications/unsloth-studio.desktop (Linux)
- /tmp/unsloth-studio-launcher-<uid>*.lock (lock dir, possibly stale)
- Launch Services cache entry for ai.unsloth.studio on macOS
- Any running `unsloth studio -p N` processes
Users who follow the documented uninstall and reinstall end up with the
new launcher layered on top of stale state from the previous install,
which has produced concrete bugs (e.g. self-referential symlink inside
the .app bundle after a reinstall over leftover state).
Add uninstall.sh at the repo root that handles all of the above, and
update README.md to point at it as the recommended path. The plain
`rm -rf ~/.unsloth/studio` line is kept as a "partial uninstall, keep
launcher for a later reinstall" alternative. The model cache at
~/.cache/huggingface is intentionally left untouched, with a note in
the script suggesting how to remove it if desired.
Script is POSIX sh, idempotent (every removal is gated on existence
and uses `2>/dev/null || true`), and handles macOS, Linux, and WSL.
Windows is intentionally not covered here; the existing PowerShell
Remove-Item line in README is kept for that.
* studio: trim uninstall.sh header
* studio: address PR review feedback on uninstall.sh
Four findings from automated review, all verified real:
1. pkill pattern only matched `-p N`, not `--port N`. Studio
instances launched with the long option form survived the
uninstall. Fix: run two pkill passes, one for each form, with
`[ =]` covering both space and `=` separators.
2. CLI shim at ~/.local/bin/unsloth (symlink into the venv created
by install.sh:2167) was left behind, becoming a broken symlink
after the venv directory is removed. Fix: add it to the removals.
3. Custom install roots via UNSLOTH_STUDIO_HOME / STUDIO_HOME were
not removed. install.sh records the install location in
~/.local/share/unsloth/studio.conf as UNSLOTH_EXE; parse it,
derive the root as three dirnames up, and remove the root if it
is non-default.
4. On WSL the installer creates 'Unsloth Studio.lnk' on the Windows
Desktop and Start Menu Programs folder via powershell.exe.
Mirror that path on uninstall by invoking powershell.exe to
Remove-Item the same two locations. Best-effort, gated on
powershell.exe being available.
Tests (T2.8b, T2.15, T2.16, T2.17, T2.18, T2.5b) added behind the
scenes; all pass on macOS Darwin 25.3 with `dash -n`, `sh -n`,
shellcheck-clean (SC2016 suppressed on the PowerShell single-quoted
heredoc since the $env: expansions must remain literal to the
shell so PowerShell receives them verbatim).
* studio: harden uninstall.sh against env-mode and shim collisions
- Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME at uninstall time and read
env-mode studio.conf at $<root>/share/studio.conf, not just the
default-mode conf under $HOME/.local/share/unsloth/. Without this,
installs done with a custom STUDIO_HOME leak the install tree even
when the env var is re-exported.
- Guard the custom-root resolver against "/" and empty so a corrupted
studio.conf (UNSLOTH_EXE='/etc/passwd' or similar) or an
UNSLOTH_STUDIO_HOME=/ cannot trick the script into rm -rf'ing root.
- Only remove $HOME/.local/bin/unsloth when it is a symlink resolving
to a Studio venv. pyproject.toml declares unsloth as a console
script, so pip install --user unsloth places a regular file at the
same path; the previous unconditional rm wiped that unrelated CLI.
- When neither env var is set, print a tail hint so users with custom
install roots know to re-run with the variable.
Verified with a sandboxed harness covering 24 scenarios (default and
env-mode installs across macOS / Linux / WSL, idempotency, hostile
lockfile names, path-traversal attempts, malformed conf, pkill long
and short forms, pip-conflict shim, broken-symlink bundle path).
Script remains POSIX (shellcheck -s sh clean, runs under /bin/dash).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Refuse non-Studio uninstall roots and tighten process matching for PR #5497
Three issues found while testing custom-root paths and process cleanup:
1. UNSLOTH_STUDIO_HOME=$HOME sh uninstall.sh rm -rf'd $HOME (same for
STUDIO_HOME and parent-of-$HOME). install.sh accepts any writable
directory for STUDIO_HOME, so the uninstaller must validate ownership
before deletion. _is_studio_root accepts a candidate root only if it
contains share/studio.conf, an unsloth_studio/ directory, or a
bin/unsloth shim pointing into unsloth_studio/bin. _is_unsafe_root is
a defense-in-depth deny list (/, $HOME, $HOME's parent, system paths).
2. pkill -f patterns "unsloth studio.*-p[ =][0-9]" over-matched on argv
substrings. A user running `less notes.md` whose filename contained
"unsloth studio ... -p N" had their less killed. New patterns anchor
on /unsloth_studio/bin/ so only processes whose actual exe lives in a
Studio venv match.
3. pkill missed processes that exec into studio/backend/run.py --port N
(the post-exec form when the unsloth CLI replaces itself). Added a
third pattern for that shape, and prefer PID files written by
install.sh's _spawn_terminal (studio-$port.pid in DATA_DIR) over
argv matching for installs that have them.
* Tighten ownership guards from review round for PR #5497
Three findings from the second reviewer round:
1. _is_studio_root accepted any directory containing an unsloth_studio/
subdir as Studio-owned. A user workspace that happens to contain a
folder named unsloth_studio/ would be deleted. install.sh's env-mode
guard at install.sh:1358-1361 already requires .unsloth-studio-owned
before treating the venv as replaceable. Mirror that: require the
owner marker, share/studio.conf, or the bin/unsloth shim target.
2. The pkill -f fallback patterns were global, so uninstalling install A
would also kill install B's running server. Scope each pattern to the
actual install root being removed by interpolating the root path into
the regex. Also adds a third pattern shape for `unsloth studio` with
no -p / --port flag (the CLI default-port form).
3. Desktop/Unsloth Studio is created by install.sh as a symlink to the
.app bundle. If a user has a regular directory by that name (photos,
notes, etc.), the previous _remove_path call rm -rf'd it. Now we only
remove it when it is a symlink or does not exist.
* Canonicalize env roots and honor UNSLOTH_STUDIO_HOME precedence for PR #5497
Two findings from the latest review round:
1. Canonicalize env-derived roots before the safety check. The deny list
only string-compares against $HOME, so a syntactic variant like
UNSLOTH_STUDIO_HOME=$HOME/../$USER (or trailing slash, or relative
path) bypassed _is_unsafe_root even though it resolves to $HOME. Now
_emit runs CDPATH= cd -P -- + pwd -P first, so all variants normalize
to the same canonical path before the deny check. Also added the same
tilde expansion install.sh's _resolve_studio_destinations does.
2. Mirror install.sh's env-var precedence (install.sh:282-290). When
both UNSLOTH_STUDIO_HOME and STUDIO_HOME are set, install.sh resolves
only UNSLOTH_STUDIO_HOME and ignores STUDIO_HOME. Uninstall was
emitting both, so running uninstall.sh for install A would also
delete install B if the user had a stale STUDIO_HOME pointing at B.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniel Han <info@unsloth.ai>
Studio bound to 0.0.0.0 by default and the installer silently auto-started
a server at end of install, exposing it on the network without consent and
contradicting the privacy-first / local-only guarantee.
- studio/backend/run.py: run_server() and argparse --host default to 127.0.0.1
- unsloth_cli/commands/studio.py: studio_default() and run() --host default to 127.0.0.1
- install.sh: drop -H 0.0.0.0 from generated launcher template; replace silent
auto-start with a [Y/n] prompt; add cloud/network note to manual hint
- install.ps1: drop -H 0.0.0.0 from PowerShell launcher template; replace
silent auto-start with a Read-Host [Y/n] prompt; add cloud/network note
- studio/setup.sh: drop -H 0.0.0.0 from launch hint; add cloud/network note
- README.md: simplify launch examples to `unsloth studio -p 8888`; note
-H 0.0.0.0 is available for cloud/LAN use
Tests:
- studio/backend/tests/test_host_defaults.py
- tests/studio/test_cli_studio_defaults.py
- tests/sh/test_install_host_defaults.sh
* One liner setup for unsloth studio
* Fix install scripts: system deps, activation bugs, curl/wget support
- install.sh: detect platform (macOS/Linux/WSL) and check for missing
system dependencies (cmake, git, build-essential, libcurl4-openssl-dev).
Prompt user once for permission to install all missing packages via
brew (macOS) or sudo apt-get (Linux/WSL). Add wget fallback via
download() helper since curl is not always present on minimal Linux
installs. Fix nested curl|sh stdin stealing by downloading uv installer
to a tempfile first. Replace venv activation (no-op in a pipe subshell)
with explicit --python flag for uv pip install and direct venv binary
invocation. Add idempotency guard for venv creation. Redirect stdin
on unsloth studio setup to prevent pipe consumption. On macOS, check
for Xcode Command Line Tools and trigger install if missing.
- install.ps1: wrap script body in Install-UnslothStudio function so
that errors use return instead of exit (exit kills the terminal when
run via irm|iex). Remove activate.ps1 invocation entirely -- use
explicit --python path for uv pip install and & $UnslothExe for
studio setup. This avoids both the child-scope activation bug (& vs
dot-source) and the execution policy error on default Windows systems.
Add winget availability check with clear error message. Fix PATH
refresh to append registry paths instead of replacing the session PATH.
Add uv installer fallback via astral.sh PowerShell script if winget
install does not put uv on PATH. Broaden Python version check to
accept 3.11-3.13. Add idempotency guard for venv creation.
- README.md: add wget one-liner alternative for systems without curl.
* Fix Tailwind CSS v4 .gitignore bug on Windows (#4444)
- Add .gitignore hiding workaround to setup.ps1 (matching existing
setup.sh logic) so venv .gitignore files containing "*" don't prevent
Tailwind's oxide scanner from finding .tsx source files
- Add CSS size validation to setup.sh, setup.ps1, and build.sh to catch
truncated Tailwind builds early
- Remove stray force-rebuild overrides that made the "skip build if
current" cache check dead code in both setup scripts
- Add rm -rf dist to build.sh to force clean rebuilds for wheel packaging
* Change default port 8000 to 8888, fix installer bugs, improve UX
- Change default Studio port from 8000 to 8888 across all entry points
(run.py, studio.py, ui.py, colab.py, vite.config.ts, setup scripts)
- Update launch banner: "Launching with studio venv..." to
"Launching Unsloth Studio... Please wait..."
- Add "Open your web browser" banner and rename labels
(Local -> Local Access, External -> Worldwide Web Address)
- Fix venv idempotency: check for bin/python instead of just directory
existence, clean up partial venvs on retry
- Fix build.sh CSS validation: handle empty CSS case that silently
bypassed the check with "integer expression expected"
- Fix install.sh sudo handling: try apt-get without sudo first (works
when root), then escalate with per-package tracking and user prompt
- Fix install.ps1: check exit code from studio setup, fail on error
- Add pciutils to WSL GGUF build dependencies
- Apply same smart apt-get escalation pattern to studio/setup.sh
* Use detected Python version for venv, abort on non-apt Linux
- install.ps1: detect existing Python 3.11/3.12/3.13 and use that
version for venv creation instead of always forcing 3.13
- install.sh: exit with error on non-apt Linux distros when required
packages cannot be auto-installed, instead of silently continuing
* Make sudo permission prompt more prominent with warning banner
* Add Accept [Y/n] sudo prompt to studio/setup.sh for consistency
* Fix native command exit code handling and sudo decline flow
install.ps1: Add $LASTEXITCODE checks after winget (Python), uv venv,
and uv pip install calls. $ErrorActionPreference only catches PowerShell
cmdlet errors, not native executable failures. The Python check also
handles winget returning non-zero for "already installed".
setup.sh: Skip llama-server build when user declines sudo or sudo is
unavailable. Previously the script continued to section 8 which would
fail with confusing errors (e.g. "gcc: command not found") since
build-essential was never installed.
* Move rm -rf llama.cpp inside build branch to preserve existing install
When _SKIP_GGUF_BUILD is set (user declined sudo or sudo unavailable),
the previous rm -rf would destroy an already-working llama-server before
the skip check ran. Move it inside the else branch so existing builds
are preserved when the rebuild is skipped.
---------
Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>