Merge remote-tracking branch 'origin/main' into r7140
# Conflicts: # studio/backend/main.py
This commit is contained in:
commit
f5517bf39c
72 changed files with 10374 additions and 492 deletions
17
README.md
17
README.md
|
|
@ -84,7 +84,7 @@ Use the same command to update.
|
|||
```bash
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
|
||||
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally.
|
||||
|
||||
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
|
||||
|
|
@ -212,10 +212,23 @@ By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach i
|
|||
```bash
|
||||
unsloth studio --secure -p 8888
|
||||
```
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. This also starts a public Cloudflare quick tunnel by default, which publishes an internet-reachable `https://*.trycloudflare.com` URL even behind a firewall. Both the raw port and the tunnel expose Studio beyond this machine, so only use this on a network you trust; pass `--no-cloudflare` to drop the public link while keeping the network bind.
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust.
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
```
|
||||
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
|
||||
|
||||
The first time Studio is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Studio shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
|
||||
|
||||
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
|
||||
|
||||
```bash
|
||||
unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history
|
||||
UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var
|
||||
printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin
|
||||
```
|
||||
|
||||
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
|
||||
|
||||
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
|
||||
|
||||
|
|
|
|||
|
|
@ -2628,8 +2628,8 @@ exit 0
|
|||
} else {
|
||||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
} else {
|
||||
|
|
@ -2649,8 +2649,8 @@ exit 0
|
|||
substep "& $_actLiteral"
|
||||
substep "unsloth studio -p 8888"
|
||||
}
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3266,8 +3266,8 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
|
|||
*)
|
||||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
|
@ -3288,7 +3288,7 @@ else
|
|||
substep "source $_li_act_q"
|
||||
substep "unsloth studio -p 8888"
|
||||
fi
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
echo ""
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ version = {attr = "unsloth.models._utils.__version__"}
|
|||
include-package-data = true
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
unsloth_cli = ["codex_fallback_prompt.md"]
|
||||
studio = [
|
||||
"*.sh",
|
||||
"*.ps1",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ from utils.paths import auth_db_path, ensure_dir
|
|||
DB_PATH = auth_db_path()
|
||||
DEFAULT_ADMIN_USERNAME = "unsloth"
|
||||
|
||||
# Single source for the password policy; models/auth.py ChangePasswordRequest
|
||||
# and the terminal prompt both enforce it. Keep the unsloth_cli mirror in sync.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
# Plaintext bootstrap password file beside auth.db, deleted on first password
|
||||
# change so the credential never lingers on disk.
|
||||
_BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
|
||||
|
|
@ -79,11 +83,42 @@ def _load_bootstrap_password() -> Optional[str]:
|
|||
|
||||
|
||||
def clear_bootstrap_password() -> None:
|
||||
"""Delete the persisted bootstrap password file (called after password change)."""
|
||||
"""Delete the persisted bootstrap password file (after a password change).
|
||||
|
||||
Best-effort: the new hash is already committed, so a locked/undeletable file
|
||||
(Windows AV, read-only auth dir) must not fail the change.
|
||||
"""
|
||||
global _bootstrap_password
|
||||
_bootstrap_password = None
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
_BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
|
||||
try:
|
||||
_BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
|
||||
except OSError as e:
|
||||
# Removal failed (Windows AV, read-only auth dir). The hash is already
|
||||
# committed, so don't fail the change -- but truncate the file so its
|
||||
# stale plaintext can't be re-seeded by generate_bootstrap_password()
|
||||
# if a later reset-password deletes auth.db and re-validates it.
|
||||
try:
|
||||
_BOOTSTRAP_PW_PATH.write_text("")
|
||||
cleared = True
|
||||
except OSError:
|
||||
cleared = False
|
||||
import sys
|
||||
|
||||
if cleared:
|
||||
message = (
|
||||
f"Warning: could not delete {_BOOTSTRAP_PW_PATH.name} ({e}); "
|
||||
"cleared its contents so the old bootstrap password cannot be reused."
|
||||
)
|
||||
else:
|
||||
# Neither removed nor truncated: stale plaintext is still on disk
|
||||
# and would be reused if auth.db is reset. Don't claim otherwise.
|
||||
message = (
|
||||
f"Warning: could not delete or clear {_BOOTSTRAP_PW_PATH.name} ({e}); "
|
||||
"its old bootstrap password is still on disk. Remove it manually to "
|
||||
"prevent reuse after a reset."
|
||||
)
|
||||
print(message, file = sys.stderr, flush = True)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
|
|
@ -547,8 +582,18 @@ def ensure_default_admin() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def update_password(username: str, new_password: str) -> bool:
|
||||
"""Update password, clear first-login requirement, rotate JWT secret."""
|
||||
def update_password(
|
||||
username: str,
|
||||
new_password: str,
|
||||
*,
|
||||
revoke_refresh_tokens: bool = False,
|
||||
) -> bool:
|
||||
"""Update password, clear first-login requirement, rotate JWT secret.
|
||||
|
||||
``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME
|
||||
transaction: a separate delete could fail after the password commit and
|
||||
leave a pre-change token still able to mint access tokens.
|
||||
"""
|
||||
from .hashing import hash_password
|
||||
|
||||
salt, pwd_hash = hash_password(new_password)
|
||||
|
|
@ -563,6 +608,8 @@ def update_password(username: str, new_password: str) -> bool:
|
|||
""",
|
||||
(salt, pwd_hash, jwt_secret, username),
|
||||
)
|
||||
if revoke_refresh_tokens and cursor.rowcount > 0:
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
|
||||
conn.commit()
|
||||
if cursor.rowcount > 0:
|
||||
clear_bootstrap_password()
|
||||
|
|
|
|||
282
studio/backend/auth/terminal_prompt.py
Normal file
282
studio/backend/auth/terminal_prompt.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Interactive terminal prompt that forces a bootstrap password change before
|
||||
Studio is exposed on a public Cloudflare URL (``--secure`` / ``--cloudflare``).
|
||||
|
||||
Masked input echoes one ``*`` per keystroke (unlike ``getpass``). Works on
|
||||
Windows (``msvcrt``) and Linux/macOS (``termios``). All output goes to stderr so
|
||||
redirected stdout never swallows the prompt.
|
||||
|
||||
Mirrored for the CLI at ``unsloth_cli/commands/_password_prompt.py`` (the CLI
|
||||
cannot import the Studio backend package); keep the two in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable, TextIO
|
||||
|
||||
_CTRL_C = "\x03"
|
||||
_CTRL_D = "\x04"
|
||||
_CTRL_Z = "\x1a"
|
||||
_BACKSPACES = ("\x7f", "\x08")
|
||||
_SUBMITS = ("\r", "\n")
|
||||
|
||||
# Env var that supplies the initial admin password non-interactively (mirror in
|
||||
# unsloth_cli/commands/_password_prompt.py). Keep the name in sync.
|
||||
SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD"
|
||||
|
||||
|
||||
def _getch_windows() -> str: # pragma: no cover - exercised via fake on Linux CI
|
||||
import msvcrt
|
||||
|
||||
ch = msvcrt.getwch()
|
||||
# Function/arrow keys arrive as a two-wchar \x00/\xe0 sequence; consume the
|
||||
# second half and report a no-op control char.
|
||||
if ch in ("\x00", "\xe0"):
|
||||
msvcrt.getwch()
|
||||
return "\x00"
|
||||
return ch
|
||||
|
||||
|
||||
class _RestoreTtyOnSignals:
|
||||
"""Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read.
|
||||
|
||||
A finally block can't run when a signal terminates the process, leaving the
|
||||
shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or
|
||||
where the signals are absent.
|
||||
"""
|
||||
|
||||
def __init__(self, fd: int, old_attrs) -> None:
|
||||
self._fd = fd
|
||||
self._old_attrs = old_attrs
|
||||
self._previous: list = []
|
||||
|
||||
def __enter__(self) -> "_RestoreTtyOnSignals":
|
||||
import signal
|
||||
import termios
|
||||
|
||||
def _restore_and_reraise(signum, frame):
|
||||
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
signal.raise_signal(signum)
|
||||
|
||||
for name in ("SIGTERM", "SIGHUP"):
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
self._previous.append((sig, signal.signal(sig, _restore_and_reraise)))
|
||||
except (ValueError, OSError): # non-main thread / unsupported
|
||||
pass
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
import signal
|
||||
for sig, previous in self._previous:
|
||||
try:
|
||||
signal.signal(sig, previous)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
class _prompt_raw_mode:
|
||||
"""Hold cbreak + cleared ISIG (no echo) on stdin for the WHOLE prompt line,
|
||||
restoring when the line finishes (and on SIGTERM/SIGHUP).
|
||||
|
||||
Echo must never re-enable mid-line: cbreak echoes on receipt, so a keystroke
|
||||
arriving while echo is on would appear in cleartext. One cbreak block for the
|
||||
whole line closes that window. No-op when stdin is not a real terminal, so
|
||||
the _getch seam can be faked in tests.
|
||||
"""
|
||||
|
||||
def __enter__(self) -> "_prompt_raw_mode":
|
||||
self._fd = None
|
||||
self._old_attrs = None
|
||||
self._signals = None
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
except ImportError: # non-POSIX (Windows uses msvcrt, no mode to hold)
|
||||
return self
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd)
|
||||
except (AttributeError, ValueError, OSError, termios.error):
|
||||
return self # redirected / captured stdin (tests): nothing to hold
|
||||
self._fd = fd
|
||||
self._old_attrs = old_attrs
|
||||
self._signals = _RestoreTtyOnSignals(fd, old_attrs)
|
||||
self._signals.__enter__()
|
||||
# cbreak (not raw) keeps output post-processing while disabling echo/line
|
||||
# buffering. It leaves ISIG on, so clear it and surface Ctrl-C as \x03 to
|
||||
# the caller loop, which restores the tty itself.
|
||||
tty.setcbreak(fd, termios.TCSADRAIN)
|
||||
new_attrs = termios.tcgetattr(fd)
|
||||
new_attrs[3] &= ~termios.ISIG
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
if self._old_attrs is None:
|
||||
return
|
||||
import termios
|
||||
try:
|
||||
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
|
||||
finally:
|
||||
if self._signals is not None:
|
||||
self._signals.__exit__(*exc)
|
||||
|
||||
|
||||
def _getch_posix() -> str: # pragma: no cover - needs a real tty
|
||||
# Terminal already in cbreak+no-echo for the whole line (_prompt_raw_mode),
|
||||
# so just read. Byte-at-a-time incremental decode so a multi-byte UTF-8 char
|
||||
# straddling a read boundary isn't dropped.
|
||||
import codecs
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace")
|
||||
while True:
|
||||
b = os.read(fd, 1)
|
||||
if not b:
|
||||
return "" # stream EOF; caller raises EOFError
|
||||
ch = decoder.decode(b)
|
||||
if ch:
|
||||
return ch
|
||||
|
||||
|
||||
_getch: Callable[[], str] = _getch_windows if os.name == "nt" else _getch_posix
|
||||
|
||||
|
||||
def _read_password(prompt: str, *, out: "TextIO | None" = None) -> str:
|
||||
"""Read one masked line: echo ``*`` per char, support backspace editing.
|
||||
|
||||
Raises KeyboardInterrupt on Ctrl-C and EOFError on Ctrl-D/Ctrl-Z with an
|
||||
empty buffer; the terminal is restored on every exit path.
|
||||
"""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
out.write(prompt)
|
||||
out.flush()
|
||||
chars: list[str] = []
|
||||
with _prompt_raw_mode():
|
||||
while True:
|
||||
key = _getch()
|
||||
if key == "": # stream ended mid-line: abort, don't submit a partial
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
raise EOFError
|
||||
for ch in key: # a paste can deliver several chars per read
|
||||
if ch in _SUBMITS:
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
return "".join(chars)
|
||||
if ch == _CTRL_C:
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
raise KeyboardInterrupt
|
||||
if ch in (_CTRL_D, _CTRL_Z):
|
||||
if not chars:
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
raise EOFError
|
||||
continue # ignore mid-input
|
||||
if ch in _BACKSPACES:
|
||||
if chars:
|
||||
chars.pop()
|
||||
out.write("\b \b")
|
||||
out.flush()
|
||||
continue
|
||||
if ch < " ": # other control characters (tab, escape, ...)
|
||||
continue
|
||||
chars.append(ch)
|
||||
out.write("*")
|
||||
out.flush()
|
||||
|
||||
|
||||
def should_prompt_password_change(
|
||||
*, tunnel_will_start: bool, requires_change: bool, stdin_isatty: bool, stderr_isatty: bool
|
||||
) -> bool:
|
||||
"""Whether to block startup on an interactive terminal password change.
|
||||
|
||||
True only when the tunnel is actually about to start, the admin still has
|
||||
the seeded password, and both stdin and stderr are real terminals (headless
|
||||
launches keep the bootstrap-timeout protection instead of hanging).
|
||||
"""
|
||||
return tunnel_will_start and requires_change and stdin_isatty and stderr_isatty
|
||||
|
||||
|
||||
def prompt_for_password_change(
|
||||
*,
|
||||
min_length: int,
|
||||
is_current_password: Callable[[str], bool],
|
||||
apply_change: Callable[[str], None],
|
||||
username: str = "unsloth",
|
||||
out: "TextIO | None" = None,
|
||||
) -> bool:
|
||||
"""Force a new admin password before public exposure; True on success.
|
||||
|
||||
Loops until a valid, confirmed password is committed via ``apply_change``.
|
||||
Ctrl-C / EOF returns False; the caller must then abort the launch.
|
||||
"""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
out.write(
|
||||
"\n"
|
||||
"Unsloth Studio will be exposed on the public internet, so set a\n"
|
||||
"password now. Ctrl+C to abort.\n\n"
|
||||
)
|
||||
out.flush()
|
||||
try:
|
||||
while True:
|
||||
new_password = _read_password("New password: ", out = out)
|
||||
if len(new_password) < min_length:
|
||||
out.write(f"Password must be at least {min_length} characters; try again.\n")
|
||||
out.flush()
|
||||
continue
|
||||
if is_current_password(new_password):
|
||||
out.write(
|
||||
"New password must differ from the current bootstrap password; try again.\n"
|
||||
)
|
||||
out.flush()
|
||||
continue
|
||||
confirmation = _read_password("Confirm new password: ", out = out)
|
||||
if confirmation != new_password:
|
||||
out.write("Passwords do not match; try again.\n")
|
||||
out.flush()
|
||||
continue
|
||||
apply_change(new_password)
|
||||
out.write(f"Password updated for '{username}'.\n")
|
||||
out.flush()
|
||||
return True
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
out.write("Password change aborted; not exposing Studio.\n")
|
||||
out.flush()
|
||||
return False
|
||||
|
||||
|
||||
def resolve_supplied_password(cli_value: "str | None", out: "TextIO | None" = None) -> "str | None":
|
||||
"""Resolve a non-interactive initial admin password, or None if unset.
|
||||
|
||||
Precedence: an explicit ``--password`` (literal ``-`` reads a line from
|
||||
stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off.
|
||||
A literal argv value is visible in the process list, so a note points at the
|
||||
env var or stdin instead. Mirror of the CLI helper -- keep the two in sync.
|
||||
"""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
if cli_value == "-":
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
return line.rstrip("\r\n") or None
|
||||
if cli_value:
|
||||
out.write(
|
||||
"Note: --password is visible in the process list and shell history; "
|
||||
f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n"
|
||||
)
|
||||
out.flush()
|
||||
return cli_value
|
||||
return os.environ.get(SUPPLIED_PASSWORD_ENV) or None
|
||||
|
|
@ -323,8 +323,8 @@ def start(port: int = 8888, *, cloudflare: bool = False):
|
|||
|
||||
logger.info(" Starting server...")
|
||||
try:
|
||||
# cloudflare=False: this helper owns the tunnel. run_server's default True
|
||||
# would tunnel this 0.0.0.0 bind if Colab detection fails, breaking the opt-out.
|
||||
# cloudflare=False: this helper owns the tunnel (Colab's own
|
||||
# start(cloudflare=...) drives it), so pin it off explicitly.
|
||||
app = run_server(
|
||||
host = "0.0.0.0",
|
||||
port = port,
|
||||
|
|
|
|||
|
|
@ -377,9 +377,10 @@ class ExportOrchestrator:
|
|||
|
||||
if rtype == "status":
|
||||
message = resp.get("message", "")
|
||||
logger.info("Export subprocess status: %s", message)
|
||||
# Surface status in the live log panel for high-level progress.
|
||||
# One structured export_progress line per phase (consolidated in the
|
||||
# server log, like training/download progress); also shown live.
|
||||
if message:
|
||||
logger.info("export_progress", phase = message)
|
||||
self._append_log(
|
||||
{
|
||||
"stream": "status",
|
||||
|
|
|
|||
|
|
@ -398,6 +398,19 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
# orchestrator spawns a fresh subprocess per checkpoint load, resetting it.
|
||||
_log_forward_gate.set()
|
||||
|
||||
# Phase milestone so the heavy export step shows in the server log; the
|
||||
# merge/save/convert itself only forwards stdout to the live panel.
|
||||
_phase = {
|
||||
"merged": f"Exporting merged model ({cmd.get('format_type', '16-bit (FP16)')})...",
|
||||
"gguf": f"Exporting GGUF ({cmd.get('quantization_method', 'Q4_K_M')})...",
|
||||
"lora": "Exporting LoRA adapter...",
|
||||
"base": "Exporting base model...",
|
||||
}.get(export_type, f"Exporting ({export_type})...")
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{"type": "status", "message": _phase, "ts": time.time()},
|
||||
)
|
||||
|
||||
output_path: Any = None
|
||||
try:
|
||||
if export_type == "merged":
|
||||
|
|
|
|||
|
|
@ -8969,16 +8969,37 @@ class LlamaCppBackend:
|
|||
disable_parallel_tool_use: bool = False,
|
||||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Agentic loop: let the model call tools, execute them, and continue.
|
||||
|
||||
permission_mode: "ask" confirms every call (with confirm_tool_calls),
|
||||
"auto" only pauses calls detected as potentially unsafe, "off" never
|
||||
pauses (sandbox stays on), "full" is the same as bypass_permissions.
|
||||
Unset/unknown behaves as "ask".
|
||||
|
||||
Yields dicts:
|
||||
{"type": "status", "text": "Searching: ..."/"Reading: ..."} -- tool status updates
|
||||
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
|
||||
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
|
||||
"""
|
||||
from core.inference.tools import build_rag_autoinject, execute_tool
|
||||
from core.inference.tools import (
|
||||
build_rag_autoinject,
|
||||
execute_tool,
|
||||
is_always_safe_tool,
|
||||
is_potentially_unsafe_tool_call,
|
||||
)
|
||||
|
||||
# Normalize the mode: "full" and bypass_permissions are the same
|
||||
# switch, whichever arrives first wins toward the permissive side.
|
||||
# "off" keeps the sandbox but never prompts.
|
||||
if permission_mode == "full":
|
||||
bypass_permissions = True
|
||||
elif bypass_permissions:
|
||||
permission_mode = "full"
|
||||
elif permission_mode not in ("ask", "auto", "off"):
|
||||
permission_mode = "ask"
|
||||
|
||||
if not self.is_loaded:
|
||||
raise RuntimeError("llama-server is not loaded")
|
||||
|
|
@ -8986,8 +9007,14 @@ class LlamaCppBackend:
|
|||
conversation = list(messages)
|
||||
|
||||
# Forced first-pass RAG so a doc question doesn't lose to web_search. Emits
|
||||
# the same tool card + citations a real call would.
|
||||
_auto = None if confirm_tool_calls else build_rag_autoinject(conversation, rag_scope)
|
||||
# the same tool card + citations a real call would. Skip it only when a
|
||||
# retrieval call would actually prompt (ask mode); auto never gates the
|
||||
# safe search_knowledge_base tool, so retrieval must still run there.
|
||||
# off never prompts either, so it also keeps first-pass retrieval.
|
||||
_skip_autoinject = (
|
||||
confirm_tool_calls and not bypass_permissions and permission_mode not in ("auto", "off")
|
||||
)
|
||||
_auto = None if _skip_autoinject else build_rag_autoinject(conversation, rag_scope)
|
||||
if _auto:
|
||||
for _ev in _auto["events"]:
|
||||
yield _ev
|
||||
|
|
@ -9357,8 +9384,16 @@ class LlamaCppBackend:
|
|||
in provisional_started_tool_calls.values()
|
||||
)
|
||||
# Later parallel cards only reconcile when parallel use is enabled.
|
||||
# In auto mode an always-safe tool (render_html) never
|
||||
# prompts, so it must stream its early card too; mirror
|
||||
# that here instead of gating on the raw confirm flag.
|
||||
_confirm_gated = (
|
||||
confirm_tool_calls and not bypass_permissions
|
||||
confirm_tool_calls
|
||||
and not bypass_permissions
|
||||
and not (
|
||||
permission_mode == "auto"
|
||||
and is_always_safe_tool(current_name)
|
||||
)
|
||||
)
|
||||
# Keep small-argument tools on the normal path.
|
||||
_args_len = len(
|
||||
|
|
@ -9925,7 +9960,18 @@ class LlamaCppBackend:
|
|||
|
||||
# Bypass wins over the confirm gate at the loop level too,
|
||||
# so a direct internal caller with both flags never prompts.
|
||||
needs_confirm = bool(confirm_tool_calls) and not bypass_permissions
|
||||
# In "auto" mode only calls detected as potentially unsafe
|
||||
# pause; read-only calls run straight through. "off" never
|
||||
# prompts (sandbox stays on).
|
||||
needs_confirm = (
|
||||
bool(confirm_tool_calls)
|
||||
and not bypass_permissions
|
||||
and permission_mode != "off"
|
||||
)
|
||||
if needs_confirm and permission_mode == "auto":
|
||||
needs_confirm = is_potentially_unsafe_tool_call(
|
||||
decision.tool_name, decision.arguments
|
||||
)
|
||||
approval_id = new_approval_id() if needs_confirm else ""
|
||||
decision_slot = (
|
||||
begin_tool_decision(session_id, approval_id) if needs_confirm else None
|
||||
|
|
|
|||
|
|
@ -1372,6 +1372,7 @@ class InferenceOrchestrator:
|
|||
rag_scope: Optional[dict] = None,
|
||||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
use_adapter: Optional[Union[bool, str]] = None,
|
||||
stats_holder: Optional[dict] = None,
|
||||
presence_penalty: float = 0.0,
|
||||
|
|
@ -1439,6 +1440,7 @@ class InferenceOrchestrator:
|
|||
rag_scope = rag_scope,
|
||||
confirm_tool_calls = confirm_tool_calls,
|
||||
bypass_permissions = bypass_permissions,
|
||||
permission_mode = permission_mode,
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ def run_safetensors_tool_loop(
|
|||
rag_scope: Optional[dict] = None,
|
||||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""Drive an agentic tool loop on top of a cumulative-text generator.
|
||||
|
||||
|
|
@ -453,10 +454,27 @@ def run_safetensors_tool_loop(
|
|||
"""
|
||||
conversation = list(messages)
|
||||
|
||||
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to web_search.
|
||||
# Normalize the mode (mirrors the GGUF loop): "full" and
|
||||
# bypass_permissions are the same switch; unset/unknown behaves as "ask".
|
||||
# "off" keeps the sandbox but never prompts.
|
||||
if permission_mode == "full":
|
||||
bypass_permissions = True
|
||||
elif bypass_permissions:
|
||||
permission_mode = "full"
|
||||
elif permission_mode not in ("ask", "auto", "off"):
|
||||
permission_mode = "ask"
|
||||
|
||||
# Forced first-pass RAG (mirrors the GGUF loop) so doc Qs don't lose to
|
||||
# web_search. Skip only when a retrieval call would actually prompt (ask
|
||||
# mode); auto never gates the safe search_knowledge_base tool.
|
||||
from core.inference.tools import build_rag_autoinject
|
||||
|
||||
_auto = None if confirm_tool_calls else build_rag_autoinject(conversation, rag_scope)
|
||||
# off never prompts, so (like auto) it must not lose first-pass retrieval
|
||||
# even if a direct caller passes a stale confirm_tool_calls flag.
|
||||
_skip_autoinject = (
|
||||
confirm_tool_calls and not bypass_permissions and permission_mode not in ("auto", "off")
|
||||
)
|
||||
_auto = None if _skip_autoinject else build_rag_autoinject(conversation, rag_scope)
|
||||
if _auto:
|
||||
for _ev in _auto["events"]:
|
||||
yield _ev
|
||||
|
|
@ -539,7 +557,16 @@ def run_safetensors_tool_loop(
|
|||
# provisional card (keyed by tool_call_id, no approval) would show the
|
||||
# tool as "running" before the user has approved it. Suppress the early
|
||||
# card in that case and let the gated tool_start be the first signal.
|
||||
_provisional_confirm_gated = bool(confirm_tool_calls) and not bypass_permissions
|
||||
# In auto mode render_html is always safe and never prompts, so keep its
|
||||
# early canvas card (the frontend sends confirm_tool_calls=true alongside
|
||||
# auto); mirrors the GGUF path's _confirm_gated exemption.
|
||||
from core.inference.tools import is_always_safe_tool
|
||||
|
||||
_provisional_confirm_gated = (
|
||||
bool(confirm_tool_calls)
|
||||
and not bypass_permissions
|
||||
and not (permission_mode == "auto" and is_always_safe_tool("render_html"))
|
||||
)
|
||||
|
||||
gen = _call_single_turn(single_turn, conversation, active_tools)
|
||||
prev_cumulative = ""
|
||||
|
|
@ -1056,8 +1083,17 @@ def run_safetensors_tool_loop(
|
|||
assistant_msg.setdefault("tool_calls", []).append(decision.as_assistant_tool_call())
|
||||
|
||||
# Bypass wins over the confirm gate at the loop level too, so a
|
||||
# direct internal caller passing both flags never prompts.
|
||||
needs_confirm = bool(confirm_tool_calls) and not bypass_permissions
|
||||
# direct internal caller passing both flags never prompts. In
|
||||
# "auto" mode only calls detected as potentially unsafe pause.
|
||||
# "off" never prompts (sandbox stays on).
|
||||
needs_confirm = (
|
||||
bool(confirm_tool_calls) and not bypass_permissions and permission_mode != "off"
|
||||
)
|
||||
if needs_confirm and permission_mode == "auto":
|
||||
from core.inference.tools import is_potentially_unsafe_tool_call
|
||||
needs_confirm = is_potentially_unsafe_tool_call(
|
||||
decision.tool_name, decision.arguments
|
||||
)
|
||||
approval_id = new_approval_id() if needs_confirm else ""
|
||||
decision_slot = begin_tool_decision(session_id, approval_id) if needs_confirm else None
|
||||
start_event = decision.tool_start_event()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,8 +11,10 @@ import os
|
|||
import sys
|
||||
import types
|
||||
|
||||
# Prevent tokenizer parallelism deadlocks when datasets forks.
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
# Off on Linux so datasets' forked map() workers can't deadlock. On spawn platforms
|
||||
# (Windows/macOS) map() runs in-process, so keep the fast tokenizer's Rust threads on
|
||||
# (the only parallelism single-process tokenize gets; off makes prep run serially).
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "true" if sys.platform in ("win32", "darwin") else "false"
|
||||
|
||||
# Make compiled cache modules importable by any subprocess. On spawn platforms
|
||||
# (Windows/macOS) spawned dataset.map() workers re-import top-level modules, and
|
||||
|
|
@ -931,7 +933,7 @@ class UnslothTrainer:
|
|||
use_gradient_checkpointing = "unsloth"
|
||||
elif use_gradient_checkpointing in ("true", "1", "yes"):
|
||||
use_gradient_checkpointing = True
|
||||
elif use_gradient_checkpointing in ("false", "0", "no"):
|
||||
elif use_gradient_checkpointing in ("false", "0", "no", "none", "off"):
|
||||
use_gradient_checkpointing = False
|
||||
else:
|
||||
# Invalid value -> "unsloth"
|
||||
|
|
|
|||
|
|
@ -774,6 +774,10 @@ class TrainingBackend:
|
|||
self._should_stop = False
|
||||
self._cancel_requested = False # True only for stop(save=False)
|
||||
|
||||
# Throttled training-status logging to the server log (not one line/step).
|
||||
self._last_progress_log_ts: float = 0.0
|
||||
self._last_progress_log_step: int = -1
|
||||
|
||||
# Training metrics (consumed by routes for SSE and /metrics)
|
||||
self.loss_history: list = []
|
||||
self.lr_history: list = []
|
||||
|
|
@ -956,6 +960,10 @@ class TrainingBackend:
|
|||
self._progress = TrainingProgress(
|
||||
is_training = True, status_message = "Initializing training..."
|
||||
)
|
||||
# Reset the progress-log throttle so the new run always logs its first step,
|
||||
# even if it starts within 30s of a prior run whose last logged step matches.
|
||||
self._last_progress_log_ts = 0.0
|
||||
self._last_progress_log_step = -1
|
||||
self.loss_history.clear()
|
||||
self.lr_history.clear()
|
||||
self.step_history.clear()
|
||||
|
|
@ -1831,6 +1839,37 @@ class TrainingBackend:
|
|||
elif db_action == "finalize":
|
||||
self._finalize_run_in_db(**db_action_kwargs)
|
||||
|
||||
if etype == "progress":
|
||||
self._log_training_progress()
|
||||
|
||||
def _log_training_progress(self) -> None:
|
||||
"""One throttled training-status line to the server log (the per-step stream
|
||||
still goes to the UI via SSE): first step, then at most every 30s, plus the
|
||||
final step; resyncs on a new run. Runs on the pump thread."""
|
||||
p = self._progress
|
||||
step = int(p.step or 0)
|
||||
if step <= 0:
|
||||
return
|
||||
total = int(p.total_steps or 0)
|
||||
is_final = total > 0 and step >= total
|
||||
prev = self._last_progress_log_step
|
||||
if step == prev:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if prev >= 0 and step > prev and not is_final and (now - self._last_progress_log_ts) < 30.0:
|
||||
return
|
||||
self._last_progress_log_ts = now
|
||||
self._last_progress_log_step = step
|
||||
logger.info(
|
||||
"training_progress",
|
||||
step = step,
|
||||
total_steps = total or None,
|
||||
percent = int(step * 100 / total) if total > 0 else None,
|
||||
loss = round(p.loss, 4) if p.loss is not None else None,
|
||||
epoch = round(p.epoch, 2) if p.epoch is not None else None,
|
||||
eta_s = int(p.eta_seconds) if p.eta_seconds else None,
|
||||
)
|
||||
|
||||
def _ensure_db_run_created(self) -> None:
|
||||
"""Create the DB row if it doesn't exist yet. An in-progress flag lets only one
|
||||
caller create at a time, and ``_db_run_created`` is published only after
|
||||
|
|
|
|||
|
|
@ -2190,7 +2190,11 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
stop_queue: mp.Queue for stop commands from the parent.
|
||||
config: Training config dict with all parameters.
|
||||
"""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
# Off on Linux (forked datasets map() workers deadlock otherwise); on spawn
|
||||
# platforms map() is in-process, so keep tokenizer threads on for faster prep.
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = (
|
||||
"true" if sys.platform in ("win32", "darwin") else "false"
|
||||
)
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # before imports
|
||||
|
||||
# HTTP-fallback respawn: disable Xet before any huggingface_hub import (the
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ summing stale blobs against the wrong total)."""
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
|
@ -34,6 +35,28 @@ logger = get_logger(__name__)
|
|||
# (repo_id, hf_token) -> (expected_total_bytes, expected_blob_hashes)
|
||||
SnapshotMetadataResolver = Callable[[str, Optional[str]], "tuple[int, frozenset[str]]"]
|
||||
|
||||
# One progress log per 10% step per job, so an active download reports progress
|
||||
# without emitting a line on every poll.
|
||||
_progress_step_lock = threading.Lock()
|
||||
_last_progress_step: dict[str, int] = {}
|
||||
|
||||
|
||||
def _log_progress_step(job_key: str, repo_id: str, variant: Optional[str], progress: float) -> None:
|
||||
step = int(progress * 10)
|
||||
with _progress_step_lock:
|
||||
last = _last_progress_step.get(job_key, -1)
|
||||
if step == last:
|
||||
return
|
||||
_last_progress_step[job_key] = step
|
||||
if step < last:
|
||||
return # download restarted; resync without logging
|
||||
logger.info(
|
||||
"hub_download_progress",
|
||||
repo_id = repo_id,
|
||||
variant = variant or "",
|
||||
percent = step * 10,
|
||||
)
|
||||
|
||||
|
||||
def _empty_progress(expected_bytes: int) -> dict:
|
||||
return {
|
||||
|
|
@ -215,6 +238,8 @@ def compute_snapshot_progress(
|
|||
else 0
|
||||
)
|
||||
)
|
||||
if force_active:
|
||||
_log_progress_step(job_key, repo_id, variant, progress)
|
||||
return {
|
||||
"downloaded_bytes": display_downloaded_bytes,
|
||||
"completed_bytes": display_completed_bytes,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ import structlog
|
|||
from loggers.handlers import filter_sensitive_data
|
||||
|
||||
|
||||
class _DropTorchDtypeDeprecation(logging.Filter):
|
||||
"""Drop transformers' once-per-run "`torch_dtype` is deprecated" warning_once.
|
||||
It is emitted via logging (not warnings), so a warnings filter cannot catch it."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return not ("torch_dtype" in msg and "deprecated" in msg)
|
||||
|
||||
|
||||
class LogConfig:
|
||||
"""Structured logging configuration for the application."""
|
||||
|
||||
|
|
@ -72,4 +81,13 @@ class LogConfig:
|
|||
cache_logger_on_first_use = True,
|
||||
)
|
||||
|
||||
# Drop transformers' cosmetic "`torch_dtype` is deprecated" warning_once (see filter).
|
||||
_dtype_filter = _DropTorchDtypeDeprecation()
|
||||
for _name in (
|
||||
"transformers.configuration_utils",
|
||||
"transformers.modeling_utils",
|
||||
"transformers.pipelines.base",
|
||||
):
|
||||
logging.getLogger(_name).addFilter(_dtype_filter)
|
||||
|
||||
return structlog.get_logger(service_name)
|
||||
|
|
|
|||
|
|
@ -28,19 +28,26 @@ def _env_int(name: str, default: int) -> int:
|
|||
return default
|
||||
|
||||
|
||||
# Drop duplicate successful-GET access logs repeated within the window: the SPA
|
||||
# fans one cache invalidation into many identical list fetches; only the first
|
||||
# informs. Loading polls, mutations, and errors are unaffected. 0 = log all.
|
||||
# Collapse identical GET/2xx logs within the window (the SPA fans one invalidation
|
||||
# into many list fetches). Mutations and errors always log. 0 = off.
|
||||
_ACCESS_LOG_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_DEDUP_MS", 300)
|
||||
# Pure-liveness/UI polls whose access line carries no signal beyond "client still
|
||||
# polling" (state changes are logged by their own modules). Collapsed to a longer
|
||||
# heartbeat instead of one line per poll; first hit and any error still log. 0 = off.
|
||||
# Liveness/UI polls whose line means only "still polling"; collapse to a longer
|
||||
# heartbeat. First hit and errors still log. 0 = off.
|
||||
_QUIET_POLL_DEDUP_MS = _env_int("UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS", 10000)
|
||||
_QUIET_POLL_PATHS = {
|
||||
"/api/health",
|
||||
"/api/auth/status",
|
||||
"/api/inference/status",
|
||||
"/api/inference/monitor",
|
||||
# List polls the tabs refetch on a timer and on every tab switch.
|
||||
"/api/train/runs",
|
||||
"/api/models/checkpoints",
|
||||
"/api/models/local",
|
||||
"/api/rag/knowledge-bases",
|
||||
# Legacy download polls emit no progress events (unlike /api/hub/*), so heartbeat them.
|
||||
"/api/models/download-progress",
|
||||
"/api/models/gguf-download-progress",
|
||||
"/api/datasets/download-progress",
|
||||
}
|
||||
_DEDUP_MAP_MAX = 4096
|
||||
_NATIVE_PATH_LEASE_RE = re.compile(
|
||||
|
|
@ -62,6 +69,46 @@ _EXCLUDED_SUFFIXES = (
|
|||
".woff2",
|
||||
".ttf",
|
||||
)
|
||||
# GET polls whose 2xx line carries no signal (their progress/phase events and the UI
|
||||
# do), so drop it entirely; non-2xx still logs. Only /api/hub download polls emit
|
||||
# events; the legacy /api/models and /api/datasets ones heartbeat via _QUIET_POLL_PATHS.
|
||||
_QUIET_SUCCESS_PATHS = {
|
||||
"/api/inference/load-progress",
|
||||
"/api/llama/update-status",
|
||||
"/api/export/logs",
|
||||
"/api/export/status",
|
||||
"/api/hub/download-status",
|
||||
"/api/hub/download-progress",
|
||||
"/api/hub/gguf-download-progress",
|
||||
"/api/hub/active-downloads",
|
||||
"/api/hub/transport-status",
|
||||
"/api/hub/datasets/download-status",
|
||||
"/api/hub/datasets/download-progress",
|
||||
"/api/hub/datasets/active-downloads",
|
||||
"/api/hub/datasets/transport-status",
|
||||
}
|
||||
# The token-refresh route. Its first 2xx means the client has obtained a valid
|
||||
# session, so from then on chat 401s are real failures and must stay visible.
|
||||
_AUTH_REFRESH_PATH = "/api/auth/refresh"
|
||||
# High-frequency chat list polls; their 2xx is covered by generation/tool-call/stats
|
||||
# events. Exact paths only, so detail/message reads (/threads/{id}, .../messages,
|
||||
# /projects/{id}) keep their logs. The pre-auth 401 race also fires on these polls.
|
||||
_CHAT_LIST_PATHS = {
|
||||
"/api/chat/threads",
|
||||
"/api/chat/projects",
|
||||
}
|
||||
|
||||
|
||||
def _is_quiet_success(method: str, path: str, status_code: int, pre_auth: bool) -> bool:
|
||||
"""GET-only. Suppress a 2xx poll line that carries no signal, plus a chat list
|
||||
poll's transient pre-auth 401 (only in the bootstrap window before the first
|
||||
successful token refresh). Mutations, real (post-refresh) auth failures, and
|
||||
all other errors always log."""
|
||||
if method != "GET":
|
||||
return False
|
||||
if 200 <= status_code < 300:
|
||||
return path in _QUIET_SUCCESS_PATHS or path in _CHAT_LIST_PATHS
|
||||
return pre_auth and status_code == 401 and path in _CHAT_LIST_PATHS
|
||||
|
||||
|
||||
class LoggingMiddleware:
|
||||
|
|
@ -71,14 +118,16 @@ class LoggingMiddleware:
|
|||
self.app = app
|
||||
# (method, path, query, status_code) -> monotonic ts of the last EMITTED log.
|
||||
self._last_log: dict[tuple[str, str, bytes, int], float] = {}
|
||||
# Flips True after the first successful /api/auth/refresh; before that, chat
|
||||
# list-poll 401s are the transient bootstrap race and are suppressed.
|
||||
self._auth_refreshed = False
|
||||
|
||||
def _is_redundant_repeat(
|
||||
self, method: str, path: str, query: bytes, status_code: int, now: float
|
||||
) -> bool:
|
||||
"""True if an identical GET/2xx log fired < window ago. The query string
|
||||
is part of the identity, so distinct query-driven GETs are not collapsed.
|
||||
Mutations and non-2xx are never deduped. Quiet-poll paths use a longer
|
||||
heartbeat window. Stamps only on emit, so steady polls still log."""
|
||||
"""True if an identical GET/2xx log fired < window ago (query string is part
|
||||
of the identity). Non-GET/non-2xx never dedup; quiet-poll paths use the longer
|
||||
heartbeat. Stamps only on emit, so steady polls still log."""
|
||||
if method != "GET" or not (200 <= status_code < 300):
|
||||
return False
|
||||
window_ms = _QUIET_POLL_DEDUP_MS if path in _QUIET_POLL_PATHS else _ACCESS_LOG_DEDUP_MS
|
||||
|
|
@ -129,8 +178,16 @@ class LoggingMiddleware:
|
|||
raise
|
||||
else:
|
||||
end_time = time.perf_counter()
|
||||
if not excluded and not self._is_redundant_repeat(
|
||||
scope["method"], path, scope.get("query_string", b""), status_code, end_time
|
||||
if 200 <= status_code < 300 and path == _AUTH_REFRESH_PATH:
|
||||
self._auth_refreshed = True
|
||||
if (
|
||||
not excluded
|
||||
and not _is_quiet_success(
|
||||
scope["method"], path, status_code, not self._auth_refreshed
|
||||
)
|
||||
and not self._is_redundant_repeat(
|
||||
scope["method"], path, scope.get("query_string", b""), status_code, end_time
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
"request_completed",
|
||||
|
|
|
|||
|
|
@ -558,8 +558,12 @@ async def lifespan(app: FastAPI):
|
|||
(_time.perf_counter() - _lifespan_started) * 1000,
|
||||
)
|
||||
|
||||
# run_server's pre-bind gate sets suppress_bootstrap_injection when a public
|
||||
# URL is about to serve with the default credential active: never (re)capture
|
||||
# the bootstrap password into app.state, or the HTML would hand it out.
|
||||
_suppress_bootstrap = getattr(app.state, "suppress_bootstrap_injection", False)
|
||||
if storage.ensure_default_admin():
|
||||
bootstrap_pw = storage.get_bootstrap_password()
|
||||
bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password()
|
||||
app.state.bootstrap_password = bootstrap_pw
|
||||
|
||||
bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
|
||||
|
|
@ -570,7 +574,7 @@ async def lifespan(app: FastAPI):
|
|||
print(" open that file to read the password, then sign in and change it.")
|
||||
print("=" * 60 + "\n")
|
||||
else:
|
||||
bootstrap_pw = storage.get_bootstrap_password()
|
||||
bootstrap_pw = None if _suppress_bootstrap else storage.get_bootstrap_password()
|
||||
app.state.bootstrap_password = bootstrap_pw
|
||||
# A restart before first login skips the creation banner above; still
|
||||
# point the operator to the seed file while the bootstrap pw is unrotated.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from typing import Optional
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth.storage import MIN_PASSWORD_LENGTH
|
||||
|
||||
|
||||
class AuthLoginRequest(BaseModel):
|
||||
"""Login payload: username/password to obtain a JWT."""
|
||||
|
|
@ -45,10 +47,14 @@ class ChangePasswordRequest(BaseModel):
|
|||
"""Change the current user's password, typically on first login."""
|
||||
|
||||
current_password: str = Field(
|
||||
..., min_length = 8, description = "Existing password for the authenticated user"
|
||||
...,
|
||||
min_length = MIN_PASSWORD_LENGTH,
|
||||
description = "Existing password for the authenticated user",
|
||||
)
|
||||
new_password: str = Field(
|
||||
..., min_length = 8, description = "Replacement password (minimum 8 characters)"
|
||||
...,
|
||||
min_length = MIN_PASSWORD_LENGTH,
|
||||
description = f"Replacement password (minimum {MIN_PASSWORD_LENGTH} characters)",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -695,6 +695,23 @@ class ThinkingConfig(BaseModel):
|
|||
type: Literal["disabled", "enabled"] = "disabled"
|
||||
|
||||
|
||||
# Recognized permission_mode values. The field accepts a plain string rather than
|
||||
# a Literal so an unrecognized value from a newer UI/client degrades to the
|
||||
# safest gate ("ask") instead of a 422; the tool loops apply the same unknown ->
|
||||
# ask fallback, so normalizing here keeps that forward-compat path reachable at
|
||||
# the API boundary. None stays unset ("behaves as 'ask'" without self-enabling
|
||||
# the confirm gate).
|
||||
_KNOWN_PERMISSION_MODES = ("ask", "auto", "off", "full")
|
||||
|
||||
|
||||
def _normalize_permission_mode(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if value not in _KNOWN_PERMISSION_MODES:
|
||||
return "ask"
|
||||
return value
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
"""OpenAI-compatible chat completion request.
|
||||
|
||||
|
|
@ -840,6 +857,19 @@ class ChatCompletionRequest(BaseModel):
|
|||
False,
|
||||
description = "[x-unsloth] Bypass Permissions: when true, skip the tool-call confirmation gate AND disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits). Secret env vars are still stripped. Takes precedence over confirm_tool_calls.",
|
||||
)
|
||||
permission_mode: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"[x-unsloth] Permission level for local tool calls. 'ask' pauses every "
|
||||
"call for approval; 'ask'/'auto' enable the confirmation gate on their "
|
||||
"own (needs a streaming request to deliver prompts). 'auto' ('Approve for "
|
||||
"me') only pauses calls detected as potentially unsafe (state-mutating "
|
||||
"terminal/python/MCP calls); read-only calls run immediately, and the "
|
||||
"sandbox stays on. 'full' is equivalent to bypass_permissions=true (no "
|
||||
"confirmation, no sandbox). Unset behaves as 'ask'. An unrecognized value "
|
||||
"(e.g. from a newer client) is treated as 'ask'."
|
||||
),
|
||||
)
|
||||
auto_heal_tool_calls: Optional[bool] = Field(
|
||||
True,
|
||||
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
|
||||
|
|
@ -1103,6 +1133,52 @@ class ChatCompletionRequest(BaseModel):
|
|||
self.enable_thinking = self.thinking.type == "enabled"
|
||||
return self
|
||||
|
||||
@field_validator("permission_mode", mode = "before")
|
||||
@classmethod
|
||||
def _coerce_permission_mode(cls, value: Any) -> Any:
|
||||
# Accept any string so an unknown mode degrades to 'ask' instead of a
|
||||
# 422; mirrors the tool loops' unknown -> ask fallback.
|
||||
return _normalize_permission_mode(value)
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _fold_full_permission_into_bypass(self) -> "ChatCompletionRequest":
|
||||
"""permission_mode='full' is the documented equivalent of
|
||||
bypass_permissions=true, so fold it in before any route guard reads
|
||||
the flag (else a full request would trip the confirm-gate rejections)."""
|
||||
if self.permission_mode == "full":
|
||||
self.bypass_permissions = True
|
||||
elif self.bypass_permissions:
|
||||
# Legacy bypass callers map onto Full access (mirrors the tool loop).
|
||||
self.permission_mode = "full"
|
||||
elif self.permission_mode == "off":
|
||||
# "Off" never prompts, so route guards must see confirm disabled.
|
||||
self.confirm_tool_calls = False
|
||||
elif (
|
||||
self.permission_mode == "ask"
|
||||
and self.confirm_tool_calls is None
|
||||
and not (self.provider_id or self.provider_type)
|
||||
and (self.enable_tools is True or bool(self.mcp_enabled))
|
||||
):
|
||||
# "Ask" gates every call, so a direct API caller that omits the legacy
|
||||
# confirm flag must still hit the confirmation gate for Studio's own
|
||||
# tool loop. An explicit confirm_tool_calls=False wins over the mode
|
||||
# (mirrors _permission_mode_confirm and the Anthropic pre-switch guard),
|
||||
# so only self-enable when the flag is unset. Only self-enable when that
|
||||
# loop is actually requested
|
||||
# (enable_tools / mcp_enabled) -- the router enters the loop on those
|
||||
# signals, not on enabled_tools alone (which merely filters which tools
|
||||
# run). A plain client-tool passthrough (client-supplied `tools` that
|
||||
# Studio does not execute) must route verbatim, and external-provider
|
||||
# routing rejects confirm_tool_calls with tools, so skip the fold there.
|
||||
#
|
||||
# "auto" is deliberately NOT folded: it only prompts for a call the
|
||||
# classifier flags, so leaving confirm_tool_calls unset lets the route's
|
||||
# _confirm_gate_needs_stream apply the safe-only exception (a safe-only
|
||||
# auto selection needs no stream) instead of an explicit-confirm forcing
|
||||
# stream=true. The mode still drives the loop's per-call gate.
|
||||
self.confirm_tool_calls = True
|
||||
return self
|
||||
|
||||
|
||||
class ToolConfirmRequest(BaseModel):
|
||||
session_id: Optional[str] = None
|
||||
|
|
@ -1758,6 +1834,10 @@ class AnthropicMessagesRequest(BaseModel):
|
|||
False,
|
||||
description = "[x-unsloth] Bypass Permissions: when true, disable the python/terminal execution sandbox (safety checks, command blocklist, resource limits) for server-side tool calls. Secret env vars are still stripped. Declared explicitly (not relied on via extra='allow') so omitted requests default to False instead of raising AttributeError.",
|
||||
)
|
||||
permission_mode: Optional[str] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] Permission level for local tool calls: 'ask' pauses every call, 'auto' only pauses calls detected as potentially unsafe, 'off' never pauses (sandbox stays on), 'full' equals bypass_permissions=true. Unset behaves as 'ask'; an unrecognized value (e.g. from a newer client) is treated as 'ask'. Declared explicitly so omitted requests default to None instead of raising AttributeError.",
|
||||
)
|
||||
auto_heal_tool_calls: Optional[bool] = Field(
|
||||
True,
|
||||
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output (mirrors the Chat Completions field; applies to the client-tool passthrough).",
|
||||
|
|
@ -1799,6 +1879,27 @@ class AnthropicMessagesRequest(BaseModel):
|
|||
normalized["system"] = _merge_anthropic_system(normalized.get("system"), system_additions)
|
||||
return normalized
|
||||
|
||||
@field_validator("permission_mode", mode = "before")
|
||||
@classmethod
|
||||
def _coerce_permission_mode(cls, value: Any) -> Any:
|
||||
# Accept any string so an unknown mode degrades to 'ask' instead of a
|
||||
# 422; mirrors the tool loops' unknown -> ask fallback.
|
||||
return _normalize_permission_mode(value)
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _fold_full_permission_into_bypass(self) -> "AnthropicMessagesRequest":
|
||||
"""permission_mode='full' equals bypass_permissions=true (mirrors the
|
||||
Chat Completions request)."""
|
||||
if self.permission_mode == "full":
|
||||
self.bypass_permissions = True
|
||||
elif self.bypass_permissions:
|
||||
# Legacy bypass callers map onto Full access (mirrors the tool loop).
|
||||
self.permission_mode = "full"
|
||||
elif self.permission_mode == "off":
|
||||
# "Off" never prompts, so route guards must see confirm disabled.
|
||||
self.confirm_tool_calls = False
|
||||
return self
|
||||
|
||||
|
||||
# ── Response models ────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -500,8 +500,9 @@ async def change_password(
|
|||
detail = "New password must be different from the current password",
|
||||
)
|
||||
|
||||
storage.update_password(current_subject, payload.new_password)
|
||||
storage.revoke_user_refresh_tokens(current_subject)
|
||||
# Single transaction: a separate refresh-token purge could fail after the
|
||||
# password commit, leaving pre-change tokens able to mint access tokens.
|
||||
storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True)
|
||||
try:
|
||||
request.app.state.bootstrap_password = None
|
||||
except AttributeError:
|
||||
|
|
|
|||
|
|
@ -2064,6 +2064,59 @@ def _explicit_studio_tool_loop_requested(payload) -> bool:
|
|||
return policy is not False and (payload.enable_tools is True or bool(payload.mcp_enabled))
|
||||
|
||||
|
||||
def _permission_mode_confirm(payload) -> bool:
|
||||
"""Effective confirm-gate intent for Studio's own local tool loop.
|
||||
|
||||
Honors the documented default that an unset permission_mode behaves as
|
||||
"ask". An explicit confirm_tool_calls (True or False) wins; explicit
|
||||
ask/auto always engage the gate (a non-streaming one is then rejected, since
|
||||
it cannot prompt); off/full never prompt. An unset mode defaults to ask, but
|
||||
that is only realizable on a streaming request, so a non-streaming unset
|
||||
request keeps the legacy run-without-gate behavior instead of 400ing. Used
|
||||
at the pre-switch guard and the per-backend tool paths so a forced tool loop
|
||||
(CLI --enable-tools) with the default mode still gates streaming requests.
|
||||
"""
|
||||
if payload.confirm_tool_calls is not None:
|
||||
return bool(payload.confirm_tool_calls)
|
||||
mode = getattr(payload, "permission_mode", None)
|
||||
if mode in ("ask", "auto"):
|
||||
return True
|
||||
if mode in ("off", "full"):
|
||||
return False
|
||||
return bool(getattr(payload, "stream", False))
|
||||
|
||||
|
||||
def _confirm_gate_needs_stream(payload) -> bool:
|
||||
"""Whether Studio's local tool-loop confirm gate still requires stream=true.
|
||||
|
||||
The gate can only prompt while streaming, so a non-streaming request that will
|
||||
prompt must 400 up front. auto ("Approve for me") only prompts for a call the
|
||||
classifier flags, so an auto request whose confirm is derived from the mode
|
||||
(not an explicit confirm_tool_calls=true) and whose selectable tools are all
|
||||
always-safe (web_search / RAG) never prompts and needs no stream. ask,
|
||||
an explicit confirm flag, MCP tools, and an unrestricted or unsafe selection
|
||||
still require streaming.
|
||||
"""
|
||||
if not _permission_mode_confirm(payload):
|
||||
return False
|
||||
if getattr(payload, "permission_mode", None) != "auto":
|
||||
return True
|
||||
if payload.confirm_tool_calls is True:
|
||||
return True
|
||||
if getattr(payload, "mcp_enabled", False):
|
||||
return True
|
||||
enabled = getattr(payload, "enabled_tools", None)
|
||||
if enabled is None:
|
||||
return True # omitted enabled_tools resolves to ALL tools (incl. terminal/python)
|
||||
if not enabled:
|
||||
# An explicit empty selection runs no built-in tool (_select_request_tools
|
||||
# skips the loop), so there is nothing to prompt and no stream is needed.
|
||||
return False
|
||||
from core.inference.tools import is_always_safe_tool
|
||||
|
||||
return not all(is_always_safe_tool(t) for t in enabled)
|
||||
|
||||
|
||||
# Cancel registry. Proxies (e.g. Colab) can swallow client fetch aborts so
|
||||
# is_disconnected() never fires. POST /inference/cancel looks up in-flight
|
||||
# cancel_events here by cancel_id (per-run) or session_id / completion_id
|
||||
|
|
@ -3814,6 +3867,17 @@ _NOT_SUPPORTED_HINTS = (
|
|||
"does not support",
|
||||
)
|
||||
|
||||
_NVFP4_INFERENCE_UNSUPPORTED_MESSAGE = (
|
||||
"We are working on supporting NVFP4 inference. For now it is not supported"
|
||||
)
|
||||
|
||||
|
||||
def _is_unsupported_nvfp4_inference_error(msg: str) -> bool:
|
||||
"""Whether ``msg`` is the verbose MLX per-module metadata error emitted
|
||||
while loading an NVFP4 checkpoint."""
|
||||
lower_msg = msg.lower()
|
||||
return "nvfp4" in lower_msg and "per-module mlx quantization metadata" in lower_msg
|
||||
|
||||
|
||||
def _maybe_unsupported_message(msg: str) -> str:
|
||||
"""Rewrite a load/validate error into the friendly "not supported yet"
|
||||
|
|
@ -3864,6 +3928,10 @@ async def load_model(
|
|||
async def _load_model_impl(request: LoadRequest, fastapi_request: Request, current_subject: str):
|
||||
from core.inference.llama_cpp import LlamaServerNotFoundError
|
||||
|
||||
# A new load starts here; arm the progress throttle so this load's first
|
||||
# sampled step logs even if it reports 100% immediately (cached/small load).
|
||||
_reset_load_progress_step()
|
||||
|
||||
native_grant_backed = False
|
||||
model_log_label = request.model_path
|
||||
try:
|
||||
|
|
@ -4482,8 +4550,17 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
if _is_unsupported_nvfp4_inference_error(redacted_msg):
|
||||
logger.warning(
|
||||
"NVFP4 inference is not supported yet while loading '%s'",
|
||||
model_log_label,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = _NVFP4_INFERENCE_UNSUPPORTED_MESSAGE,
|
||||
)
|
||||
if native_grant_backed:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
logger.warning(
|
||||
"Rejected inference selection for native model %s: %s",
|
||||
model_log_label,
|
||||
|
|
@ -4492,7 +4569,7 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
raise HTTPException(status_code = 400, detail = redacted_msg)
|
||||
logger.warning("Rejected inference GPU selection: %s", e)
|
||||
# User-facing validation (e.g. "Invalid gpu_ids [99]"): redact paths, keep detail.
|
||||
raise HTTPException(status_code = 400, detail = redact_native_paths(str(e)))
|
||||
raise HTTPException(status_code = 400, detail = redacted_msg)
|
||||
except LlamaServerNotFoundError as e:
|
||||
# Missing GGUF runtime: 400 with the install message, not a generic 500.
|
||||
logger.warning("GGUF runtime missing while loading '%s': %s", model_log_label, e)
|
||||
|
|
@ -4504,8 +4581,17 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
# Lost the spawn-time race to a sidecar install/repair: retryable 409.
|
||||
raise HTTPException(status_code = 409, detail = str(e))
|
||||
# Friendlier message for models Unsloth cannot load.
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
if _is_unsupported_nvfp4_inference_error(redacted_msg):
|
||||
logger.warning(
|
||||
"NVFP4 inference is not supported yet while loading '%s'",
|
||||
model_log_label,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = _NVFP4_INFERENCE_UNSUPPORTED_MESSAGE,
|
||||
)
|
||||
if native_grant_backed:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
logger.error(
|
||||
"Error loading native model %s: %s",
|
||||
model_log_label,
|
||||
|
|
@ -4517,7 +4603,7 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
detail = f"Failed to load native model {model_log_label}: {msg}",
|
||||
)
|
||||
logger.error(f"Error loading model: {e}", exc_info = True)
|
||||
msg = _maybe_unsupported_message(redact_native_paths(str(e)))
|
||||
msg = _maybe_unsupported_message(redacted_msg)
|
||||
raise HTTPException(status_code = 500, detail = f"Failed to load model: {msg}")
|
||||
|
||||
|
||||
|
|
@ -4763,8 +4849,17 @@ async def validate_model(
|
|||
logger.warning("GGUF runtime missing while validating '%s': %s", request.model_path, e)
|
||||
raise HTTPException(status_code = 400, detail = str(e))
|
||||
except Exception as e:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
if _is_unsupported_nvfp4_inference_error(redacted_msg):
|
||||
logger.warning(
|
||||
"NVFP4 inference is not supported yet while validating '%s'",
|
||||
model_log_label,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = _NVFP4_INFERENCE_UNSUPPORTED_MESSAGE,
|
||||
)
|
||||
if native_grant_backed:
|
||||
redacted_msg = redact_native_paths(str(e))
|
||||
logger.error(
|
||||
"Error validating native model %s: %s",
|
||||
model_log_label,
|
||||
|
|
@ -4785,7 +4880,7 @@ async def validate_model(
|
|||
# Path-redact for safety and keep any other exception type generic so an
|
||||
# unexpected internal error never leaks its details to the client.
|
||||
if isinstance(e, (RuntimeError, ValueError)):
|
||||
msg = redact_native_paths(str(e)).strip()
|
||||
msg = redacted_msg.strip()
|
||||
if msg:
|
||||
msg = _maybe_unsupported_message(msg)
|
||||
raise HTTPException(
|
||||
|
|
@ -5442,6 +5537,33 @@ async def get_status(current_subject: str = Depends(get_current_subject)):
|
|||
raise HTTPException(status_code = 500, detail = "Failed to get status")
|
||||
|
||||
|
||||
_load_progress_lock = threading.Lock()
|
||||
_last_load_progress_step = -1
|
||||
|
||||
|
||||
def _log_load_progress_step(fraction, phase):
|
||||
"""One inference_load_progress line per 10% step, so a model load shows
|
||||
progress without a line per poll. Reset per load by _reset_load_progress_step."""
|
||||
global _last_load_progress_step
|
||||
step = int(max(0.0, min(float(fraction), 1.0)) * 10)
|
||||
with _load_progress_lock:
|
||||
prev = _last_load_progress_step
|
||||
if step == prev:
|
||||
return
|
||||
_last_load_progress_step = step
|
||||
if step < prev:
|
||||
return # load regressed/restarted mid-poll; resync without logging
|
||||
logger.info("inference_load_progress", phase = phase or "", percent = step * 10)
|
||||
|
||||
|
||||
def _reset_load_progress_step():
|
||||
"""Arm the throttle for a new load so its first sampled step always logs,
|
||||
even a cached load that already reports fraction=1.0 on the first poll."""
|
||||
global _last_load_progress_step
|
||||
with _load_progress_lock:
|
||||
_last_load_progress_step = -1
|
||||
|
||||
|
||||
@router.get("/load-progress", response_model = LoadProgressResponse)
|
||||
async def get_load_progress(current_subject: str = Depends(get_current_subject)):
|
||||
"""
|
||||
|
|
@ -5460,7 +5582,9 @@ async def get_load_progress(current_subject: str = Depends(get_current_subject))
|
|||
progress = llama_backend.load_progress()
|
||||
if progress is None:
|
||||
return LoadProgressResponse()
|
||||
return LoadProgressResponse(**progress)
|
||||
resp = LoadProgressResponse(**progress)
|
||||
_log_load_progress_step(resp.fraction, resp.phase)
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sampling load progress: {e}")
|
||||
return LoadProgressResponse()
|
||||
|
|
@ -6611,25 +6735,52 @@ async def openai_chat_completions(
|
|||
)
|
||||
# Reject confirm-without-stream local tool requests before the switch: the
|
||||
# local tool path requires stream=true for the confirm gate, so this shape
|
||||
# is invalid and must not evict the resident model first. Mirror that path's
|
||||
# enablement exactly (_effective_enable_tools honors a CLI --enable-tools
|
||||
# policy hard-override; mcp_enabled opens the tool loop on its own but still
|
||||
# defers to a CLI --disable-tools policy), or an mcp_enabled/policy-forced
|
||||
# request would slip past this guard and only 400 after the swap.
|
||||
from state.tool_policy import get_tool_policy as _get_confirm_tool_policy
|
||||
# is invalid and must not evict the resident model first.
|
||||
#
|
||||
# Enter the local-loop arm exactly when the passthrough router below would
|
||||
# run Studio's own tool loop. That gate is `_tools_on or _mcp_allowed`
|
||||
# (see the use_tools block): _effective_enable_tools (which lets a
|
||||
# process-wide --enable-tools policy force the loop on) plus mcp_enabled
|
||||
# honoring --disable-tools, and tool_choice="none" disabling it unless the
|
||||
# request explicitly asked. enabled_tools never enters loop entry (it only
|
||||
# filters which tools run), so it is not a signal here.
|
||||
#
|
||||
# But a policy-forced loop must not steal client-tool passthrough: when the
|
||||
# request did not explicitly ask for the loop (enable_tools/mcp) and carries
|
||||
# client tools, the router forwards to the provider branch, so only treat it
|
||||
# as the local loop when the request explicitly asked OR there is no client
|
||||
# passthrough to defer to.
|
||||
from state.tool_policy import get_tool_policy as _get_tool_policy_pre
|
||||
|
||||
_confirm_cli_policy = _get_confirm_tool_policy()
|
||||
_cli_policy_pre = _get_tool_policy_pre()
|
||||
_use_tools_intent = _effective_enable_tools(payload) or (
|
||||
bool(payload.mcp_enabled) and _cli_policy_pre is not False
|
||||
)
|
||||
if payload.tool_choice == "none" and not _explicit_studio_tool_loop_requested(payload):
|
||||
_use_tools_intent = False
|
||||
_client_tool_passthrough = (
|
||||
bool(payload.tools)
|
||||
or bool(payload.openai_code_exec_container_id)
|
||||
or bool(payload.anthropic_code_exec_container_id)
|
||||
# A JSON-schema response_format is guided-decoding structured output the
|
||||
# router forwards to the llama-server passthrough, not Studio's tool
|
||||
# loop, so a --enable-tools policy must not 400 it as a local-confirm
|
||||
# request under ask/auto.
|
||||
or bool(_extract_response_format(payload))
|
||||
)
|
||||
# permission_mode only implies the confirm gate for that local loop.
|
||||
# Client-tool passthrough forwards to the provider branch and the validator
|
||||
# intentionally leaves confirm_tool_calls unset there, so only an explicit
|
||||
# confirm_tool_calls=True should force the local-confirm rejection for it.
|
||||
_studio_local_tool_loop = bool(_use_tools_intent) and (
|
||||
_explicit_studio_tool_loop_requested(payload) or not _client_tool_passthrough
|
||||
)
|
||||
if (
|
||||
payload.confirm_tool_calls
|
||||
and not payload.bypass_permissions
|
||||
not payload.bypass_permissions
|
||||
and not payload.stream
|
||||
and (
|
||||
_effective_enable_tools(payload)
|
||||
or (bool(payload.mcp_enabled) and _confirm_cli_policy is not False)
|
||||
or bool(payload.enabled_tools)
|
||||
or bool(payload.tools)
|
||||
or bool(payload.openai_code_exec_container_id)
|
||||
or bool(payload.anthropic_code_exec_container_id)
|
||||
(_confirm_gate_needs_stream(payload) and _studio_local_tool_loop)
|
||||
or (payload.confirm_tool_calls is True and _client_tool_passthrough)
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -7144,9 +7295,23 @@ async def openai_chat_completions(
|
|||
use_tools = False
|
||||
|
||||
if use_tools:
|
||||
# permission_mode ask/auto require the confirm gate for Studio's own
|
||||
# tool loop. The request validator self-enables confirm only for
|
||||
# request-level tool signals (enable_tools/enabled_tools/mcp_enabled);
|
||||
# when a CLI policy (--enable-tools) forces the loop on without those,
|
||||
# derive confirm here so the mode still gates the call (and a
|
||||
# non-stream ask/auto request is rejected below rather than running
|
||||
# unprompted). off/full never prompt, so they are excluded.
|
||||
_effective_confirm = _permission_mode_confirm(payload)
|
||||
# Bypass Permissions suppresses confirm, so the stream requirement
|
||||
# (the gate needs streaming to prompt) no longer applies.
|
||||
if payload.confirm_tool_calls and not payload.bypass_permissions and not payload.stream:
|
||||
# (the gate needs streaming to prompt) no longer applies. auto with an
|
||||
# always-safe-only selection never prompts, so it needs no stream even
|
||||
# though _effective_confirm stays true for the loop's per-call gate.
|
||||
if (
|
||||
_confirm_gate_needs_stream(payload)
|
||||
and not payload.bypass_permissions
|
||||
and not payload.stream
|
||||
):
|
||||
raise _reject(
|
||||
400,
|
||||
openai_error_body(
|
||||
|
|
@ -7223,9 +7388,9 @@ async def openai_chat_completions(
|
|||
disable_parallel_tool_use = payload.parallel_tool_calls is False,
|
||||
# Bypass Permissions takes precedence over the confirm gate:
|
||||
# never prompt while bypassing.
|
||||
confirm_tool_calls = bool(payload.confirm_tool_calls)
|
||||
and not bool(payload.bypass_permissions),
|
||||
confirm_tool_calls = _effective_confirm and not bool(payload.bypass_permissions),
|
||||
bypass_permissions = bool(payload.bypass_permissions),
|
||||
permission_mode = payload.permission_mode,
|
||||
)
|
||||
|
||||
_tool_admission_mode = "chat_tool_stream" if payload.stream else "chat_tool_nonstream"
|
||||
|
|
@ -8439,9 +8604,20 @@ async def openai_chat_completions(
|
|||
_sf_use_tools = False
|
||||
|
||||
if _sf_use_tools:
|
||||
# permission_mode ask/auto require the confirm gate for Studio's own tool
|
||||
# loop; when a CLI policy (--enable-tools) forces the loop on without a
|
||||
# request-level tool signal, derive confirm here so the mode still gates
|
||||
# the call (matching the GGUF path). off/full never prompt.
|
||||
_sf_effective_confirm = _permission_mode_confirm(payload)
|
||||
# Bypass Permissions suppresses confirm, so the stream requirement
|
||||
# (the gate needs streaming to prompt) no longer applies.
|
||||
if payload.confirm_tool_calls and not payload.bypass_permissions and not payload.stream:
|
||||
# (the gate needs streaming to prompt) no longer applies. auto with an
|
||||
# always-safe-only selection never prompts, so it needs no stream even
|
||||
# though _sf_effective_confirm stays true for the loop's per-call gate.
|
||||
if (
|
||||
_confirm_gate_needs_stream(payload)
|
||||
and not payload.bypass_permissions
|
||||
and not payload.stream
|
||||
):
|
||||
raise _reject(
|
||||
400,
|
||||
openai_error_body(
|
||||
|
|
@ -8519,9 +8695,9 @@ async def openai_chat_completions(
|
|||
rag_scope = payload.rag_scope,
|
||||
# Bypass Permissions takes precedence over the confirm gate:
|
||||
# never prompt while bypassing.
|
||||
confirm_tool_calls = bool(payload.confirm_tool_calls)
|
||||
and not bool(payload.bypass_permissions),
|
||||
confirm_tool_calls = _sf_effective_confirm and not bool(payload.bypass_permissions),
|
||||
bypass_permissions = bool(payload.bypass_permissions),
|
||||
permission_mode = payload.permission_mode,
|
||||
use_adapter = payload.use_adapter,
|
||||
stats_holder = _sf_stats_holder,
|
||||
)
|
||||
|
|
@ -11537,6 +11713,13 @@ _STUDIO_ANTHROPIC_TOOL_ALIASES = {
|
|||
"python": "python",
|
||||
"terminal": "terminal",
|
||||
}
|
||||
# Server tools that never need a confirmation prompt (read-only / non code-
|
||||
# executing; mirrors the unconditional-safe names in is_potentially_unsafe_tool_call).
|
||||
# Any other selected tool (terminal, python, render_html) can require the gate
|
||||
# this channel has no way to present, so an omitted permission_mode ("ask") only
|
||||
# asks then. render_html is excluded because a networked canvas prompts in auto,
|
||||
# and this channel invokes the loop without confirm; auto/ask reject, off/full run.
|
||||
_ANTHROPIC_UNPROMPTED_SAFE_TOOLS = frozenset({"web_search", "search_knowledge_base"})
|
||||
|
||||
|
||||
def _anthropic_requested_studio_tools(tools: Optional[list]) -> set[str]:
|
||||
|
|
@ -11797,6 +11980,53 @@ async def anthropic_messages(
|
|||
),
|
||||
)
|
||||
|
||||
# Reject an unsupported confirm-gated permission mode for Studio's own
|
||||
# ("server") Anthropic tools before the switch, mirroring the malformed- and
|
||||
# mixed-tool checks above. ask always wants a per-call pause this passthrough
|
||||
# cannot offer, so it 400s whenever server tools are selected. auto only needs
|
||||
# the gate for an unsafe call, so (like the omitted default) it runs for a
|
||||
# safe-only selection (web_search/RAG) and 400s when a gate-needing tool is
|
||||
# selected (local terminal/python, or render_html whose networked canvas
|
||||
# prompts and cannot be gated on this channel). Rejecting must happen before the
|
||||
# switch so an invalid request never evicts the resident model; it is
|
||||
# determined from the requested tools alone (backend tool support is only known
|
||||
# post-switch); an image request can never take the server-tool path, so it is
|
||||
# excluded as in the server_tools gate below. off/full and an explicit
|
||||
# confirm_tool_calls=False opt-out always pass.
|
||||
_enable_pre = _effective_enable_tools(payload)
|
||||
_server_tools_requested_pre = (
|
||||
_enable_pre or (_enable_pre is None and bool(requested_studio_tools))
|
||||
) and not _anthropic_request_has_image(payload)
|
||||
if _server_tools_requested_pre:
|
||||
from core.inference.tools import ALL_TOOLS as _ALL_TOOLS_PRE
|
||||
|
||||
_selected_pre = _select_anthropic_server_tools(
|
||||
_ALL_TOOLS_PRE, requested_studio_tools, payload.enabled_tools
|
||||
)
|
||||
_perm_mode_pre = getattr(payload, "permission_mode", None)
|
||||
_confirm_opt_out_pre = getattr(payload, "confirm_tool_calls", None) is False
|
||||
_gated_tool_selected_pre = any(
|
||||
tool["function"]["name"] not in _ANTHROPIC_UNPROMPTED_SAFE_TOOLS
|
||||
for tool in _selected_pre
|
||||
)
|
||||
# An explicit confirm_tool_calls=False opts out of the gate entirely (it
|
||||
# wins over the mode, mirroring _permission_mode_confirm and the GGUF path),
|
||||
# so it never rejects -- not even under ask.
|
||||
if not _confirm_opt_out_pre and (
|
||||
_perm_mode_pre == "ask"
|
||||
or (_perm_mode_pre in ("auto", None) and _gated_tool_selected_pre)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = anthropic_error_body(
|
||||
"permission_mode 'ask' has no confirmation channel for Anthropic "
|
||||
"Messages server tools, and 'auto' (or the omitted default) cannot "
|
||||
"gate a local 'terminal'/'python' tool here; set 'off' or 'full'.",
|
||||
status = 400,
|
||||
err_type = "invalid_request_error",
|
||||
),
|
||||
)
|
||||
|
||||
# require_vision rejects a swap to a text-only target before it runs, so an
|
||||
# image request can't evict the resident vision model only to hit the vision
|
||||
# guard (_normalize_anthropic_openai_images) below after the load.
|
||||
|
|
@ -11996,6 +12226,10 @@ async def anthropic_messages(
|
|||
)
|
||||
from core.inference.tools import ALL_TOOLS
|
||||
|
||||
# ask/auto (and an omitted mode selecting a gate-needing terminal/python
|
||||
# tool) were already rejected before the auto-switch above, so an invalid
|
||||
# confirm-gated request never evicts the resident model; the selection
|
||||
# here just picks the tools for the actual server-tool loop.
|
||||
openai_tools = _select_anthropic_server_tools(
|
||||
ALL_TOOLS,
|
||||
requested_studio_tools,
|
||||
|
|
@ -12051,6 +12285,7 @@ async def anthropic_messages(
|
|||
rag_scope = getattr(payload, "rag_scope", None),
|
||||
disable_parallel_tool_use = _disable_parallel,
|
||||
bypass_permissions = bool(payload.bypass_permissions),
|
||||
permission_mode = getattr(payload, "permission_mode", None),
|
||||
)
|
||||
|
||||
if payload.stream:
|
||||
|
|
|
|||
|
|
@ -14,14 +14,17 @@ never blocks on a missing marker / offline GitHub.
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from loggers import get_logger
|
||||
from utils.llama_cpp_update import get_update_status, start_update
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
|
@ -69,6 +72,27 @@ class LlamaUpdateActionResponse(BaseModel):
|
|||
job: LlamaUpdateJob = Field(default_factory = LlamaUpdateJob)
|
||||
|
||||
|
||||
_llama_update_lock = threading.Lock()
|
||||
_last_llama_update_step = -1
|
||||
|
||||
|
||||
def _log_llama_update_progress(job: LlamaUpdateJob) -> None:
|
||||
"""One llama_update_progress line per 10% step so a prebuilt update reports
|
||||
progress without a line per poll. Resyncs when a new update starts."""
|
||||
global _last_llama_update_step
|
||||
if job.state != "running" or job.progress is None:
|
||||
return
|
||||
step = int(max(0.0, min(float(job.progress), 1.0)) * 10)
|
||||
with _llama_update_lock:
|
||||
prev = _last_llama_update_step
|
||||
if step == prev:
|
||||
return
|
||||
_last_llama_update_step = step
|
||||
if step < prev:
|
||||
return # new update; resync without logging
|
||||
logger.info("llama_update_progress", to_tag = job.to_tag or "", percent = step * 10)
|
||||
|
||||
|
||||
@router.get("/update-status", response_model = LlamaUpdateStatusResponse)
|
||||
async def llama_update_status(
|
||||
force_refresh: bool = Query(
|
||||
|
|
@ -78,7 +102,9 @@ async def llama_update_status(
|
|||
) -> LlamaUpdateStatusResponse:
|
||||
# Off the event loop: detection may probe the host and read GitHub.
|
||||
status = await asyncio.to_thread(get_update_status, force_refresh = force_refresh)
|
||||
return LlamaUpdateStatusResponse(**status)
|
||||
resp = LlamaUpdateStatusResponse(**status)
|
||||
_log_llama_update_progress(resp.job)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/update", response_model = LlamaUpdateActionResponse)
|
||||
|
|
|
|||
|
|
@ -708,7 +708,6 @@ class PersonalizationCustomization(BaseModel):
|
|||
pointerCursors: bool = False
|
||||
reduceMotion: Literal["system", "on", "off"] = "system"
|
||||
fontSmoothing: bool = True
|
||||
edgeFades: bool = True
|
||||
sidebarMenu: list[PersonalizationSidebarMenuItem] = Field(
|
||||
default_factory = _default_sidebar_menu,
|
||||
max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS,
|
||||
|
|
|
|||
|
|
@ -733,7 +733,9 @@ async def stream_training_progress(
|
|||
if last_event_id is not None:
|
||||
try:
|
||||
resume_from_step = int(last_event_id)
|
||||
logger.info(f"SSE reconnect: resuming from step {resume_from_step}")
|
||||
# Fires on every reconnect (each tab switch); the meaningful signal is
|
||||
# the "replayed N missed steps" line below, logged only when N > 0.
|
||||
logger.debug(f"SSE reconnect: resuming from step {resume_from_step}")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid Last-Event-ID: {last_event_id}")
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import os
|
|||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def _fix_torch_cuda_ld_path():
|
||||
|
|
@ -616,24 +616,28 @@ def _print_cloudflare_line(secure: bool = False, loopback_host: str = "127.0.0.1
|
|||
f"bind {loopback_host} or close firewall access to keep Studio private.",
|
||||
warn,
|
||||
)
|
||||
elif not _cloudflare_flag:
|
||||
elif _cloudflare_flag is False or _cloudflare_flag is None:
|
||||
# None = off by default (no flag); False = explicit --no-cloudflare.
|
||||
_reason = "default" if _cloudflare_flag is None else "--no-cloudflare"
|
||||
if _public_reachable is True:
|
||||
_emit(
|
||||
" Cloudflare tunnel: OFF (--no-cloudflare). The raw port is still "
|
||||
f" Cloudflare tunnel: OFF ({_reason}). The raw port is still "
|
||||
"reachable from the public internet (see the reachability check above): "
|
||||
"--no-cloudflare disables only the Cloudflare link, not the public bind.",
|
||||
"pass --cloudflare to also expose a public Cloudflare HTTPS link, or "
|
||||
f"bind {loopback_host} to keep Studio private.",
|
||||
warn,
|
||||
)
|
||||
elif _public_reachable is False:
|
||||
_emit(
|
||||
" Cloudflare tunnel: OFF (--no-cloudflare). Studio is reachable on your "
|
||||
"local network only. Omit --no-cloudflare to expose a public "
|
||||
f" Cloudflare tunnel: OFF ({_reason}). Studio is reachable on your "
|
||||
"local network only. Pass --cloudflare to expose a public "
|
||||
"Cloudflare HTTPS link."
|
||||
)
|
||||
else:
|
||||
_emit(
|
||||
" Cloudflare tunnel: OFF (--no-cloudflare). There is no Cloudflare "
|
||||
"public link. Raw port reachability was not verified; "
|
||||
f" Cloudflare tunnel: OFF ({_reason}). There is no Cloudflare "
|
||||
"public link. Raw port reachability was not verified; pass --cloudflare "
|
||||
"to expose a public Cloudflare HTTPS link, or "
|
||||
f"bind {loopback_host} or close firewall access to keep Studio private.",
|
||||
warn,
|
||||
)
|
||||
|
|
@ -874,7 +878,9 @@ _cloudflare_url = None
|
|||
_public_reachable = None
|
||||
|
||||
_cloudflare_requested = False
|
||||
_cloudflare_flag = True
|
||||
# Opt-in tri-state (mirrors the CLI): None = off by default, True = on,
|
||||
# False = explicit --no-cloudflare. run_server overwrites it before the banner.
|
||||
_cloudflare_flag = None
|
||||
|
||||
|
||||
_DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
|
|
@ -1057,6 +1063,199 @@ def _cloudflare_tunnel_should_start(
|
|||
return host in ("0.0.0.0", "::") and not api_only
|
||||
|
||||
|
||||
def _stream_isatty(stream) -> bool:
|
||||
"""isatty() that treats broken streams as non-interactive.
|
||||
|
||||
isatty() can raise under service wrappers (closed stdin -> ValueError;
|
||||
sys.stdin None in Windows GUI -> AttributeError); such a stream can't host a
|
||||
prompt, which is a fallback, not an error.
|
||||
"""
|
||||
try:
|
||||
return stream.isatty()
|
||||
except (AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _terminal_password_gate(
|
||||
*,
|
||||
tunnel_will_start: bool,
|
||||
host: str,
|
||||
secure: bool,
|
||||
api_only: bool,
|
||||
frontend_served: bool,
|
||||
is_colab: bool = False,
|
||||
) -> Tuple[bool, bool]:
|
||||
"""Force a terminal password change before the public tunnel goes up.
|
||||
|
||||
When the tunnel is about to publish Studio and the seeded admin password was
|
||||
never changed, ask for a new one (masked, confirmed) before any public URL
|
||||
exists. The CLI normally does this before re-exec'ing the backend; this is
|
||||
the backstop for direct `python run.py` launches and older-CLI installs.
|
||||
Must run BEFORE the uvicorn socket binds: on a wildcard bind the served HTML
|
||||
injects the bootstrap credential, so a pre-gate listener would hand the
|
||||
default password to anyone reaching the raw port while the operator types.
|
||||
|
||||
Returns (proceed, drop_bootstrap_injection):
|
||||
proceed False -> abort the launch (interactive refusal, or a headless
|
||||
public launch nothing would protect); fail closed.
|
||||
drop_bootstrap_injection True -> caller must null
|
||||
app.state.bootstrap_password: the password just changed (stale), or a
|
||||
public URL is about to serve the default credential and must not leak it.
|
||||
|
||||
Without a usable terminal the prompt is skipped: proceed if the bootstrap
|
||||
deadline (armed later) will protect the launch; if even that is disabled
|
||||
(api-only, timeout 0) nothing protects it, so refuse. NOT wrapped in a broad
|
||||
try/except: an auth storage failure must abort rather than expose the default.
|
||||
"""
|
||||
if not tunnel_will_start:
|
||||
return True, False
|
||||
|
||||
from auth import hashing as _auth_hashing
|
||||
from auth import storage as _auth_storage
|
||||
from auth.bootstrap_timeout import (
|
||||
bootstrap_timeout_seconds,
|
||||
should_arm_bootstrap_timeout,
|
||||
)
|
||||
from auth.terminal_prompt import (
|
||||
prompt_for_password_change,
|
||||
should_prompt_password_change,
|
||||
)
|
||||
|
||||
_admin = _auth_storage.DEFAULT_ADMIN_USERNAME
|
||||
# Gate can run before lifespan: seed the admin row here (idempotent).
|
||||
_auth_storage.ensure_default_admin()
|
||||
requires_change = _auth_storage.requires_password_change(_admin)
|
||||
if not requires_change:
|
||||
return True, False
|
||||
|
||||
if not should_prompt_password_change(
|
||||
tunnel_will_start = tunnel_will_start,
|
||||
requires_change = requires_change,
|
||||
stdin_isatty = _stream_isatty(sys.stdin),
|
||||
stderr_isatty = _stream_isatty(sys.stderr),
|
||||
):
|
||||
# No terminal: only proceed if the bootstrap deadline will arm; api-only
|
||||
# and TIMEOUT=0 never arm it, leaving the default credential public.
|
||||
deadline_arms = should_arm_bootstrap_timeout(
|
||||
host = host,
|
||||
secure = secure,
|
||||
api_only = api_only,
|
||||
frontend_served = frontend_served,
|
||||
is_colab = is_colab,
|
||||
requires_change = True,
|
||||
timeout_seconds = bootstrap_timeout_seconds(),
|
||||
)
|
||||
if not deadline_arms:
|
||||
print(
|
||||
"Refusing to publish Studio on a public Cloudflare URL: the "
|
||||
"default admin password was never changed, no terminal is "
|
||||
"attached to change it here, and the bootstrap shutdown "
|
||||
"deadline does not apply to this launch (api-only, or "
|
||||
"UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0). Change the password "
|
||||
"first (run `unsloth studio` locally and log in, or re-run "
|
||||
"with a terminal attached), then retry.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
return False, False
|
||||
# The public page won't auto-fill the bootstrap credential (suppressed
|
||||
# below) and the seeded file may already be gone, so point recovery at a
|
||||
# terminal-attached run / reset-password instead of reading it from disk.
|
||||
print(
|
||||
" WARNING: the default admin password is still active while "
|
||||
"Studio is about to be published on a public Cloudflare URL, and "
|
||||
"no terminal is attached to change it here. The public page will "
|
||||
"NOT auto-fill the bootstrap credential. Set a new password by "
|
||||
"running `unsloth studio` locally with a terminal attached, or "
|
||||
"`unsloth studio reset-password`. Studio shuts down after the "
|
||||
"bootstrap deadline (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT, default 1h) "
|
||||
"unless the password is changed.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
# Never serve the default credential in HTML over a public URL.
|
||||
return True, True
|
||||
|
||||
def _is_current_password(candidate: str) -> bool:
|
||||
record = _auth_storage.get_user_and_secret(_admin)
|
||||
if record is None:
|
||||
return False
|
||||
salt, pwd_hash, _jwt_secret, _must_change = record
|
||||
return _auth_hashing.verify_password(candidate, salt, pwd_hash)
|
||||
|
||||
def _apply_change(new_password: str) -> None:
|
||||
# Same effects as routes/auth.py change_password: rehash, rotate the JWT
|
||||
# secret, revoke refresh tokens in the SAME transaction.
|
||||
_auth_storage.update_password(_admin, new_password, revoke_refresh_tokens = True)
|
||||
|
||||
changed = prompt_for_password_change(
|
||||
min_length = _auth_storage.MIN_PASSWORD_LENGTH,
|
||||
is_current_password = _is_current_password,
|
||||
apply_change = _apply_change,
|
||||
out = sys.stderr,
|
||||
)
|
||||
return (True, True) if changed else (False, False)
|
||||
|
||||
|
||||
def _apply_supplied_password(password_value: "Optional[str]") -> None:
|
||||
"""Non-interactively set the INITIAL admin password before the socket binds,
|
||||
for a direct ``python run.py`` launch (the CLI does this in its own parent).
|
||||
Value comes from --password / UNSLOTH_STUDIO_PASSWORD / stdin.
|
||||
|
||||
Only ever sets the FIRST password: an already-set one is a hard error, an
|
||||
invalid value fails closed. NOT wrapped in a broad try/except: an auth
|
||||
storage failure must abort rather than expose the default credential.
|
||||
"""
|
||||
from auth import hashing as _auth_hashing
|
||||
from auth import storage as _auth_storage
|
||||
from auth.terminal_prompt import SUPPLIED_PASSWORD_ENV, resolve_supplied_password
|
||||
|
||||
supplied = resolve_supplied_password(password_value)
|
||||
# Strip the env var once read so child subprocesses (cloudflared, llama-server,
|
||||
# code-exec tools) can't inherit the plaintext via /proc/PID/environ. Mirrors
|
||||
# the CLI. Unconditional: strips a leftover value even when a literal --password won.
|
||||
os.environ.pop(SUPPLIED_PASSWORD_ENV, None)
|
||||
if not supplied:
|
||||
return
|
||||
|
||||
_admin = _auth_storage.DEFAULT_ADMIN_USERNAME
|
||||
_auth_storage.ensure_default_admin()
|
||||
if not _auth_storage.requires_password_change(_admin):
|
||||
print(
|
||||
"Error: a Studio admin password is already set; --password only sets "
|
||||
"the initial password. Run `unsloth studio reset-password` first.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _is_current_password(candidate: str) -> bool:
|
||||
record = _auth_storage.get_user_and_secret(_admin)
|
||||
if record is None:
|
||||
return False
|
||||
salt, pwd_hash, _jwt_secret, _must_change = record
|
||||
return _auth_hashing.verify_password(candidate, salt, pwd_hash)
|
||||
|
||||
if len(supplied) < _auth_storage.MIN_PASSWORD_LENGTH:
|
||||
print(
|
||||
f"Error: password must be at least {_auth_storage.MIN_PASSWORD_LENGTH} "
|
||||
"characters; not starting.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
sys.exit(1)
|
||||
if _is_current_password(supplied):
|
||||
print(
|
||||
"Error: the new password must differ from the current bootstrap "
|
||||
"password; not starting.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
sys.exit(1)
|
||||
_auth_storage.update_password(_admin, supplied, revoke_refresh_tokens = True)
|
||||
print(f"Password updated for '{_admin}'.", file = sys.stderr, flush = True)
|
||||
|
||||
|
||||
def _apply_cli_tool_policy(enable_tools: "Optional[bool]") -> None:
|
||||
"""Honor an explicit --enable-tools/--disable-tools; None leaves the policy
|
||||
unset (tools default on, per-request enable_tools honored). Host is never
|
||||
|
|
@ -1075,9 +1274,10 @@ def run_server(
|
|||
silent: bool = False,
|
||||
api_only: bool = False,
|
||||
llama_parallel_slots: int = 1,
|
||||
cloudflare: bool = True,
|
||||
cloudflare: "Optional[bool]" = None,
|
||||
secure: bool = False,
|
||||
enable_tools: "Optional[bool]" = None,
|
||||
password: "Optional[str]" = None,
|
||||
emit_tauri_port: bool = True,
|
||||
):
|
||||
"""
|
||||
|
|
@ -1090,6 +1290,9 @@ def run_server(
|
|||
silent: Suppress startup messages
|
||||
api_only: API server only, no frontend (for Tauri desktop app)
|
||||
llama_parallel_slots: parallel slots for llama-server
|
||||
cloudflare: opt in to the public Cloudflare HTTPS tunnel for a wildcard
|
||||
bind. Tri-state: None (unset) and False both mean off; True enables it.
|
||||
--secure implies it (True) and rejects an explicit False.
|
||||
enable_tools: explicit --enable-tools/--disable-tools policy; None leaves
|
||||
the default (tools on, per-request enable_tools honored)
|
||||
emit_tauri_port: print the machine-readable TAURI_PORT line the desktop
|
||||
|
|
@ -1111,13 +1314,16 @@ def run_server(
|
|||
|
||||
initialize_parent_lifetime()
|
||||
|
||||
# --secure exposes only the Cloudflare link: force a loopback bind so the raw
|
||||
# port is never public (even with -H 0.0.0.0), and reject the contradictory combo.
|
||||
if secure and not cloudflare:
|
||||
raise SystemExit(
|
||||
"A secure Cloudflare link is not allowed, use --no-secure which provides a 0.0.0.0 link"
|
||||
)
|
||||
# --secure exposes ONLY the Cloudflare link: reject --secure --no-cloudflare,
|
||||
# then force a loopback bind so the raw port is never public (even -H 0.0.0.0).
|
||||
# Otherwise keep the tri-state so the banner distinguishes "off by default"
|
||||
# from an explicit --no-cloudflare.
|
||||
if secure:
|
||||
if cloudflare is False:
|
||||
raise SystemExit(
|
||||
"--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare."
|
||||
)
|
||||
cloudflare = True
|
||||
host = "127.0.0.1"
|
||||
|
||||
# `unsloth studio run` installs its own resolved policy and passes None here.
|
||||
|
|
@ -1202,7 +1408,7 @@ def run_server(
|
|||
print("=" * 50)
|
||||
if blocker:
|
||||
pid, name = blocker
|
||||
print(f"Port {original_port} is already in use by " f"{name} (PID {pid}).")
|
||||
print(f"Port {original_port} is already in use by {name} (PID {pid}).")
|
||||
else:
|
||||
print(f"Port {original_port} is already in use.")
|
||||
print(f"Unsloth Studio will use port {port} instead.")
|
||||
|
|
@ -1315,6 +1521,44 @@ def run_server(
|
|||
|
||||
app.state.trigger_shutdown = _trigger_shutdown
|
||||
|
||||
# A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial
|
||||
# admin password before the gate and socket bind (direct `python run.py`;
|
||||
# the CLI applies it in its own parent).
|
||||
_apply_supplied_password(password)
|
||||
|
||||
# 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 can't hand out the injected credential.
|
||||
_pw_proceed, _pw_drop_bootstrap = _terminal_password_gate(
|
||||
tunnel_will_start = _cloudflare_tunnel_should_start(
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
api_only = api_only,
|
||||
is_colab = _IS_COLAB,
|
||||
),
|
||||
host = host,
|
||||
secure = secure,
|
||||
api_only = api_only,
|
||||
frontend_served = bool(frontend_path) and not api_only,
|
||||
is_colab = _IS_COLAB,
|
||||
)
|
||||
if not _pw_proceed:
|
||||
print(
|
||||
"Not starting Studio; set a new admin password first, or launch "
|
||||
"without --secure/--cloudflare.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
sys.exit(1)
|
||||
if _pw_drop_bootstrap:
|
||||
# Password just changed (stale) or a public URL is about to serve the
|
||||
# default credential: don't leak it in the HTML. Lifespan runs AFTER this
|
||||
# and re-reads the bootstrap password, so the flag (not a plain None)
|
||||
# makes it skip that re-read.
|
||||
app.state.suppress_bootstrap_injection = True
|
||||
app.state.bootstrap_password = None
|
||||
|
||||
# Run server in a daemon thread with explicit new_event_loop() +
|
||||
# run_until_complete() (not asyncio.run) so nest_asyncio's patches don't
|
||||
# interfere when Colab/IPython already runs a loop on the main thread.
|
||||
|
|
@ -1384,6 +1628,7 @@ def run_server(
|
|||
is_colab = _IS_COLAB,
|
||||
)
|
||||
_cloudflare_requested = _cloudflare_enabled
|
||||
|
||||
if _cloudflare_enabled:
|
||||
try: # best-effort: any failure must not block startup
|
||||
from cloudflare_tunnel import start_studio_tunnel, stop_studio_tunnel
|
||||
|
|
@ -1471,6 +1716,14 @@ def _build_arg_parser():
|
|||
default = "127.0.0.1",
|
||||
help = "Host to bind to (default: 127.0.0.1; use 0.0.0.0 for network/cloud access)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
default = None,
|
||||
help = "Set the INITIAL admin password non-interactively (headless), only when "
|
||||
"none is set yet. Also reads UNSLOTH_STUDIO_PASSWORD, or --password - for stdin. "
|
||||
"A literal value is visible in the process list. Rotate later via "
|
||||
"`unsloth studio reset-password`.",
|
||||
)
|
||||
parser.add_argument("--port", type = int, default = 8888, help = "Port to bind to")
|
||||
parser.add_argument(
|
||||
"--frontend",
|
||||
|
|
@ -1487,11 +1740,13 @@ def _build_arg_parser():
|
|||
parser.add_argument(
|
||||
"--cloudflare",
|
||||
action = argparse.BooleanOptionalAction,
|
||||
default = True,
|
||||
help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard "
|
||||
"binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). "
|
||||
"Pass --no-cloudflare to disable that Cloudflare URL; it does not change a "
|
||||
"public wildcard bind. --api-only keeps it off unless paired with --secure.",
|
||||
default = None,
|
||||
help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS "
|
||||
"tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; "
|
||||
"pass --cloudflare to enable it (--secure implies it), --no-cloudflare to "
|
||||
"force it off. It does not change a raw wildcard bind. If the admin "
|
||||
"password was never changed, Studio asks for a new one in the terminal "
|
||||
"before publishing the URL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--secure",
|
||||
|
|
@ -1499,7 +1754,9 @@ def _build_arg_parser():
|
|||
default = False,
|
||||
help = "Expose ONLY a Cloudflare HTTPS link: bind localhost and fail closed "
|
||||
"if the tunnel can't start. Without it, --no-secure also serves the raw "
|
||||
"0.0.0.0 port, which is reachable from anywhere on the network",
|
||||
"0.0.0.0 port, which is reachable from anywhere on the network. If the "
|
||||
"admin password was never changed, Studio asks for a new one in the "
|
||||
"terminal before publishing the URL.",
|
||||
)
|
||||
# Back-compat: accept --not-secure as a hidden alias for --no-secure.
|
||||
parser.add_argument(
|
||||
|
|
@ -1561,7 +1818,7 @@ if __name__ == "__main__":
|
|||
args = parser.parse_args()
|
||||
if not _PARALLEL_MIN <= args.parallel <= _PARALLEL_MAX:
|
||||
parser.error(f"--parallel must be between {_PARALLEL_MIN} and {_PARALLEL_MAX}")
|
||||
if args.secure and not args.cloudflare:
|
||||
if args.secure and args.cloudflare is False:
|
||||
parser.error(
|
||||
"--secure requires the Cloudflare tunnel; do not combine it with --no-cloudflare"
|
||||
)
|
||||
|
|
@ -1575,6 +1832,7 @@ if __name__ == "__main__":
|
|||
cloudflare = args.cloudflare,
|
||||
secure = args.secure,
|
||||
enable_tools = args.enable_tools,
|
||||
password = args.password,
|
||||
)
|
||||
if args.frontend is not None:
|
||||
kwargs["frontend_path"] = Path(args.frontend)
|
||||
|
|
|
|||
|
|
@ -1739,7 +1739,9 @@ class TestAnthropicMessagesToolRouting:
|
|||
assert backend.calls[0][0] == "plain"
|
||||
|
||||
def test_server_tool_alias_enters_tool_path_when_policy_unset(self, monkeypatch):
|
||||
# Mirror of the previous test for the default (None) policy.
|
||||
# Mirror of the previous test for the default (None) policy. An omitted
|
||||
# permission_mode still runs here because web_search is a safe server tool
|
||||
# (only a selected terminal/python would require the missing gate).
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(
|
||||
tools = [{"type": "web_search_20250305", "name": "web_search"}],
|
||||
|
|
@ -1761,6 +1763,126 @@ class TestAnthropicMessagesToolRouting:
|
|||
assert "confirm_tool_calls is not supported" in exc.value.detail["error"]["message"]
|
||||
assert backend.calls == []
|
||||
|
||||
def test_permission_mode_gating_for_server_tools(self, monkeypatch):
|
||||
# ask is a request for a per-call pause this channel cannot honor, so it is
|
||||
# always rejected, even for a safe-only server tool (web_search).
|
||||
safe_tools = [{"type": "web_search_20250305", "name": "web_search"}]
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(tools = safe_tools, permission_mode = "ask")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert exc.value.status_code == 400
|
||||
assert "no confirmation channel" in exc.value.detail["error"]["message"]
|
||||
assert backend.calls == []
|
||||
|
||||
# auto only gates unsafe calls, so a safe-only selection runs (nothing to
|
||||
# gate), like the omitted default. Both keep existing callers working.
|
||||
for extra in ({"permission_mode": "auto"}, {}):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(tools = safe_tools, **extra)
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert backend.calls[0][0] == "tools"
|
||||
|
||||
# But auto or an omitted mode that would run a local tool (terminal/python,
|
||||
# via a bare Anthropic tool type or enabled_tools) is rejected, since that
|
||||
# tool could need the gate this channel lacks.
|
||||
for local_payload in (
|
||||
_basic_payload(tools = [{"type": "terminal", "name": "terminal"}]),
|
||||
_basic_payload(
|
||||
tools = [{"type": "terminal", "name": "terminal"}], permission_mode = "auto"
|
||||
),
|
||||
_basic_payload(tools = safe_tools, enable_tools = True, enabled_tools = ["python"]),
|
||||
):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_drive(anthropic_messages(local_payload, request = None, current_subject = "t"))
|
||||
assert exc.value.status_code == 400
|
||||
assert "terminal" in exc.value.detail["error"]["message"]
|
||||
assert backend.calls == []
|
||||
|
||||
# off, full, and a legacy confirm_tool_calls=False opt-out all run, even
|
||||
# with a local tool selected. The explicit opt-out wins over the mode
|
||||
# (mirrors _permission_mode_confirm and the GGUF path), so it runs even
|
||||
# under ask, which otherwise always rejects.
|
||||
for extra in (
|
||||
{"tools": safe_tools, "permission_mode": "off"},
|
||||
{"tools": safe_tools, "permission_mode": "full"},
|
||||
{"tools": safe_tools, "enabled_tools": ["python"], "confirm_tool_calls": False},
|
||||
{"tools": safe_tools, "permission_mode": "ask", "confirm_tool_calls": False},
|
||||
{
|
||||
"tools": [{"type": "terminal", "name": "terminal"}],
|
||||
"permission_mode": "ask",
|
||||
"confirm_tool_calls": False,
|
||||
},
|
||||
):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(**extra)
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert backend.calls[0][0] == "tools"
|
||||
|
||||
def test_render_html_gated_for_server_tools(self, monkeypatch):
|
||||
# render_html is no longer unconditionally safe: a networked canvas prompts
|
||||
# in auto and this channel cannot present that gate, so selecting it under
|
||||
# ask/auto/omitted rejects like terminal/python; off/full (and an explicit
|
||||
# confirm opt-out) run it.
|
||||
rh = {"enable_tools": True, "enabled_tools": ["render_html"]}
|
||||
for mode in ("ask", "auto", None):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
fields = dict(rh)
|
||||
if mode is not None:
|
||||
fields["permission_mode"] = mode
|
||||
payload = _basic_payload(**fields)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert exc.value.status_code == 400
|
||||
assert "no confirmation channel" in exc.value.detail["error"]["message"]
|
||||
assert backend.calls == []
|
||||
for extra in (
|
||||
{"permission_mode": "off"},
|
||||
{"permission_mode": "full"},
|
||||
{"confirm_tool_calls": False},
|
||||
):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(**{**rh, **extra})
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert backend.calls[0][0] == "tools"
|
||||
|
||||
def test_permission_mode_rejected_before_auto_switch(self, monkeypatch):
|
||||
# The unsupported-mode rejection must run before _maybe_auto_switch_model,
|
||||
# so an invalid confirm-gated request never evicts the resident model
|
||||
# (mirrors the pre-switch malformed- and mixed-tool guards).
|
||||
import routes.inference as inf_mod
|
||||
|
||||
switch_calls = []
|
||||
|
||||
async def _rec_switch(*_args, **_kwargs):
|
||||
switch_calls.append(1)
|
||||
|
||||
monkeypatch.setattr(inf_mod, "_maybe_auto_switch_model", _rec_switch)
|
||||
safe_tools = [{"type": "web_search_20250305", "name": "web_search"}]
|
||||
local_tools = [{"type": "terminal", "name": "terminal"}]
|
||||
|
||||
# ask (any server tool), auto with a local tool, and an omitted mode
|
||||
# selecting a local tool are all rejected up front, before the switch runs.
|
||||
for payload in (
|
||||
_basic_payload(tools = safe_tools, permission_mode = "ask"),
|
||||
_basic_payload(tools = local_tools, permission_mode = "auto"),
|
||||
_basic_payload(tools = local_tools),
|
||||
):
|
||||
switch_calls.clear()
|
||||
_mock_backend(monkeypatch)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert exc.value.status_code == 400
|
||||
assert switch_calls == [], "rejection must precede the auto-switch"
|
||||
|
||||
# A supported request (off) still reaches the switch and runs the loop.
|
||||
switch_calls.clear()
|
||||
_mock_backend(monkeypatch)
|
||||
payload = _basic_payload(tools = safe_tools, permission_mode = "off")
|
||||
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
|
||||
assert switch_calls == [1]
|
||||
|
||||
def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch):
|
||||
backend = _mock_backend(monkeypatch)
|
||||
payload = _basic_payload(
|
||||
|
|
|
|||
|
|
@ -691,13 +691,14 @@ def _argparse_default(source, option):
|
|||
return None
|
||||
|
||||
|
||||
def test_run_server_cloudflare_default_true():
|
||||
def test_run_server_cloudflare_default_off():
|
||||
defaults = _func_param_defaults(_RUN_PY.read_text(), "run_server")
|
||||
assert defaults.get("cloudflare") is True
|
||||
assert "cloudflare" in defaults
|
||||
assert defaults["cloudflare"] is None
|
||||
|
||||
|
||||
def test_argparse_cloudflare_default_true():
|
||||
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is True
|
||||
def test_argparse_cloudflare_default_off():
|
||||
assert _argparse_default(_RUN_PY.read_text(), "--cloudflare") is None
|
||||
|
||||
|
||||
def test_verify_global_reachability_marks_private_address_unreachable():
|
||||
|
|
@ -832,6 +833,31 @@ def test_cloudflare_line_states_disabled_when_off(monkeypatch):
|
|||
assert "local network only" in out
|
||||
|
||||
|
||||
def test_cloudflare_line_labels_unset_as_default(monkeypatch):
|
||||
# None = off by default (no flag) -> banner says "(default)", not "(--no-cloudflare)".
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch,
|
||||
cloudflare_url = None,
|
||||
public_reachable = False,
|
||||
cloudflare_requested = False,
|
||||
cloudflare_flag = None,
|
||||
)
|
||||
assert "Cloudflare tunnel: OFF (default)" in out
|
||||
assert "--no-cloudflare" not in out
|
||||
|
||||
|
||||
def test_cloudflare_line_labels_explicit_no_cloudflare(monkeypatch):
|
||||
# False = explicit --no-cloudflare -> banner says "(--no-cloudflare)".
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch,
|
||||
cloudflare_url = None,
|
||||
public_reachable = False,
|
||||
cloudflare_requested = False,
|
||||
cloudflare_flag = False,
|
||||
)
|
||||
assert "Cloudflare tunnel: OFF (--no-cloudflare)" in out
|
||||
|
||||
|
||||
def test_cloudflare_line_states_failed_when_requested_but_no_url(monkeypatch):
|
||||
out = _run_print_cloudflare_line(
|
||||
monkeypatch,
|
||||
|
|
|
|||
|
|
@ -1826,6 +1826,39 @@ def test_large_python_tool_call_emits_early_provisional_start(monkeypatch):
|
|||
assert any(e.get("type") == "tool_end" and e.get("tool_name") == "python" for e in events)
|
||||
|
||||
|
||||
def test_auto_mode_render_html_suppresses_provisional_card_under_confirm(monkeypatch):
|
||||
"""render_html is no longer unconditionally safe (a networked canvas asks), so
|
||||
with confirm_tool_calls set under permission_mode="auto" its early provisional
|
||||
card is suppressed; the real full-argument tool_start still fires and a static
|
||||
canvas runs without a prompt."""
|
||||
args = {"code": "<html>" + "x" * 80 + "</html>"}
|
||||
first_stream = _streamed_structured_tool_call("render_html", args, "call_rh")
|
||||
final_stream = [_sse({"content": "Done."}), _done()]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [first_stream, final_stream], payloads)
|
||||
|
||||
monkeypatch.setattr("core.inference.tools.execute_tool", lambda name, arguments, **_k: "OK")
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "make a card"}],
|
||||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
confirm_tool_calls = True,
|
||||
permission_mode = "auto",
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
)
|
||||
|
||||
tool_starts = [e for e in events if e.get("type") == "tool_start"]
|
||||
provisional = [e for e in tool_starts if not e.get("arguments")]
|
||||
# The confirm gate now suppresses the early provisional card for render_html.
|
||||
assert provisional == [], tool_starts
|
||||
real = [e for e in tool_starts if e.get("arguments")]
|
||||
assert real and real[0]["tool_name"] == "render_html"
|
||||
# A static canvas is classified safe, so it still runs without an approval gate.
|
||||
assert real[0].get("awaiting_confirmation") in (False, None)
|
||||
|
||||
|
||||
def test_small_python_tool_call_has_no_provisional_start(monkeypatch):
|
||||
"""A small tool-call argument finishes streaming instantly, so it keeps the
|
||||
existing behavior of a single (real) tool_start with no provisional card."""
|
||||
|
|
|
|||
48
studio/backend/tests/test_load_progress_throttle.py
Normal file
48
studio/backend/tests/test_load_progress_throttle.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 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 /api/inference/load-progress throttle: one line per 10% step, reset per load."""
|
||||
|
||||
import pytest
|
||||
|
||||
import routes.inference as ri
|
||||
|
||||
|
||||
class _Capture:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def info(self, event, **kw):
|
||||
self.events.append((event, kw))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cap(monkeypatch):
|
||||
capture = _Capture()
|
||||
monkeypatch.setattr(ri, "logger", capture)
|
||||
ri._reset_load_progress_step()
|
||||
return capture
|
||||
|
||||
|
||||
def _percents(cap):
|
||||
return [kw["percent"] for _event, kw in cap.events]
|
||||
|
||||
|
||||
def test_new_load_first_step_logs_after_reset(cap):
|
||||
# Load A reaches 100%.
|
||||
ri._log_load_progress_step(1.0, "ready")
|
||||
assert _percents(cap) == [100]
|
||||
# Same value keeps deduping (steady poll on a finished load stays quiet).
|
||||
ri._log_load_progress_step(1.0, "ready")
|
||||
assert _percents(cap) == [100]
|
||||
# A new load arms the throttle, so a cached load B that reports 100% on its
|
||||
# first poll still emits its progress line instead of hitting step == prev.
|
||||
ri._reset_load_progress_step()
|
||||
ri._log_load_progress_step(1.0, "ready")
|
||||
assert _percents(cap) == [100, 100]
|
||||
|
||||
|
||||
def test_steady_poll_dedups_within_a_load(cap):
|
||||
for _ in range(3):
|
||||
ri._log_load_progress_step(0.3, "mmap")
|
||||
assert _percents(cap) == [30] # one line per 10% step, not one per poll
|
||||
|
|
@ -135,7 +135,7 @@ def test_duplicate_get_within_window_deduped(logs, monkeypatch):
|
|||
|
||||
mw = LoggingMiddleware(app)
|
||||
for _ in range(3):
|
||||
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send))
|
||||
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send))
|
||||
|
||||
# Only the first of the identical GET/200 burst is logged.
|
||||
assert len(logs.events) == 1
|
||||
|
|
@ -183,11 +183,11 @@ def test_quiet_poll_paths_use_longer_heartbeat_window(logs, monkeypatch):
|
|||
for _ in range(3):
|
||||
_run(mw(_http_scope("/api/inference/monitor"), _noop_receive, send)) # quiet
|
||||
for _ in range(3):
|
||||
_run(mw(_http_scope("/api/chat/projects"), _noop_receive, send)) # normal
|
||||
_run(mw(_http_scope("/api/models/browse-folders"), _noop_receive, send)) # normal
|
||||
|
||||
paths = [e[2]["path"] for e in logs.events]
|
||||
assert paths.count("/api/inference/monitor") == 1 # collapsed to one heartbeat
|
||||
assert paths.count("/api/chat/projects") == 3 # base dedup off -> all logged
|
||||
assert paths.count("/api/models/browse-folders") == 3 # base dedup off -> all logged
|
||||
|
||||
|
||||
def test_distinct_query_strings_are_not_deduped(logs, monkeypatch):
|
||||
|
|
@ -242,3 +242,118 @@ def test_fastapi_static_asset_success_skips_log(tmp_path, logs):
|
|||
assert response.status_code == 200
|
||||
assert response.text == "body { color: black; }"
|
||||
assert len(logs.events) == log_count
|
||||
|
||||
|
||||
def _status_app(status):
|
||||
async def app(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": status, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _drop(message):
|
||||
pass
|
||||
|
||||
|
||||
def _paths_logged(logs):
|
||||
return [e[2]["path"] for e in logs.events]
|
||||
|
||||
|
||||
def test_quiet_success_get_2xx_suppressed(logs):
|
||||
# A GET/2xx poll on a quiet-success path logs nothing; the signal is in events.
|
||||
for path in ("/api/chat/threads", "/api/export/status", "/api/hub/download-status"):
|
||||
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
|
||||
assert logs.events == []
|
||||
|
||||
|
||||
def test_chat_detail_and_message_reads_still_log(logs):
|
||||
# Only the exact list polls are suppressed; detail/message reads carry latency
|
||||
# signal and keep their access line.
|
||||
for path in (
|
||||
"/api/chat/threads/abc123",
|
||||
"/api/chat/threads/abc123/messages",
|
||||
"/api/chat/threads/abc123/messages/m1",
|
||||
"/api/chat/projects/p1",
|
||||
):
|
||||
_run(LoggingMiddleware(_status_app(200))(_http_scope(path), _noop_receive, _drop))
|
||||
assert _paths_logged(logs) == [
|
||||
"/api/chat/threads/abc123",
|
||||
"/api/chat/threads/abc123/messages",
|
||||
"/api/chat/threads/abc123/messages/m1",
|
||||
"/api/chat/projects/p1",
|
||||
]
|
||||
|
||||
|
||||
def test_quiet_success_is_get_only(logs):
|
||||
# Mutations on the same paths still log (suppression is GET-only).
|
||||
for method in ("POST", "PUT", "DELETE"):
|
||||
_run(
|
||||
LoggingMiddleware(_status_app(200))(
|
||||
_http_scope("/api/chat/threads", method = method), _noop_receive, _drop
|
||||
)
|
||||
)
|
||||
assert len(logs.events) == 3
|
||||
|
||||
|
||||
def test_chat_pre_auth_401_suppressed_other_errors_logged(logs):
|
||||
# The transient bootstrap 401 on a chat list GET is dropped, but a 500 (or any
|
||||
# other status) still logs so real failures stay visible.
|
||||
_run(
|
||||
LoggingMiddleware(_status_app(401))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
|
||||
)
|
||||
assert logs.events == []
|
||||
_run(
|
||||
LoggingMiddleware(_status_app(500))(_http_scope("/api/chat/projects"), _noop_receive, _drop)
|
||||
)
|
||||
assert _paths_logged(logs) == ["/api/chat/projects"]
|
||||
|
||||
|
||||
def test_chat_401_logged_after_first_auth_refresh(logs):
|
||||
# A chat 401 before any successful token refresh is the bootstrap race and is
|
||||
# dropped, but once /api/auth/refresh has succeeded on this instance later chat
|
||||
# 401s are real failures and stay visible.
|
||||
responses: dict[tuple[str, str], int] = {}
|
||||
|
||||
async def app(scope, receive, send):
|
||||
status = responses.get((scope["method"], scope["path"]), 200)
|
||||
await send({"type": "http.response.start", "status": status, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
mw = LoggingMiddleware(app)
|
||||
|
||||
responses[("GET", "/api/chat/threads")] = 401
|
||||
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
|
||||
assert logs.events == [] # bootstrap race: suppressed
|
||||
|
||||
# A successful refresh (POST, always logged) closes the bootstrap window.
|
||||
responses[("POST", "/api/auth/refresh")] = 200
|
||||
_run(mw(_http_scope("/api/auth/refresh", method = "POST"), _noop_receive, _drop))
|
||||
assert _paths_logged(logs) == ["/api/auth/refresh"]
|
||||
|
||||
# Now the same chat 401 is a real failure and logs.
|
||||
_run(mw(_http_scope("/api/chat/threads"), _noop_receive, _drop))
|
||||
assert _paths_logged(logs) == ["/api/auth/refresh", "/api/chat/threads"]
|
||||
|
||||
|
||||
def test_export_status_error_still_logs(logs):
|
||||
# 2xx suppressed, but an HTTP-level error on export status remains visible.
|
||||
_run(
|
||||
LoggingMiddleware(_status_app(200))(_http_scope("/api/export/status"), _noop_receive, _drop)
|
||||
)
|
||||
assert logs.events == []
|
||||
_run(
|
||||
LoggingMiddleware(_status_app(500))(_http_scope("/api/export/status"), _noop_receive, _drop)
|
||||
)
|
||||
assert _paths_logged(logs) == ["/api/export/status"]
|
||||
|
||||
|
||||
def test_legacy_download_progress_heartbeats_not_suppressed(logs, monkeypatch):
|
||||
# Legacy /api/models download polls emit no progress events, so they heartbeat
|
||||
# (first hit logs, the burst collapses) rather than vanish entirely.
|
||||
monkeypatch.setattr(hmod, "_ACCESS_LOG_DEDUP_MS", 0)
|
||||
monkeypatch.setattr(hmod, "_QUIET_POLL_DEDUP_MS", 1000)
|
||||
mw = LoggingMiddleware(_status_app(200))
|
||||
for _ in range(3):
|
||||
_run(mw(_http_scope("/api/models/download-progress"), _noop_receive, _drop))
|
||||
assert _paths_logged(logs) == ["/api/models/download-progress"]
|
||||
|
|
|
|||
154
studio/backend/tests/test_nvfp4_load_error_message.py
Normal file
154
studio/backend/tests/test_nvfp4_load_error_message.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""NVFP4 load failures should not expose verbose MLX quantization metadata."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.inference import LoadRequest, ValidateModelRequest
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_route_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"inference_route_nvfp4_error",
|
||||
_BACKEND_ROOT / "routes/inference.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_failure(
|
||||
message: str,
|
||||
exception_type: type[Exception] = RuntimeError,
|
||||
native: bool = False,
|
||||
) -> HTTPException:
|
||||
inference_route = _load_route_module()
|
||||
model_path = "unsloth/Qwen3.6-35B-A3B-NVFP4-Fast"
|
||||
model_label = "Qwen3.6-35B-A3B-NVFP4-Fast" if native else model_path
|
||||
request = LoadRequest(model_path = model_path)
|
||||
backend = MagicMock(active_model_name = None)
|
||||
with (
|
||||
patch.object(
|
||||
inference_route,
|
||||
"_resolve_model_identifier_for_request",
|
||||
return_value = (model_path, model_label, native),
|
||||
),
|
||||
patch.object(
|
||||
inference_route,
|
||||
"resolve_effective_chat_template_override",
|
||||
return_value = None,
|
||||
),
|
||||
patch.object(inference_route, "get_inference_backend", return_value = backend),
|
||||
patch.object(inference_route, "get_llama_cpp_backend", return_value = MagicMock()),
|
||||
patch.object(
|
||||
inference_route.ModelConfig,
|
||||
"from_identifier",
|
||||
side_effect = exception_type(message),
|
||||
),
|
||||
pytest.raises(HTTPException) as exc,
|
||||
):
|
||||
asyncio.run(inference_route.load_model(request, MagicMock(), current_subject = "test-user"))
|
||||
return exc.value
|
||||
|
||||
|
||||
def _validation_failure(
|
||||
message: str,
|
||||
exception_type: type[Exception] = RuntimeError,
|
||||
native: bool = False,
|
||||
) -> HTTPException:
|
||||
inference_route = _load_route_module()
|
||||
model_path = "unsloth/Qwen3.6-35B-A3B-NVFP4-Fast"
|
||||
model_label = "Qwen3.6-35B-A3B-NVFP4-Fast" if native else model_path
|
||||
request = ValidateModelRequest(model_path = model_path)
|
||||
with (
|
||||
patch.object(
|
||||
inference_route,
|
||||
"_resolve_model_identifier_for_request",
|
||||
return_value = (model_path, model_label, native),
|
||||
),
|
||||
patch.object(
|
||||
inference_route.ModelConfig,
|
||||
"from_identifier",
|
||||
side_effect = exception_type(message),
|
||||
),
|
||||
pytest.raises(HTTPException) as exc,
|
||||
):
|
||||
asyncio.run(inference_route.validate_model(request, current_subject = "test-user"))
|
||||
return exc.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception_type", [Exception, RuntimeError, ValueError])
|
||||
@pytest.mark.parametrize("native", [False, True])
|
||||
def test_nvfp4_mlx_metadata_error_is_replaced_with_short_message(exception_type, native):
|
||||
error = _load_failure(
|
||||
"Unsloth: 'unsloth/Qwen3.6-35B-A3B-NVFP4-Fast' has per-module MLX "
|
||||
"quantization metadata {'config_groups': {'group_0': {'format': "
|
||||
"'float-quantized'}, 'group_1': {'format': 'nvfp4-pack-quantized'}}}",
|
||||
exception_type = exception_type,
|
||||
native = native,
|
||||
)
|
||||
|
||||
assert error.status_code == 500
|
||||
assert error.detail == (
|
||||
"We are working on supporting NVFP4 inference. For now it is not supported"
|
||||
)
|
||||
assert "quantization metadata" not in error.detail
|
||||
|
||||
|
||||
def test_unrelated_load_error_keeps_existing_message():
|
||||
error = _load_failure("Network connection timed out")
|
||||
|
||||
assert error.status_code == 500
|
||||
assert error.detail == "Failed to load model: Network connection timed out"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("native", [False, True])
|
||||
def test_unrelated_value_error_keeps_existing_message(native):
|
||||
error = _load_failure("Invalid gpu_ids [99]", exception_type = ValueError, native = native)
|
||||
|
||||
assert error.status_code == 400
|
||||
assert error.detail == "Invalid gpu_ids [99]"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception_type", [Exception, RuntimeError, ValueError])
|
||||
@pytest.mark.parametrize("native", [False, True])
|
||||
def test_nvfp4_validation_error_is_replaced_with_short_message(exception_type, native):
|
||||
error = _validation_failure(
|
||||
"Unsloth: 'unsloth/Qwen3.6-35B-A3B-NVFP4-Fast' has per-module MLX "
|
||||
"quantization metadata {'config_groups': {'group_0': {'format': "
|
||||
"'float-quantized'}, 'group_1': {'format': 'nvfp4-pack-quantized'}}}",
|
||||
exception_type = exception_type,
|
||||
native = native,
|
||||
)
|
||||
|
||||
assert error.status_code == 400
|
||||
assert error.detail == (
|
||||
"We are working on supporting NVFP4 inference. For now it is not supported"
|
||||
)
|
||||
assert "quantization metadata" not in error.detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("native", "expected_detail"),
|
||||
[
|
||||
(False, "Network connection timed out"),
|
||||
(
|
||||
True,
|
||||
"Invalid native model Qwen3.6-35B-A3B-NVFP4-Fast: Network connection timed out",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_unrelated_validation_error_keeps_existing_message(native, expected_detail):
|
||||
error = _validation_failure("Network connection timed out", native = native)
|
||||
|
||||
assert error.status_code == 400
|
||||
assert error.detail == expected_detail
|
||||
|
|
@ -706,6 +706,160 @@ class TestChatCompletionRequestToolFields:
|
|||
assert entry["status"] == "completed"
|
||||
assert monitor.active_count() == 0
|
||||
|
||||
def test_permission_mode_does_not_reject_client_tool_passthrough(self, monkeypatch):
|
||||
# A non-streaming client-tool passthrough (client tools, no Studio tool
|
||||
# loop) that also carries permission_mode "ask"/"auto" must reach the
|
||||
# provider passthrough, not the confirm-without-stream guard: the
|
||||
# validator leaves confirm_tool_calls unset for passthrough, and a bare
|
||||
# permission_mode only gates Studio's own local tool loop. An explicit
|
||||
# confirm_tool_calls=True still forces the local-confirm rejection.
|
||||
# The pre-switch guard only runs when an automatic load may run, so force
|
||||
# that predicate on to exercise it against a resident passthrough backend.
|
||||
import routes.inference as inference_route
|
||||
|
||||
class _GGUFBackend:
|
||||
is_loaded = True
|
||||
model_identifier = "test-gguf"
|
||||
supports_tools = False
|
||||
supports_tool_passthrough = True
|
||||
is_vision = False
|
||||
_is_audio = False
|
||||
context_length = 4096
|
||||
base_url = "http://llama.permission-passthrough.test"
|
||||
_request_reasoning_kwargs = lambda *_args, **_kwargs: None
|
||||
|
||||
def generate_chat_completion(self, **_kwargs):
|
||||
raise AssertionError("client tools must use passthrough")
|
||||
|
||||
def generate_chat_completion_with_tools(self, **_kwargs):
|
||||
raise AssertionError("Studio tool loop must stay disabled")
|
||||
|
||||
async def fake_passthrough(llama_backend, payload, model_name, **kwargs):
|
||||
inference_route.api_monitor.finish(kwargs.get("monitor_id"))
|
||||
return inference_route.JSONResponse({"ok": True, "model": model_name})
|
||||
|
||||
client_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "parameters": {"type": "object"}},
|
||||
}
|
||||
]
|
||||
|
||||
def _setup(policy = None):
|
||||
reset_tool_policy()
|
||||
if policy is not None:
|
||||
set_tool_policy(policy)
|
||||
monkeypatch.setattr(inference_route, "_automatic_model_load_may_run", lambda: True)
|
||||
monkeypatch.setattr(inference_route, "api_monitor", ApiMonitor(max_entries = 3))
|
||||
monkeypatch.setattr(
|
||||
inference_route, "_openai_passthrough_non_streaming", fake_passthrough
|
||||
)
|
||||
return self._v1_client(monkeypatch, _GGUFBackend())
|
||||
|
||||
# A process --enable-tools policy must not turn a client-tool passthrough
|
||||
# into a Studio local loop, so a policy of None or True both keep the
|
||||
# passthrough (the guard mirrors _explicit_studio_tool_loop_requested).
|
||||
for policy in (None, True):
|
||||
for mode in ("ask", "auto"):
|
||||
client = _setup(policy)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json = {
|
||||
"messages": [{"role": "user", "content": "use client tool"}],
|
||||
"tools": client_tools,
|
||||
"permission_mode": mode,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
# A JSON-schema response_format is guided-decoding passthrough, not a local
|
||||
# tool loop, so a --enable-tools policy must not 400 a non-streaming ask/auto
|
||||
# structured-output request under the confirm guard.
|
||||
for mode in ("ask", "auto"):
|
||||
client = _setup(True)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json = {
|
||||
"messages": [{"role": "user", "content": "give me json"}],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "s", "schema": {"type": "object"}},
|
||||
},
|
||||
"permission_mode": mode,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
# An explicit confirm_tool_calls=True with client tools and no stream is
|
||||
# still a confirm-without-stream request and must be rejected up front.
|
||||
client = _setup()
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json = {
|
||||
"messages": [{"role": "user", "content": "use client tool"}],
|
||||
"tools": client_tools,
|
||||
"confirm_tool_calls": True,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "requires stream=true" in resp.json()["error"]["message"]
|
||||
|
||||
def test_permission_mode_policy_forced_local_loop_rejected_before_switch(self, monkeypatch):
|
||||
# A process --enable-tools policy forces Studio's own tool loop on even
|
||||
# when the request omits enable_tools and carries no client tools. A
|
||||
# non-streaming ask/auto request is then confirm-gated with no stream to
|
||||
# prompt on, so it must 400 at the pre-switch guard -- before
|
||||
# _maybe_auto_switch_model runs -- rather than evicting the resident model
|
||||
# and 400ing only at the per-backend check.
|
||||
import routes.inference as inference_route
|
||||
|
||||
class _GGUFBackend:
|
||||
is_loaded = True
|
||||
model_identifier = "test-gguf"
|
||||
supports_tools = True
|
||||
supports_tool_passthrough = True
|
||||
is_vision = False
|
||||
_is_audio = False
|
||||
context_length = 4096
|
||||
base_url = "http://llama.policy-forced.test"
|
||||
_request_reasoning_kwargs = lambda *_args, **_kwargs: None
|
||||
|
||||
switch_calls = []
|
||||
|
||||
async def _no_switch(*_args, **_kwargs):
|
||||
switch_calls.append(1)
|
||||
|
||||
def _setup():
|
||||
reset_tool_policy()
|
||||
set_tool_policy(True)
|
||||
monkeypatch.setattr(inference_route, "_automatic_model_load_may_run", lambda: True)
|
||||
monkeypatch.setattr(inference_route, "api_monitor", ApiMonitor(max_entries = 3))
|
||||
monkeypatch.setattr(inference_route, "_maybe_auto_switch_model", _no_switch)
|
||||
return self._v1_client(monkeypatch, _GGUFBackend())
|
||||
|
||||
try:
|
||||
for mode in ("ask", "auto"):
|
||||
switch_calls.clear()
|
||||
client = _setup()
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json = {
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"permission_mode": mode,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "requires stream=true" in resp.json()["error"]["message"]
|
||||
assert switch_calls == [], "guard must reject before the auto-switch"
|
||||
finally:
|
||||
reset_tool_policy()
|
||||
|
||||
def test_enable_tools_on_non_tool_backend_keeps_client_tools_on_passthrough(self, monkeypatch):
|
||||
# DiffusionGemma forces supports_tools off while passthrough stays
|
||||
# available (#6851): enable_tools=True must not steal client tools
|
||||
|
|
|
|||
324
studio/backend/tests/test_password_prompt.py
Normal file
324
studio/backend/tests/test_password_prompt.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Masked terminal password prompt (auth/terminal_prompt.py): reader echo and
|
||||
editing, the change loop's validation/re-prompt behavior, and the pure
|
||||
should-prompt gate. Drives the reader through a scripted fake getch, so no
|
||||
tty (and no msvcrt on Linux) is needed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND))
|
||||
|
||||
from auth import terminal_prompt as tp # noqa: E402
|
||||
|
||||
|
||||
def _fake_getch(keys):
|
||||
"""Scripted keystroke source: yields one item per _getch() call. Items may
|
||||
be multi-char strings to simulate a paste burst arriving in one read."""
|
||||
it = iter(keys)
|
||||
|
||||
def getch():
|
||||
return next(it)
|
||||
|
||||
return getch
|
||||
|
||||
|
||||
def _read(
|
||||
monkeypatch,
|
||||
keys,
|
||||
prompt = "P: ",
|
||||
):
|
||||
monkeypatch.setattr(tp, "_getch", _fake_getch(keys))
|
||||
out = io.StringIO()
|
||||
value = tp._read_password(prompt, out = out)
|
||||
return value, out.getvalue()
|
||||
|
||||
|
||||
# ── _read_password ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reader_echoes_one_star_per_char(monkeypatch):
|
||||
value, out = _read(monkeypatch, list("secret") + ["\r"])
|
||||
assert value == "secret"
|
||||
assert out.count("*") == 6
|
||||
assert "secret" not in out
|
||||
|
||||
|
||||
def test_reader_backspace_edits_and_erases_star(monkeypatch):
|
||||
value, out = _read(monkeypatch, list("abc") + ["\x7f"] + list("d") + ["\n"])
|
||||
assert value == "abd"
|
||||
assert "\b \b" in out
|
||||
# 4 stars were printed (a, b, c, d); one was erased.
|
||||
assert out.count("*") == 4
|
||||
|
||||
|
||||
def test_reader_backspace_on_empty_buffer_is_noop(monkeypatch):
|
||||
value, out = _read(monkeypatch, ["\x08", "\x7f"] + list("x") + ["\r"])
|
||||
assert value == "x"
|
||||
assert "\b \b" not in out
|
||||
|
||||
|
||||
def test_reader_paste_burst_delivers_all_chars(monkeypatch):
|
||||
# A paste can arrive as one multi-char read; every char must count.
|
||||
value, out = _read(monkeypatch, ["pasted-secret", "\r"])
|
||||
assert value == "pasted-secret"
|
||||
assert out.count("*") == len("pasted-secret")
|
||||
|
||||
|
||||
def test_reader_unicode_password(monkeypatch):
|
||||
value, _ = _read(monkeypatch, list("pässwörd✓") + ["\r"])
|
||||
assert value == "pässwörd✓"
|
||||
|
||||
|
||||
def test_reader_ignores_other_control_chars(monkeypatch):
|
||||
value, _ = _read(monkeypatch, ["\t", "\x1b"] + list("ok") + ["\r"])
|
||||
assert value == "ok"
|
||||
|
||||
|
||||
def test_reader_ctrl_c_raises_keyboard_interrupt(monkeypatch):
|
||||
monkeypatch.setattr(tp, "_getch", _fake_getch(list("ab") + ["\x03"]))
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
tp._read_password("P: ", out = io.StringIO())
|
||||
|
||||
|
||||
def test_reader_ctrl_d_on_empty_raises_eof(monkeypatch):
|
||||
monkeypatch.setattr(tp, "_getch", _fake_getch(["\x04"]))
|
||||
with pytest.raises(EOFError):
|
||||
tp._read_password("P: ", out = io.StringIO())
|
||||
|
||||
|
||||
def test_reader_ctrl_d_mid_input_is_ignored(monkeypatch):
|
||||
value, _ = _read(monkeypatch, list("ab") + ["\x04"] + list("c") + ["\r"])
|
||||
assert value == "abc"
|
||||
|
||||
|
||||
def test_reader_windows_key_prefix_is_ignored(monkeypatch):
|
||||
# _getch_windows reports swallowed function-key sequences as "\x00".
|
||||
value, _ = _read(monkeypatch, ["\x00"] + list("w") + ["\r"])
|
||||
assert value == "w"
|
||||
|
||||
|
||||
def test_reader_holds_raw_mode_once_for_whole_line(monkeypatch):
|
||||
# Regression: cbreak/no-echo must be held for the ENTIRE line, not toggled
|
||||
# per keystroke. Re-enabling echo between reads opens a window where a
|
||||
# keystroke arriving in the gap echoes the password in cleartext. Assert the
|
||||
# raw-mode context wraps the whole read exactly once and every keystroke is
|
||||
# read while it is active.
|
||||
events = []
|
||||
|
||||
class _SpyRawMode:
|
||||
def __enter__(self):
|
||||
events.append("enter")
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
events.append("exit")
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(tp, "_prompt_raw_mode", _SpyRawMode)
|
||||
|
||||
src = _fake_getch(list("s3cr3t!!") + ["\r"])
|
||||
|
||||
def _getch_recording():
|
||||
assert events and events[-1] == "enter", "keystroke read outside raw mode"
|
||||
return src()
|
||||
|
||||
monkeypatch.setattr(tp, "_getch", _getch_recording)
|
||||
value = tp._read_password("P: ", out = io.StringIO())
|
||||
assert value == "s3cr3t!!"
|
||||
assert events == ["enter", "exit"]
|
||||
|
||||
|
||||
# ── prompt_for_password_change ───────────────────────────────────────
|
||||
|
||||
|
||||
def _run_loop(
|
||||
monkeypatch,
|
||||
keys,
|
||||
*,
|
||||
min_length = 8,
|
||||
current = "bootstrap-pw",
|
||||
):
|
||||
monkeypatch.setattr(tp, "_getch", _fake_getch(keys))
|
||||
out = io.StringIO()
|
||||
applied = []
|
||||
ok = tp.prompt_for_password_change(
|
||||
min_length = min_length,
|
||||
is_current_password = lambda pw: pw == current,
|
||||
apply_change = applied.append,
|
||||
out = out,
|
||||
)
|
||||
return ok, applied, out.getvalue()
|
||||
|
||||
|
||||
def _keys(*lines):
|
||||
keys = []
|
||||
for line in lines:
|
||||
keys.extend(list(line))
|
||||
keys.append("\r")
|
||||
return keys
|
||||
|
||||
|
||||
def test_loop_success_applies_once(monkeypatch):
|
||||
ok, applied, out = _run_loop(monkeypatch, _keys("new-password", "new-password"))
|
||||
assert ok is True
|
||||
assert applied == ["new-password"]
|
||||
assert "Password updated" in out
|
||||
assert "new-password" not in out
|
||||
|
||||
|
||||
def test_loop_short_password_reprompts(monkeypatch):
|
||||
ok, applied, out = _run_loop(monkeypatch, _keys("short", "long-enough-pw", "long-enough-pw"))
|
||||
assert ok is True
|
||||
assert applied == ["long-enough-pw"]
|
||||
assert "at least 8 characters" in out
|
||||
|
||||
|
||||
def test_loop_rejects_current_password(monkeypatch):
|
||||
ok, applied, out = _run_loop(
|
||||
monkeypatch, _keys("bootstrap-pw", "fresh-password", "fresh-password")
|
||||
)
|
||||
assert ok is True
|
||||
assert applied == ["fresh-password"]
|
||||
assert "must differ" in out
|
||||
|
||||
|
||||
def test_loop_mismatch_reprompts_then_succeeds(monkeypatch):
|
||||
ok, applied, out = _run_loop(
|
||||
monkeypatch,
|
||||
_keys("first-attempt", "typo-attempt", "second-attempt", "second-attempt"),
|
||||
)
|
||||
assert ok is True
|
||||
assert applied == ["second-attempt"]
|
||||
assert "do not match" in out
|
||||
|
||||
|
||||
def test_loop_ctrl_c_aborts_without_applying(monkeypatch):
|
||||
ok, applied, out = _run_loop(monkeypatch, list("ab") + ["\x03"])
|
||||
assert ok is False
|
||||
assert applied == []
|
||||
assert "aborted" in out
|
||||
|
||||
|
||||
def test_loop_eof_aborts_without_applying(monkeypatch):
|
||||
ok, applied, out = _run_loop(monkeypatch, ["\x04"])
|
||||
assert ok is False
|
||||
assert applied == []
|
||||
assert "aborted" in out
|
||||
|
||||
|
||||
def test_loop_ctrl_c_at_confirmation_aborts(monkeypatch):
|
||||
ok, applied, _ = _run_loop(monkeypatch, _keys("valid-password") + ["\x03"])
|
||||
assert ok is False
|
||||
assert applied == []
|
||||
|
||||
|
||||
def test_loop_min_length_counts_code_points(monkeypatch):
|
||||
# 8 unicode code points must pass a min_length of 8.
|
||||
pw = "pässwörd"
|
||||
assert len(pw) == 8
|
||||
ok, applied, _ = _run_loop(monkeypatch, _keys(pw, pw))
|
||||
assert ok is True
|
||||
assert applied == [pw]
|
||||
|
||||
|
||||
# ── should_prompt_password_change ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tunnel,requires,stdin_tty,stderr_tty,expected",
|
||||
[
|
||||
(True, True, True, True, True),
|
||||
(False, True, True, True, False), # tunnel not starting (loopback no-op)
|
||||
(True, False, True, True, False), # password already changed
|
||||
(True, True, False, True, False), # piped stdin (headless)
|
||||
(True, True, True, False, False), # redirected stderr
|
||||
(False, False, False, False, False),
|
||||
],
|
||||
)
|
||||
def test_should_prompt_matrix(tunnel, requires, stdin_tty, stderr_tty, expected):
|
||||
assert (
|
||||
tp.should_prompt_password_change(
|
||||
tunnel_will_start = tunnel,
|
||||
requires_change = requires,
|
||||
stdin_isatty = stdin_tty,
|
||||
stderr_isatty = stderr_tty,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
def test_stream_eof_aborts_instead_of_submitting(monkeypatch):
|
||||
# A dead stream ("" from _getch, e.g. a closed pty) must abort the line,
|
||||
# never silently submit the partial password typed so far.
|
||||
import io
|
||||
|
||||
err = io.StringIO()
|
||||
monkeypatch.setattr(tp, "_getch", _fake_getch(list("abc") + [""]))
|
||||
with pytest.raises(EOFError):
|
||||
tp._read_password("New password: ", out = err)
|
||||
|
||||
|
||||
# ── resolve_supplied_password: non-interactive --password / env / stdin ──
|
||||
|
||||
|
||||
def test_resolve_supplied_password_literal_value_and_note(monkeypatch):
|
||||
import io
|
||||
|
||||
monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False)
|
||||
out = io.StringIO()
|
||||
assert tp.resolve_supplied_password("hunter2pw", out = out) == "hunter2pw"
|
||||
# A literal value warns that it is visible in the process list / history.
|
||||
assert "process list" in out.getvalue()
|
||||
|
||||
|
||||
def test_resolve_supplied_password_stdin(monkeypatch):
|
||||
import io
|
||||
|
||||
monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False)
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("from-stdin-pw\n"))
|
||||
assert tp.resolve_supplied_password("-") == "from-stdin-pw"
|
||||
|
||||
|
||||
def test_resolve_supplied_password_stdin_empty_is_none(monkeypatch):
|
||||
import io
|
||||
|
||||
monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False)
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
||||
assert tp.resolve_supplied_password("-") is None
|
||||
|
||||
|
||||
def test_resolve_supplied_password_env(monkeypatch):
|
||||
monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw")
|
||||
assert tp.resolve_supplied_password("") == "env-secret-pw"
|
||||
assert tp.resolve_supplied_password(None) == "env-secret-pw"
|
||||
|
||||
|
||||
def test_resolve_supplied_password_literal_beats_env(monkeypatch):
|
||||
import io
|
||||
monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw")
|
||||
assert tp.resolve_supplied_password("cli-wins-pw", out = io.StringIO()) == "cli-wins-pw"
|
||||
|
||||
|
||||
def test_resolve_supplied_password_stdin_beats_env(monkeypatch):
|
||||
# `--password -` reads stdin and short-circuits, so a set env var does not win.
|
||||
import io
|
||||
|
||||
monkeypatch.setenv(tp.SUPPLIED_PASSWORD_ENV, "env-secret-pw")
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO("stdin-wins-pw\n"))
|
||||
assert tp.resolve_supplied_password("-") == "stdin-wins-pw"
|
||||
|
||||
|
||||
def test_resolve_supplied_password_off_by_default(monkeypatch):
|
||||
monkeypatch.delenv(tp.SUPPLIED_PASSWORD_ENV, raising = False)
|
||||
assert tp.resolve_supplied_password("") is None
|
||||
assert tp.resolve_supplied_password(None) is None
|
||||
405
studio/backend/tests/test_password_prompt_backstop.py
Normal file
405
studio/backend/tests/test_password_prompt_backstop.py
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Pre-tunnel terminal password gate: never publish a public Cloudflare URL
|
||||
while the seeded default admin password is active. Imports run.py directly,
|
||||
so run under the Studio venv."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND))
|
||||
|
||||
import run # noqa: E402
|
||||
from auth import storage as auth_storage # noqa: E402
|
||||
from auth import terminal_prompt # noqa: E402
|
||||
from auth.terminal_prompt import should_prompt_password_change # noqa: E402
|
||||
|
||||
_GATE_KWARGS = dict(
|
||||
host = "127.0.0.1",
|
||||
secure = True,
|
||||
api_only = False,
|
||||
frontend_served = True,
|
||||
)
|
||||
|
||||
|
||||
# ── pure decision matrix ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tunnel_will_start,requires_change,stdin_isatty,stderr_isatty,expected",
|
||||
[
|
||||
(True, True, True, True, True),
|
||||
# Any missing precondition suppresses the prompt.
|
||||
(False, True, True, True, False),
|
||||
(True, False, True, True, False),
|
||||
(True, True, False, True, False),
|
||||
(True, True, True, False, False),
|
||||
(False, False, False, False, False),
|
||||
],
|
||||
)
|
||||
def test_should_prompt_matrix(
|
||||
tunnel_will_start, requires_change, stdin_isatty, stderr_isatty, expected
|
||||
):
|
||||
assert (
|
||||
should_prompt_password_change(
|
||||
tunnel_will_start = tunnel_will_start,
|
||||
requires_change = requires_change,
|
||||
stdin_isatty = stdin_isatty,
|
||||
stderr_isatty = stderr_isatty,
|
||||
)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
# ── _terminal_password_gate unit tests ───────────────────────────────
|
||||
|
||||
|
||||
class _Stream(io.StringIO):
|
||||
def __init__(self, isatty: bool):
|
||||
super().__init__()
|
||||
self._isatty = isatty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty
|
||||
|
||||
|
||||
class _BrokenStream(io.StringIO):
|
||||
"""Service-wrapper stand-in whose isatty() raises (closed stdin)."""
|
||||
|
||||
def isatty(self) -> bool:
|
||||
raise ValueError("I/O operation on closed file")
|
||||
|
||||
|
||||
def _patch_streams(monkeypatch, *, tty: bool) -> _Stream:
|
||||
stderr = _Stream(isatty = tty)
|
||||
monkeypatch.setattr(sys, "stdin", _Stream(isatty = tty))
|
||||
monkeypatch.setattr(sys, "stderr", stderr)
|
||||
return stderr
|
||||
|
||||
|
||||
def _patch_seeded_admin(monkeypatch, *, requires_change: bool) -> None:
|
||||
# The gate seeds the admin row itself (it can run before lifespan startup);
|
||||
# tests fake both the seeding no-op and the flag.
|
||||
monkeypatch.setattr(auth_storage, "ensure_default_admin", lambda: False)
|
||||
monkeypatch.setattr(auth_storage, "requires_password_change", lambda u: requires_change)
|
||||
|
||||
|
||||
def test_gate_skips_when_tunnel_off(monkeypatch):
|
||||
# Short-circuits before touching auth storage at all.
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("storage must not be consulted when the tunnel is off")
|
||||
|
||||
monkeypatch.setattr(auth_storage, "requires_password_change", _boom)
|
||||
monkeypatch.setattr(auth_storage, "ensure_default_admin", _boom)
|
||||
assert run._terminal_password_gate(tunnel_will_start = False, **_GATE_KWARGS) == (True, False)
|
||||
|
||||
|
||||
def test_gate_skips_when_password_already_changed(monkeypatch):
|
||||
_patch_streams(monkeypatch, tty = True)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = False)
|
||||
monkeypatch.setattr(
|
||||
terminal_prompt,
|
||||
"prompt_for_password_change",
|
||||
lambda **k: pytest.fail("prompt must not run when no change is required"),
|
||||
)
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, False)
|
||||
|
||||
|
||||
def test_gate_warns_and_proceeds_without_tty_when_deadline_arms(monkeypatch):
|
||||
stderr = _patch_streams(monkeypatch, tty = False)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False)
|
||||
monkeypatch.setattr(
|
||||
terminal_prompt,
|
||||
"prompt_for_password_change",
|
||||
lambda **k: pytest.fail("prompt must not run without a tty"),
|
||||
)
|
||||
# Proceeds, but the public HTML must not auto-fill the default credential.
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True)
|
||||
out = stderr.getvalue()
|
||||
assert "default admin password is still active" in out
|
||||
assert "UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT" in out
|
||||
# The seeded file may already be gone (the CLI parent deletes it before
|
||||
# re-exec), so the warning must point at the reset-password recovery path
|
||||
# instead of promising a file to read.
|
||||
assert "reset-password" in out
|
||||
assert ".bootstrap_password" not in out
|
||||
|
||||
|
||||
def test_gate_fails_closed_without_tty_when_deadline_cannot_arm(monkeypatch):
|
||||
# api-only launches never arm the bootstrap deadline, so a headless public
|
||||
# launch with the default password has NO safeguard: refuse to start.
|
||||
stderr = _patch_streams(monkeypatch, tty = False)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False)
|
||||
kwargs = dict(_GATE_KWARGS)
|
||||
kwargs["api_only"] = True
|
||||
kwargs["frontend_served"] = False
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **kwargs) == (False, False)
|
||||
assert "Refusing to publish" in stderr.getvalue()
|
||||
|
||||
|
||||
def test_gate_fails_closed_without_tty_when_deadline_disabled(monkeypatch):
|
||||
stderr = _patch_streams(monkeypatch, tty = False)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "0")
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (False, False)
|
||||
assert "Refusing to publish" in stderr.getvalue()
|
||||
|
||||
|
||||
def test_gate_treats_broken_streams_as_non_interactive(monkeypatch):
|
||||
# A closed/None stdin must take the headless path, not blow up.
|
||||
stderr = _Stream(isatty = False)
|
||||
monkeypatch.setattr(sys, "stdin", _BrokenStream())
|
||||
monkeypatch.setattr(sys, "stderr", stderr)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", raising = False)
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True)
|
||||
|
||||
|
||||
def test_gate_refusal_fails_closed(monkeypatch):
|
||||
_patch_streams(monkeypatch, tty = True)
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setattr(terminal_prompt, "prompt_for_password_change", lambda **k: False)
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (False, False)
|
||||
|
||||
|
||||
def test_gate_success_applies_route_equivalent_change(monkeypatch):
|
||||
_patch_streams(monkeypatch, tty = True)
|
||||
calls = []
|
||||
_patch_seeded_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setattr(
|
||||
auth_storage,
|
||||
"get_user_and_secret",
|
||||
lambda u: ("salt", "hash", "jwt", True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_storage,
|
||||
"update_password",
|
||||
lambda u, p, **kw: calls.append(("update", u, p, kw)),
|
||||
)
|
||||
|
||||
def _fake_prompt(*, min_length, is_current_password, apply_change, out):
|
||||
# The gate wires the policy constant and route-equivalent apply hook.
|
||||
assert min_length == auth_storage.MIN_PASSWORD_LENGTH
|
||||
# Wired to the real hash comparison: a wrong guess is rejected.
|
||||
assert is_current_password("wrong-guess") is False
|
||||
apply_change("brand-new-password")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(terminal_prompt, "prompt_for_password_change", _fake_prompt)
|
||||
assert run._terminal_password_gate(tunnel_will_start = True, **_GATE_KWARGS) == (True, True)
|
||||
admin = auth_storage.DEFAULT_ADMIN_USERNAME
|
||||
# One atomic call: refresh tokens revoked in the same transaction as the
|
||||
# password commit (a separable follow-up delete can fail and leave a
|
||||
# pre-change refresh token able to mint access tokens).
|
||||
assert calls == [("update", admin, "brand-new-password", {"revoke_refresh_tokens": True})]
|
||||
|
||||
|
||||
# ── ordering inside run_server (source-level, repo convention) ───────
|
||||
|
||||
|
||||
def test_gate_runs_before_server_bind_in_source():
|
||||
# The gate must run before the uvicorn socket binds: on a wildcard bind
|
||||
# the served HTML injects the bootstrap credential for first login, so a
|
||||
# pre-gate listener would hand out the default password mid-prompt.
|
||||
src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
|
||||
gate_call = src.index("_pw_proceed, _pw_drop_bootstrap = _terminal_password_gate(")
|
||||
thread_start = src.index("thread.start()")
|
||||
tunnel_start = src.index("_cloudflare_url = start_studio_tunnel(port)")
|
||||
assert gate_call < thread_start < tunnel_start
|
||||
# The fail-closed branch exits before any server exists.
|
||||
refusal = src[gate_call:thread_start]
|
||||
assert "sys.exit(1)" in refusal
|
||||
|
||||
|
||||
def test_min_password_length_single_source():
|
||||
# models/auth.py must reference the storage constant, not a literal.
|
||||
models_src = (_BACKEND / "models" / "auth.py").read_text(encoding = "utf-8")
|
||||
assert "MIN_PASSWORD_LENGTH" in models_src
|
||||
assert not re.search(r"min_length\s*=\s*8\b", models_src)
|
||||
assert auth_storage.MIN_PASSWORD_LENGTH == 8
|
||||
|
||||
|
||||
def test_lifespan_honors_bootstrap_suppression_in_source():
|
||||
# The lifespan runs AFTER the gate and re-reads the bootstrap password
|
||||
# into app.state; without the suppress flag it would overwrite the gate's
|
||||
# None and the public HTML would inject the default credential again.
|
||||
main_src = (_BACKEND / "main.py").read_text(encoding = "utf-8")
|
||||
assert "suppress_bootstrap_injection" in main_src
|
||||
# Every lifespan capture of the bootstrap password must be flag-guarded.
|
||||
for line in main_src.splitlines():
|
||||
if "storage.get_bootstrap_password()" in line and "=" in line:
|
||||
assert "_suppress_bootstrap" in line, line
|
||||
run_src = (_BACKEND / "run.py").read_text(encoding = "utf-8")
|
||||
assert "app.state.suppress_bootstrap_injection = True" in run_src
|
||||
|
||||
|
||||
def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path):
|
||||
# If the file cannot be unlinked (Windows AV / read-only auth dir), clear must
|
||||
# truncate it so its stale plaintext cannot be re-seeded by
|
||||
# generate_bootstrap_password() after a later reset-password deletes auth.db,
|
||||
# which would re-validate the revoked bootstrap password.
|
||||
import pathlib
|
||||
|
||||
pw_path = tmp_path / ".bootstrap_password"
|
||||
pw_path.write_text("old-diceware-passphrase")
|
||||
monkeypatch.setattr(auth_storage, "_BOOTSTRAP_PW_PATH", pw_path)
|
||||
monkeypatch.setattr(auth_storage, "_bootstrap_password", "old-diceware-passphrase")
|
||||
|
||||
_real_unlink = pathlib.Path.unlink
|
||||
|
||||
def _boom(self, *a, **k):
|
||||
if self == pw_path:
|
||||
raise OSError("locked")
|
||||
return _real_unlink(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "unlink", _boom)
|
||||
|
||||
auth_storage.clear_bootstrap_password()
|
||||
|
||||
assert pw_path.exists() # unlink failed
|
||||
assert pw_path.read_text() == "" # but truncated -> no reusable plaintext
|
||||
|
||||
# The stale value must not load back (empty file -> None), so a later re-seed
|
||||
# generates fresh rather than resurrecting the revoked credential.
|
||||
monkeypatch.setattr(auth_storage, "_bootstrap_password", None)
|
||||
assert auth_storage._load_bootstrap_password() is None
|
||||
|
||||
|
||||
def test_clear_bootstrap_password_warns_truthfully_when_not_cleared(monkeypatch, tmp_path, capsys):
|
||||
# If the file can be neither unlinked NOR truncated, the stale plaintext stays
|
||||
# on disk. The warning must NOT claim it was made unreusable (Codex 3571888584):
|
||||
# it must say it could not be cleared and ask the user to remove it manually.
|
||||
import pathlib
|
||||
|
||||
pw_path = tmp_path / ".bootstrap_password"
|
||||
pw_path.write_text("old-diceware-passphrase")
|
||||
monkeypatch.setattr(auth_storage, "_BOOTSTRAP_PW_PATH", pw_path)
|
||||
monkeypatch.setattr(auth_storage, "_bootstrap_password", "old-diceware-passphrase")
|
||||
|
||||
_real_unlink = pathlib.Path.unlink
|
||||
_real_write_text = pathlib.Path.write_text
|
||||
|
||||
def _boom_unlink(self, *a, **k):
|
||||
if self == pw_path:
|
||||
raise OSError("locked")
|
||||
return _real_unlink(self, *a, **k)
|
||||
|
||||
def _boom_write_text(self, *a, **k):
|
||||
if self == pw_path:
|
||||
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)
|
||||
|
||||
auth_storage.clear_bootstrap_password()
|
||||
|
||||
# The stale plaintext survives untouched.
|
||||
assert pw_path.read_text() == "old-diceware-passphrase"
|
||||
warning = capsys.readouterr().err.lower()
|
||||
assert "could not delete or clear" in warning
|
||||
assert "still on disk" in warning
|
||||
assert "remove it manually" in warning
|
||||
# Must not falsely claim the contents were cleared (the bug being fixed).
|
||||
assert "cleared its contents" not in warning
|
||||
|
||||
|
||||
# ── _apply_supplied_password: non-interactive initial password (direct run.py) ──
|
||||
|
||||
|
||||
def _seed_stub_admin(
|
||||
monkeypatch,
|
||||
*,
|
||||
requires_change,
|
||||
bootstrap_pw = "bootstrap-secret",
|
||||
):
|
||||
"""Stub storage so _apply_supplied_password sees a seeded admin whose current
|
||||
password is ``bootstrap_pw`` and whose must-change flag is ``requires_change``;
|
||||
return the recorded update_password calls."""
|
||||
from auth import hashing
|
||||
|
||||
salt, pwd_hash = hashing.hash_password(bootstrap_pw)
|
||||
monkeypatch.setattr(auth_storage, "ensure_default_admin", lambda: False)
|
||||
monkeypatch.setattr(auth_storage, "requires_password_change", lambda u: requires_change)
|
||||
monkeypatch.setattr(
|
||||
auth_storage, "get_user_and_secret", lambda u: (salt, pwd_hash, "jwt", requires_change)
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
auth_storage, "update_password", lambda u, p, **kw: calls.append((u, p, kw))
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def test_apply_supplied_password_sets_initial(monkeypatch):
|
||||
calls = _seed_stub_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password")
|
||||
run._apply_supplied_password(None) # resolves from the env var
|
||||
admin = auth_storage.DEFAULT_ADMIN_USERNAME
|
||||
assert calls == [(admin, "brand-new-password", {"revoke_refresh_tokens": True})]
|
||||
|
||||
|
||||
def test_apply_supplied_password_off_is_noop(monkeypatch):
|
||||
calls = _seed_stub_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.delenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, raising = False)
|
||||
run._apply_supplied_password(None)
|
||||
run._apply_supplied_password("")
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_apply_supplied_password_already_set_fails_closed(monkeypatch):
|
||||
calls = _seed_stub_admin(monkeypatch, requires_change = False)
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run._apply_supplied_password(None)
|
||||
assert exc.value.code == 1
|
||||
assert calls == [] # never overrides an existing password
|
||||
|
||||
|
||||
def test_apply_supplied_password_too_short_fails_closed(monkeypatch):
|
||||
calls = _seed_stub_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "short")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run._apply_supplied_password(None)
|
||||
assert exc.value.code == 1
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_apply_supplied_password_must_differ_fails_closed(monkeypatch):
|
||||
calls = _seed_stub_admin(monkeypatch, requires_change = True, bootstrap_pw = "bootstrap-secret")
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "bootstrap-secret")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run._apply_supplied_password(None)
|
||||
assert exc.value.code == 1
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_apply_supplied_password_strips_env_from_subprocess_environment(monkeypatch):
|
||||
# The plaintext password must not linger in os.environ: run_server later spawns
|
||||
# cloudflared/llama-server/code-exec tools that would otherwise inherit it (also
|
||||
# readable via /proc/PID/environ). The direct-run.py path pops it itself; the CLI
|
||||
# pops it before re-exec. Assert the pop happens on the apply path...
|
||||
_seed_stub_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "brand-new-password")
|
||||
run._apply_supplied_password(None)
|
||||
assert terminal_prompt.SUPPLIED_PASSWORD_ENV not in run.os.environ
|
||||
|
||||
|
||||
def test_apply_supplied_password_strips_env_even_when_literal_wins(monkeypatch):
|
||||
# A literal --password wins over the env var, but a stale env value would still
|
||||
# leak to subprocesses; the unconditional pop must clear it regardless of source.
|
||||
_seed_stub_admin(monkeypatch, requires_change = True)
|
||||
monkeypatch.setenv(terminal_prompt.SUPPLIED_PASSWORD_ENV, "env-should-be-stripped")
|
||||
run._apply_supplied_password("literal-new-password")
|
||||
assert terminal_prompt.SUPPLIED_PASSWORD_ENV not in run.os.environ
|
||||
1594
studio/backend/tests/test_permission_mode.py
Normal file
1594
studio/backend/tests/test_permission_mode.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -56,7 +56,6 @@ def test_customization_defaults():
|
|||
assert c.contrast == 50
|
||||
assert c.reduceMotion == "system"
|
||||
assert c.fontSmoothing is True
|
||||
assert c.edgeFades is True
|
||||
assert c.pointerCursors is False
|
||||
assert c.colors.light.accent is None
|
||||
assert c.headingFont is None
|
||||
|
|
@ -370,7 +369,6 @@ def test_personalization_route_roundtrip_real_shape(monkeypatch):
|
|||
"pointerCursors": True,
|
||||
"reduceMotion": "off",
|
||||
"fontSmoothing": True,
|
||||
"edgeFades": False,
|
||||
"sidebarMenu": [
|
||||
{"id": "darkMode", "visible": True},
|
||||
{"id": "api", "visible": False},
|
||||
|
|
|
|||
|
|
@ -2592,6 +2592,50 @@ class TestLoopBasic:
|
|||
assert tool_starts[0]["arguments"] == {}
|
||||
assert "<!doctype html>" in tool_starts[1]["arguments"]["code"]
|
||||
|
||||
def test_render_html_auto_mode_static_runs_without_prompt(self):
|
||||
"""permission_mode="auto" ships confirm_tool_calls=true. render_html is no
|
||||
longer unconditionally safe (a networked canvas must ask), so its early
|
||||
provisional card is suppressed under the confirm gate; a static canvas is
|
||||
still classified safe and runs without an approval prompt."""
|
||||
exec_fn = FakeExecuteTool(["Rendered HTML canvas."])
|
||||
turn_iter = iter(
|
||||
[
|
||||
[
|
||||
"<function=render_html>",
|
||||
"<parameter=code><!doctype html><html>",
|
||||
"<body>Hi</body></html></parameter></function>",
|
||||
],
|
||||
["Done."],
|
||||
]
|
||||
)
|
||||
|
||||
def _gen(_messages):
|
||||
chunks = next(turn_iter)
|
||||
acc = ""
|
||||
for chunk in chunks:
|
||||
acc += chunk
|
||||
yield acc
|
||||
|
||||
loop = run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "make html"}],
|
||||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
execute_tool = exec_fn,
|
||||
confirm_tool_calls = True,
|
||||
permission_mode = "auto",
|
||||
session_id = "sess",
|
||||
max_tool_iterations = 3,
|
||||
)
|
||||
events = _collect_events(loop)
|
||||
tool_starts = [e for e in events if e["type"] == "tool_start"]
|
||||
|
||||
# No early provisional card under the auto confirm gate; just the real call.
|
||||
assert len(tool_starts) == 1
|
||||
assert tool_starts[0]["tool_name"] == "render_html"
|
||||
assert "<!doctype html>" in tool_starts[0]["arguments"]["code"]
|
||||
# A static canvas is classified safe, so it runs without an approval gate.
|
||||
assert tool_starts[0].get("awaiting_confirmation") in (False, None)
|
||||
|
||||
def test_render_html_provisional_card_closed_on_generator_exception(self):
|
||||
"""If the model generator raises mid-stream after a provisional render_html
|
||||
card was surfaced, the loop must close that card as errored before the
|
||||
|
|
@ -3674,6 +3718,26 @@ class TestGuardrails:
|
|||
assert any(e.get("type") == "content" and e.get("text") == "plain answer" for e in events)
|
||||
assert exec_fn.calls == []
|
||||
|
||||
def test_auto_mode_still_runs_rag_autoinject(self, monkeypatch):
|
||||
# "auto" sends confirm_tool_calls=true so unsafe calls gate, but the
|
||||
# safe search_knowledge_base retrieval never gates, so autoinject must
|
||||
# still run (unlike ask mode above).
|
||||
ran = {"called": False}
|
||||
|
||||
def fake_autoinject(*_args, **_kwargs):
|
||||
ran["called"] = True
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("core.inference.tools.build_rag_autoinject", fake_autoinject)
|
||||
loop, _exec_fn = _make_loop(
|
||||
turns = [["plain answer"]],
|
||||
confirm_tool_calls = True,
|
||||
permission_mode = "auto",
|
||||
rag_scope = {"thread_id": "t1"},
|
||||
)
|
||||
_collect_events(loop)
|
||||
assert ran["called"] is True
|
||||
|
||||
def test_auto_heal_disabled_preserves_xml_on_final_no_tools_pass(self):
|
||||
turns = iter(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
|
|||
@pytest.mark.parametrize(
|
||||
"cloudflare,host,secure,api_only,is_colab,expected",
|
||||
[
|
||||
# Non-secure wildcard binds tunnel by default.
|
||||
# Non-secure wildcard binds tunnel only when --cloudflare is passed (True).
|
||||
(True, "0.0.0.0", False, False, False, True),
|
||||
(True, "::", False, False, False, True),
|
||||
(True, "127.0.0.1", False, False, False, False),
|
||||
|
|
@ -33,6 +33,10 @@ from run import _cloudflare_tunnel_should_start as should_start # noqa: E402
|
|||
(False, "0.0.0.0", False, False, False, False),
|
||||
(False, "::", False, False, False, False),
|
||||
(False, "127.0.0.1", True, False, False, False),
|
||||
# Unset (None, no flag) behaves as off for non-secure binds.
|
||||
(None, "0.0.0.0", False, False, False, False),
|
||||
(None, "::", False, False, False, False),
|
||||
(None, "127.0.0.1", False, False, False, False),
|
||||
# Non-secure api-only never tunnels (Tauri).
|
||||
(True, "0.0.0.0", False, True, False, False),
|
||||
(True, "::", False, True, False, False),
|
||||
|
|
@ -155,11 +159,12 @@ def test_startup_output_emits_disabled_notice(capsys, monkeypatch):
|
|||
|
||||
|
||||
def test_run_server_rejects_secure_without_cloudflare():
|
||||
# Direct backend callers (not just the CLI) must reject the contradictory combo.
|
||||
# Direct backend callers (not just the CLI) must reject the contradictory
|
||||
# combo: --secure asks for the tunnel, --no-cloudflare (cloudflare=False) forbids it.
|
||||
import run
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run.run_server(secure = True, cloudflare = False)
|
||||
assert "A secure Cloudflare link is not allowed" in str(exc.value)
|
||||
assert "do not combine it with --no-cloudflare" in str(exc.value)
|
||||
|
||||
|
||||
def test_failclosed_message_present_in_source():
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ def scan_checkpoints(
|
|||
# Sort by modification time (newest first)
|
||||
models.sort(key = lambda x: Path(x[1][0][1]).stat().st_mtime, reverse = True)
|
||||
|
||||
logger.info(f"Found {len(models)} training runs in {outputs_dir}")
|
||||
logger.debug(f"Found {len(models)} training runs in {outputs_dir}")
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import { McpComposerButton } from "@/features/chat/mcp-composer-button";
|
|||
import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities";
|
||||
import { useRagToolDisabled } from "@/features/chat/hooks/use-rag-tool-disabled";
|
||||
import { BypassPermissionsMenuItem } from "@/features/chat/bypass-permissions-menu-item";
|
||||
import { PermissionModeComposerPill } from "@/features/chat/permission-mode-select";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
|
||||
import { PROMPT_QUEUE_STOP_EVENT } from "@/features/chat/utils/prompt-queue-boundary";
|
||||
|
|
@ -131,7 +132,6 @@ import {
|
|||
Image03Icon,
|
||||
McpServerIcon,
|
||||
PencilRulerIcon,
|
||||
ShieldBanIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -1428,11 +1428,14 @@ const Composer: FC<{
|
|||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
|
||||
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
// More than 4 pills: collapse to icons only. Search and Code always show;
|
||||
// More than 4 pills: collapse to icons only. Search and Code always show; the
|
||||
// permission pill shows in every mode except "off" (it renders null there);
|
||||
// Images, RAG, Canvas and MCP are conditional.
|
||||
const pillsCompact =
|
||||
2 +
|
||||
(permissionMode !== "off" ? 1 : 0) +
|
||||
(ragEnabled ? 1 : 0) +
|
||||
(supportsBuiltinImageGeneration ? 1 : 0) +
|
||||
(artifactsEnabled ? 1 : 0) +
|
||||
|
|
@ -1856,9 +1859,9 @@ const Composer: FC<{
|
|||
data-pill-compact={pillsCompact ? "true" : undefined}
|
||||
>
|
||||
<ComposerToolsMenu side={effectiveMenuSide} />
|
||||
{/* Active-mode badge: always visible when bypass is on, even while
|
||||
the pill row is collapsed (returns null when off). */}
|
||||
<BypassPermissionsToggle />
|
||||
{/* Permission-level pill: always visible, even while the pill row
|
||||
is collapsed; opens the permission level dropdown. */}
|
||||
<PermissionModeComposerPill side={effectiveMenuSide} />
|
||||
{composerExpanded ? (
|
||||
<>
|
||||
<WebSearchToggle />
|
||||
|
|
@ -2620,36 +2623,6 @@ const ArtifactsToggle: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
// Claude gold pill shown while Bypass permissions is on; click to turn it off.
|
||||
// Mirror of shared-composer's badge so both composers surface the state.
|
||||
const BypassPermissionsToggle: FC = () => {
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
if (!bypassPermissions) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBypassPermissions(false)}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
data-variant="danger"
|
||||
aria-label="Disable Bypass permissions"
|
||||
title="Bypass permissions is on (no confirmation, no sandbox). Click to turn off."
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon
|
||||
icon={ShieldBanIcon}
|
||||
strokeWidth={2}
|
||||
className="size-[15px]"
|
||||
/>
|
||||
</PillGlyph>
|
||||
<span>Bypass permissions</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolStatusDisplay: FC = () => {
|
||||
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ interface ResponseDetailsMetadata {
|
|||
artifacts: boolean;
|
||||
confirmToolCalls: boolean;
|
||||
bypassPermissions: boolean;
|
||||
permissionMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1951,6 +1952,7 @@ export function createOpenAIStreamAdapter(
|
|||
mcpEnabledForChat,
|
||||
confirmToolCalls,
|
||||
bypassPermissions,
|
||||
permissionMode,
|
||||
webFetchToolsEnabled,
|
||||
ragEnabled,
|
||||
ragSource,
|
||||
|
|
@ -2642,6 +2644,7 @@ export function createOpenAIStreamAdapter(
|
|||
artifacts: renderHtmlToolEnabledForThisTurn,
|
||||
confirmToolCalls,
|
||||
bypassPermissions,
|
||||
permissionMode,
|
||||
},
|
||||
});
|
||||
const externalCapabilities = getProviderCapabilities(
|
||||
|
|
@ -2953,6 +2956,16 @@ export function createOpenAIStreamAdapter(
|
|||
...(supportsPreserveThinking
|
||||
? { preserve_thinking: preserveThinking }
|
||||
: {}),
|
||||
// Permission level for local tool calls is sent for every local
|
||||
// chat, not only when a tool pill is on: a process policy
|
||||
// (unsloth run --enable-tools) can open the tool loop with no pill,
|
||||
// and the backend must still see the selected gate. ask/auto request
|
||||
// the confirm gate ("auto" only pauses calls flagged unsafe); off
|
||||
// and full never prompt, full also drops the sandbox.
|
||||
permission_mode: permissionMode,
|
||||
confirm_tool_calls:
|
||||
permissionMode === "ask" || permissionMode === "auto",
|
||||
bypass_permissions: bypassPermissions,
|
||||
...(supportsTools &&
|
||||
(toolsEnabled ||
|
||||
codeToolsEnabled ||
|
||||
|
|
@ -2974,10 +2987,6 @@ export function createOpenAIStreamAdapter(
|
|||
: []),
|
||||
],
|
||||
mcp_enabled: mcpEnabledForChat,
|
||||
// Bypass Permissions wins: never request the confirm gate
|
||||
// while bypassing, and tell the backend to drop the sandbox.
|
||||
confirm_tool_calls: confirmToolCalls && !bypassPermissions,
|
||||
bypass_permissions: bypassPermissions,
|
||||
// Scope: thread_id = this thread's docs, kb_id = a KB,
|
||||
// project_id = the thread's project sources (auto-on whenever
|
||||
// the project has indexed sources, no Docs pill needed).
|
||||
|
|
|
|||
|
|
@ -14,45 +14,49 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { PermissionModeMenuItems } from "./permission-mode-select";
|
||||
|
||||
// "Bypass permissions" entry for the composer "+" -> More menu. Mirrors the
|
||||
// settings toggle: enabling demands the danger warning, disabling is immediate.
|
||||
// The menu closes normally on select (no preventDefault) -- the warning dialog
|
||||
// lives outside the menu (BypassPermissionsConfirmDialog, mounted once at the
|
||||
// chat-page root and driven by the store), so it survives the menu unmounting
|
||||
// and the "+"/More popovers don't stay frozen.
|
||||
// "Bypass permissions" entry for the composer "+" -> More menu. Like the MCP
|
||||
// pill, it opens a submenu where the user picks the permission level (Ask for
|
||||
// approval / Approve for me / Full access). Picking Full access demands the
|
||||
// danger warning; the other levels apply immediately. The menu closes normally
|
||||
// on select (no preventDefault) -- the warning dialog lives outside the menu
|
||||
// (BypassPermissionsConfirmDialog, mounted once at the chat-page root and
|
||||
// driven by the store), so it survives the menu unmounting and the "+"/More
|
||||
// popovers don't stay frozen.
|
||||
export function BypassPermissionsMenuItem() {
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const setBypassConfirmOpen = useChatRuntimeStore(
|
||||
(s) => s.setBypassConfirmOpen,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={bypassPermissions ? "text-bypass font-medium" : undefined}
|
||||
onSelect={() => {
|
||||
if (bypassPermissions) {
|
||||
setBypassPermissions(false);
|
||||
} else {
|
||||
// Defer past Radix's menu-close focus restoration: opening the dialog
|
||||
// synchronously here lets the dropdown grab focus back and breaks the
|
||||
// dialog's focus trap.
|
||||
setTimeout(() => setBypassConfirmOpen(true), 0);
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
className={
|
||||
permissionMode === "full" ? "text-bypass font-medium" : undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={ShieldBanIcon} strokeWidth={2} />
|
||||
Bypass permissions
|
||||
{bypassPermissions ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
>
|
||||
<HugeiconsIcon icon={ShieldBanIcon} strokeWidth={2} />
|
||||
Bypass permissions
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="unsloth-plus-menu w-[300px]">
|
||||
<PermissionModeMenuItems
|
||||
// Defer past Radix's menu-close focus restoration: opening the
|
||||
// dialog synchronously here lets the dropdown grab focus back and
|
||||
// breaks the dialog's focus trap.
|
||||
onRequestFullAccess={() =>
|
||||
setTimeout(() => setBypassConfirmOpen(true), 0)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -63,19 +67,17 @@ export function BypassPermissionsMenuItem() {
|
|||
export function BypassPermissionsConfirmDialog() {
|
||||
const open = useChatRuntimeStore((s) => s.bypassConfirmOpen);
|
||||
const setOpen = useChatRuntimeStore((s) => s.setBypassConfirmOpen);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Bypass permissions?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Enable Full access?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bypass permissions is dangerous since the AI model might delete,
|
||||
corrupt your machine, and or cause real world damage to you or the
|
||||
world - only accept if you are certain
|
||||
Full access (Bypass permissions) is dangerous since the AI model
|
||||
might delete, corrupt your machine, and or cause real world damage
|
||||
to you or the world - only accept if you are certain
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -84,7 +86,7 @@ export function BypassPermissionsConfirmDialog() {
|
|||
variant="destructive"
|
||||
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
|
||||
onClick={() => {
|
||||
setBypassPermissions(true);
|
||||
setPermissionMode("full");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,6 @@ import {
|
|||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -81,6 +71,7 @@ import { Fragment, type ReactNode } from "react";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
|
||||
import { PermissionModeDropdown } from "./permission-mode-select";
|
||||
import { resyncInferenceStatusAfterServerModelChange } from "./hooks/use-chat-model-runtime";
|
||||
import {
|
||||
type ExternalProviderConfig,
|
||||
|
|
@ -2037,9 +2028,8 @@ function NudgeToolCallsToggle() {
|
|||
}
|
||||
|
||||
function ConfirmToolCallsToggle() {
|
||||
const confirmToolCalls = useChatRuntimeStore((s) => s.confirmToolCalls);
|
||||
const setConfirmToolCalls = useChatRuntimeStore((s) => s.setConfirmToolCalls);
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
|
@ -2049,85 +2039,49 @@ function ConfirmToolCallsToggle() {
|
|||
Confirm tool calls
|
||||
</span>
|
||||
<InfoHint>
|
||||
When on, local Studio tool calls pause for your approval before they
|
||||
run. Provider-hosted tools are not gated here.
|
||||
When on, every local Unsloth tool call pauses for your approval
|
||||
before it runs (the "Ask for approval" level). When off, tool calls
|
||||
run without prompts inside the sandbox (the "Off" level).
|
||||
Provider-hosted tools are not gated here.
|
||||
</InfoHint>
|
||||
</div>
|
||||
{bypassPermissions ? (
|
||||
{permissionMode === "full" ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Overridden by Bypass permissions
|
||||
Overridden by Full access (Bypass permissions)
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch"
|
||||
checked={confirmToolCalls && !bypassPermissions}
|
||||
checked={permissionMode === "ask"}
|
||||
onCheckedChange={setConfirmToolCalls}
|
||||
disabled={bypassPermissions}
|
||||
disabled={permissionMode === "full"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BypassPermissionsToggle() {
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Bypass permissions
|
||||
</span>
|
||||
<InfoHint>
|
||||
Dangerous. Runs every tool call with no confirmation and disables
|
||||
the python/terminal sandbox. Environment secrets are stripped, but
|
||||
code can still read files and credentials on your machine.
|
||||
</InfoHint>
|
||||
</div>
|
||||
<Switch
|
||||
className="panel-switch"
|
||||
checked={bypassPermissions}
|
||||
onCheckedChange={(next) => {
|
||||
if (next) setDialogOpen(true);
|
||||
else setBypassPermissions(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="whitespace-nowrap text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
||||
Bypass permissions
|
||||
</span>
|
||||
<InfoHint>
|
||||
How Unsloth approves tool calls before they run. Full access is
|
||||
dangerous: it disables confirmations and the code sandbox.
|
||||
</InfoHint>
|
||||
</div>
|
||||
{bypassPermissions ? (
|
||||
{/* Full width, styled like the panel selects/preset input. */}
|
||||
<PermissionModeDropdown triggerClassName="h-9 w-full justify-between rounded-full border-0 bg-[var(--panel-input-surface)] px-3.5 text-[13px] font-medium text-nav-fg shadow-none hover:bg-[var(--panel-input-surface)]" />
|
||||
{permissionMode === "full" ? (
|
||||
<span className="text-[11px] text-bypass">
|
||||
Tool calls run with no confirmation and no sandbox.
|
||||
</span>
|
||||
) : null}
|
||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Bypass permissions?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Bypass permissions is dangerous since the AI model might delete,
|
||||
corrupt your machine, and or cause real world damage to you or the
|
||||
world - only accept if you are certain
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
|
||||
onClick={() => {
|
||||
setBypassPermissions(true);
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
I understand
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export {
|
|||
type Preset,
|
||||
} from "./chat-settings-sheet";
|
||||
export { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
export { PermissionModeDropdown } from "./permission-mode-select";
|
||||
export { useChatSearchStore } from "./stores/chat-search-store";
|
||||
export { usePinnedChatsStore } from "./stores/pinned-chats-store";
|
||||
export { useChatPreferencesStore } from "./stores/chat-preferences-store";
|
||||
|
|
|
|||
338
studio/frontend/src/features/chat/permission-mode-select.tsx
Normal file
338
studio/frontend/src/features/chat/permission-mode-select.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
CircleOff,
|
||||
Hand,
|
||||
ShieldCheck,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type PermissionMode,
|
||||
useChatRuntimeStore,
|
||||
} from "./stores/chat-runtime-store";
|
||||
|
||||
/**
|
||||
* Permission levels for the Bypass permissions dropdowns (General settings,
|
||||
* chat settings sheet, composer "+" menu). Off sits last as the toggle that
|
||||
* turns the feature off entirely.
|
||||
*/
|
||||
export const PERMISSION_MODE_OPTIONS: readonly {
|
||||
value: PermissionMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Hand;
|
||||
}[] = [
|
||||
{
|
||||
value: "ask",
|
||||
label: "Ask for approval",
|
||||
description: "Always ask before tool calls edit files or use the internet",
|
||||
icon: Hand,
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "Approve for me",
|
||||
description: "Only ask for actions detected as potentially unsafe",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: "Full access",
|
||||
description:
|
||||
"Unrestricted: no approval prompts and the code sandbox is disabled",
|
||||
icon: CircleAlert,
|
||||
},
|
||||
{
|
||||
value: "off",
|
||||
label: "Off",
|
||||
description: "Turn off bypass permissions",
|
||||
icon: CircleOff,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function permissionModeOption(mode: PermissionMode) {
|
||||
return (
|
||||
PERMISSION_MODE_OPTIONS.find((option) => option.value === mode) ??
|
||||
PERMISSION_MODE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
/** The option rows shared by every permission dropdown/submenu. Non-full
|
||||
* levels apply directly; picking Full access must go through the caller's
|
||||
* danger confirmation, so it's a separate callback. */
|
||||
export function PermissionModeMenuItems({
|
||||
onRequestFullAccess,
|
||||
}: {
|
||||
onRequestFullAccess: () => void;
|
||||
}) {
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
|
||||
|
||||
return (
|
||||
<>
|
||||
{PERMISSION_MODE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
// Reselecting the active level toggles the feature off.
|
||||
if (option.value === permissionMode) {
|
||||
setPermissionMode("off");
|
||||
} else if (option.value === "full") {
|
||||
onRequestFullAccess();
|
||||
} else {
|
||||
setPermissionMode(option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"items-start gap-2 py-2",
|
||||
permissionMode === option.value && "font-medium",
|
||||
option.value === "full" &&
|
||||
permissionMode === "full" &&
|
||||
"text-bypass",
|
||||
)}
|
||||
>
|
||||
<option.icon className="mt-0.5 size-4 shrink-0" strokeWidth={2} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-[13px] leading-tight">{option.label}</span>
|
||||
<span className="text-xs font-normal leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
{permissionMode === option.value ? (
|
||||
<HugeiconsIcon
|
||||
icon={Tick02Icon}
|
||||
strokeWidth={2}
|
||||
className="ml-auto mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Danger confirmation shown before Full access turns on. Self-contained so
|
||||
* the dropdown works outside the chat page (e.g. the Settings dialog). */
|
||||
export function FullAccessConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Enable Full access?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Full access (Bypass permissions) is dangerous since the AI model
|
||||
might delete, corrupt your machine, and or cause real world damage
|
||||
to you or the world - only accept if you are certain
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
|
||||
onClick={() => {
|
||||
setPermissionMode("full");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
I understand
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select-style dropdown (like the MCP composer menu) for picking the
|
||||
* permission level. Used in General settings and the chat settings sheet.
|
||||
*/
|
||||
export function PermissionModeDropdown({
|
||||
side = "bottom",
|
||||
align = "end",
|
||||
triggerClassName,
|
||||
}: {
|
||||
side?: "top" | "bottom";
|
||||
align?: "start" | "end";
|
||||
triggerClassName?: string;
|
||||
} = {}) {
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const active = permissionModeOption(permissionMode);
|
||||
const ActiveIcon = active.icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"gap-1.5",
|
||||
triggerClassName,
|
||||
// Last so a text color in triggerClassName cannot override it.
|
||||
permissionMode === "full" &&
|
||||
"text-bypass hover:text-bypass border-bypass/50",
|
||||
)}
|
||||
aria-label="Permission level for tool calls"
|
||||
>
|
||||
<ActiveIcon className="size-3.5 shrink-0" strokeWidth={2} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{active.label}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={side}
|
||||
align={align}
|
||||
className="w-[300px]"
|
||||
avoidCollisions={true}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
How should tool calls be approved?
|
||||
</DropdownMenuLabel>
|
||||
<PermissionModeMenuItems
|
||||
// Defer past the menu-close focus restoration so the dialog's
|
||||
// focus trap isn't broken by the dropdown grabbing focus back.
|
||||
onRequestFullAccess={() =>
|
||||
setTimeout(() => setConfirmOpen(true), 0)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FullAccessConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer pill (mirrors the MCP pill) showing the current permission level
|
||||
* in the chat box; clicking opens the level dropdown. Danger-styled while
|
||||
* Full access is on. The Full access pick routes through the store-driven
|
||||
* BypassPermissionsConfirmDialog mounted at the chat-page root, so the
|
||||
* warning survives this menu unmounting.
|
||||
*/
|
||||
export function PermissionModeComposerPill({
|
||||
side = "bottom",
|
||||
}: {
|
||||
side?: "top" | "bottom";
|
||||
} = {}) {
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const setBypassConfirmOpen = useChatRuntimeStore(
|
||||
(s) => s.setBypassConfirmOpen,
|
||||
);
|
||||
const setPermissionMode = useChatRuntimeStore((s) => s.setPermissionMode);
|
||||
const active = permissionModeOption(permissionMode);
|
||||
const ActiveIcon = active.icon;
|
||||
const fullAccess = permissionMode === "full";
|
||||
|
||||
// Off means the feature is off: no pill (re-enable via the "+" menu or
|
||||
// settings, like the pre-levels bypass badge).
|
||||
if (permissionMode === "off") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-pill-btn composer-pill-permissions"
|
||||
data-pill-label={active.label}
|
||||
data-active={fullAccess ? "true" : "false"}
|
||||
data-variant={fullAccess ? "danger" : undefined}
|
||||
aria-label="Permission level for tool calls"
|
||||
title={`${active.label}: ${active.description}`}
|
||||
>
|
||||
{/* The icon doubles as an off switch (mirrors the MCP pill): hover
|
||||
swaps it to an X; clicking it turns bypass permissions Off (no
|
||||
prompts, sandbox on) without opening the menu. In compact
|
||||
icon-only mode the glyph is the whole button, so clicks fall
|
||||
through and open the menu instead. */}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Turn off bypass permissions"
|
||||
tabIndex={-1}
|
||||
onPointerDown={(e) => {
|
||||
if (e.currentTarget.closest('[data-pill-compact="true"]')) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.currentTarget.closest('[data-pill-compact="true"]')) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
setPermissionMode("off");
|
||||
}}
|
||||
className="composer-pill-glyph cursor-pointer"
|
||||
>
|
||||
<ActiveIcon className="size-[15px]" strokeWidth={2} />
|
||||
<XIcon className="composer-pill-x" />
|
||||
</span>
|
||||
<span>{active.label}</span>
|
||||
<HugeiconsIcon
|
||||
icon={ChevronDownStandardIcon}
|
||||
strokeWidth={1.5}
|
||||
className="composer-pill-caret size-[15px]"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side={side}
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
avoidCollisions={true}
|
||||
className="unsloth-plus-menu w-[300px]"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
How should tool calls be approved?
|
||||
</DropdownMenuLabel>
|
||||
<PermissionModeMenuItems
|
||||
// Defer past the menu-close focus restoration (see PermissionModeDropdown).
|
||||
onRequestFullAccess={() =>
|
||||
setTimeout(() => setBypassConfirmOpen(true), 0)
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +48,6 @@ import {
|
|||
Image03Icon,
|
||||
McpServerIcon,
|
||||
PencilRulerIcon,
|
||||
ShieldBanIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -62,6 +61,7 @@ import {
|
|||
import { listPromptEntries, type PromptEntry } from "./api/prompts-api";
|
||||
import { McpComposerButton } from "./mcp-composer-button";
|
||||
import { BypassPermissionsMenuItem } from "./bypass-permissions-menu-item";
|
||||
import { PermissionModeComposerPill } from "./permission-mode-select";
|
||||
import { reasoningCapsFromLoad } from "./lib/apply-inference-status-to-store";
|
||||
import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button";
|
||||
import { NewProjectDialog } from "./components/new-project-dialog";
|
||||
|
|
@ -510,6 +510,7 @@ export function SharedComposer({
|
|||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
|
||||
const setMcpEnabledForChat = useChatRuntimeStore(
|
||||
(s) => s.setMcpEnabledForChat,
|
||||
|
|
@ -529,10 +530,6 @@ export function SharedComposer({
|
|||
const setWebFetchToolsEnabled = useChatRuntimeStore(
|
||||
(s) => s.setWebFetchToolsEnabled,
|
||||
);
|
||||
const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions);
|
||||
const setBypassPermissions = useChatRuntimeStore(
|
||||
(s) => s.setBypassPermissions,
|
||||
);
|
||||
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
|
||||
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
|
||||
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
||||
|
|
@ -685,9 +682,12 @@ export function SharedComposer({
|
|||
const ragDisabled = modelLoaded && (isExternalModel || !supportsTools);
|
||||
const showRagPill = !isExternalModel;
|
||||
// Above 4 pills, collapse to icons only to cut clutter. Compare, Search and
|
||||
// Code always show; the rest are conditional.
|
||||
// Code always show; the permission pill shows in every mode except "off"
|
||||
// (it renders null there); the rest are conditional.
|
||||
const permissionPillVisible = permissionMode !== "off";
|
||||
const pillsCompact =
|
||||
3 +
|
||||
(permissionPillVisible ? 1 : 0) +
|
||||
(showImagePill ? 1 : 0) +
|
||||
(showRagPill && ragEnabled && !ragDisabled ? 1 : 0) +
|
||||
(showWebFetchPill ? 1 : 0) +
|
||||
|
|
@ -1656,29 +1656,10 @@ export function SharedComposer({
|
|||
</PillGlyph>
|
||||
<span>Compare</span>
|
||||
</button>
|
||||
{/* Bypass sits immediately after Compare and ahead of every other
|
||||
tool pill (Search, Code, ...) so the active danger state reads
|
||||
first; only Compare outranks it. */}
|
||||
{bypassPermissions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBypassPermissions(false)}
|
||||
className="composer-pill-btn"
|
||||
data-active="true"
|
||||
data-variant="danger"
|
||||
aria-label="Disable Bypass permissions"
|
||||
title="Bypass permissions is on (no confirmation, no sandbox). Click to turn off."
|
||||
>
|
||||
<PillGlyph>
|
||||
<HugeiconsIcon
|
||||
icon={ShieldBanIcon}
|
||||
strokeWidth={2}
|
||||
className="size-[15px]"
|
||||
/>
|
||||
</PillGlyph>
|
||||
<span>Bypass permissions</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Permission-level pill sits immediately after Compare and ahead
|
||||
of every other tool pill (Search, Code, ...) so the Full access
|
||||
danger state reads first; only Compare outranks it. */}
|
||||
<PermissionModeComposerPill side="top" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={searchDisabled}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ export const CHAT_SHOW_ALL_QUANTIZATIONS_KEY =
|
|||
export const MODELS_FIT_ON_DEVICE_ONLY_KEY =
|
||||
"unsloth_models_fit_on_device_only";
|
||||
export const CHAT_BYPASS_PERMISSIONS_KEY = "unsloth_chat_bypass_permissions";
|
||||
export const CHAT_PERMISSION_MODE_KEY = "unsloth_chat_permission_mode";
|
||||
|
||||
/**
|
||||
* Permission level for local tool calls:
|
||||
* - "ask": always ask before every tool call runs.
|
||||
* - "auto" ("Approve for me"): only ask for calls the backend detects as
|
||||
* potentially unsafe; read-only calls run immediately. Sandbox stays on.
|
||||
* - "off": never ask; tool calls run automatically inside the sandbox
|
||||
* (the original default before permission levels existed).
|
||||
* - "full" ("Full access"): no confirmations and the python/terminal sandbox
|
||||
* is disabled. Session-only; never restored from storage.
|
||||
*/
|
||||
export type PermissionMode = "ask" | "auto" | "off" | "full";
|
||||
export const CHAT_WEB_FETCH_TOOLS_ENABLED_KEY =
|
||||
"unsloth_chat_web_fetch_tools_enabled";
|
||||
export const CHAT_RAG_SOURCE_KEY = "unsloth_chat_rag_source";
|
||||
|
|
@ -319,8 +332,10 @@ export function loadOptionalBool(key: string): boolean | null {
|
|||
/**
|
||||
* Resolve the web-search / code-execution pill state to apply when a model
|
||||
* loads. Honors the user's persisted preference so a tool-capable model never
|
||||
* re-enables a pill the user turned off; falls back to the model's capability
|
||||
* only when no preference has been expressed.
|
||||
* re-enables a pill the user turned off, and never re-disables one they turned
|
||||
* on. When no preference has been expressed the pills stay off: tool execution
|
||||
* is opt-in, so the person enables it with a click rather than a tool-capable
|
||||
* model turning it on for them.
|
||||
*/
|
||||
export function resolveToolsEnabledOnLoad(supportsTools: boolean): {
|
||||
toolsEnabled: boolean;
|
||||
|
|
@ -328,8 +343,8 @@ export function resolveToolsEnabledOnLoad(supportsTools: boolean): {
|
|||
} {
|
||||
if (!supportsTools) return { toolsEnabled: false, codeToolsEnabled: false };
|
||||
return {
|
||||
toolsEnabled: loadOptionalBool(CHAT_TOOLS_ENABLED_KEY) ?? true,
|
||||
codeToolsEnabled: loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY) ?? true,
|
||||
toolsEnabled: loadOptionalBool(CHAT_TOOLS_ENABLED_KEY) ?? false,
|
||||
codeToolsEnabled: loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -342,6 +357,37 @@ function saveBool(key: string, value: boolean): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "full" is intentionally not restorable: it disables the sandbox and every
|
||||
* confirmation gate, so it must be re-enabled (through the warning dialog)
|
||||
* each session. First run falls back to the legacy "Confirm tool calls"
|
||||
* toggle so existing users keep their behavior (on -> ask, explicitly
|
||||
* off -> "off", i.e. no prompts); fresh installs default to "auto".
|
||||
*/
|
||||
function loadPermissionMode(): PermissionMode {
|
||||
if (!canUseStorage()) return "auto";
|
||||
try {
|
||||
const raw = localStorage.getItem(CHAT_PERMISSION_MODE_KEY);
|
||||
if (raw === "ask" || raw === "auto" || raw === "off") return raw;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const legacyConfirm = loadOptionalBool(CHAT_CONFIRM_TOOL_CALLS_KEY);
|
||||
if (legacyConfirm === null) return "auto";
|
||||
return legacyConfirm ? "ask" : "off";
|
||||
}
|
||||
|
||||
function savePermissionMode(mode: PermissionMode): void {
|
||||
if (!canUseStorage() || mode === "full") return;
|
||||
try {
|
||||
localStorage.setItem(CHAT_PERMISSION_MODE_KEY, mode);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const INITIAL_PERMISSION_MODE: PermissionMode = loadPermissionMode();
|
||||
|
||||
function loadString(key: string, fallback: string): string {
|
||||
if (!canUseStorage()) return fallback;
|
||||
try {
|
||||
|
|
@ -514,7 +560,11 @@ export function isPendingGguf(pending: PendingModelSelection | null): boolean {
|
|||
* wrong file. */
|
||||
export function pendingSelectionMatches(
|
||||
pending: PendingModelSelection | null,
|
||||
pick: { id: string; ggufVariant?: string | null; nativePathToken?: string | null },
|
||||
pick: {
|
||||
id: string;
|
||||
ggufVariant?: string | null;
|
||||
nativePathToken?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
pending != null &&
|
||||
|
|
@ -615,8 +665,15 @@ type ChatRuntimeStore = {
|
|||
* Bypass Permissions: when on, tool calls run with no confirmation gate
|
||||
* AND the python/terminal execution sandbox is disabled on the backend
|
||||
* (secrets are still stripped). Takes precedence over confirmToolCalls.
|
||||
* Kept in sync with permissionMode ("full" <=> true).
|
||||
*/
|
||||
bypassPermissions: boolean;
|
||||
/**
|
||||
* Permission level. Single source of truth for the bypass dropdowns;
|
||||
* bypassPermissions and confirmToolCalls mirror it so legacy call sites
|
||||
* keep working. "full" is session-only (never persisted).
|
||||
*/
|
||||
permissionMode: PermissionMode;
|
||||
/** Whether the "Enable Bypass Permissions?" warning dialog is open. Lifted out
|
||||
* of the composer menu so confirming/cancelling it doesn't leave the menu frozen. */
|
||||
bypassConfirmOpen: boolean;
|
||||
|
|
@ -759,6 +816,7 @@ type ChatRuntimeStore = {
|
|||
setMcpEnabledForChat: (enabled: boolean) => void;
|
||||
setConfirmToolCalls: (enabled: boolean) => void;
|
||||
setBypassPermissions: (enabled: boolean) => void;
|
||||
setPermissionMode: (mode: PermissionMode) => void;
|
||||
setBypassConfirmOpen: (open: boolean) => void;
|
||||
allowToolAlways: (sessionId: string, toolName: string) => void;
|
||||
setToolConfirmation: (
|
||||
|
|
@ -1081,11 +1139,15 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
false,
|
||||
),
|
||||
mcpEnabledForChat: loadBool(CHAT_MCP_ENABLED_KEY, false),
|
||||
confirmToolCalls: loadBool(CHAT_CONFIRM_TOOL_CALLS_KEY, false),
|
||||
// Mirrors permissionMode (gate requested for ask/auto) so both controls
|
||||
// agree on load.
|
||||
confirmToolCalls:
|
||||
INITIAL_PERMISSION_MODE === "ask" || INITIAL_PERMISSION_MODE === "auto",
|
||||
// Never restore Bypass Permissions from storage: it disables the sandbox and
|
||||
// the confirmation gate, so it must be re-enabled (through the warning
|
||||
// dialog) each session rather than silently reactivating on reload.
|
||||
bypassPermissions: false,
|
||||
permissionMode: INITIAL_PERMISSION_MODE,
|
||||
bypassConfirmOpen: false,
|
||||
alwaysAllowToolsBySession: new Map<string, Set<string>>(),
|
||||
toolConfirmations: {},
|
||||
|
|
@ -1453,14 +1515,53 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
return { mcpEnabledForChat };
|
||||
}),
|
||||
setConfirmToolCalls: (confirmToolCalls) =>
|
||||
set(() => {
|
||||
set((state) => {
|
||||
saveBool(CHAT_CONFIRM_TOOL_CALLS_KEY, confirmToolCalls);
|
||||
return { confirmToolCalls };
|
||||
// The legacy toggle is a view over the permission level: on -> "ask",
|
||||
// off -> "off" (no prompts). While "full" is active the level is left
|
||||
// alone (the toggle is disabled in the UI anyway).
|
||||
if (state.permissionMode === "full") return { confirmToolCalls };
|
||||
const permissionMode: PermissionMode = confirmToolCalls ? "ask" : "off";
|
||||
savePermissionMode(permissionMode);
|
||||
return { confirmToolCalls, permissionMode };
|
||||
}),
|
||||
setPermissionMode: (permissionMode) =>
|
||||
set(() => {
|
||||
// "full" is session-only (never persisted, see init); ask/auto/off
|
||||
// persist and keep the legacy confirm toggle in sync (the gate is
|
||||
// requested for both ask and auto).
|
||||
savePermissionMode(permissionMode);
|
||||
if (permissionMode === "full") {
|
||||
// Full access sends confirm_tool_calls=false; keep the store flag in
|
||||
// sync so response metadata does not report confirmations as enabled.
|
||||
return { permissionMode, bypassPermissions: true, confirmToolCalls: false };
|
||||
}
|
||||
const confirmToolCalls =
|
||||
permissionMode === "ask" || permissionMode === "auto";
|
||||
saveBool(CHAT_CONFIRM_TOOL_CALLS_KEY, confirmToolCalls);
|
||||
return { permissionMode, bypassPermissions: false, confirmToolCalls };
|
||||
}),
|
||||
setBypassPermissions: (bypassPermissions) =>
|
||||
// Deliberately not persisted (see init): a reload must not silently keep
|
||||
// the sandbox/confirmation bypass active without re-accepting the warning.
|
||||
set(() => ({ bypassPermissions })),
|
||||
// Turning bypass off returns to the last persisted ask/auto level.
|
||||
set(() => {
|
||||
if (bypassPermissions) {
|
||||
// Full access never prompts; mirror confirm_tool_calls=false in the
|
||||
// store so metadata does not report confirmations as enabled.
|
||||
return {
|
||||
bypassPermissions,
|
||||
permissionMode: "full" as PermissionMode,
|
||||
confirmToolCalls: false,
|
||||
};
|
||||
}
|
||||
const permissionMode = loadPermissionMode();
|
||||
return {
|
||||
bypassPermissions,
|
||||
permissionMode,
|
||||
confirmToolCalls: permissionMode === "ask" || permissionMode === "auto",
|
||||
};
|
||||
}),
|
||||
setBypassConfirmOpen: (bypassConfirmOpen) =>
|
||||
set(() => ({ bypassConfirmOpen })),
|
||||
allowToolAlways: (sessionId, toolName) =>
|
||||
|
|
|
|||
|
|
@ -349,6 +349,15 @@ export interface OpenAIChatCompletionsRequest {
|
|||
mcp_enabled?: boolean;
|
||||
/** Local models + enable_tools only. */
|
||||
confirm_tool_calls?: boolean;
|
||||
/**
|
||||
* Local models + enable_tools only. Gate level for local tool calls: "ask"
|
||||
* prompts on every call, "auto" prompts only on calls flagged unsafe, "off"
|
||||
* never prompts, "full" never prompts and drops the sandbox. Unset behaves
|
||||
* as "ask".
|
||||
*/
|
||||
permission_mode?: "ask" | "auto" | "off" | "full";
|
||||
/** Local models + enable_tools only. Full-access escape hatch. */
|
||||
bypass_permissions?: boolean;
|
||||
/** `kb_id` is exclusive; otherwise project and thread scopes may combine. */
|
||||
rag_scope?: {
|
||||
kb_id?: string;
|
||||
|
|
|
|||
|
|
@ -1527,16 +1527,3 @@ html.dark .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
|
|||
body[data-scroll-locked] .hub-modal-pe-guard {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Edge fades off (Appearance setting): hub scroll dissolves become thin
|
||||
divider lines. */
|
||||
html.no-edge-fades .hub-page .hub-detail-bar::after {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
html.no-edge-fades .hub-page .hub-scroll-fade {
|
||||
background: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -846,17 +846,6 @@ export function CodeFontSizeRow() {
|
|||
);
|
||||
}
|
||||
|
||||
export function EdgeFadesSwitch() {
|
||||
const edgeFades = useAppearanceCustomStore((s) => s.customization.edgeFades);
|
||||
const patch = useAppearanceCustomStore((s) => s.patch);
|
||||
return (
|
||||
<Switch
|
||||
checked={edgeFades}
|
||||
onCheckedChange={(checked) => patch({ edgeFades: checked })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FontSmoothingSwitch() {
|
||||
const fontSmoothing = useAppearanceCustomStore(
|
||||
(s) => s.customization.fontSmoothing,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.appearance.custom.uiFontSize.label",
|
||||
"settings.appearance.custom.codeFontSize.label",
|
||||
"settings.appearance.custom.fontSmoothing.label",
|
||||
"settings.appearance.custom.edgeFades.label",
|
||||
"settings.appearance.layout.compactSidebar",
|
||||
"settings.appearance.sidebarMenu.title",
|
||||
"settings.appearance.sidebarMenu.darkModeToggle",
|
||||
|
|
|
|||
|
|
@ -111,8 +111,6 @@ export type AppearanceCustomization = {
|
|||
reduceMotion: ReduceMotionSetting;
|
||||
/** true = the app default (antialiased). */
|
||||
fontSmoothing: boolean;
|
||||
/** true = content dissolves at panel edges; false = thin divider lines. */
|
||||
edgeFades: boolean;
|
||||
/** Order and visibility of the optional sidebar profile menu items. */
|
||||
sidebarMenu: SidebarMenuItemPref[];
|
||||
};
|
||||
|
|
@ -136,7 +134,6 @@ export const DEFAULT_CUSTOMIZATION: AppearanceCustomization = {
|
|||
pointerCursors: false,
|
||||
reduceMotion: "system",
|
||||
fontSmoothing: true,
|
||||
edgeFades: true,
|
||||
sidebarMenu: SIDEBAR_MENU_ITEM_IDS.map((id) => ({
|
||||
id,
|
||||
visible: SIDEBAR_MENU_DEFAULT_VISIBLE[id],
|
||||
|
|
@ -275,7 +272,6 @@ export function sanitizeCustomization(value: unknown): AppearanceCustomization {
|
|||
? source.reduceMotion
|
||||
: "system",
|
||||
fontSmoothing: source.fontSmoothing !== false,
|
||||
edgeFades: source.edgeFades !== false,
|
||||
sidebarMenu: sanitizeSidebarMenu(source.sidebarMenu),
|
||||
};
|
||||
}
|
||||
|
|
@ -548,9 +544,6 @@ export function applyCustomizationToDocument(
|
|||
// the media rules in index.css skip html.force-motion.
|
||||
el.classList.toggle("force-motion", c.reduceMotion === "off");
|
||||
el.classList.toggle("no-font-smoothing", !c.fontSmoothing);
|
||||
// Off swaps the scroll-edge dissolves for thin divider lines (index.css
|
||||
// and hub.css key their fade rules off this class).
|
||||
el.classList.toggle("no-edge-fades", !c.edgeFades);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
CodeFontRow,
|
||||
CodeFontSizeRow,
|
||||
ContrastSliderRow,
|
||||
EdgeFadesSwitch,
|
||||
FontSmoothingSwitch,
|
||||
HeadingFontRow,
|
||||
PointerCursorsSwitch,
|
||||
|
|
@ -142,12 +141,6 @@ export function AppearanceTab() {
|
|||
>
|
||||
<FontSmoothingSwitch />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.custom.edgeFades.label")}
|
||||
description={t("settings.appearance.custom.edgeFades.description")}
|
||||
>
|
||||
<EdgeFadesSwitch />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={t("settings.appearance.layout.compactSidebar")}
|
||||
description={t(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePlatformStore } from "@/config/env";
|
||||
import { resetOnboardingDone } from "@/features/auth";
|
||||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import { PermissionModeDropdown, useChatRuntimeStore } from "@/features/chat";
|
||||
import { openModelsDir } from "@/features/native-intents";
|
||||
import { emitTrainingRunsChanged } from "@/features/training";
|
||||
import {
|
||||
|
|
@ -80,6 +80,10 @@ const PREFS_KEYS: string[] = [
|
|||
"unsloth_settings_active_tab",
|
||||
// Chat runtime prefs
|
||||
"unsloth_chat_auto_title",
|
||||
"unsloth_chat_permission_mode",
|
||||
// Legacy confirm key: loadPermissionMode falls back to it, so clear both or
|
||||
// a reset would restore the old level instead of the fresh default.
|
||||
"unsloth_chat_confirm_tool_calls",
|
||||
"unsloth_hf_token",
|
||||
"unsloth_auto_heal_tool_calls",
|
||||
"unsloth_nudge_tool_calls",
|
||||
|
|
@ -583,6 +587,15 @@ export function GeneralTab() {
|
|||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.general.permissions.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.permissions.bypassLabel")}
|
||||
description={t("settings.general.permissions.bypassDescription")}
|
||||
>
|
||||
<PermissionModeDropdown />
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.general.notifications.sectionTitle")}>
|
||||
<SettingsRow
|
||||
label={t("settings.general.notifications.showLlamaUpdates")}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ export const en = {
|
|||
revoked: "All preview links revoked",
|
||||
revokeError: "Couldn't revoke preview links",
|
||||
},
|
||||
permissions: {
|
||||
sectionTitle: "Permissions",
|
||||
bypassLabel: "Bypass permissions",
|
||||
bypassDescription:
|
||||
"How Unsloth approves chat tool calls (terminal, python, web, MCP) before they run. Full access disables approvals and the code sandbox.",
|
||||
},
|
||||
notifications: {
|
||||
sectionTitle: "Notifications",
|
||||
showLlamaUpdates: "llama.cpp update notifications",
|
||||
|
|
@ -340,11 +346,6 @@ export const en = {
|
|||
label: "Font smoothing",
|
||||
description: "Use smoothed font anti-aliasing.",
|
||||
},
|
||||
edgeFades: {
|
||||
label: "Edge fades",
|
||||
description:
|
||||
"Fade content at panel edges. Off shows a thin divider line instead.",
|
||||
},
|
||||
contrast: {
|
||||
label: "Contrast",
|
||||
description: "Strength of borders and secondary text.",
|
||||
|
|
|
|||
|
|
@ -321,10 +321,6 @@ export const ja = {
|
|||
label: "フォントスムージング",
|
||||
description: "滑らかなアンチエイリアスを使用します。",
|
||||
},
|
||||
edgeFades: {
|
||||
label: "エッジのフェード",
|
||||
description: "パネル端でコンテンツをフェードします。オフにすると細い区切り線を表示します。",
|
||||
},
|
||||
contrast: {
|
||||
label: "コントラスト",
|
||||
description: "枠線と補助テキストの強さ。",
|
||||
|
|
|
|||
|
|
@ -344,10 +344,6 @@ export const ptBR = {
|
|||
label: "Suavização de fonte",
|
||||
description: "Usar anti-aliasing suavizado nas fontes.",
|
||||
},
|
||||
edgeFades: {
|
||||
label: "Esmaecimento nas bordas",
|
||||
description: "Esmaece o conteúdo nas bordas dos painéis. Desligado mostra uma linha divisória fina.",
|
||||
},
|
||||
contrast: {
|
||||
label: "Contraste",
|
||||
description: "Intensidade das bordas e do texto secundário.",
|
||||
|
|
|
|||
|
|
@ -343,10 +343,6 @@ export const zhCN = {
|
|||
label: "字体平滑",
|
||||
description: "使用平滑的字体抗锯齿。",
|
||||
},
|
||||
edgeFades: {
|
||||
label: "边缘淡出",
|
||||
description: "在面板边缘淡出内容。关闭后显示细分隔线。",
|
||||
},
|
||||
contrast: {
|
||||
label: "对比度",
|
||||
description: "边框和次要文本的强度。",
|
||||
|
|
|
|||
|
|
@ -93,47 +93,6 @@
|
|||
);
|
||||
mask-image: linear-gradient(to bottom, #000 calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
/* Edge fades off (Appearance setting): every dissolve above becomes a
|
||||
thin divider line at the same boundary. */
|
||||
html.no-edge-fades .sidebar-scroll-fade.is-scrolled,
|
||||
html.no-edge-fades .model-list-scroll.is-scrolled,
|
||||
html.no-edge-fades .model-list-scroll.is-bottom-faded,
|
||||
html.no-edge-fades .model-list-scroll.is-scrolled.is-bottom-faded,
|
||||
html.no-edge-fades .rag-docs-bottom-fade {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
html.no-edge-fades .sidebar-scroll-fade.is-scrolled {
|
||||
border-top: 1px solid var(--sidebar-border);
|
||||
}
|
||||
html.no-edge-fades .sidebar-bottom-fade::after {
|
||||
height: 1px;
|
||||
background: var(--sidebar-border);
|
||||
}
|
||||
html.no-edge-fades .model-list-scroll.is-scrolled {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
html.no-edge-fades .model-list-scroll.is-bottom-faded {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
html.no-edge-fades .rag-docs-bottom-fade {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* Chat thread: gradient wash above the composer becomes a hairline at
|
||||
its top edge; the chat header's under-fade becomes a hairline too.
|
||||
!important beats the Tailwind gradient/mask utilities on the nodes. */
|
||||
html.no-edge-fades .thread-bottom-fade {
|
||||
background: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
html.no-edge-fades .chat-header-fade {
|
||||
background: none !important;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -1483,6 +1442,15 @@ html[data-chat-font] .aui-root {
|
|||
.composer-pill-btn[data-active="true"] {
|
||||
color: var(--primary);
|
||||
}
|
||||
/* Permission-level pill: higher-contrast grey than the resting pills so
|
||||
the active level stays legible (darker in light mode, lighter in dark).
|
||||
Full access keeps the danger yellow below. */
|
||||
.composer-pill-btn.composer-pill-permissions:not([data-variant="danger"]) {
|
||||
color: color-mix(in oklab, var(--foreground) 60%, transparent);
|
||||
}
|
||||
.dark .composer-pill-btn.composer-pill-permissions:not([data-variant="danger"]) {
|
||||
color: color-mix(in oklab, var(--foreground) 72%, transparent);
|
||||
}
|
||||
/* Bypass permissions badge: bright yellow text, no resting fill; the
|
||||
rounded hover pill picks up the yellow accent like other toggles. */
|
||||
.composer-pill-btn[data-variant="danger"] {
|
||||
|
|
|
|||
|
|
@ -4086,7 +4086,9 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|||
}
|
||||
Write-Host " $Rule" -ForegroundColor DarkGray
|
||||
}
|
||||
step "launch" "unsloth studio -H 0.0.0.0 -p 8888"
|
||||
step "launch" "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
Write-Host ""
|
||||
|
||||
# Match studio/setup.sh: exit non-zero for degraded llama.cpp when called
|
||||
|
|
|
|||
|
|
@ -1975,8 +1975,8 @@ else
|
|||
else
|
||||
printf " ${C_DIM}%-15s${C_OK}%s${C_RST}\n" "launch" "unsloth studio -p 8888"
|
||||
fi
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
|
||||
printf " ${C_DIM}%-15s%s${C_RST}\n" "" "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
|
|
|||
275
unsloth_cli/codex_fallback_prompt.md
Normal file
275
unsloth_cli/codex_fallback_prompt.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
|
||||
|
||||
Your capabilities:
|
||||
|
||||
- Receive user prompts and other context provided by the harness, such as files in the workspace.
|
||||
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
|
||||
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
|
||||
|
||||
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
|
||||
|
||||
# How you work
|
||||
|
||||
## Personality
|
||||
|
||||
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
|
||||
|
||||
# AGENTS.md spec
|
||||
- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.
|
||||
- These files are a way for humans to give you (the agent) instructions or tips for working within the container.
|
||||
- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.
|
||||
- Instructions in AGENTS.md files:
|
||||
- The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.
|
||||
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
|
||||
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
|
||||
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
|
||||
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
|
||||
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
|
||||
|
||||
## Responsiveness
|
||||
|
||||
### Preamble messages
|
||||
|
||||
Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:
|
||||
|
||||
- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
|
||||
- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).
|
||||
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.
|
||||
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
|
||||
- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- “I’ve explored the repo; now checking the API route definitions.”
|
||||
- “Next, I’ll patch the config and update the related tests.”
|
||||
- “I’m about to scaffold the CLI commands and helper functions.”
|
||||
- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.”
|
||||
- “Config’s looking tidy. Next up is patching helpers to keep things in sync.”
|
||||
- “Finished poking at the DB gateway. I will now chase down error handling.”
|
||||
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
|
||||
- “Spotted a clever caching util; now hunting where it gets used.”
|
||||
|
||||
## Planning
|
||||
|
||||
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.
|
||||
|
||||
Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
|
||||
|
||||
Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
|
||||
|
||||
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
|
||||
|
||||
Use a plan when:
|
||||
|
||||
- The task is non-trivial and will require multiple actions over a long time horizon.
|
||||
- There are logical phases or dependencies where sequencing matters.
|
||||
- The work has ambiguity that benefits from outlining high-level goals.
|
||||
- You want intermediate checkpoints for feedback and validation.
|
||||
- When the user asked you to do more than one thing in a single prompt
|
||||
- The user has asked you to use the plan tool (aka "TODOs")
|
||||
- You generate additional steps while working, and plan to do them before yielding to the user
|
||||
|
||||
### Examples
|
||||
|
||||
**High-quality plans**
|
||||
|
||||
Example 1:
|
||||
|
||||
1. Add CLI entry with file args
|
||||
2. Parse Markdown via CommonMark library
|
||||
3. Apply semantic HTML template
|
||||
4. Handle code blocks, images, links
|
||||
5. Add error handling for invalid files
|
||||
|
||||
Example 2:
|
||||
|
||||
1. Define CSS variables for colors
|
||||
2. Add toggle with localStorage state
|
||||
3. Refactor components to use variables
|
||||
4. Verify all views for readability
|
||||
5. Add smooth theme-change transition
|
||||
|
||||
Example 3:
|
||||
|
||||
1. Set up Node.js + WebSocket server
|
||||
2. Add join/leave broadcast events
|
||||
3. Implement messaging with timestamps
|
||||
4. Add usernames + mention highlighting
|
||||
5. Persist messages in lightweight DB
|
||||
6. Add typing indicators + unread count
|
||||
|
||||
**Low-quality plans**
|
||||
|
||||
Example 1:
|
||||
|
||||
1. Create CLI tool
|
||||
2. Add Markdown parser
|
||||
3. Convert to HTML
|
||||
|
||||
Example 2:
|
||||
|
||||
1. Add dark mode toggle
|
||||
2. Save preference
|
||||
3. Make styles look good
|
||||
|
||||
Example 3:
|
||||
|
||||
1. Create single-file HTML game
|
||||
2. Run quick sanity check
|
||||
3. Summarize usage instructions
|
||||
|
||||
If you need to write a plan, only write high quality plans, not low quality ones.
|
||||
|
||||
## Task execution
|
||||
|
||||
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
|
||||
|
||||
You MUST adhere to the following criteria when solving queries:
|
||||
|
||||
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
|
||||
- Analyzing code for vulnerabilities is allowed.
|
||||
- Showing user code and tool call details is allowed.
|
||||
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
|
||||
|
||||
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
|
||||
|
||||
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
|
||||
- Avoid unneeded complexity in your solution.
|
||||
- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
|
||||
- Update documentation as necessary.
|
||||
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
|
||||
- Use `git log` and `git blame` to search the history of the codebase if additional context is required.
|
||||
- NEVER add copyright or license headers unless specifically requested.
|
||||
- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.
|
||||
- Do not `git commit` your changes or create new git branches unless explicitly requested.
|
||||
- Do not add inline comments within code unless explicitly requested.
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.
|
||||
|
||||
Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.
|
||||
|
||||
For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
|
||||
|
||||
Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance:
|
||||
|
||||
- When running in the non-interactive approval mode **never**, proactively run tests, lint and do whatever you need to ensure you've completed the task.
|
||||
- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.
|
||||
- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.
|
||||
|
||||
## Ambition vs. precision
|
||||
|
||||
For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.
|
||||
|
||||
If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.
|
||||
|
||||
You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.
|
||||
|
||||
## Sharing progress updates
|
||||
|
||||
For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next.
|
||||
|
||||
Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why.
|
||||
|
||||
The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.
|
||||
|
||||
You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.
|
||||
|
||||
The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path.
|
||||
|
||||
If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.
|
||||
|
||||
Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.
|
||||
|
||||
### Final answer structure and style guidelines
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
**Section Headers**
|
||||
|
||||
- Use only when they improve clarity — they are not mandatory for every answer.
|
||||
- Choose descriptive names that fit the content
|
||||
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
|
||||
- Leave no blank line before the first bullet under a header.
|
||||
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
|
||||
|
||||
**Bullets**
|
||||
|
||||
- Use `-` followed by a space for every bullet.
|
||||
- Merge related points when possible; avoid a bullet for every trivial detail.
|
||||
- Keep bullets to one line unless breaking for clarity is unavoidable.
|
||||
- Group into short lists (4–6 bullets) ordered by importance.
|
||||
- Use consistent keyword phrasing and formatting across sections.
|
||||
|
||||
**Monospace**
|
||||
|
||||
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
|
||||
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
|
||||
- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).
|
||||
|
||||
**File References**
|
||||
When referencing files in your response, make sure to include the relevant start line and always follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
|
||||
**Structure**
|
||||
|
||||
- Place related bullets together; don’t mix unrelated concepts in the same section.
|
||||
- Order sections from general → specific → supporting info.
|
||||
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
|
||||
- Match structure to complexity:
|
||||
- Multi-part or detailed results → use clear headers and grouped bullets.
|
||||
- Simple results → minimal headers, possibly just a short list or paragraph.
|
||||
|
||||
**Tone**
|
||||
|
||||
- Keep the voice collaborative and natural, like a coding partner handing off work.
|
||||
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
|
||||
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
|
||||
- Keep descriptions self-contained; don’t refer to “above” or “below”.
|
||||
- Use parallel structure in lists for consistency.
|
||||
|
||||
**Don’t**
|
||||
|
||||
- Don’t use literal words “bold” or “monospace” in the content.
|
||||
- Don’t nest bullets or create deep hierarchies.
|
||||
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
|
||||
- Don’t cram unrelated keywords into a single bullet; split for clarity.
|
||||
- Don’t let keyword lists run long — wrap or reformat for scanability.
|
||||
|
||||
Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
|
||||
|
||||
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
|
||||
|
||||
# Tool Guidelines
|
||||
|
||||
## Shell commands
|
||||
|
||||
When using the shell, you must adhere to the following guidelines:
|
||||
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
- Do not use python scripts to attempt to output larger chunks of a file.
|
||||
|
||||
## `update_plan`
|
||||
|
||||
A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.
|
||||
|
||||
To create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).
|
||||
|
||||
When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.
|
||||
|
||||
If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.
|
||||
238
unsloth_cli/commands/_password_prompt.py
Normal file
238
unsloth_cli/commands/_password_prompt.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Masked terminal password prompt for the first-exposure password change.
|
||||
|
||||
Mirror of ``studio/backend/auth/terminal_prompt.py`` -- keep the two in sync.
|
||||
The CLI parent cannot import the backend package outside the studio venv, so the
|
||||
reader is duplicated here (like the auth mirroring in ``commands/studio.py``).
|
||||
|
||||
Input echoes one ``*`` per character (unlike ``getpass``). All output goes to
|
||||
stderr so redirected stdout stays clean.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable, TextIO
|
||||
|
||||
# Keep in sync with studio/backend/models/auth.py ChangePasswordRequest
|
||||
# (new_password min_length) and studio/backend/auth/storage.py.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
# Env var that supplies the initial admin password non-interactively (mirror in
|
||||
# studio/backend/auth/terminal_prompt.py). Keep the name in sync.
|
||||
SUPPLIED_PASSWORD_ENV = "UNSLOTH_STUDIO_PASSWORD"
|
||||
|
||||
_BACKSPACE_CHARS = ("\x7f", "\x08")
|
||||
_SUBMIT_CHARS = ("\r", "\n")
|
||||
|
||||
|
||||
class _RestoreTtyOnSignals:
|
||||
"""Restore terminal attrs if SIGTERM/SIGHUP kills the prompt mid-read.
|
||||
|
||||
A finally block can't run when a signal terminates the process, leaving the
|
||||
shared terminal in cbreak/no-echo. Best-effort: no-op off the main thread or
|
||||
where the signals are absent.
|
||||
"""
|
||||
|
||||
def __init__(self, fd: int, old_attrs) -> None:
|
||||
self._fd = fd
|
||||
self._old_attrs = old_attrs
|
||||
self._previous: list = []
|
||||
|
||||
def __enter__(self) -> "_RestoreTtyOnSignals":
|
||||
import signal
|
||||
import termios
|
||||
|
||||
def _restore_and_reraise(signum, frame):
|
||||
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
signal.raise_signal(signum)
|
||||
|
||||
for name in ("SIGTERM", "SIGHUP"):
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
self._previous.append((sig, signal.signal(sig, _restore_and_reraise)))
|
||||
except (ValueError, OSError): # non-main thread / unsupported
|
||||
pass
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
import signal
|
||||
for sig, previous in self._previous:
|
||||
try:
|
||||
signal.signal(sig, previous)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _read_masked_posix(prompt: str, out: TextIO) -> str:
|
||||
import codecs
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd)
|
||||
out.write(prompt)
|
||||
out.flush()
|
||||
chars: list[str] = []
|
||||
try:
|
||||
with _RestoreTtyOnSignals(fd, old_attrs):
|
||||
# cbreak + ISIG off (mirrors terminal_prompt.py): with ISIG on,
|
||||
# Ctrl-Z would suspend mid-read and leave the shell no-echo before
|
||||
# the finally restores it. Ctrl-C/Ctrl-Z arrive as \x03/\x1a here.
|
||||
tty.setcbreak(fd)
|
||||
new_attrs = termios.tcgetattr(fd)
|
||||
new_attrs[3] &= ~termios.ISIG
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, new_attrs)
|
||||
# Decode byte-at-a-time with errors="replace" (mirrors
|
||||
# terminal_prompt.py): text-mode read(1) can raise UnicodeDecodeError
|
||||
# on a pasted non-UTF-8 password or yield a lone surrogate that later
|
||||
# crashes pbkdf2. os.read + incremental decoder maps bad bytes to
|
||||
# U+FFFD and continues.
|
||||
decoder = codecs.getincrementaldecoder(sys.stdin.encoding or "utf-8")("replace")
|
||||
submitted = False
|
||||
while not submitted:
|
||||
raw = os.read(fd, 1)
|
||||
if not raw: # stream ended mid-line: abort, don't submit
|
||||
raise EOFError
|
||||
# One byte can complete >1 char, so iterate over the decoder's output.
|
||||
for ch in decoder.decode(raw):
|
||||
if ch in _SUBMIT_CHARS:
|
||||
submitted = True
|
||||
break
|
||||
if ch == "\x03": # Ctrl-C (ISIG off: surfaces as a char)
|
||||
raise KeyboardInterrupt
|
||||
if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z
|
||||
if not chars:
|
||||
raise EOFError
|
||||
continue
|
||||
if ch in _BACKSPACE_CHARS:
|
||||
if chars:
|
||||
chars.pop()
|
||||
out.write("\b \b")
|
||||
out.flush()
|
||||
continue
|
||||
if ch < " ": # other control characters
|
||||
continue
|
||||
chars.append(ch)
|
||||
out.write("*")
|
||||
out.flush()
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _read_masked_windows(prompt: str, out: TextIO) -> str:
|
||||
import msvcrt
|
||||
|
||||
out.write(prompt)
|
||||
out.flush()
|
||||
chars: list[str] = []
|
||||
try:
|
||||
while True:
|
||||
ch = msvcrt.getwch()
|
||||
if ch in _SUBMIT_CHARS:
|
||||
break
|
||||
if ch == "\x03": # Ctrl-C: getwch swallows the signal, re-raise
|
||||
raise KeyboardInterrupt
|
||||
if ch in ("\x04", "\x1a"): # Ctrl-D / Ctrl-Z
|
||||
if not chars:
|
||||
raise EOFError
|
||||
continue
|
||||
if ch in ("\x00", "\xe0"): # function/arrow key: swallow the code
|
||||
msvcrt.getwch()
|
||||
continue
|
||||
if ch in _BACKSPACE_CHARS:
|
||||
if chars:
|
||||
chars.pop()
|
||||
out.write("\b \b")
|
||||
out.flush()
|
||||
continue
|
||||
if ch < " ":
|
||||
continue
|
||||
chars.append(ch)
|
||||
out.write("*")
|
||||
out.flush()
|
||||
finally:
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def read_masked(prompt: str, out: TextIO | None = None) -> str:
|
||||
"""Read one line with ``*`` echo. Raises KeyboardInterrupt on Ctrl-C and
|
||||
EOFError on Ctrl-D/Ctrl-Z at an empty prompt."""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
if os.name == "nt":
|
||||
return _read_masked_windows(prompt, out)
|
||||
return _read_masked_posix(prompt, out)
|
||||
|
||||
|
||||
def prompt_new_password(verify_current: Callable[[str], bool], out: TextIO | None = None) -> str:
|
||||
"""Prompt for a new admin password until a valid, confirmed one is given.
|
||||
|
||||
``verify_current`` returns True when the candidate equals the current stored
|
||||
password; such candidates are rejected. KeyboardInterrupt/EOFError propagate
|
||||
so the caller can abort the launch.
|
||||
"""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
while True:
|
||||
password = read_masked("New password: ", out)
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
out.write(f"Password must be at least {MIN_PASSWORD_LENGTH} characters. Try again.\n")
|
||||
out.flush()
|
||||
continue
|
||||
if verify_current(password):
|
||||
out.write("New password must differ from the current password. Try again.\n")
|
||||
out.flush()
|
||||
continue
|
||||
confirmation = read_masked("Confirm new password: ", out)
|
||||
if confirmation != password:
|
||||
out.write("Passwords do not match. Try again.\n")
|
||||
out.flush()
|
||||
continue
|
||||
return password
|
||||
|
||||
|
||||
def resolve_supplied_password(cli_value: "str | None", out: TextIO | None = None) -> "str | None":
|
||||
"""Resolve a non-interactive initial admin password, or None if unset.
|
||||
|
||||
Precedence: an explicit ``--password`` (literal ``-`` reads a line from
|
||||
stdin), then the ``UNSLOTH_STUDIO_PASSWORD`` env var; empty/omitted means off.
|
||||
A literal argv value is visible in the process list, so a note points at the
|
||||
env var or stdin instead. Mirror of the backend helper -- keep the two in sync.
|
||||
"""
|
||||
if out is None:
|
||||
out = sys.stderr
|
||||
if cli_value == "-":
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
return line.rstrip("\r\n") or None
|
||||
if cli_value:
|
||||
out.write(
|
||||
"Note: --password is visible in the process list and shell history; "
|
||||
f"prefer {SUPPLIED_PASSWORD_ENV} or --password - (stdin).\n"
|
||||
)
|
||||
out.flush()
|
||||
return cli_value
|
||||
return os.environ.get(SUPPLIED_PASSWORD_ENV) or None
|
||||
|
||||
|
||||
def validate_new_password(candidate: str, verify_current: Callable[[str], bool]) -> "str | None":
|
||||
"""Error message if ``candidate`` is unacceptable (too short or equal to the
|
||||
current password), else None. Same policy as the interactive loop."""
|
||||
if len(candidate) < MIN_PASSWORD_LENGTH:
|
||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
if verify_current(candidate):
|
||||
return "New password must differ from the current password."
|
||||
return None
|
||||
|
|
@ -170,6 +170,43 @@ def _hermes_install_hint() -> str:
|
|||
return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT
|
||||
|
||||
|
||||
def _hermes_resume_oneshot_args(args: list[str]) -> list[str]:
|
||||
"""Route resumed one-shot prompts through Hermes' session-aware chat command."""
|
||||
has_resume = any(
|
||||
arg in ("--resume", "-r", "--continue", "-c")
|
||||
or arg.startswith(("--resume=", "--continue="))
|
||||
or (len(arg) > 2 and arg.startswith(("-r", "-c")))
|
||||
for arg in args
|
||||
)
|
||||
if not has_resume:
|
||||
return args
|
||||
|
||||
rewritten = list(args)
|
||||
for index, arg in enumerate(rewritten):
|
||||
if arg in ("-z", "--oneshot"):
|
||||
rewritten[index] = "-q"
|
||||
elif len(arg) > 2 and arg.startswith("-z"):
|
||||
# argparse accepts attached short-option values (`-zPROMPT` and
|
||||
# `-z=PROMPT`); preserve the value byte-for-byte when switching to -q.
|
||||
rewritten[index] = f"-q{arg[2:]}"
|
||||
elif arg.startswith("--oneshot="):
|
||||
rewritten[index] = f"--query={arg.partition('=')[2]}"
|
||||
else:
|
||||
continue
|
||||
if any(item == "--usage-file" or item.startswith("--usage-file=") for item in args):
|
||||
raise typer.BadParameter(
|
||||
"Hermes cannot resume a one-shot session with --usage-file; remove that option."
|
||||
)
|
||||
prefix = ["chat", "-Q"]
|
||||
if "--yolo" not in rewritten:
|
||||
prefix.append("--yolo")
|
||||
if "--accept-hooks" not in rewritten:
|
||||
prefix.append("--accept-hooks")
|
||||
rewritten = prefix + rewritten
|
||||
return rewritten
|
||||
return args
|
||||
|
||||
|
||||
class LoadOptions(NamedTuple):
|
||||
"""Model-load knobs forwarded to /api/inference/load when --model triggers a load."""
|
||||
|
||||
|
|
@ -840,6 +877,60 @@ def _merge_codex_config(existing: str, base: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
# Keep custom-model behavior aligned with Codex's own unknown-model fallback. This
|
||||
# Apache-2.0 prompt is copied from openai/codex rust-v0.144.0 models-manager/prompt.md.
|
||||
_CODEX_FALLBACK_PROMPT = Path(__file__).parent.parent / "codex_fallback_prompt.md"
|
||||
_CODEX_MODEL_CATALOG_MIN_VERSION = (0, 110, 0)
|
||||
|
||||
|
||||
def _codex_supports_model_catalog() -> bool:
|
||||
executable = shutil.which("codex")
|
||||
if executable is None:
|
||||
# A --no-launch recipe may be copied to another machine; assume a current Codex.
|
||||
return True
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
[executable, "--version"], text = True, timeout = 10, stderr = subprocess.DEVNULL
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
|
||||
return bool(match) and tuple(int(part) for part in match.groups()) >= (
|
||||
_CODEX_MODEL_CATALOG_MIN_VERSION
|
||||
)
|
||||
|
||||
|
||||
def _codex_model_catalog(model: dict) -> dict:
|
||||
"""Return conservative metadata for a Studio model unknown to Codex's built-in catalog."""
|
||||
model_id = model["id"]
|
||||
window = model.get("context_length") or model.get("max_context_length")
|
||||
entry = {
|
||||
"slug": model_id,
|
||||
"display_name": model_id,
|
||||
"description": "Model served by Unsloth Studio",
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "default",
|
||||
"visibility": "none",
|
||||
"supported_in_api": True,
|
||||
"priority": 99,
|
||||
"availability_nux": None,
|
||||
"upgrade": None,
|
||||
"base_instructions": _CODEX_FALLBACK_PROMPT.read_text(encoding = "utf-8"),
|
||||
"supports_reasoning_summaries": False,
|
||||
"supports_reasoning_summary_parameter": False,
|
||||
"support_verbosity": False,
|
||||
"default_verbosity": None,
|
||||
"apply_patch_tool_type": None,
|
||||
"truncation_policy": {"mode": "bytes", "limit": 10_000},
|
||||
"supports_parallel_tool_calls": False,
|
||||
"experimental_supported_tools": [],
|
||||
}
|
||||
if window:
|
||||
entry["context_window"] = int(window)
|
||||
entry["max_context_window"] = int(window)
|
||||
return {"models": [entry]}
|
||||
|
||||
|
||||
def write_codex_config(base: str, model: dict, home: Path) -> None:
|
||||
home.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
|
|
@ -857,6 +948,16 @@ def write_codex_config(base: str, model: dict, home: Path) -> None:
|
|||
f'model_provider = "{_CODEX_PROFILE}"\n'
|
||||
f"model = {json.dumps(model['id'])}\n"
|
||||
)
|
||||
if _codex_supports_model_catalog() and _CODEX_FALLBACK_PROMPT.is_file():
|
||||
catalog = home / "model-catalog.json"
|
||||
catalog_text = json.dumps(_codex_model_catalog(model), indent = 2) + "\n"
|
||||
if not catalog.exists() or catalog.read_text(encoding = "utf-8") != catalog_text:
|
||||
catalog.write_text(catalog_text, encoding = "utf-8")
|
||||
typer.echo(f"Updated {catalog}")
|
||||
# Resolve relative to the profile file. This also survives WSL launching a Windows
|
||||
# Codex binary, where a Linux absolute path inside TOML would not be usable.
|
||||
profile_text += f"model_catalog_json = {json.dumps(catalog.name)}\n"
|
||||
|
||||
window = model.get("context_length") or model.get("max_context_length")
|
||||
if window:
|
||||
profile_text += f"model_context_window = {int(window)}\n"
|
||||
|
|
@ -875,6 +976,16 @@ def _wsl_windows_executable(command: list) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _wsl_windows_path(path: Path) -> str:
|
||||
try:
|
||||
translated = subprocess.check_output(["wslpath", "-w", str(path)], text = True).strip()
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
_fail(f"Could not translate WSL path {path}: {exc}")
|
||||
if not translated:
|
||||
_fail(f"Could not translate WSL path {path}")
|
||||
return translated
|
||||
|
||||
|
||||
def _looks_like_path(value: str) -> bool:
|
||||
# A var only wants the WSLENV /p flag if its value is a filesystem path: an
|
||||
# absolute POSIX path (/...), a UNC path (\\...), or a drive-qualified Windows
|
||||
|
|
@ -1184,6 +1295,7 @@ def write_openclaw_config(
|
|||
model: dict,
|
||||
path: Path,
|
||||
yolo: bool = False,
|
||||
workspace_path: Optional[str] = None,
|
||||
) -> None:
|
||||
config = _read_json_object(path)
|
||||
if config is None:
|
||||
|
|
@ -1208,8 +1320,23 @@ def write_openclaw_config(
|
|||
"models": [provider_model],
|
||||
}
|
||||
# Pin a default model, else OpenClaw drops into its setup agent ("no models available").
|
||||
defaults = _subdict(_subdict(config, "agents"), "defaults")
|
||||
agents = _subdict(config, "agents")
|
||||
defaults = _subdict(agents, "defaults")
|
||||
_subdict(defaults, "model")["primary"] = f"unsloth/{model['id']}"
|
||||
# OPENCLAW_STATE_DIR does not relocate the workspace. Keep it beside the managed
|
||||
# config so ephemeral launches avoid ~/.openclaw and persisted sessions retain it.
|
||||
workspace = path.parent / "workspace"
|
||||
workspace.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
||||
defaults["workspace"] = workspace_path or str(workspace)
|
||||
# Per-agent paths override agents.defaults.workspace and OPENCLAW_STATE_DIR. This
|
||||
# config is itself an isolated Unsloth copy, so remove stale explicit paths and let
|
||||
# OpenClaw resolve every listed agent beneath the managed defaults/state directory.
|
||||
agent_list = agents.get("list")
|
||||
if isinstance(agent_list, list):
|
||||
for agent_config in agent_list:
|
||||
if isinstance(agent_config, dict):
|
||||
agent_config.pop("workspace", None)
|
||||
agent_config.pop("agentDir", None)
|
||||
# Unauthenticated loopback gateway: without auth.mode=none the client won't open
|
||||
# the websocket. The daemon must still be started separately (`openclaw gateway`).
|
||||
gateway = _subdict(config, "gateway")
|
||||
|
|
@ -1339,9 +1466,11 @@ def write_opencode_config(
|
|||
tools = ("edit", "bash", "webfetch")
|
||||
if yolo:
|
||||
# OpenCode has no --yolo flag; auto-approve is the config `permission` block
|
||||
# (singular). Allow the prompting tools so tool calls don't block on the TUI. This
|
||||
# rides inline (OPENCODE_CONFIG_CONTENT) so --yolo works even over a project config.
|
||||
# (singular). Allow the prompting tools and paths outside the launch directory so
|
||||
# tool calls don't block on the TUI. This rides inline (OPENCODE_CONFIG_CONTENT) so
|
||||
# --yolo works even over a project config.
|
||||
session_permission = {t: "allow" for t in tools}
|
||||
session_permission["external_directory"] = {"*": "allow"}
|
||||
config["permission"] = dict(session_permission)
|
||||
else:
|
||||
# Undo only what --yolo wrote: our yolo sets an explicit per-tool "allow" for these
|
||||
|
|
@ -1358,6 +1487,8 @@ def write_opencode_config(
|
|||
for tool in tools:
|
||||
if permission.get(tool) == "allow":
|
||||
permission[tool] = "ask"
|
||||
if permission.get("external_directory") == {"*": "allow"}:
|
||||
permission["external_directory"] = {"*": "ask"}
|
||||
if json.dumps(config, sort_keys = True) != before:
|
||||
_write_private_json(path, config)
|
||||
typer.echo(f"Updated {path}")
|
||||
|
|
@ -1629,8 +1760,18 @@ def openclaw(
|
|||
)
|
||||
with _session_config("openclaw", launch, persist = persist) as cfg:
|
||||
config_path = cfg / "openclaw.json"
|
||||
workspace_path = None
|
||||
if _wsl_windows_executable(command):
|
||||
workspace_path = _wsl_windows_path(cfg / "workspace")
|
||||
# key lives in the config, not the env; --yolo writes the exec policy here too.
|
||||
write_openclaw_config(base, key, entry, config_path, yolo = yolo)
|
||||
write_openclaw_config(
|
||||
base,
|
||||
key,
|
||||
entry,
|
||||
config_path,
|
||||
yolo = yolo,
|
||||
workspace_path = workspace_path,
|
||||
)
|
||||
# Scope both config and state so OpenClaw never touches the user's ~/.openclaw.
|
||||
env = {"OPENCLAW_CONFIG_PATH": str(config_path), "OPENCLAW_STATE_DIR": str(cfg)}
|
||||
_run(base, entry, env, command, launch = launch, install_hint = install_hint)
|
||||
|
|
@ -1729,6 +1870,8 @@ def hermes(
|
|||
persist: bool = _PERSIST_OPTION,
|
||||
):
|
||||
"""Point Hermes (Nous Research) at the running Studio server and start it."""
|
||||
native_args = [*_yolo_command_flags("hermes", yolo), *ctx.args]
|
||||
command = ["hermes", *_hermes_resume_oneshot_args(native_args)]
|
||||
base, key, entry = _connect(
|
||||
api_key,
|
||||
model,
|
||||
|
|
@ -1736,7 +1879,6 @@ def hermes(
|
|||
serve = serve,
|
||||
launch = launch,
|
||||
)
|
||||
command = ["hermes", *_yolo_command_flags("hermes", yolo), *ctx.args]
|
||||
install_hint = _hermes_install_hint()
|
||||
with _session_config("hermes", launch, persist = persist) as home:
|
||||
# HERMES_HOME relocates hermes' whole home dir (config.yaml, sessions, state)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
|
|
@ -21,6 +22,8 @@ from pathlib import Path
|
|||
from typing import List, Optional
|
||||
import typer
|
||||
|
||||
from unsloth_cli.commands import _password_prompt
|
||||
|
||||
studio_app = typer.Typer(help = "Unsloth Studio commands.")
|
||||
|
||||
|
||||
|
|
@ -480,6 +483,14 @@ def _connect_auth_db() -> sqlite3.Connection:
|
|||
auth_dir = STUDIO_HOME / "auth"
|
||||
auth_dir.mkdir(parents = True, exist_ok = True)
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
# Mirror backend storage.get_connection: this path can create auth/ and
|
||||
# auth.db (the pre-exposure gate writes here first), and sqlite3.connect
|
||||
# makes the DB 0644 under a 022 umask. Keep both private.
|
||||
for _path, _mode in ((auth_dir, 0o700), (auth_dir / "auth.db", 0o600)):
|
||||
try:
|
||||
os.chmod(_path, _mode)
|
||||
except OSError:
|
||||
pass
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_user (
|
||||
|
|
@ -627,6 +638,526 @@ def _create_desktop_secret_in_cli() -> str:
|
|||
conn.close()
|
||||
|
||||
|
||||
def _should_prompt_password_change(
|
||||
*, cloudflare: Optional[bool], host: str, secure: bool, api_only: bool
|
||||
) -> bool:
|
||||
"""Whether this launch will expose Studio through the Cloudflare tunnel.
|
||||
|
||||
CLI mirror of run.py's _cloudflare_tunnel_should_start, minus the Colab
|
||||
case (Colab launches never come through this CLI path). --secure implies
|
||||
the tunnel; --cloudflare only tunnels non-api-only wildcard binds.
|
||||
"""
|
||||
if secure:
|
||||
return True
|
||||
if cloudflare is not True:
|
||||
return False
|
||||
return host in ("0.0.0.0", "::") and not api_only
|
||||
|
||||
|
||||
def _prompt_streams_interactive() -> bool:
|
||||
"""The prompt needs a real terminal for input and for the masked echo."""
|
||||
try:
|
||||
return sys.stdin.isatty() and sys.stderr.isatty()
|
||||
except (AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _bootstrap_deadline_active() -> bool:
|
||||
"""Whether the backend's bootstrap shutdown deadline will arm.
|
||||
|
||||
Mirror of studio/backend/auth/bootstrap_timeout.py bootstrap_timeout_seconds:
|
||||
unset/blank/malformed UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT falls back to the 1h
|
||||
default (a typo must not remove protection); 0 or negative disables it.
|
||||
"""
|
||||
raw = os.environ.get("UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT", "").strip()
|
||||
if not raw:
|
||||
return True
|
||||
try:
|
||||
return int(raw) > 0
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: str) -> None:
|
||||
"""CLI mirror of backend update_password + change-password route effects.
|
||||
|
||||
One transaction: rehash, rotate the JWT secret, clear must_change_password,
|
||||
revoke refresh tokens (PR #6651 finding), and drop the desktop secret. File
|
||||
cleanup happens after commit; a failed unlink must not roll the change back.
|
||||
"""
|
||||
password_salt, password_hash = _hash_password(new_password)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
|
||||
WHERE username = ?
|
||||
""",
|
||||
(password_salt, password_hash, secrets.token_urlsafe(64), username),
|
||||
)
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
|
||||
conn.execute(
|
||||
"DELETE FROM app_secrets WHERE key IN (?, ?)",
|
||||
(DESKTOP_SECRET_HASH_KEY, DESKTOP_SECRET_CREATED_AT_KEY),
|
||||
)
|
||||
for stale in (BOOTSTRAP_PASSWORD_FILE, DESKTOP_SECRET_FILE):
|
||||
stale_path = STUDIO_HOME / "auth" / stale
|
||||
try:
|
||||
stale_path.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
# The hash is already committed, so a failed unlink must NOT roll the
|
||||
# change back. But a locked-yet-writable file (Windows AV, read-only
|
||||
# auth dir) must be truncated: otherwise its stale plaintext survives
|
||||
# and generate_bootstrap_password() would re-validate this revoked
|
||||
# credential after a later reset-password deletes auth.db. Mirrors
|
||||
# backend clear_bootstrap_password().
|
||||
try:
|
||||
stale_path.write_text("")
|
||||
cleared = True
|
||||
except OSError:
|
||||
cleared = False
|
||||
if cleared:
|
||||
typer.echo(
|
||||
f"Warning: could not remove stale {stale} file ({exc}); cleared its "
|
||||
"contents so the old credential cannot be reused.",
|
||||
err = True,
|
||||
)
|
||||
else:
|
||||
typer.echo(
|
||||
f"Warning: could not remove or clear stale {stale} file ({exc}); the "
|
||||
"old credential is still on disk. Remove it manually to prevent reuse "
|
||||
"after a reset.",
|
||||
err = True,
|
||||
)
|
||||
|
||||
|
||||
def _apply_supplied_password_before_launch(supplied_password: "str | None") -> None:
|
||||
"""Non-interactively set the INITIAL admin password (from --password /
|
||||
UNSLOTH_STUDIO_PASSWORD / stdin) before the server binds, while the account
|
||||
still has its auto-generated bootstrap password.
|
||||
|
||||
Only ever sets the FIRST password: an already-set one is a hard error (an
|
||||
override would be an auth bypass on a public launch), and an invalid value
|
||||
fails closed. Runs in the parent before any re-exec so the secret never
|
||||
crosses to the child argv.
|
||||
"""
|
||||
if not supplied_password:
|
||||
return
|
||||
try:
|
||||
conn = _connect_auth_db()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
typer.echo(
|
||||
f"Error: --password could not open the Studio auth database ({exc}); not starting.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
_ensure_cli_default_admin(conn)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT password_salt, password_hash, must_change_password "
|
||||
"FROM auth_user WHERE username = ?",
|
||||
(DEFAULT_ADMIN_USERNAME,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
typer.echo(
|
||||
"Error: --password could not initialize the admin account; not starting.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not row[2]:
|
||||
typer.echo(
|
||||
"Error: a Studio admin password is already set; --password only sets "
|
||||
"the initial password. Run `unsloth studio reset-password` first "
|
||||
"(or change it in the UI).",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
password_salt, password_hash = row[0], row[1]
|
||||
|
||||
def _is_current_password(candidate: str) -> bool:
|
||||
return hmac.compare_digest(
|
||||
_pbkdf2_hex(candidate, password_salt.encode("utf-8")), password_hash
|
||||
)
|
||||
|
||||
problem = _password_prompt.validate_new_password(supplied_password, _is_current_password)
|
||||
if problem is not None:
|
||||
typer.echo(f"Error: {problem} Not starting.", err = True)
|
||||
raise typer.Exit(1)
|
||||
_cli_update_password(conn, DEFAULT_ADMIN_USERNAME, supplied_password)
|
||||
typer.echo(f"Password updated for '{DEFAULT_ADMIN_USERNAME}'.", err = True)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
# Any DB failure fails closed (typer.Exit is not caught here, so the
|
||||
# deliberate Exit(1) branches above propagate unchanged).
|
||||
typer.echo(
|
||||
f"Error: --password could not update the Studio auth database ({exc}); not starting.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _strip_seeded_bootstrap_password_or_exit(*, context: str) -> None:
|
||||
"""Remove the seeded plaintext bootstrap password before a public re-exec.
|
||||
|
||||
Version-independent protection: a re-exec'd child of ANY version (including an
|
||||
old studio-venv predating the pre-bind gate) then reads None instead of
|
||||
injecting the default credential into the public page. must_change_password
|
||||
stays set, so the login page still forces a change and the timer still arms.
|
||||
Removal IS the protection, so if it fails (locked file, read-only auth dir)
|
||||
fail closed rather than publish it.
|
||||
"""
|
||||
bootstrap_file = STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE
|
||||
try:
|
||||
bootstrap_file.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
typer.echo(
|
||||
"Error: refusing to publish Studio on a public Cloudflare URL: "
|
||||
f"could not remove the seeded bootstrap password file ({exc}), so an "
|
||||
f"older Studio child could still serve the default credential ({context}). "
|
||||
"Delete it manually or change the admin password (run `unsloth studio` "
|
||||
"locally with a terminal attached, or `unsloth studio reset-password`), "
|
||||
"then retry.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _require_servable_frontend_or_exit(
|
||||
*, frontend: Optional[Path], api_only: bool, cloudflare: Optional[bool], host: str, secure: bool
|
||||
) -> Optional[Path]:
|
||||
"""Fail closed BEFORE the pre-exposure gate if a public UI launch has no
|
||||
login page to change the seeded password.
|
||||
|
||||
The gate strips the seeded .bootstrap_password on a headless public launch,
|
||||
so if the child then cannot serve the login page the admin is locked out
|
||||
(must_change_password=1, no file, no UI) until `unsloth studio reset-password`.
|
||||
The login page is the ONLY in-band way to change the seeded password, so a
|
||||
public non-api-only launch must have a servable dist before the strip.
|
||||
|
||||
Returns the dist to serve: a user-supplied --frontend (validated to contain
|
||||
index.html) or the auto-resolved built dist. Returns `frontend` unchanged for
|
||||
non-public or --api-only launches (no login page needed).
|
||||
"""
|
||||
if api_only or not _should_prompt_password_change(
|
||||
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
||||
):
|
||||
return frontend
|
||||
if frontend is not None:
|
||||
# A user-supplied dist is not vetted by _find_frontend_dist, so verify it
|
||||
# can serve the login page; else `--frontend /bad/path` bypasses the guard.
|
||||
if (Path(frontend) / "index.html").is_file():
|
||||
return frontend
|
||||
typer.echo(
|
||||
"Error: --frontend points at a directory with no index.html, so a "
|
||||
"public Studio launch would have no login page to change the seeded "
|
||||
"admin password. Point --frontend at a built dist, rebuild it (re-run "
|
||||
"install.sh), or use --api-only.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
# _find_frontend_dist only returns a path that already contains index.html.
|
||||
resolved = _find_frontend_dist()
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
typer.echo(
|
||||
"Error: the Studio frontend is not built, so a public launch would have "
|
||||
"no login page to change the seeded admin password. Build it (re-run "
|
||||
"install.sh), pass --frontend PATH to a built dist, or use --api-only.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _validate_inproc_backend_before_strip(
|
||||
*, cloudflare: Optional[bool], host: str, secure: bool, api_only: bool
|
||||
) -> None:
|
||||
"""In-venv (in-process) analogue of the re-exec launcher check.
|
||||
|
||||
In-venv there is no re-exec, so the backend is imported in-process only AFTER
|
||||
the gate. On the headless public path the gate strips the seeded
|
||||
.bootstrap_password, so a broken venv that fails at import would leave
|
||||
must_change_password=1 with no password to log in. Import the backend up front
|
||||
on that path and exit cleanly if broken, before anything is stripped.
|
||||
Headless-only so an interactive prompt is not delayed behind the import.
|
||||
"""
|
||||
if not _should_prompt_password_change(
|
||||
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
||||
):
|
||||
return
|
||||
if _prompt_streams_interactive():
|
||||
return
|
||||
try:
|
||||
_load_run_module()
|
||||
except Exception as exc:
|
||||
typer.echo(
|
||||
f"Error: the Studio backend could not be loaded ({exc}); refusing to "
|
||||
"expose Studio publicly before it is confirmed runnable. Re-run: "
|
||||
"unsloth studio setup",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _tunnel_binary_confirmed_unavailable() -> bool:
|
||||
"""True only if cloudflared is provably unavailable (found nowhere on PATH or
|
||||
in the Studio cache AND the download failed), so the tunnel cannot start.
|
||||
|
||||
Used on the --secure path (loopback bind, so the tunnel is the ONLY public
|
||||
exposure) to skip stripping the seeded recovery password before a public URL
|
||||
that will never come up. Loads the stdlib-only cloudflare_tunnel helper by
|
||||
file path so the check runs in the parent, before the strip.
|
||||
|
||||
Returns False on ANY uncertainty: a possible credential leak outweighs a
|
||||
recoverable lockout, so the caller keeps the strip unless the tunnel is
|
||||
provably dead.
|
||||
"""
|
||||
run_py = _find_run_py()
|
||||
if run_py is None:
|
||||
return False
|
||||
backend_dir = run_py.parent
|
||||
tunnel_py = backend_dir / "cloudflare_tunnel.py"
|
||||
if not tunnel_py.is_file():
|
||||
return False
|
||||
# ensure_cloudflared() lazily imports utils.paths.storage_roots to resolve the
|
||||
# Studio bin cache. The outer CLI hasn't added studio/backend to sys.path yet,
|
||||
# so that import would fail and return None (a false "unavailable" that wrongly
|
||||
# refuses --secure). Add the backend dir so the cache path resolves as in the child.
|
||||
added_backend_path = False
|
||||
try:
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
added_backend_path = True
|
||||
spec = importlib.util.spec_from_file_location("studio.backend.cloudflare_tunnel", tunnel_py)
|
||||
if spec is None or spec.loader is None:
|
||||
return False
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.ensure_cloudflared() is None
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if added_backend_path:
|
||||
try:
|
||||
sys.path.remove(str(backend_dir))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def _child_self_suppresses(*, in_studio_venv: bool, child_run_py: Optional[Path]) -> bool:
|
||||
"""True when the child that will serve Studio is provably THIS install's
|
||||
backend, whose pre-bind gate sets app.state.suppress_bootstrap_injection and
|
||||
so never serves the seeded credential publicly -- even with .bootstrap_password
|
||||
on disk. The parent-side strip is then unnecessary and can be skipped to avoid
|
||||
a lockout if the tunnel never comes up, keeping the file for LOCAL recovery.
|
||||
|
||||
True iff we run in-process here, or the re-exec target is the outer install's
|
||||
own run.py (identity match). False on ANY doubt -- a studio-venv console script
|
||||
or a venv run.py that may predate the gate -- so the strip stays in force
|
||||
wherever an old child is possible.
|
||||
"""
|
||||
if in_studio_venv:
|
||||
return True
|
||||
if child_run_py is None:
|
||||
return False
|
||||
try:
|
||||
outer_run_py = (_PACKAGE_ROOT / "studio" / "backend" / "run.py").resolve()
|
||||
return child_run_py.resolve() == outer_run_py
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _enforce_password_change_before_exposure(
|
||||
*,
|
||||
cloudflare: Optional[bool],
|
||||
host: str,
|
||||
secure: bool,
|
||||
api_only: bool,
|
||||
child_self_suppresses: bool = False,
|
||||
) -> None:
|
||||
"""Force a terminal password change before the first public (tunnel) exposure.
|
||||
|
||||
When the launch will start the tunnel and the admin still has its
|
||||
auto-generated bootstrap password, ask for a new one in the terminal (masked,
|
||||
confirmed) before any server or tunnel exists. Committing here, in the parent,
|
||||
keeps the password off argv/env and an older studio-venv child sees it
|
||||
immediately. Without a terminal, warn and fall back to the bootstrap shutdown
|
||||
timer (~1h, UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT).
|
||||
"""
|
||||
if not _should_prompt_password_change(
|
||||
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
||||
):
|
||||
return
|
||||
# Before public exposure we must PROVE the admin password is no longer the
|
||||
# seeded default. If we cannot (auth DB won't open, or a fresh admin cannot be
|
||||
# seeded + committed below), an old studio-venv child could regenerate a fresh
|
||||
# bootstrap credential and serve it; stripping a file we can't vouch for cannot
|
||||
# stop a regeneration. So those cases fail closed, as does a failure after the
|
||||
# user typed a new password.
|
||||
try:
|
||||
conn = _connect_auth_db()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
# Cannot open the auth DB, so cannot confirm a committed admin exists.
|
||||
# Refuse rather than risk a child serving the default login; a transient
|
||||
# lock clears on retry.
|
||||
typer.echo(
|
||||
"Error: refusing to publish Studio on a public Cloudflare URL: could "
|
||||
f"not open the Studio auth database ({exc}) to confirm the admin "
|
||||
"password was changed. Retry (a transient database lock clears), or "
|
||||
"change the password first (run `unsloth studio` locally with a "
|
||||
"terminal attached, or `unsloth studio reset-password`).",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
try:
|
||||
_ensure_cli_default_admin(conn)
|
||||
# Persist a freshly seeded admin before we might re-exec: the INSERT is
|
||||
# otherwise uncommitted and rolls back on conn.close(). If the seed or
|
||||
# commit fails, no admin is committed, so a re-exec'd OLD child finds
|
||||
# none, regenerates a fresh bootstrap password + file, and serves THAT
|
||||
# -- stripping cannot stop a regeneration. Can't prove a committed
|
||||
# admin, so fail closed.
|
||||
conn.commit()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
# Best-effort remove any half-written seed file (its row rolled back);
|
||||
# the launch is refused regardless.
|
||||
try:
|
||||
(STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE).unlink(missing_ok = True)
|
||||
except OSError:
|
||||
pass
|
||||
typer.echo(
|
||||
"Error: refusing to publish Studio on a public Cloudflare URL: could "
|
||||
f"not initialize the admin account ({exc}), so a re-exec'd Studio "
|
||||
"child could regenerate and serve a default credential. Retry (a "
|
||||
"transient database lock clears), or change the password first (run "
|
||||
"`unsloth studio` locally with a terminal attached, or `unsloth "
|
||||
"studio reset-password`).",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT password_salt, password_hash, must_change_password "
|
||||
"FROM auth_user WHERE username = ?",
|
||||
(DEFAULT_ADMIN_USERNAME,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
if child_self_suppresses:
|
||||
# Could not read must_change back, but the child is this install's
|
||||
# own backend and suppresses the injection, so nothing serves the
|
||||
# seeded credential; proceed without stripping.
|
||||
return
|
||||
# The admin is committed above, so an old child finds it and won't
|
||||
# regenerate; we just couldn't read must_change back. Strip the seeded
|
||||
# file so nothing serves it, failing closed if the strip itself fails.
|
||||
typer.echo(
|
||||
f"Warning: could not read the Studio admin state back ({exc}); "
|
||||
"removing the seeded bootstrap password before public exposure.",
|
||||
err = True,
|
||||
)
|
||||
_strip_seeded_bootstrap_password_or_exit(context = "auth DB row unreadable")
|
||||
return
|
||||
if not row or not row[2]:
|
||||
return
|
||||
if not _prompt_streams_interactive():
|
||||
# Only proceed headless if the bootstrap shutdown deadline will protect
|
||||
# the launch: it never arms for api-only, and TIMEOUT=0 disables it.
|
||||
if api_only or not _bootstrap_deadline_active():
|
||||
typer.echo(
|
||||
"Error: refusing to publish Studio on a public Cloudflare "
|
||||
"URL: the default admin password was never changed, no "
|
||||
"terminal is attached to change it here, and the bootstrap "
|
||||
"shutdown deadline does not apply to this launch (api-only, "
|
||||
"or UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0). Change the "
|
||||
"password first (run `unsloth studio` locally and log in, "
|
||||
"or re-run with a terminal attached), then retry.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if child_self_suppresses:
|
||||
# The child is this install's own backend, whose pre-bind gate sets
|
||||
# app.state.suppress_bootstrap_injection, so the seeded credential
|
||||
# is never served publicly even with the file on disk. Skip the
|
||||
# strip: unnecessary here, and it would lock the user out if the
|
||||
# tunnel never comes up (e.g. a --secure loopback whose tunnel
|
||||
# fails). Keep the file for LOCAL recovery; must_change stays set
|
||||
# and the deadline arms.
|
||||
typer.echo(
|
||||
"Warning: Studio is being exposed publicly while the admin "
|
||||
"account still uses its auto-generated bootstrap password. The "
|
||||
"login page forces a change and the credential is never served "
|
||||
"on the public page. Set a new password by running `unsloth "
|
||||
"studio` locally with a terminal attached, or `unsloth studio "
|
||||
"reset-password`; Studio shuts down after ~1h if the password "
|
||||
"stays unchanged (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT).",
|
||||
err = True,
|
||||
)
|
||||
return
|
||||
# The strip permanently removes the only plaintext recovery credential.
|
||||
# On --secure the bind is loopback, so the tunnel is the ONLY public
|
||||
# exposure: if cloudflared is provably unavailable no public URL can
|
||||
# start, so stripping would just lock the user out. Refuse with the
|
||||
# credential preserved. (A wildcard --cloudflare bind is public
|
||||
# regardless of the tunnel, so it still strips below, as does any
|
||||
# uncertainty.)
|
||||
if secure and _tunnel_binary_confirmed_unavailable():
|
||||
typer.echo(
|
||||
"Error: refusing to expose Studio: the Cloudflare tunnel binary "
|
||||
"(cloudflared) is unavailable and could not be downloaded, so no "
|
||||
"public URL can start. The seeded bootstrap password is preserved "
|
||||
"for recovery; fix connectivity and retry, or change the password "
|
||||
"first (`unsloth studio` locally, or `unsloth studio "
|
||||
"reset-password`).",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
# Mixed-version safety: an OLD studio-venv child (predating this gate)
|
||||
# has no pre-bind suppression and would read the seeded credential back
|
||||
# from disk and inject it into the public HTML until the deadline.
|
||||
# Delete the file here, in the parent, so a fresh child of ANY version
|
||||
# reads None. must_change_password stays set, so the login page still
|
||||
# forces a change and the timer still arms; only the on-disk copy goes.
|
||||
_strip_seeded_bootstrap_password_or_exit(context = "no terminal to change it")
|
||||
typer.echo(
|
||||
"Warning: Studio is being exposed publicly while the admin account "
|
||||
"still uses its auto-generated bootstrap password. The seeded password "
|
||||
"file has been removed so it is not served on the public page. Set a new "
|
||||
"password by running `unsloth studio` locally with a terminal attached, "
|
||||
"or `unsloth studio reset-password`; Studio shuts down after ~1h if the "
|
||||
"password stays unchanged (UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT).",
|
||||
err = True,
|
||||
)
|
||||
return
|
||||
password_salt, password_hash = row[0], row[1]
|
||||
|
||||
def _is_current_password(candidate: str) -> bool:
|
||||
return hmac.compare_digest(
|
||||
_pbkdf2_hex(candidate, password_salt.encode("utf-8")), password_hash
|
||||
)
|
||||
|
||||
typer.echo(
|
||||
"Unsloth Studio will be exposed on the public internet, so set a "
|
||||
"password now. Ctrl+C to abort.",
|
||||
err = True,
|
||||
)
|
||||
try:
|
||||
new_password = _password_prompt.prompt_new_password(_is_current_password)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
typer.echo(
|
||||
"\nError: password change aborted; refusing to expose Studio "
|
||||
"with the default admin password. Re-run and set a password, "
|
||||
"or launch without --secure/--cloudflare.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_cli_update_password(conn, DEFAULT_ADMIN_USERNAME, new_password)
|
||||
typer.echo(f"Password updated for '{DEFAULT_ADMIN_USERNAME}'.", err = True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_model_via_http(
|
||||
port: int,
|
||||
api_key: str,
|
||||
|
|
@ -713,13 +1244,13 @@ def studio_default(
|
|||
f"defaults to {_PARALLEL_DEFAULT_RUN}."
|
||||
),
|
||||
),
|
||||
cloudflare: bool = typer.Option(
|
||||
True,
|
||||
cloudflare: Optional[bool] = typer.Option(
|
||||
None,
|
||||
"--cloudflare/--no-cloudflare",
|
||||
help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard "
|
||||
"binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). "
|
||||
"Pass --no-cloudflare to disable that Cloudflare URL; it does not change a "
|
||||
"public wildcard bind. --api-only keeps it off unless paired with --secure.",
|
||||
help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS "
|
||||
"tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; "
|
||||
"pass --cloudflare to enable it (--secure implies it). --no-cloudflare forces "
|
||||
"it off but does not change a raw wildcard bind.",
|
||||
),
|
||||
secure: bool = typer.Option(
|
||||
False,
|
||||
|
|
@ -747,6 +1278,14 @@ def studio_default(
|
|||
help = "Force server-side tools (web search, code execution) on or off for "
|
||||
"every request. Default: on for every bind, with the per-chat UI toggle honored.",
|
||||
),
|
||||
password: str = typer.Option(
|
||||
"",
|
||||
"--password",
|
||||
help = "Set the INITIAL admin password non-interactively (headless setups), "
|
||||
"only when none is set yet. Also reads the UNSLOTH_STUDIO_PASSWORD env var, or "
|
||||
"`--password -` to read one line from stdin. A literal value is visible in the "
|
||||
"process list and shell history. Rotate later with `unsloth studio reset-password`.",
|
||||
),
|
||||
):
|
||||
"""Launch the Unsloth Studio server."""
|
||||
# Back-compat: --not-secure is a deprecated alias for --no-secure.
|
||||
|
|
@ -766,13 +1305,14 @@ def studio_default(
|
|||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
# Same for --no-cloudflare: it would not reach the subcommand.
|
||||
if not cloudflare:
|
||||
# Same for --cloudflare/--no-cloudflare: it would not reach the subcommand.
|
||||
if cloudflare is not None:
|
||||
_cf_flag = "--cloudflare" if cloudflare else "--no-cloudflare"
|
||||
typer.echo(
|
||||
f"Error: --no-cloudflare on `unsloth studio` applies to the "
|
||||
f"Error: {_cf_flag} on `unsloth studio` applies to the "
|
||||
f"plain-server path only. For `unsloth studio "
|
||||
f"{ctx.invoked_subcommand}`, put it after the subcommand: "
|
||||
f"`unsloth studio {ctx.invoked_subcommand} --no-cloudflare ...`",
|
||||
f"`unsloth studio {ctx.invoked_subcommand} {_cf_flag} ...`",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
|
|
@ -817,17 +1357,34 @@ def studio_default(
|
|||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
# Same for --password: it applies to the plain-server path only.
|
||||
if password:
|
||||
typer.echo(
|
||||
f"Error: --password on `unsloth studio` applies to the "
|
||||
f"plain-server path only. For `unsloth studio "
|
||||
f"{ctx.invoked_subcommand}`, put it after the subcommand: "
|
||||
f"`unsloth studio {ctx.invoked_subcommand} --password ...`",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
return
|
||||
|
||||
# --secure requires the tunnel; force a loopback bind.
|
||||
if secure:
|
||||
if not cloudflare:
|
||||
if cloudflare is False:
|
||||
typer.echo(
|
||||
"Error: --secure requires the Cloudflare tunnel; do not combine it "
|
||||
"with --no-cloudflare.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
typer.echo(
|
||||
"Note: --secure ignores -H (it binds loopback and serves only "
|
||||
"through the Cloudflare tunnel). Drop --secure to bind "
|
||||
f"{host} directly, or keep --secure for a tunnel-only public link.",
|
||||
err = True,
|
||||
)
|
||||
host = "127.0.0.1"
|
||||
|
||||
# --verbose restores the per-request access logs that are suppressed by
|
||||
|
|
@ -835,13 +1392,76 @@ def studio_default(
|
|||
if verbose:
|
||||
_enable_verbose_access_logs()
|
||||
|
||||
# Use the studio venv if it exists and we aren't already in it.
|
||||
# Use the studio venv if present and not already in it. Resolve the child
|
||||
# launcher BEFORE the gate: a headless gate strips the seeded
|
||||
# .bootstrap_password, so aborting afterward (venv/run.py missing) would leave
|
||||
# must_change_password=1 with no password to log in.
|
||||
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
|
||||
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
|
||||
|
||||
studio_python = run_py = None
|
||||
resolved_frontend = frontend
|
||||
if not in_studio_venv:
|
||||
studio_python = _studio_venv_python()
|
||||
run_py = _find_run_py()
|
||||
if not (studio_python and run_py):
|
||||
typer.echo("Studio not set up. Run install.sh first.")
|
||||
raise typer.Exit(1)
|
||||
# A public UI launch must have a servable login page BEFORE the gate can
|
||||
# strip the seeded .bootstrap_password, or the child has no way to change
|
||||
# it. Also returns the resolved dist so the child serves a real build
|
||||
# regardless of where its __file__ lands (fixes the shadowed silent 404).
|
||||
resolved_frontend = _require_servable_frontend_or_exit(
|
||||
frontend = resolved_frontend,
|
||||
api_only = api_only,
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
)
|
||||
# Non-public / api-only launches skip that validation but still forward an
|
||||
# explicitly resolved dist for the same silent-404 reason.
|
||||
if resolved_frontend is None and not api_only:
|
||||
resolved_frontend = _find_frontend_dist()
|
||||
else:
|
||||
# Already in the studio venv: no re-exec, served in-process below. On the
|
||||
# headless public path the gate strips the seeded .bootstrap_password, so
|
||||
# validate BOTH FIRST -- else a bad dist or broken venv fails only after
|
||||
# the strip (must_change_password=1, no password to log in). Frontend check
|
||||
# first (cheap); the backend import is headless-only so an interactive
|
||||
# prompt is not delayed behind it.
|
||||
resolved_frontend = _require_servable_frontend_or_exit(
|
||||
frontend = resolved_frontend,
|
||||
api_only = api_only,
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
)
|
||||
_validate_inproc_backend_before_strip(
|
||||
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
||||
)
|
||||
|
||||
# A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial
|
||||
# admin password here in the parent, before the gate and any re-exec, so the
|
||||
# secret never reaches the child argv; strip the env var so a re-exec'd child
|
||||
# can't re-read it. The interactive gate below then no-ops.
|
||||
_apply_supplied_password_before_launch(_password_prompt.resolve_supplied_password(password))
|
||||
os.environ.pop(_password_prompt.SUPPLIED_PASSWORD_ENV, None)
|
||||
|
||||
# Public (tunnel) exposure with the seeded default password: force a terminal
|
||||
# password change first, before any re-exec or server exists. The child is
|
||||
# self-suppressing when we serve in-process or re-exec this install's own
|
||||
# run.py (its pre-bind gate suppresses the injection), so the gate can skip
|
||||
# the destructive strip.
|
||||
_enforce_password_change_before_exposure(
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
api_only = api_only,
|
||||
child_self_suppresses = _child_self_suppresses(
|
||||
in_studio_venv = in_studio_venv, child_run_py = run_py
|
||||
),
|
||||
)
|
||||
|
||||
if not in_studio_venv:
|
||||
if studio_python and run_py:
|
||||
if not silent:
|
||||
typer.echo("Launching Unsloth Studio... Please wait...")
|
||||
|
|
@ -855,20 +1475,22 @@ def studio_default(
|
|||
"--parallel",
|
||||
str(parallel),
|
||||
]
|
||||
# Resolve frontend explicitly so the spawned run.py uses a real
|
||||
# built dist regardless of where its __file__ lands. Skip in
|
||||
# --api-only (no UI served).
|
||||
resolved_frontend = frontend
|
||||
if resolved_frontend is None and not api_only:
|
||||
resolved_frontend = _find_frontend_dist()
|
||||
# Forward the frontend dist resolved before the gate (skipped in
|
||||
# --api-only, which serves no UI).
|
||||
if resolved_frontend is not None:
|
||||
args.extend(["--frontend", str(resolved_frontend)])
|
||||
if silent:
|
||||
args.append("--silent")
|
||||
if api_only:
|
||||
args.append("--api-only")
|
||||
# Forward the explicit polarity (matches run.py's BooleanOptionalAction).
|
||||
args.append("--cloudflare" if cloudflare else "--no-cloudflare")
|
||||
# Forward polarity explicitly: _find_run_py can fall back to an older
|
||||
# run.py (--cloudflare defaulted on), so an unset default must not let a
|
||||
# mixed install silently re-enable the tunnel. --secure implies it, so
|
||||
# forward nothing then.
|
||||
if cloudflare is True:
|
||||
args.append("--cloudflare")
|
||||
elif not secure:
|
||||
args.append("--no-cloudflare")
|
||||
args.append("--secure" if secure else "--no-secure")
|
||||
# Forward an explicit tool policy; None -> run.py leaves it unset (tools on).
|
||||
if enable_tools is True:
|
||||
|
|
@ -920,8 +1542,10 @@ def studio_default(
|
|||
secure = secure,
|
||||
enable_tools = enable_tools,
|
||||
)
|
||||
if frontend is not None:
|
||||
run_kwargs["frontend_path"] = frontend
|
||||
# Forward the frontend validated before the gate (in-venv path), so the
|
||||
# in-process server serves exactly the dist we vouched for.
|
||||
if resolved_frontend is not None:
|
||||
run_kwargs["frontend_path"] = resolved_frontend
|
||||
run_server(**run_kwargs)
|
||||
|
||||
try:
|
||||
|
|
@ -1106,13 +1730,13 @@ def run(
|
|||
f"{_PARALLEL_DEFAULT_RUN} (pre-PR hardcoded value)."
|
||||
),
|
||||
),
|
||||
cloudflare: bool = typer.Option(
|
||||
True,
|
||||
cloudflare: Optional[bool] = typer.Option(
|
||||
None,
|
||||
"--cloudflare/--no-cloudflare",
|
||||
help = "Auto-create a free Cloudflare HTTPS tunnel for non-api-only wildcard "
|
||||
"binds (0.0.0.0 or ::), exposing Studio on a PUBLIC internet URL (default on). "
|
||||
"Pass --no-cloudflare to disable that Cloudflare URL; it does not change a "
|
||||
"public wildcard bind. --api-only keeps it off unless paired with --secure.",
|
||||
help = "Expose Studio on a PUBLIC internet URL via a free Cloudflare HTTPS "
|
||||
"tunnel, for non-api-only wildcard binds (0.0.0.0 or ::). Off by default; "
|
||||
"pass --cloudflare to enable it (--secure implies it). --no-cloudflare forces "
|
||||
"it off but does not change a raw wildcard bind.",
|
||||
),
|
||||
secure: bool = typer.Option(
|
||||
False,
|
||||
|
|
@ -1136,6 +1760,14 @@ def run(
|
|||
"decode speed, MoE usually don't."
|
||||
),
|
||||
),
|
||||
password: str = typer.Option(
|
||||
"",
|
||||
"--password",
|
||||
help = "Set the INITIAL admin password non-interactively (headless setups), "
|
||||
"only when none is set yet. Also reads the UNSLOTH_STUDIO_PASSWORD env var, or "
|
||||
"`--password -` to read one line from stdin. A literal value is visible in the "
|
||||
"process list and shell history. Rotate later with `unsloth studio reset-password`.",
|
||||
),
|
||||
):
|
||||
"""Start Studio, load a model, print an API key -- one-liner server.
|
||||
|
||||
|
|
@ -1207,13 +1839,20 @@ def run(
|
|||
|
||||
# --secure requires the tunnel; force a loopback bind so the raw port is never public.
|
||||
if secure:
|
||||
if not cloudflare:
|
||||
if cloudflare is False:
|
||||
typer.echo(
|
||||
"Error: --secure requires the Cloudflare tunnel; do not combine it "
|
||||
"with --no-cloudflare.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
typer.echo(
|
||||
"Note: --secure ignores -H (it binds loopback and serves only "
|
||||
"through the Cloudflare tunnel). Drop --secure to bind "
|
||||
f"{host} directly, or keep --secure for a tunnel-only public link.",
|
||||
err = True,
|
||||
)
|
||||
host = "127.0.0.1"
|
||||
|
||||
# Tool policy no longer depends on the bind: tools default on everywhere
|
||||
|
|
@ -1228,10 +1867,14 @@ def run(
|
|||
silent = silent,
|
||||
)
|
||||
|
||||
# 1. Re-exec into the studio venv (same pattern as studio_default).
|
||||
# 1. Re-exec into the studio venv (same pattern as studio_default). Resolve
|
||||
# the child launcher BEFORE the gate: a headless gate strips the seeded
|
||||
# .bootstrap_password, so aborting afterward (venv/entry point missing) would
|
||||
# leave must_change_password=1 with no password to log in.
|
||||
studio_venv_dir = STUDIO_HOME / "unsloth_studio"
|
||||
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
|
||||
|
||||
studio_bin = None
|
||||
resolved_frontend = frontend
|
||||
if not in_studio_venv:
|
||||
studio_python = _studio_venv_python()
|
||||
if not studio_python:
|
||||
|
|
@ -1242,6 +1885,56 @@ def run(
|
|||
if not studio_bin.is_file():
|
||||
typer.echo("Studio venv missing 'unsloth' entry point. Re-run: unsloth studio setup")
|
||||
raise typer.Exit(1)
|
||||
# `run` serves the same Studio UI (unless --api-only); a public launch must
|
||||
# have a servable login page BEFORE the gate strips the seeded password, or
|
||||
# the child has no way to change it. Validate here and forward the resolved
|
||||
# dist so a shadowed child that can't self-resolve one still serves it.
|
||||
resolved_frontend = _require_servable_frontend_or_exit(
|
||||
frontend = frontend,
|
||||
api_only = api_only,
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
)
|
||||
else:
|
||||
# In-venv (in-process) run: validate the servable frontend and importable
|
||||
# backend before the headless gate strips the seeded password. Frontend
|
||||
# check first (cheap); backend import is headless-only so a prompt isn't
|
||||
# delayed.
|
||||
resolved_frontend = _require_servable_frontend_or_exit(
|
||||
frontend = frontend,
|
||||
api_only = api_only,
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
)
|
||||
_validate_inproc_backend_before_strip(
|
||||
cloudflare = cloudflare, host = host, secure = secure, api_only = api_only
|
||||
)
|
||||
|
||||
# A supplied --password / UNSLOTH_STUDIO_PASSWORD / stdin sets the initial
|
||||
# admin password here in the parent, before the gate and any re-exec, so the
|
||||
# secret never reaches the child argv; strip the env var so a re-exec'd child
|
||||
# can't re-read it. The interactive gate below then no-ops.
|
||||
_apply_supplied_password_before_launch(_password_prompt.resolve_supplied_password(password))
|
||||
os.environ.pop(_password_prompt.SUPPLIED_PASSWORD_ENV, None)
|
||||
|
||||
# Public (tunnel) exposure with the seeded default password: force a terminal
|
||||
# password change first, before any re-exec or server exists. The re-exec here
|
||||
# runs the studio venv's `unsloth` console script (a possibly-OLD child), so it
|
||||
# is NOT provably self-suppressing -- only the in-process case is, and the
|
||||
# strip stays in force otherwise.
|
||||
_enforce_password_change_before_exposure(
|
||||
cloudflare = cloudflare,
|
||||
host = host,
|
||||
secure = secure,
|
||||
api_only = api_only,
|
||||
child_self_suppresses = _child_self_suppresses(
|
||||
in_studio_venv = in_studio_venv, child_run_py = None
|
||||
),
|
||||
)
|
||||
|
||||
if not in_studio_venv:
|
||||
args = [
|
||||
str(studio_bin),
|
||||
"studio",
|
||||
|
|
@ -1262,8 +1955,12 @@ def run(
|
|||
# Forward the explicit polarity; a future default flip on one
|
||||
# layer must not silently invert behaviour for the other.
|
||||
args.append("--load-in-4bit" if load_in_4bit else "--no-load-in-4bit")
|
||||
if frontend:
|
||||
args.extend(["--frontend", str(frontend)])
|
||||
# Forward the frontend resolved before the gate, not just a user-supplied
|
||||
# one: the parent may have found a built dist the shadowed child cannot,
|
||||
# and stripping without forwarding it would abort the child at frontend
|
||||
# setup (lockout).
|
||||
if resolved_frontend is not None:
|
||||
args.extend(["--frontend", str(resolved_frontend)])
|
||||
if api_only:
|
||||
args.append("--api-only")
|
||||
if silent:
|
||||
|
|
@ -1279,8 +1976,13 @@ def run(
|
|||
# Typer claims --parallel outside ctx.args; without this the
|
||||
# child reverts to its default and silently drops the value.
|
||||
args.extend(["--parallel", str(parallel)])
|
||||
# Forward the explicit polarity (same rationale as --load-in-4bit above).
|
||||
args.append("--cloudflare" if cloudflare else "--no-cloudflare")
|
||||
# Always forward explicit polarity: a mixed-version studio venv whose old
|
||||
# default was --cloudflare-on must not silently re-enable the tunnel.
|
||||
# --secure implies it, so forward nothing then.
|
||||
if cloudflare is True:
|
||||
args.append("--cloudflare")
|
||||
elif not secure:
|
||||
args.append("--no-cloudflare")
|
||||
args.append("--secure" if secure else "--no-secure")
|
||||
args.append("--tensor-parallel" if tensor_parallel else "--no-tensor-parallel")
|
||||
if verbose:
|
||||
|
|
@ -1322,8 +2024,9 @@ def run(
|
|||
# TAURI_PORT line would corrupt that machine-parseable output.
|
||||
emit_tauri_port = False,
|
||||
)
|
||||
if frontend is not None:
|
||||
run_kwargs["frontend_path"] = frontend
|
||||
# Forward the frontend validated before the gate (in-venv path).
|
||||
if resolved_frontend is not None:
|
||||
run_kwargs["frontend_path"] = resolved_frontend
|
||||
app = run_server(**run_kwargs)
|
||||
actual_port = getattr(app.state, "server_port", port) or port
|
||||
|
||||
|
|
@ -1943,9 +2646,44 @@ def reset_password():
|
|||
]
|
||||
had_db = db_file.exists()
|
||||
|
||||
db_file.unlink(missing_ok = True)
|
||||
# Delete auth.db FIRST and prove it is gone before touching the seeded
|
||||
# credential files. If it cannot be removed (a running Studio or Windows
|
||||
# holds it open, or a read-only auth dir), abort with the credential files
|
||||
# untouched: deleting them while an un-resettable DB (must_change_password=1)
|
||||
# survives would lock a forgotten-password reset out of any recovery
|
||||
# credential. Failing here leaves a consistent, still-recoverable state.
|
||||
try:
|
||||
db_file.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
typer.echo(
|
||||
f"Error: could not delete the auth database ({exc}). Stop any running "
|
||||
"Studio and retry; no credential files were changed.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# The DB is gone, so the next start re-seeds. Invalidate the seeded plaintext
|
||||
# credential files so that re-seed generates a FRESH password instead of
|
||||
# reusing a stale one: unlink only ignores FileNotFoundError, so a
|
||||
# locked/undeletable file (Windows AV, read-only dir) would otherwise survive
|
||||
# and generate_bootstrap_password() would read it back and re-validate the
|
||||
# credential this reset revoked. Truncate on unlink failure; if a file can be
|
||||
# neither removed nor truncated, fail closed -- the DB is already gone, so a
|
||||
# surviving plaintext would be reused, and the user must remove it manually.
|
||||
for path in stale_files:
|
||||
path.unlink(missing_ok = True)
|
||||
try:
|
||||
path.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
try:
|
||||
path.write_text("")
|
||||
except OSError as exc:
|
||||
typer.echo(
|
||||
f"Error: could not remove or clear {path.name} ({exc}); delete "
|
||||
"it manually before restarting Studio or the old password may "
|
||||
"be reused.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not had_db:
|
||||
typer.echo("No auth database found -- nothing to reset.")
|
||||
|
|
|
|||
|
|
@ -294,17 +294,58 @@ def test_merge_codex_config_keeps_user_oss_provider():
|
|||
assert _parse_toml(merged)["oss_provider"] == "ollama"
|
||||
|
||||
|
||||
def test_write_codex_config_profile(tmp_path):
|
||||
def test_write_codex_config_profile(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: True)
|
||||
start.write_codex_config(BASE, MODEL, tmp_path)
|
||||
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
|
||||
assert profile["oss_provider"] == "unsloth_api"
|
||||
assert profile["model_provider"] == "unsloth_api"
|
||||
assert profile["model"] == MODEL["id"]
|
||||
assert profile["model_context_window"] == 131072
|
||||
|
||||
catalog_path = Path(profile["model_catalog_json"])
|
||||
assert catalog_path == Path("model-catalog.json")
|
||||
catalog = json.loads((tmp_path / catalog_path).read_text())
|
||||
assert catalog["models"][0]["slug"] == MODEL["id"]
|
||||
assert catalog["models"][0]["context_window"] == 131072
|
||||
assert catalog["models"][0]["max_context_window"] == 131072
|
||||
assert catalog["models"][0]["supports_reasoning_summary_parameter"] is False
|
||||
assert catalog["models"][0]["supports_parallel_tool_calls"] is False
|
||||
|
||||
assert catalog["models"][0]["base_instructions"] == start._CODEX_FALLBACK_PROMPT.read_text()
|
||||
config = _parse_toml((tmp_path / "config.toml").read_text())
|
||||
assert config["model_providers"]["unsloth_api"]["env_key"] == "UNSLOTH_STUDIO_AUTH_TOKEN"
|
||||
|
||||
|
||||
def test_write_codex_config_catalog_without_context_length(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: True)
|
||||
start.write_codex_config(BASE, {"id": "unsloth/no-window"}, tmp_path)
|
||||
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
|
||||
catalog = json.loads((tmp_path / profile["model_catalog_json"]).read_text())
|
||||
entry = catalog["models"][0]
|
||||
assert entry["slug"] == "unsloth/no-window"
|
||||
assert "context_window" not in entry
|
||||
assert "max_context_window" not in entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[("codex-cli 0.109.0", False), ("codex-cli 0.110.0", True), ("codex-cli 0.144.4", True)],
|
||||
)
|
||||
def test_codex_model_catalog_version_gate(monkeypatch, version, expected):
|
||||
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex")
|
||||
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: version)
|
||||
assert start._codex_supports_model_catalog() is expected
|
||||
|
||||
|
||||
def test_write_codex_config_omits_catalog_for_old_codex(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: False)
|
||||
start.write_codex_config(BASE, MODEL, tmp_path)
|
||||
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
|
||||
assert "model_catalog_json" not in profile
|
||||
assert not (tmp_path / "model-catalog.json").exists()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_studio(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
|
|
@ -742,7 +783,12 @@ def test_opencode_inline_config_beats_project_config(fake_studio):
|
|||
assert result.exit_code == 0, result.output
|
||||
inline = _opencode_inline_config(result.output)
|
||||
assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}"
|
||||
assert inline["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
||||
assert inline["permission"] == {
|
||||
"edit": "allow",
|
||||
"bash": "allow",
|
||||
"webfetch": "allow",
|
||||
"external_directory": {"*": "allow"},
|
||||
}
|
||||
assert "sk-unsloth" not in result.output # key stays in the private file, not the env
|
||||
|
||||
|
||||
|
|
@ -1611,12 +1657,50 @@ def test_write_openclaw_config_fresh(tmp_path):
|
|||
]
|
||||
# The default model must be pinned or OpenClaw has nothing active.
|
||||
assert config["agents"]["defaults"]["model"]["primary"] == f"unsloth/{MODEL['id']}"
|
||||
assert config["agents"]["defaults"]["workspace"] == str(tmp_path / "workspace")
|
||||
assert (tmp_path / "workspace").is_dir()
|
||||
assert config["gateway"]["mode"] == "local"
|
||||
assert config["gateway"]["auth"]["mode"] == "none" # unauth loopback gateway
|
||||
if os.name != "nt": # the file holds an API key
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_write_openclaw_config_clears_per_agent_path_overrides(tmp_path):
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {"workspace": "/old/default"},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"default": True,
|
||||
"workspace": "/old/main-workspace",
|
||||
"agentDir": "/old/main-agent",
|
||||
"model": "keep/me",
|
||||
},
|
||||
{
|
||||
"id": "reviewer",
|
||||
"workspace": "/old/reviewer-workspace",
|
||||
"agentDir": "/old/reviewer-agent",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path)
|
||||
|
||||
agents = json.loads(path.read_text())["agents"]
|
||||
assert agents["defaults"]["workspace"] == str(tmp_path / "workspace")
|
||||
assert agents["list"] == [
|
||||
{"id": "main", "default": True, "model": "keep/me"},
|
||||
{"id": "reviewer"},
|
||||
]
|
||||
|
||||
|
||||
def test_write_openclaw_config_preserves_and_idempotent(tmp_path):
|
||||
path = tmp_path / "openclaw.json"
|
||||
path.write_text(
|
||||
|
|
@ -1660,11 +1744,32 @@ def test_connect_openclaw_no_launch(fake_studio, tmp_path):
|
|||
config = json.loads(config_path.read_text())
|
||||
assert config["models"]["providers"]["unsloth"]["apiKey"] == "sk-unsloth-feedfacefeedface"
|
||||
assert config["agents"]["defaults"]["model"]["primary"] == f"unsloth/{MODEL['id']}"
|
||||
assert config["agents"]["defaults"]["workspace"] == str(
|
||||
tmp_path / "agents" / "openclaw" / "workspace"
|
||||
)
|
||||
assert _launch_command(result.output) == ["openclaw", "tui", "--local"]
|
||||
# OpenAI /v1/chat/completions works on either backend — no GGUF gate.
|
||||
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason = "WSL scenario")
|
||||
def test_connect_openclaw_wsl_windows_shim_translates_workspace(fake_studio, tmp_path, monkeypatch):
|
||||
windows_workspace = r"\\wsl.localhost\Ubuntu\tmp\openclaw\workspace"
|
||||
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
|
||||
monkeypatch.setattr(
|
||||
start.shutil, "which", lambda _: "/mnt/c/Users/x/AppData/Roaming/npm/openclaw"
|
||||
)
|
||||
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: windows_workspace)
|
||||
|
||||
result = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
config_path = tmp_path / "agents" / "openclaw" / "openclaw.json"
|
||||
config = json.loads(config_path.read_text())
|
||||
assert config["agents"]["defaults"]["workspace"] == windows_workspace
|
||||
assert (config_path.parent / "workspace").is_dir()
|
||||
|
||||
|
||||
def test_connect_openclaw_no_launch_keeps_explicit_subcommand(fake_studio):
|
||||
result = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch", "crestodian"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
@ -2090,7 +2195,12 @@ def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
|
|||
result = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
|
||||
assert config["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
||||
assert config["permission"] == {
|
||||
"edit": "allow",
|
||||
"bash": "allow",
|
||||
"webfetch": "allow",
|
||||
"external_directory": {"*": "allow"},
|
||||
}
|
||||
|
||||
|
||||
def test_no_yolo_opencode_has_no_permission_block(fake_studio, tmp_path):
|
||||
|
|
@ -2112,6 +2222,7 @@ def test_no_yolo_opencode_flips_prior_yolo_allow_to_ask(fake_studio, tmp_path):
|
|||
"edit": "allow",
|
||||
"bash": "allow",
|
||||
"webfetch": "allow",
|
||||
"external_directory": {"*": "allow"},
|
||||
}
|
||||
plain = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
|
||||
assert plain.exit_code == 0, plain.output
|
||||
|
|
@ -2119,6 +2230,7 @@ def test_no_yolo_opencode_flips_prior_yolo_allow_to_ask(fake_studio, tmp_path):
|
|||
"edit": "ask",
|
||||
"bash": "ask",
|
||||
"webfetch": "ask",
|
||||
"external_directory": {"*": "ask"},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2152,7 +2264,12 @@ def test_write_opencode_config_yolo_unit(tmp_path):
|
|||
path = tmp_path / "opencode.json"
|
||||
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
|
||||
config = json.loads(path.read_text())
|
||||
assert config["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
|
||||
assert config["permission"] == {
|
||||
"edit": "allow",
|
||||
"bash": "allow",
|
||||
"webfetch": "allow",
|
||||
"external_directory": {"*": "allow"},
|
||||
}
|
||||
|
||||
|
||||
def test_write_openclaw_config_yolo_unit(tmp_path):
|
||||
|
|
@ -2180,7 +2297,12 @@ def test_no_launch_rerun_clears_stale_opencode_yolo_permissions(fake_studio, tmp
|
|||
config = json.loads(config_path.read_text())
|
||||
# The yolo allow policy is replaced by a prompting one, not deleted (which would
|
||||
# revert to OpenCode's permissive "allow" default).
|
||||
assert config["permission"] == {"edit": "ask", "bash": "ask", "webfetch": "ask"}
|
||||
assert config["permission"] == {
|
||||
"edit": "ask",
|
||||
"bash": "ask",
|
||||
"webfetch": "ask",
|
||||
"external_directory": {"*": "ask"},
|
||||
}
|
||||
# The session provider survives the cleanup.
|
||||
assert start._OPENCODE_PROVIDER in config["provider"]
|
||||
|
||||
|
|
@ -2218,7 +2340,12 @@ def test_write_opencode_config_yolo_then_plain_unit(tmp_path):
|
|||
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = False)
|
||||
config = json.loads(path.read_text())
|
||||
# A plain rerun replaces the yolo allow policy with a prompting one.
|
||||
assert config["permission"] == {"edit": "ask", "bash": "ask", "webfetch": "ask"}
|
||||
assert config["permission"] == {
|
||||
"edit": "ask",
|
||||
"bash": "ask",
|
||||
"webfetch": "ask",
|
||||
"external_directory": {"*": "ask"},
|
||||
}
|
||||
|
||||
|
||||
def test_openclaw_non_yolo_keeps_runtime_approvals(tmp_path):
|
||||
|
|
@ -2670,8 +2797,7 @@ def test_default_launch_has_no_resume_token(fake_studio, monkeypatch):
|
|||
|
||||
|
||||
def test_resume_persist_only_agents_have_no_resume_token(fake_studio, monkeypatch):
|
||||
# openclaw/hermes persist their session dir but have no non-interactive resume
|
||||
# selector, so --persist must not append a token; their own picker resumes.
|
||||
# Persistence alone must not select a session.
|
||||
for agent in ("openclaw", "hermes"):
|
||||
monkeypatch.setattr(start.shutil, "which", lambda _, a = agent: f"/usr/local/bin/{a}")
|
||||
captured = _capture_launch(monkeypatch, [agent, "--persist"])
|
||||
|
|
@ -2679,6 +2805,135 @@ def test_resume_persist_only_agents_have_no_resume_token(fake_studio, monkeypatc
|
|||
assert "--continue" not in captured["command"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(
|
||||
["--resume", "session-id", "-z", "follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"--resume",
|
||||
"session-id",
|
||||
"-q",
|
||||
"follow up",
|
||||
],
|
||||
),
|
||||
(
|
||||
["-rsession-id", "-zfollow up"],
|
||||
["chat", "-Q", "--yolo", "--accept-hooks", "-rsession-id", "-qfollow up"],
|
||||
),
|
||||
(
|
||||
["-c=project", "-z=follow up"],
|
||||
["chat", "-Q", "--yolo", "--accept-hooks", "-c=project", "-q=follow up"],
|
||||
),
|
||||
(
|
||||
["-r", "session-id", "--oneshot=follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"-r",
|
||||
"session-id",
|
||||
"--query=follow up",
|
||||
],
|
||||
),
|
||||
(
|
||||
["--continue", "project", "--oneshot", "follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"--continue",
|
||||
"project",
|
||||
"-q",
|
||||
"follow up",
|
||||
],
|
||||
),
|
||||
(
|
||||
["--yolo", "--resume", "session-id", "-z", "follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--accept-hooks",
|
||||
"--yolo",
|
||||
"--resume",
|
||||
"session-id",
|
||||
"-q",
|
||||
"follow up",
|
||||
],
|
||||
),
|
||||
(
|
||||
["--accept-hooks", "--resume", "session-id", "-z", "follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"--resume",
|
||||
"session-id",
|
||||
"-q",
|
||||
"follow up",
|
||||
],
|
||||
),
|
||||
(
|
||||
["--resume", "chat", "-z", "follow up"],
|
||||
[
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"--resume",
|
||||
"chat",
|
||||
"-q",
|
||||
"follow up",
|
||||
],
|
||||
),
|
||||
(["--resume", "session-id"], ["--resume", "session-id"]),
|
||||
(["-z", "new session"], ["-z", "new session"]),
|
||||
],
|
||||
)
|
||||
def test_hermes_resume_oneshot_args(args, expected):
|
||||
assert start._hermes_resume_oneshot_args(args) == expected
|
||||
|
||||
|
||||
def test_hermes_resume_oneshot_uses_session_aware_chat(fake_studio, monkeypatch):
|
||||
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/hermes")
|
||||
captured = _capture_launch(
|
||||
monkeypatch,
|
||||
["hermes", "--persist", "--resume", "session-id", "-z", "follow up"],
|
||||
)
|
||||
assert captured["command"][1:] == [
|
||||
"chat",
|
||||
"-Q",
|
||||
"--yolo",
|
||||
"--accept-hooks",
|
||||
"--resume",
|
||||
"session-id",
|
||||
"-q",
|
||||
"follow up",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("usage_arg", ["--usage-file", "--usage-file=usage.json"])
|
||||
def test_hermes_resume_oneshot_rejects_usage_file(monkeypatch, usage_arg):
|
||||
monkeypatch.setattr(
|
||||
start,
|
||||
"_connect",
|
||||
lambda *args, **kwargs: pytest.fail("argument validation must run before connect"),
|
||||
)
|
||||
argv = ["hermes", "--resume", "session-id", "-z", "follow up", usage_arg]
|
||||
if usage_arg == "--usage-file":
|
||||
argv.append("usage.json")
|
||||
result = CliRunner().invoke(start.start_app, argv)
|
||||
assert result.exit_code == 2
|
||||
assert "cannot resume a one-shot session with --usage-file" in result.output
|
||||
|
||||
|
||||
def test_native_resume_flag_passes_through_unchanged(fake_studio, monkeypatch):
|
||||
# The persistence flag is --persist, NOT --resume, so an agent's own
|
||||
# `--resume <id>` (e.g. `unsloth start claude --resume <guid>`) still flows
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
"""Tests for the `--cloudflare/--no-cloudflare` Studio flag.
|
||||
|
||||
Pins the typer Option (default on) on both `unsloth studio` and
|
||||
`unsloth studio run`, and that the chosen polarity reaches the re-exec'd
|
||||
Pins the typer Option (tri-state, default off / None) on both `unsloth studio`
|
||||
and `unsloth studio run`, and that the chosen polarity reaches the re-exec'd
|
||||
child and run_server. Modeled on test_studio_run_parallel_flag.py.
|
||||
"""
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ _BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"]
|
|||
# ── option registration ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_run_exposes_cloudflare_option_default_on():
|
||||
def test_run_exposes_cloudflare_option_default_off():
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(_studio().run)
|
||||
|
|
@ -41,16 +41,16 @@ def test_run_exposes_cloudflare_option_default_on():
|
|||
opt = sig.parameters["cloudflare"].default
|
||||
decls = set(getattr(opt, "param_decls", []) or [])
|
||||
assert "--cloudflare/--no-cloudflare" in decls
|
||||
assert getattr(opt, "default", None) is True
|
||||
assert getattr(opt, "default", "missing") is None
|
||||
|
||||
|
||||
def test_studio_default_exposes_cloudflare_option_default_on():
|
||||
def test_studio_default_exposes_cloudflare_option_default_off():
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(_studio().studio_default)
|
||||
assert "cloudflare" in sig.parameters
|
||||
opt = sig.parameters["cloudflare"].default
|
||||
assert getattr(opt, "default", None) is True
|
||||
assert getattr(opt, "default", "missing") is None
|
||||
|
||||
|
||||
# ── re-exec forwarding: `unsloth studio run` ─────────────────────────
|
||||
|
|
@ -69,6 +69,11 @@ def _install_run_reexec_capture(monkeypatch, *, platform = "linux"):
|
|||
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
||||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
||||
# A built frontend dist is present so the public-launch UI check passes
|
||||
# deterministically (independent of whether the repo dist was built).
|
||||
monkeypatch.setattr(
|
||||
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
||||
)
|
||||
fake_bin = fake_venv / "bin" / "unsloth"
|
||||
real_is_file = Path.is_file
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -107,19 +112,23 @@ def _invoke_run(monkeypatch, args):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_flag,expected,unexpected",
|
||||
"extra_flags,expected,unexpected",
|
||||
[
|
||||
(None, "--cloudflare", "--no-cloudflare"), # default on
|
||||
("--cloudflare", "--cloudflare", "--no-cloudflare"),
|
||||
("--no-cloudflare", "--no-cloudflare", "--cloudflare"),
|
||||
# Default (no flag) forwards --no-cloudflare explicitly so a mixed-version
|
||||
# child venv (old default: --cloudflare on) can't re-enable the tunnel.
|
||||
([], "--no-cloudflare", "--cloudflare"),
|
||||
(["--cloudflare"], "--cloudflare", "--no-cloudflare"),
|
||||
(["--no-cloudflare"], "--no-cloudflare", "--cloudflare"),
|
||||
# --secure implies the tunnel; never forward --no-cloudflare with it.
|
||||
(["--secure"], None, "--no-cloudflare"),
|
||||
],
|
||||
)
|
||||
def test_run_reexec_forwards_cloudflare_polarity(monkeypatch, user_flag, expected, unexpected):
|
||||
extras = [user_flag] if user_flag else []
|
||||
captured = _invoke_run(monkeypatch, _BASE + extras)
|
||||
def test_run_reexec_forwards_cloudflare_polarity(monkeypatch, extra_flags, expected, unexpected):
|
||||
captured = _invoke_run(monkeypatch, _BASE + extra_flags)
|
||||
assert len(captured) == 1, captured
|
||||
argv = captured[0]
|
||||
assert expected in argv, f"expected {expected} in child argv; got {argv}"
|
||||
if expected is not None:
|
||||
assert expected in argv, f"expected {expected} in child argv; got {argv}"
|
||||
assert unexpected not in argv, f"unexpected {unexpected} in child argv; got {argv}"
|
||||
|
||||
|
||||
|
|
@ -142,7 +151,11 @@ def _invoke_studio_default(
|
|||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
||||
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
||||
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
||||
# A built frontend dist is present so the public-launch UI check passes; this
|
||||
# suite exercises flag forwarding, not the missing-dist lockout guard.
|
||||
monkeypatch.setattr(
|
||||
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
||||
)
|
||||
monkeypatch.setattr(sys, "platform", platform)
|
||||
|
||||
def fake_execvp(file, argv):
|
||||
|
|
@ -158,18 +171,24 @@ def _invoke_studio_default(
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_flag,expected,unexpected",
|
||||
"extra_flags,expected,unexpected",
|
||||
[
|
||||
(None, "--cloudflare", "--no-cloudflare"),
|
||||
("--no-cloudflare", "--no-cloudflare", "--cloudflare"),
|
||||
# Default (no flag) forwards --no-cloudflare explicitly: _find_run_py can fall
|
||||
# back to an older studio-venv run.py (default on), so a mixed install must
|
||||
# not re-enable the tunnel.
|
||||
([], "--no-cloudflare", "--cloudflare"),
|
||||
(["--cloudflare"], "--cloudflare", "--no-cloudflare"),
|
||||
(["--no-cloudflare"], "--no-cloudflare", "--cloudflare"),
|
||||
# --secure implies the tunnel; never forward --no-cloudflare with it.
|
||||
(["--secure"], None, "--no-cloudflare"),
|
||||
],
|
||||
)
|
||||
def test_studio_default_reexec_forwards_cloudflare(monkeypatch, user_flag, expected, unexpected):
|
||||
extras = [user_flag] if user_flag else []
|
||||
captured = _invoke_studio_default(monkeypatch, ["-H", "0.0.0.0"] + extras)
|
||||
def test_studio_default_reexec_forwards_cloudflare(monkeypatch, extra_flags, expected, unexpected):
|
||||
captured = _invoke_studio_default(monkeypatch, ["-H", "0.0.0.0"] + extra_flags)
|
||||
assert len(captured) == 1, captured
|
||||
argv = captured[0]
|
||||
assert expected in argv, f"expected {expected}; got {argv}"
|
||||
if expected is not None:
|
||||
assert expected in argv, f"expected {expected}; got {argv}"
|
||||
assert unexpected not in argv, f"unexpected {unexpected}; got {argv}"
|
||||
|
||||
|
||||
|
|
@ -182,7 +201,10 @@ class _RunServerCaptured(SystemExit):
|
|||
self.kwargs = dict(kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_flag,expected", [(None, True), ("--no-cloudflare", False)])
|
||||
@pytest.mark.parametrize(
|
||||
"user_flag,expected",
|
||||
[(None, None), ("--cloudflare", True), ("--no-cloudflare", False)],
|
||||
)
|
||||
def test_run_in_venv_passes_cloudflare_to_run_server(monkeypatch, user_flag, expected):
|
||||
import types
|
||||
|
||||
|
|
@ -348,21 +370,22 @@ def test_run_silent_emits_cloudflare_notice_for_external_bind(monkeypatch):
|
|||
assert ("print", {"secure": False, "loopback_host": "127.0.0.1"}) in calls
|
||||
|
||||
|
||||
# ── parent-level --no-cloudflare with a subcommand is rejected ───────
|
||||
# ── parent-level --cloudflare/--no-cloudflare with a subcommand is rejected ─
|
||||
|
||||
|
||||
def test_studio_default_rejects_no_cloudflare_with_subcommand(monkeypatch):
|
||||
# `unsloth studio --no-cloudflare run ...` would not reach the subcommand,
|
||||
# so it must error (mirrors --parallel) rather than silently still tunnel.
|
||||
@pytest.mark.parametrize("flag", ["--cloudflare", "--no-cloudflare"])
|
||||
def test_studio_default_rejects_cloudflare_flag_with_subcommand(monkeypatch, flag):
|
||||
# `unsloth studio --cloudflare run ...` (or --no-cloudflare) would not reach the
|
||||
# subcommand, so it must error (mirrors --parallel) rather than silently drop it.
|
||||
import typer as _typer
|
||||
|
||||
studio_mod = _studio()
|
||||
app = _typer.Typer()
|
||||
app.add_typer(studio_mod.studio_app, name = "studio")
|
||||
result = CliRunner().invoke(app, ["studio", "--no-cloudflare", "run", "--model", "X"])
|
||||
result = CliRunner().invoke(app, ["studio", flag, "run", "--model", "X"])
|
||||
assert result.exit_code == 2, result.output
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "--no-cloudflare" in combined, combined
|
||||
assert flag in combined, combined
|
||||
|
||||
|
||||
# ── run() tears the server + tunnel down if startup aborts ───────────
|
||||
|
|
|
|||
1397
unsloth_cli/tests/test_studio_password_prompt.py
Normal file
1397
unsloth_cli/tests/test_studio_password_prompt.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,11 @@ def _install_run_reexec_capture(monkeypatch):
|
|||
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
||||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
||||
# A built frontend dist is present so the public-launch UI check passes
|
||||
# deterministically (independent of whether the repo dist was built).
|
||||
monkeypatch.setattr(
|
||||
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
||||
)
|
||||
fake_bin = fake_venv / "bin" / "unsloth"
|
||||
real_is_file = Path.is_file
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -120,7 +125,11 @@ def _invoke_studio_default(monkeypatch, args):
|
|||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
||||
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
||||
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
||||
# A built frontend dist is present so the public-launch UI check passes; this
|
||||
# suite exercises flag forwarding, not the missing-dist lockout guard.
|
||||
monkeypatch.setattr(
|
||||
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
||||
)
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
|
||||
def fake_execvp(file, argv):
|
||||
|
|
@ -172,6 +181,35 @@ def test_studio_default_reexec_forwards_secure(monkeypatch):
|
|||
assert argv[argv.index("--host") + 1] == "127.0.0.1", argv
|
||||
|
||||
|
||||
def test_run_secure_warns_when_host_overridden(monkeypatch):
|
||||
# -H 0.0.0.0 --secure forces the loopback bind; warn (not error) that -H is
|
||||
# ignored so it does not silently read as "secure and on the network".
|
||||
import typer as _typer
|
||||
|
||||
_install_run_reexec_capture(monkeypatch)
|
||||
app = _typer.Typer()
|
||||
app.command(
|
||||
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
||||
)(_studio().run)
|
||||
result = CliRunner().invoke(app, _BASE + ["-H", "0.0.0.0", "--secure"], catch_exceptions = True)
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "ignores -H" in combined, combined
|
||||
|
||||
|
||||
def test_run_secure_no_warning_when_already_loopback(monkeypatch):
|
||||
# --secure with an already-loopback -H must not warn about ignoring -H.
|
||||
import typer as _typer
|
||||
|
||||
_install_run_reexec_capture(monkeypatch)
|
||||
app = _typer.Typer()
|
||||
app.command(
|
||||
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
||||
)(_studio().run)
|
||||
result = CliRunner().invoke(app, _BASE + ["-H", "127.0.0.1", "--secure"], catch_exceptions = True)
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "ignores -H" not in combined, combined
|
||||
|
||||
|
||||
def test_studio_default_not_secure_alias_forwards_no_secure(monkeypatch):
|
||||
# --not-secure on `unsloth studio` forwards the canonical --no-secure.
|
||||
captured = _invoke_studio_default(monkeypatch, ["--not-secure"])
|
||||
|
|
@ -206,13 +244,23 @@ class _RunServerCaptured(SystemExit):
|
|||
self.kwargs = dict(kwargs)
|
||||
|
||||
|
||||
def test_run_in_venv_passes_secure_and_forces_host(monkeypatch):
|
||||
def test_run_in_venv_passes_secure_and_forces_host(monkeypatch, tmp_path):
|
||||
import types
|
||||
|
||||
studio_mod = _studio()
|
||||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
# Real STUDIO_HOME with an already-changed admin (must_change_password=0) so
|
||||
# the pre-exposure gate is a no-op and the in-venv path reaches run_server.
|
||||
# (The gate now fails closed if it cannot open the auth DB, so a fake path
|
||||
# would refuse the launch before this assertion.)
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed = studio_mod._connect_auth_db()
|
||||
studio_mod._ensure_cli_default_admin(_seed)
|
||||
_seed.execute("UPDATE auth_user SET must_change_password = 0")
|
||||
_seed.commit()
|
||||
_seed.close()
|
||||
|
||||
fake_venv = tmp_path / "unsloth_studio"
|
||||
monkeypatch.setattr(sys, "prefix", str(fake_venv))
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", fake_venv.parent)
|
||||
|
||||
from unsloth_cli import _tool_policy as _tp_mod
|
||||
|
||||
|
|
@ -284,6 +332,11 @@ def test_run_secure_resolves_tools_against_loopback(monkeypatch):
|
|||
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
||||
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
||||
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
||||
# A built frontend dist is present so the public-launch UI check passes
|
||||
# deterministically (independent of whether the repo dist was built).
|
||||
monkeypatch.setattr(
|
||||
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
|
||||
)
|
||||
fake_bin = fake_venv / "bin" / "unsloth"
|
||||
real_is_file = Path.is_file
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue