unsloth/unsloth_cli/tests/test_studio_secure_flag.py
Daniel Han 098a6a0957
Studio: honor a request's enable_tools: false instead of overriding it (#8547)
* Studio: honor a request's enable_tools: false instead of overriding it

The process-wide tool policy was an override, not a default. unsloth studio run
installed set_tool_policy(True) at startup, and _effective_enable_tools returned
that value whenever it was non-None, so the request's own enable_tools field was
never read.

The Studio UI sends its tool pills as an explicit request field, and expresses
'every pill off' by omitting enable_tools entirely. Against a True override that
omission read as 'tools on', and with enabled_tools also absent the route
selected ALL_TOOLS, so a chat with every tool switched off still advertised
web_search, python, terminal and render_html. Thread-title generation, which
posts to /v1/chat/completions with no tool fields, picked them up the same way.
Only unsloth studio run installed the policy, so unsloth studio, the desktop app
and Colab behaved correctly and the two commands disagreed on the same UI.

Split the policy into two slots. The override still comes from an explicit
--enable-tools/--disable-tools and still beats the request. The new default is
what an omitted enable_tools falls back to, installed as True by every launcher,
so tools stay on for every bind including --secure. A request that says
enable_tools: false now turns them off.

Frontend sends the off state explicitly rather than by omission, in the local
chat, external provider and token-count paths, and pins enable_tools: false on
title generation so a 24-token summarisation never carries tool schemas.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: do not let the tools-on default answer a request that stated its intent

The safetensors/MLX path resolves _sf_tools_on straight from
_effective_enable_tools, without the two withdrawals the GGUF router applies. So
the launcher default introduced here reached requests that had already expressed
their own tool intent:

- tool_choice: "none" with enable_tools omitted resolved to tools on, and with
  no enabled_tools allowlist that selected every built-in, python and terminal
  included, turning a standard opt-out into server-side execution.
- A client tools catalog with enable_tools omitted made _sf_client_tools false,
  so the request left the client-tool passthrough for Unsloth's own loop and the
  caller got built-ins instead of calls for its own functions.

The GGUF router avoids both with _client_disabled_tool_calls and
_explicit_studio_tool_loop_requested. Draw the same line on the safetensors gate:
the default only answers a request that said nothing, so tool_choice: "none",
a client catalog, or tool-result history withdraws it. An explicit
enable_tools/mcp_enabled ask, and a CLI --enable-tools or --disable-tools, are
unchanged.

Also read the resolved _sf_tools_on in the _sf_client_tools gate rather than
recomputing _effective_enable_tools, which would have hidden the withdrawal.

* Studio: let a response_format contract withdraw the tools-on default too

_takes_tool_passthrough already ends with _extract_response_format(payload) is
not None, so on the GGUF router a structured-output request keeps the passthrough
and never enters the server tool loop. The safetensors withdrawal missed it, so a
request supplying response_format while omitting enable_tools still resolved
_sf_tools_on to true and could select and run every built-in.

response_format is not a declared field on ChatCompletionRequest; the model is
extra=allow and OpenAI-SDK clients spread extra_body at the top level, so read it
through _extract_response_format rather than an attribute.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: resolve the tool policy before protocol selection, and scope the default to unsloth studio run

Two fixes for the same root cause: the tools-on default reaching code built
around an omitted enable_tools meaning no tools.

_sf_server_tool_intent read the raw policy while the withdrawal ran ~40 lines
later, so a tool_choice: "none" or response_format request classified the
response protocol on the template's tool_use branch and then generated on the
plain one. On a model whose reasoning markers live only in the tool template the
extractor starts in the wrong mode and can return the answer as
reasoning_content. Resolve _sf_cli_policy / _sf_tools_on / _sf_mcp_allowed once,
above the classification, and derive the intent from the resolved value.

The default is also no longer installed by run_server. It belongs to
unsloth studio run, the launcher that has always forced tools on, and which
installs it itself. Installing it in _apply_cli_tool_policy extended it to
unsloth studio, the desktop app and Colab, where paths that assume an omitted
enable_tools means no tools started seeing it: n > 1 is rejected by the tool
loop though the plain path implements it, max_tool_calls_per_message: 0 still
advertises schemas and the nudge, and the pre-switch passthrough guard does not
recognise tool-result history so it 400s a non-streaming continuation. Those
paths predate this PR and are unchanged on unsloth studio run; scoping the
default keeps them that way everywhere else.

unsloth studio run still defaults tools on for every bind, --secure included,
and a request's enable_tools: false is still honored.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: correct the tool-policy help and docstrings for the scoped default

Scoping the tools-on default to unsloth studio run left three places claiming it
applies everywhere. The --help for plain unsloth studio and for a direct
run.py launch both said 'Default: on for every bind', which is now the opposite
of what those launchers do, and the tool_policy module said 'Launchers install
True'. A --help line about a tool-execution default is worth keeping exact.

unsloth studio run's own help is unchanged, since it is the launcher that does
default them on.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-12 06:26:58 -07:00

