* Studio: add --secure Cloudflare-only mode and revamp API usage examples --secure / --not-secure on `unsloth studio` and `unsloth studio run`: - --secure binds 127.0.0.1, requires the Cloudflare tunnel, and advertises only the Cloudflare link. cloudflared reaches the server over localhost, so the raw port is never exposed on a public interface. - If the tunnel cannot start, fail closed with a clear message instead of silently leaving a raw 0.0.0.0 link. - Default stays not-secure (no behavior change); coexists with the existing --cloudflare/--no-cloudflare flag. Host defaults are unchanged. - /api/health (authed) now reports the live tunnel URL. API usage examples (Profile > API): - Example tabs for curl, Python, curl + tools, Python + tools, plus an OS row (Linux/macOS/WSL vs Windows) auto-detected from the platform. - Windows curl passes the JSON body via a file so PowerShell does not strip the quotes when calling curl.exe. - Python + tools forwards enable_tools/enabled_tools through extra_body and guards chunk.choices, since tool-lifecycle events carry no choices. - Shows the loaded model name and the real API key while it is still revealed. - A Cloudflare Tunnel toggle (default on) shows the public tunnel URL and uses it as the base_url in the examples when a tunnel is running. Tests cover the tunnel start gate and the --secure flag on both commands. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate --secure tools on public exposure and harden API examples In secure mode the server binds loopback but is reachable via the public Cloudflare tunnel, so resolve the tool policy against the public exposure (0.0.0.0) rather than the loopback bind. This keeps server-side tools off by default and prompts before enabling them, instead of inheriting the loopback default of on. The startup tool notice now names the public surface. Also reject --secure with --no-cloudflare directly in run_server and the run.py argparse (not only the CLI), JSON-encode interpolated model names so Windows paths and quotes cannot produce invalid JSON or broken snippets, and force-refresh /api/health on the API panel so a tunnel that starts after the first health read still surfaces its URL. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: API examples show direct host when tunnel toggle is off; move Copy onto code The Cloudflare Tunnel toggle had no visible effect when Studio was opened through the tunnel: the off state fell back to window.location.origin, which equals the tunnel URL in that case. /api/health now reports the direct host:port (server_url), and the API panel uses it for the off state so it shows the real non-tunnel base. Also move the Copy button out of the tab row and onto the code block. * Studio: highlight API examples, add advanced tabs, fix tunnel toggle row Syntax-highlight the curl/PowerShell/Python snippets with the app's shared shiki plugin (bash/powershell/python). Add 'curl + advanced' and 'Python + advanced' tabs that set temperature/top_p/top_k/min_p/ repetition_penalty/max_tokens, enable thinking, and turn on all tools. The Cloudflare Tunnel row no longer shifts the code block: the tunnel URL is always rendered (dimmed when off) so toggling keeps the row height constant. Key the highlighted block on its content so it remounts when only the base URL changes (the renderer's block memo otherwise kept a stale URL). * Studio: rename API tunnel toggle to Secure HTTPS, hint --secure when exposed Rename the API examples toggle from Cloudflare Tunnel to Secure HTTPS. When the server was not launched with --secure, show an info tooltip noting the raw 0.0.0.0 port is still globally reachable and pointing at --secure. /api/health now reports whether --secure was used so the hint is hidden in secure mode. * Studio: force tools off for plain network/secure launches The plain 'unsloth studio --secure' (and '-H 0.0.0.0') launcher re-execs run.py and never installed a tool policy, so the process default (honor per-request enable_tools) let any API-key holder run Python/terminal tools over the public endpoint. Force the policy off at the run.py entrypoint when network-reachable (0.0.0.0 or --secure); 'unsloth studio run' still installs its own resolved policy and does not go through this path. * Studio: apply default tool policy in run_server, not the run.py entrypoint The plain launcher runs from the studio venv and calls run_server directly, so it never hit the run.py __main__ guard. Move the network/secure default-off tool policy into run_server so every launch path (plain, --secure, direct run.py) gets it; the run subcommand still overrides it with its resolved policy. * Studio: clarify --secure help text on the network exposure tradeoff Spell out in --help (both unsloth studio and unsloth studio run, plus the run.py argparse) that --not-secure also serves the raw 0.0.0.0 port reachable from anywhere on the network, matching the API panel's Secure HTTPS hint. * Studio: cache API-key PBKDF2 derivation to cut per-request /v1 auth overhead validate_api_key re-ran the 100k-round PBKDF2 on every authenticated request, adding ~15ms to each /v1 call made with an sk-unsloth- key. Benchmarked against the bare llama-server it proxies to, API-key requests carried ~22ms of fixed overhead vs ~7ms for the JWT path; the gap was entirely this redundant key derivation (Pydantic validation measured 0.005ms, so it is not a factor). The raw-key to hash mapping is a pure deterministic function of the fixed server salt, so memoize it per process, keyed by a salted HMAC of the key (never the key or a recoverable digest). The cached value equals what is already stored at rest. Revocation and expiry remain enforced by the SQLite read on every call, so a cache hit only skips the KDF, never the active or expiry checks. Only keys that exist in the DB are cached, so unknown-key spam cannot grow it. After the change the API-key /v1 overhead drops to ~8ms, at parity with JWT, while the at-rest PBKDF2 hashing is unchanged. Adds test_api_key_expiry.py covering API-key and JWT expiry enforcement and the new cache: it skips the KDF on repeat and still rejects revoked or expired keys. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten comments across the secure-tunnel and API-key changes Condense multi-line comments and docstrings to one or two lines, drop the ones that restate obvious code, and remove an orphaned test section header. Comment-only: verified with comment_tools.py check (9/9 code unchanged), the auth/secure-tunnel/CLI test suites, and a clean frontend typecheck and build. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
294 lines
9.9 KiB
Python
294 lines
9.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the `--secure/--not-secure` Studio flag: option registration,
|
|
re-exec/run_server forwarding, the forced 127.0.0.1 bind, and rejection
|
|
alongside --no-cloudflare or before a subcommand. Modeled on
|
|
test_studio_cloudflare_flag.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
def _studio():
|
|
from unsloth_cli.commands import studio as _studio_mod
|
|
return _studio_mod
|
|
|
|
|
|
_BASE = ["--model", "unsloth/Qwen3-1.7B-GGUF"]
|
|
|
|
|
|
# ── option registration ──────────────────────────────────────────────
|
|
|
|
|
|
def test_run_exposes_secure_option_default_off():
|
|
import inspect
|
|
|
|
opt = inspect.signature(_studio().run).parameters["secure"].default
|
|
decls = set(getattr(opt, "param_decls", []) or [])
|
|
assert "--secure/--not-secure" in decls
|
|
assert getattr(opt, "default", None) is False
|
|
|
|
|
|
def test_studio_default_exposes_secure_option_default_off():
|
|
import inspect
|
|
|
|
opt = inspect.signature(_studio().studio_default).parameters["secure"].default
|
|
decls = set(getattr(opt, "param_decls", []) or [])
|
|
assert "--secure/--not-secure" in decls
|
|
assert getattr(opt, "default", None) is False
|
|
|
|
|
|
# ── re-exec capture plumbing (mirrors test_studio_cloudflare_flag.py) ─
|
|
|
|
|
|
class _ExecCaptured(SystemExit):
|
|
def __init__(self, argv):
|
|
super().__init__(0)
|
|
self.argv = list(argv)
|
|
|
|
|
|
def _install_run_reexec_capture(monkeypatch):
|
|
studio_mod = _studio()
|
|
captured = []
|
|
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")
|
|
fake_bin = fake_venv / "bin" / "unsloth"
|
|
real_is_file = Path.is_file
|
|
monkeypatch.setattr(
|
|
Path,
|
|
"is_file",
|
|
lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
|
|
)
|
|
from unsloth_cli import _tool_policy as _tp_mod
|
|
|
|
monkeypatch.setattr(
|
|
_tp_mod,
|
|
"resolve_tool_policy",
|
|
lambda host, flag, yes, silent: False if flag is None else bool(flag),
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
captured.append(list(argv))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
return captured
|
|
|
|
|
|
def _invoke_run(monkeypatch, args):
|
|
import typer as _typer
|
|
|
|
captured = _install_run_reexec_capture(monkeypatch)
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(_studio().run)
|
|
CliRunner().invoke(app, args, catch_exceptions = True)
|
|
return captured
|
|
|
|
|
|
def _invoke_studio_default(monkeypatch, args):
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
captured = []
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
monkeypatch.setattr(studio_mod, "_ensure_studio_env_exported", lambda: None)
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
monkeypatch.setattr(studio_mod, "_find_run_py", lambda: Path("/fake/studio/run.py"))
|
|
monkeypatch.setattr(studio_mod, "_find_frontend_dist", lambda: None)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
def fake_execvp(file, argv):
|
|
captured.append(list(argv))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
app = _typer.Typer()
|
|
app.command()(studio_mod.studio_default)
|
|
CliRunner().invoke(app, args, catch_exceptions = True)
|
|
return captured
|
|
|
|
|
|
# ── re-exec forwarding ────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_flag,expected,unexpected",
|
|
[
|
|
(None, "--not-secure", "--secure"), # default off
|
|
("--secure", "--secure", "--not-secure"),
|
|
("--not-secure", "--not-secure", "--secure"),
|
|
],
|
|
)
|
|
def test_run_reexec_forwards_secure_polarity(monkeypatch, user_flag, expected, unexpected):
|
|
extras = [user_flag] if user_flag else []
|
|
captured = _invoke_run(monkeypatch, _BASE + extras)
|
|
assert len(captured) == 1, captured
|
|
argv = captured[0]
|
|
assert expected in argv and unexpected not in argv, argv
|
|
|
|
|
|
def test_run_secure_forces_localhost_in_reexec(monkeypatch):
|
|
# `unsloth studio run -H 0.0.0.0 --secure` must re-exec with --host 127.0.0.1.
|
|
captured = _invoke_run(monkeypatch, _BASE + ["-H", "0.0.0.0", "--secure"])
|
|
assert len(captured) == 1, captured
|
|
argv = captured[0]
|
|
assert "--secure" in argv
|
|
assert argv[argv.index("--host") + 1] == "127.0.0.1", argv
|
|
|
|
|
|
def test_studio_default_reexec_forwards_secure(monkeypatch):
|
|
captured = _invoke_studio_default(monkeypatch, ["-H", "0.0.0.0", "--secure"])
|
|
assert len(captured) == 1, captured
|
|
argv = captured[0]
|
|
assert "--secure" in argv
|
|
# studio_default also forces the loopback bind under --secure.
|
|
assert argv[argv.index("--host") + 1] == "127.0.0.1", argv
|
|
|
|
|
|
# ── in-venv path forwards secure + forced host into run_server ────────
|
|
|
|
|
|
class _RunServerCaptured(SystemExit):
|
|
def __init__(self, kwargs):
|
|
super().__init__(0)
|
|
self.kwargs = dict(kwargs)
|
|
|
|
|
|
def test_run_in_venv_passes_secure_and_forces_host(monkeypatch):
|
|
import types
|
|
|
|
studio_mod = _studio()
|
|
fake_venv = Path("/fake/studio/venv/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
|
|
|
|
monkeypatch.setattr(
|
|
_tp_mod,
|
|
"resolve_tool_policy",
|
|
lambda host, flag, yes, silent: False if flag is None else bool(flag),
|
|
)
|
|
|
|
captured: dict = {}
|
|
|
|
def fake_run_server(**kwargs):
|
|
captured.update(kwargs)
|
|
raise _RunServerCaptured(kwargs)
|
|
|
|
fake_backend_run = sys.modules.setdefault(
|
|
"studio.backend.run", types.ModuleType("studio.backend.run")
|
|
)
|
|
fake_backend_run.run_server = fake_run_server
|
|
fake_backend_run._resolve_external_ip = lambda: "127.0.0.1"
|
|
monkeypatch.setattr(studio_mod, "_RUN_MODULE", fake_backend_run)
|
|
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
CliRunner().invoke(app, _BASE + ["-H", "0.0.0.0", "--secure"], catch_exceptions = True)
|
|
|
|
assert captured.get("secure") is True, captured
|
|
assert captured.get("host") == "127.0.0.1", captured
|
|
|
|
|
|
# ── --secure + --no-cloudflare is rejected ───────────────────────────
|
|
|
|
|
|
def test_run_secure_rejects_no_cloudflare(monkeypatch):
|
|
studio_mod = _studio()
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
result = CliRunner().invoke(app, _BASE + ["--secure", "--no-cloudflare"])
|
|
assert result.exit_code == 2, result.output
|
|
|
|
|
|
def test_studio_default_rejects_secure_with_subcommand():
|
|
import typer as _typer
|
|
|
|
studio_mod = _studio()
|
|
app = _typer.Typer()
|
|
app.add_typer(studio_mod.studio_app, name = "studio")
|
|
result = CliRunner().invoke(app, ["studio", "--secure", "run", "--model", "X"])
|
|
assert result.exit_code == 2, result.output
|
|
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
|
assert "--secure" in combined, combined
|
|
|
|
|
|
# ── secure resolves tools against the PUBLIC exposure, not the loopback bind ──
|
|
|
|
|
|
def test_run_secure_resolves_tools_against_public_host(monkeypatch):
|
|
# --secure is public via the tunnel, so tools resolve against 0.0.0.0 (OFF), not loopback (ON).
|
|
studio_mod = _studio()
|
|
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
|
|
fake_venv = Path("/fake/studio/venv/unsloth_studio")
|
|
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
|
|
fake_bin = fake_venv / "bin" / "unsloth"
|
|
real_is_file = Path.is_file
|
|
monkeypatch.setattr(
|
|
Path,
|
|
"is_file",
|
|
lambda self: True if str(self) == str(fake_bin) else real_is_file(self),
|
|
)
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
|
|
from unsloth_cli import _tool_policy as _tp_mod
|
|
|
|
calls = []
|
|
|
|
def rec(host, flag, yes, silent):
|
|
calls.append(host)
|
|
return (not _tp_mod.is_external_host(host)) if flag is None else bool(flag)
|
|
|
|
monkeypatch.setattr(_tp_mod, "resolve_tool_policy", rec)
|
|
|
|
captured = []
|
|
|
|
def fake_execvp(file, argv):
|
|
captured.append(list(argv))
|
|
raise _ExecCaptured(argv)
|
|
|
|
monkeypatch.setattr(studio_mod.os, "execvp", fake_execvp)
|
|
|
|
import typer as _typer
|
|
|
|
app = _typer.Typer()
|
|
app.command(
|
|
context_settings = {"allow_extra_args": True, "ignore_unknown_options": True},
|
|
)(studio_mod.run)
|
|
CliRunner().invoke(app, _BASE + ["-H", "0.0.0.0", "--secure"], catch_exceptions = True)
|
|
|
|
assert calls and calls[0] == "0.0.0.0", calls
|
|
assert len(captured) == 1, captured
|
|
assert "--disable-tools" in captured[0] and "--enable-tools" not in captured[0], captured[0]
|
|
|
|
|
|
def test_run_secure_enable_tools_forwards_yes(monkeypatch):
|
|
# Enabling tools on a secure endpoint forwards --yes so the child doesn't re-prompt.
|
|
captured = _invoke_run(monkeypatch, _BASE + ["-H", "0.0.0.0", "--secure", "--enable-tools"])
|
|
assert len(captured) == 1, captured
|
|
argv = captured[0]
|
|
assert "--enable-tools" in argv and "--yes" in argv, argv
|