* Rebuild Studio branch on top of main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix security and code quality issues for Studio PR #4237 - Validate models_dir query param against allowed directory roots to prevent path traversal in /api/models/local endpoint - Replace string startswith() with Path.is_relative_to() for frontend path traversal check in serve_frontend - Sanitize SSE error messages to not leak exception details to clients (4 locations in inference.py) - Bind port-discovery socket to 127.0.0.1 instead of all interfaces in llama_cpp backend - Import datasets_root and resolve_output_dir in embedding training function to fix NameError and use managed output directory - Remove stale .gitignore entries for package-lock.json and test directories so tests can be tracked in version control - Add venv-reexecution logic to ui CLI command matching the studio command behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move models_dir path validation before try/except block The HTTPException(403) was inside the try/except Exception handler, so it would be caught and re-raised as a 500. Moving the validation before the try block ensures the 403 is returned directly and also makes the control flow clearer for static analysis (path is validated before any filesystem operations). * Use os.path.realpath + startswith for models_dir validation CodeQL py/path-injection does not recognize Path.is_relative_to() as a sanitizer. Switched to os.path.realpath + str.startswith which is a recognized sanitizer pattern in CodeQL's taint analysis. The startswith check uses root_str + os.sep to prevent prefix collisions (e.g. /app/models_evil matching /app/models). * Never pass user input to Path constructor in models_dir validation CodeQL traces taint through Path(resolved) even after a startswith barrier guard. Fix: the user-supplied models_dir is only used as a string for comparison against allowed roots. The Path object passed to _scan_models_dir comes from the trusted allowed_roots list, not from user input. This fully breaks the taint chain. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
144 lines
4.7 KiB
Python
144 lines
4.7 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
|
|
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from cli.config import Config, load_config
|
|
from cli.options import add_options_from_config
|
|
|
|
|
|
@add_options_from_config(Config)
|
|
def train(
|
|
config: Optional[Path] = typer.Option(
|
|
None,
|
|
"--config",
|
|
"-c",
|
|
help = "Path to YAML/JSON config file. CLI flags override config values.",
|
|
),
|
|
hf_token: Optional[str] = typer.Option(
|
|
None, "--hf-token", envvar = "HF_TOKEN", help = "Hugging Face token if needed."
|
|
),
|
|
wandb_token: Optional[str] = typer.Option(
|
|
None, "--wandb-token", envvar = "WANDB_API_KEY", help = "Weights & Biases API key."
|
|
),
|
|
dry_run: bool = typer.Option(
|
|
False,
|
|
"--dry-run",
|
|
help = "Show resolved config and exit without training.",
|
|
),
|
|
config_overrides: dict = None,
|
|
):
|
|
"""Launch training using the existing Unsloth training backend."""
|
|
try:
|
|
cfg = load_config(config)
|
|
except FileNotFoundError as e:
|
|
typer.echo(f"Error: {e}", err = True)
|
|
raise typer.Exit(code = 2)
|
|
|
|
cfg.apply_overrides(**config_overrides)
|
|
|
|
# CLI/env tokens take precedence over config
|
|
# Handle case where typer.Option isn't resolved (decorator interaction)
|
|
from typer.models import OptionInfo
|
|
|
|
if isinstance(hf_token, OptionInfo):
|
|
hf_token = None
|
|
if isinstance(wandb_token, OptionInfo):
|
|
wandb_token = None
|
|
hf_token = hf_token or cfg.logging.hf_token
|
|
wandb_token = wandb_token or cfg.logging.wandb_token
|
|
|
|
if dry_run:
|
|
import yaml
|
|
|
|
data = cfg.model_dump()
|
|
data["training"]["output_dir"] = str(data["training"]["output_dir"])
|
|
typer.echo(yaml.dump(data, default_flow_style = False, sort_keys = False))
|
|
raise typer.Exit(code = 0)
|
|
|
|
if not cfg.model:
|
|
typer.echo("Error: provide --model or set model in --config", err = True)
|
|
raise typer.Exit(code = 2)
|
|
|
|
if not cfg.data.dataset and not cfg.data.local_dataset:
|
|
typer.echo(
|
|
"Error: provide --dataset or --local-dataset (or via --config)", err = True
|
|
)
|
|
raise typer.Exit(code = 2)
|
|
|
|
# Check if the model path is a LoRA adapter (has adapter_config.json)
|
|
model_path = Path(cfg.model) if cfg.model else None
|
|
model_is_lora = (
|
|
model_path
|
|
and model_path.is_dir()
|
|
and (model_path / "adapter_config.json").exists()
|
|
)
|
|
use_lora = cfg.training.training_type.lower() == "lora"
|
|
|
|
if model_is_lora and not use_lora:
|
|
typer.echo(
|
|
"Error: Cannot do full finetuning on a LoRA adapter. "
|
|
"Use --training-type lora or provide a base model.",
|
|
err = True,
|
|
)
|
|
raise typer.Exit(code = 2)
|
|
|
|
from studio.backend.core.training.trainer import UnslothTrainer
|
|
|
|
trainer = UnslothTrainer()
|
|
|
|
# Load model (trainer.is_vlm is set after this)
|
|
if not trainer.load_model(
|
|
model_name = cfg.model,
|
|
max_seq_length = cfg.training.max_seq_length,
|
|
load_in_4bit = cfg.training.load_in_4bit if use_lora else False,
|
|
hf_token = hf_token,
|
|
):
|
|
typer.echo("Model load failed", err = True)
|
|
raise typer.Exit(code = 1)
|
|
|
|
is_vision = trainer.is_vlm
|
|
|
|
if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)):
|
|
typer.echo("Model preparation failed", err = True)
|
|
raise typer.Exit(code = 1)
|
|
|
|
result = trainer.load_and_format_dataset(
|
|
dataset_source = cfg.data.dataset or "",
|
|
format_type = cfg.data.format_type,
|
|
local_datasets = cfg.data.local_dataset,
|
|
)
|
|
if result is None:
|
|
typer.echo("Dataset load failed", err = True)
|
|
raise typer.Exit(code = 1)
|
|
|
|
ds, eval_ds = result
|
|
|
|
training_kwargs = cfg.training_kwargs()
|
|
training_kwargs["wandb_token"] = wandb_token # CLI/env takes precedence
|
|
started = trainer.start_training(
|
|
dataset = ds, eval_dataset = eval_ds, **training_kwargs
|
|
)
|
|
|
|
if not started:
|
|
typer.echo("Training failed to start", err = True)
|
|
raise typer.Exit(code = 1)
|
|
|
|
try:
|
|
while trainer.training_thread and trainer.training_thread.is_alive():
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
typer.echo("Stopping training (Ctrl+C detected)...")
|
|
trainer.stop_training()
|
|
finally:
|
|
if trainer.training_thread:
|
|
trainer.training_thread.join()
|
|
|
|
final = trainer.get_training_progress()
|
|
if getattr(final, "error", None):
|
|
typer.echo(f"Training error: {final.error}", err = True)
|
|
raise typer.Exit(code = 1)
|