453 lines
17 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/--no-secure` Unsloth 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/--no-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/--no-secure" in decls
assert getattr(opt, "default", None) is False
def test_secure_exposes_hidden_not_secure_alias():
# --not-secure is a hidden, deprecated alias for --no-secure on both commands.
import inspect
for fn in (_studio().run, _studio().studio_default):
opt = inspect.signature(fn).parameters["not_secure"].default
decls = set(getattr(opt, "param_decls", []) or [])
assert "--not-secure" in decls
assert getattr(opt, "hidden", False) is True
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")
# 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(
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"))
# 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):
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, "--no-secure", "--secure"), # default off
("--secure", "--secure", "--no-secure"),
("--no-secure", "--no-secure", "--secure"),
("--not-secure", "--no-secure", "--secure"), # deprecated alias -> canonical
],
)
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
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"])
assert len(captured) == 1, captured
argv = captured[0]
assert "--no-secure" in argv and "--secure" not in argv, argv
@pytest.mark.parametrize(
"argv_order,expected,unexpected",
[
# --not-secure tracks --no-secure: the last secure flag on argv wins,
# matching the backend BooleanOptionalAction.
(["--secure", "--not-secure"], "--no-secure", "--secure"),
(["--not-secure", "--secure"], "--secure", "--no-secure"),
],
)
def test_run_not_secure_alias_respects_last_wins(monkeypatch, argv_order, expected, unexpected):
monkeypatch.setattr(sys, "argv", ["unsloth", "studio", "run", *argv_order])
captured = _invoke_run(monkeypatch, _BASE + argv_order)
assert len(captured) == 1, captured
argv = captured[0]
assert expected in argv and unexpected not in argv, 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, tmp_path, stub_tool_policy_state):
import types
studio_mod = _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))
# A built dist is not present in a fresh clone, and without it the public
# launch gate exits before run_server is ever reached.
monkeypatch.setattr(
studio_mod, "_find_frontend_dist", lambda: Path("/fake/studio/frontend/dist")
)
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 loopback bind (no flag -> no override) ──
def test_run_secure_resolves_tools_against_loopback(monkeypatch):
# --secure is a loopback bind behind an authenticated tunnel, so tools resolve
# against 127.0.0.1. With no flag the resolver returns None, and the child gets
# neither --enable-tools nor --disable-tools (per-request enable_tools decides).
studio_mod = _studio()
monkeypatch.setattr(sys, "prefix", "/nonexistent/outer/venv")
fake_venv = Path("/fake/studio/venv/unsloth_studio")
monkeypatch.setattr(studio_mod, "_studio_venv_python", lambda: fake_venv / "bin" / "python")
# A built frontend dist is present 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(
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 flag # no flag -> None -> no process-wide override
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)
# Resolved against the forced-loopback bind, not the public 0.0.0.0 exposure.
assert calls and calls[0] == "127.0.0.1", calls
assert len(captured) == 1, captured
assert "--enable-tools" not in captured[0], captured[0]
assert "--disable-tools" not in captured[0], captured[0]
def test_run_secure_enable_tools_no_auto_yes(monkeypatch):
# No prompt now, so a secure --enable-tools forwards --enable-tools but not
# --yes (only an explicit --yes is forwarded).
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, argv
assert "--yes" not in argv, argv
# ── plain `unsloth studio` exposes + forwards --enable-tools/--disable-tools ──
def test_studio_default_exposes_enable_tools_option_default_none():
import inspect
opt = inspect.signature(_studio().studio_default).parameters["enable_tools"].default
decls = set(getattr(opt, "param_decls", []) or [])
assert "--enable-tools/--disable-tools" in decls
assert opt.default is None # tri-state: omitted -> leave policy unset (tools on)
def test_studio_default_forwards_disable_tools(monkeypatch):
captured = _invoke_studio_default(monkeypatch, ["--disable-tools"])
assert len(captured) == 1, captured
assert "--disable-tools" in captured[0] and "--enable-tools" not in captured[0], captured[0]
def test_studio_default_forwards_enable_tools(monkeypatch):
captured = _invoke_studio_default(monkeypatch, ["--enable-tools"])
assert len(captured) == 1, captured
assert "--enable-tools" in captured[0] and "--disable-tools" not in captured[0], captured[0]
def test_studio_default_no_tool_flag_omits_both(monkeypatch):
# No flag -> neither flag forwarded; run.py leaves the policy unset (tools on).
captured = _invoke_studio_default(monkeypatch, [])
assert len(captured) == 1, captured
assert "--enable-tools" not in captured[0] and "--disable-tools" not in captured[0], captured[0]
def test_studio_default_rejects_enable_tools_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", "--enable-tools", "run", "--model", "X"])
assert result.exit_code == 2, result.output
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
assert "--enable-tools" in combined, combined
def test_run_tool_help_reflects_default_on_everywhere():
# Help must match the policy (tools on by default everywhere, no prompt).
import inspect
params = inspect.signature(_studio().run).parameters
tools_help = params["enable_tools"].default.help or ""
assert "on for every bind" in tools_help, tools_help
assert "enable_tools: false" in tools_help, tools_help
assert "0.0.0.0" not in tools_help, tools_help
yes_help = params["yes"].default.help or ""
assert "Skip the 0.0.0.0" not in yes_help, yes_help
assert "no longer prompts" in yes_help, yes_help