diff --git a/.github/scripts/agent-guides-drive.sh b/.github/scripts/agent-guides-drive.sh
index 3c7cea919c..f4189a159e 100755
--- a/.github/scripts/agent-guides-drive.sh
+++ b/.github/scripts/agent-guides-drive.sh
@@ -6,12 +6,12 @@
# Local Agent Guides CI. All failures from here are failure class (c)
# "guide drift": the server preflight already passed and the agent CLI
# already installed, so a failure here means the documented recipe in
-# unsloth_cli/commands/connect.py no longer produces a working flow.
+# unsloth_cli/commands/start.py no longer produces a working flow.
#
-# Self-updating: for the 5 agents with a connect.py recipe we obtain the
-# exact env + command from `unsloth connect --no-launch` and run
-# THAT, so a recipe change is exercised automatically. Pi (no connect.py
-# command at HEAD) is driven by a hand-written recipe.
+# Self-updating: for all six agents (claude, codex, hermes, openclaw,
+# opencode, pi) we obtain the exact env + command from
+# `unsloth start --no-launch` and run THAT, so a recipe change is
+# exercised automatically.
#
# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt
# can never hang the runner -- a timeout is reported as guide drift with a
@@ -53,14 +53,14 @@ REDACTED_DIR="$REPO_ROOT/redacted-configs"
WORKDIR_BASE="$REPO_ROOT/agent-workdir"
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
-CONNECT_REF="unsloth_cli/commands/connect.py"
+CONNECT_REF="unsloth_cli/commands/start.py"
# Prefill-shrinking flags for Claude Code. The heavyweight agents send
# multi-thousand-token system prompts + full tool schemas, which on a CPU-only
# runner is minutes of prefill per model round-trip (~16 tok/s for a 4B model).
# Replacing the ~5.7k default system prompt with a tiny one (--system-prompt-file)
# and restricting tools cuts the prefill to a few hundred tokens so it completes
-# quickly on CPU. These only shape the request size; the connect.py recipe
+# quickly on CPU. These only shape the request size; the start.py recipe
# (endpoint, auth, model) is still exercised end to end.
#
# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured
@@ -105,6 +105,13 @@ redact() {
done
}
+# Print a file to the log with the key scrubbed, without mutating it (the raw file is
+# still needed to parse the real env). Use this instead of `cat` for any transcript that
+# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs.
+cat_redacted() {
+ sed "s#${UNSLOTH_API_KEY}##g" "$1"
+}
+
# A reply must be non-empty and free of connection/auth errors.
assert_reply() {
local out="$1"
@@ -131,45 +138,34 @@ run_timed() { # $1=outfile, rest=command
return "$rc"
}
-# ── Pi: no connect.py command at HEAD -> hand-written recipe ──────────────
-write_pi_config() {
- if unsloth connect pi --help >/dev/null 2>&1; then
- # Tripwire: once a real recipe exists, the hand-written config would mask any
- # drift in it, defeating the point of this CI. Fail hard so the cell is
- # migrated to the self-updating `unsloth connect pi --no-launch` path.
- guide_fail "connect.py now ships a 'pi' command -- migrate this CI cell to the 'unsloth connect pi --no-launch' path so the documented recipe is exercised (the hand-written Pi config no longer reflects it)"
- fi
- mkdir -p "$HOME/.pi/agent"
- python3 - "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY" "$UNSLOTH_MODEL_ID" <<'PY'
-import json, os, sys
-base, key, model = sys.argv[1], sys.argv[2], sys.argv[3]
-cfg = {"providers": {"unsloth": {
- "api": "openai-completions",
- "baseUrl": f"{base}/v1",
- "apiKey": key,
- "models": [{"id": model}],
-}}}
-path = os.path.expanduser("~/.pi/agent/models.json")
-with open(path, "w") as fh:
- json.dump(cfg, fh, indent=2)
-PY
- cp "$HOME/.pi/agent/models.json" "$REDACTED_DIR/pi-models.json" 2>/dev/null || true
- redact "$REDACTED_DIR/pi-models.json"
+# Read a value from an `export VAR=...` line in the connect --no-launch output.
+# `unsloth start` writes each agent's session config off the user's ~ and points
+# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG /
+# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here.
+raw_env() { # $1 = var name -> value (one shlex-quote layer stripped)
+ local raw="$LOGS_DIR/connect-${AGENT}.txt"
+ local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)"
+ v="${v#\'}"; v="${v%\'}"; printf '%s' "$v"
}
-# ── 5-agent connect.py path: parse env + command from --no-launch ─────────
+# ── 5-agent start.py path: parse env + command from --no-launch ─────────
# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the
-# launch command on the last printed line), and runs connect.py's config
-# writers as a side effect (it writes ~/.codex, ~/.claude, etc.).
+# launch command on the last printed line), and runs start.py's config
+# writers as a side effect (it writes each agent's relocated session config).
parse_connect() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
- if ! unsloth connect "$AGENT" --no-launch --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
- cat "$raw"
- guide_fail "'unsloth connect ${AGENT} --no-launch' exited non-zero"
+ # CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their
+ # config (which now prompts by default), so the file-edit test opts into auto-approval
+ # here, the same intent as claude/codex's per-call bypass flags.
+ local yolo=()
+ [ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo)
+ if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
+ cat_redacted "$raw"
+ guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
fi
- echo "[$AGENT] connect --no-launch printed:"; cat "$raw"
+ echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
- # The launch command is the last non-export, non-status line. connect.py
+ # The launch command is the last non-export, non-status line. start.py
# prints "Studio · model " and "Updated ..." status lines first.
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
| grep -E '[^[:space:]]' | tail -1)"
@@ -177,45 +173,63 @@ parse_connect() {
redact "$raw"
}
-# Cross-check the documented contract knobs so silent connect.py changes
+# Cross-check the documented contract knobs so silent start.py changes
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
crosscheck_contract() {
local raw="$LOGS_DIR/connect-${AGENT}.txt"
+ local cfg home
case "$AGENT" in
codex)
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
- || guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (connect.py _CODEX_ENV_KEY)"
- if [ -f "$HOME/.codex/config.toml" ]; then
- grep -q 'wire_api = "responses"' "$HOME/.codex/config.toml" \
- || guide_fail "Codex wire_api is no longer \"responses\" in ~/.codex/config.toml"
- cp "$HOME/.codex/config.toml" "$REDACTED_DIR/codex-config.toml"
+ || guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)"
+ home="$(raw_env CODEX_HOME)"
+ # An empty relocation var would make cfg "/config.toml" and silently
+ # skip the [ -f ] contract check below; fail loudly instead.
+ [ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())"
+ cfg="$home/config.toml"
+ if [ -f "$cfg" ]; then
+ grep -q 'wire_api = "responses"' "$cfg" \
+ || guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml"
+ cp "$cfg" "$REDACTED_DIR/codex-config.toml"
fi
grep -q 'codex --oss --profile unsloth_api' "$raw" \
|| echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'"
;;
claude)
grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \
- || guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (connect.py claude())"
- if [ -f "$HOME/.claude/settings.json" ]; then
- grep -q '"CLAUDE_CODE_ATTRIBUTION_HEADER"' "$HOME/.claude/settings.json" \
- || echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER not written to ~/.claude/settings.json (ensure_claude_attribution_header)"
- cp "$HOME/.claude/settings.json" "$REDACTED_DIR/claude-settings.json"
- fi
+ || guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())"
+ grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \
+ || echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())"
;;
hermes)
grep -q 'UNSLOTH_API_KEY' "$raw" \
- || guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (connect.py _HERMES_ENV_KEY)"
- [ -f "$HOME/.hermes/config.yaml" ] && cp "$HOME/.hermes/config.yaml" "$REDACTED_DIR/hermes-config.yaml"
+ || guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)"
+ home="$(raw_env HERMES_HOME)"
+ [ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())"
+ cfg="$home/config.yaml"
+ [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml"
;;
openclaw)
- if [ -f "$HOME/.openclaw/openclaw.json" ]; then
- grep -q '"openai-completions"' "$HOME/.openclaw/openclaw.json" \
+ cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
+ if [ -n "$cfg" ] && [ -f "$cfg" ]; then
+ grep -q '"openai-completions"' "$cfg" \
|| echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)"
- cp "$HOME/.openclaw/openclaw.json" "$REDACTED_DIR/openclaw.json"
+ cp "$cfg" "$REDACTED_DIR/openclaw.json"
fi
;;
opencode)
- [ -f "$HOME/.config/opencode/opencode.json" ] && cp "$HOME/.config/opencode/opencode.json" "$REDACTED_DIR/opencode.json"
+ cfg="$(raw_env OPENCODE_CONFIG)"
+ [ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json"
+ ;;
+ pi)
+ # Pi has no config-dir env var; the session is HOME-relocated, and the
+ # provider config lives at $HOME/.pi/agent/models.json.
+ cfg="$(raw_env HOME)/.pi/agent/models.json"
+ if [ -f "$cfg" ]; then
+ grep -q '"openai-completions"' "$cfg" \
+ || echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)"
+ cp "$cfg" "$REDACTED_DIR/pi-models.json"
+ fi
;;
esac
redact "$REDACTED_DIR"/* 2>/dev/null || true
@@ -229,16 +243,23 @@ crosscheck_contract() {
# Hermes: an explicit empty cli toolset disables all tools (and drops the
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
-# hermes ships a DEFAULT config.yaml that already has a populated
-# platform_toolsets, and `unsloth connect` merges into it, so we must override
-# cli (not just append). That needs a YAML parser, and the runner's bare
-# python3 has no PyYAML -- but the venv that ships `unsloth` does (connect.py
-# imports yaml), so run the patch with that interpreter.
+# Hermes enables its default cli toolset when the session config does not pin one,
+# so we must set platform_toolsets.cli explicitly to [] (not just append) to get
+# zero tools. That needs a YAML parser, and the runner's bare python3 has no
+# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run
+# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml
+# that `unsloth start` printed, not the user's ~/.hermes.
# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.)
patch_hermes_tools() { # $1 = none|default
+ # Check the raw var BEFORE appending /config.yaml: the joined path is never
+ # empty, so the old guard could not fire and the patcher would die on
+ # "/config.yaml" with a bare traceback instead of this clear failure.
+ local home; home="$(raw_env HERMES_HOME)"
+ [ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())"
+ local cfg; cfg="$home/config.yaml"
# Find a python that can import yaml. The runner's bare python3 cannot, but the
# interpreter in the `unsloth` console-script shebang provably can (it runs
- # connect.py's write_hermes_config, which imports yaml). Try that first, then
+ # start.py's write_hermes_config, which imports yaml). Try that first, then
# any python on PATH, then the venv sibling, picking the first with PyYAML.
local cand py="" shebang
shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')"
@@ -247,13 +268,13 @@ patch_hermes_tools() { # $1 = none|default
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
done
- [ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch ~/.hermes/config.yaml"
- echo "[hermes] patching config with $py"
- "$py" - "$1" <<'PY'
+ [ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config"
+ echo "[hermes] patching $cfg with $py"
+ "$py" - "$1" "$cfg" <<'PY'
import os, sys
import yaml
mode = sys.argv[1]
-p = os.path.expanduser("~/.hermes/config.yaml")
+p = sys.argv[2]
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
ts = cfg.get("platform_toolsets")
if not isinstance(ts, dict):
@@ -274,10 +295,14 @@ PY
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for
# both modes. --agent must reference a defined agent, so write it before invoking.
patch_openclaw_agent() { # $1 = notools|tools
- python3 - "$1" <<'PY'
+ # OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that
+ # `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw).
+ local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
+ [ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())"
+ python3 - "$1" "$cfg" <<'PY'
import os, sys, json
mode = sys.argv[1]
-p = os.path.expanduser("~/.openclaw/openclaw.json")
+p = sys.argv[2]
cfg = json.load(open(p)) if os.path.exists(p) else {}
agents = cfg.setdefault("agents", {})
agents.setdefault("defaults", {})["skipBootstrap"] = True
@@ -293,20 +318,24 @@ print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
PY
}
-# Build an invoke script that applies connect.py's env then runs the launch
+# Build an invoke script that applies start.py's env then runs the launch
# command (with extra args appended) under bash. We do NOT eval connect's env
# into this shell; we write it into a one-shot script so the export/unset
-# semantics are exactly what connect.py printed. The script path is absolute
+# semantics are exactly what start.py printed. The script path is absolute
# so it is valid even when the caller has cd'd into a scratch work dir.
invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
local out="$1"; shift
local script="$LOGS_DIR/invoke-${AGENT}.sh"
local real; real="$(mktemp)"
+ # CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a
+ # session knob without editing the user's config; empty -> use what start.py emitted.
+ local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}"
{
echo "set -uo pipefail"
echo "$CONNECT_ENV"
+ [ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA"
# Append extra args (the prompt / flags) to the launch command verbatim.
- printf '%s' "$CONNECT_CMD"
+ printf '%s' "$cmd"
local a
for a in "$@"; do printf ' %q' "$a"; done
printf '\n'
@@ -318,7 +347,9 @@ invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
# Writing the redacted copy up front keeps the key out of the artifact even if
# the run times out (run_timed exits before returning here).
cp "$real" "$script"; redact "$script"
- echo "[$AGENT] invoking (timeout ${TIMEOUT}s): $CONNECT_CMD $*"
+ # The connect one-liner now carries the key as an inline env assignment; scrub it on
+ # the way to the log (the executed $real keeps the live value).
+ echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/} $*"
run_timed "$out" bash "$real"
local rc=$?
rm -f "$real"
@@ -332,27 +363,23 @@ case "$MODE" in
connection)
PROMPT='Reply with exactly the single word: pong'
OUT="$LOGS_DIR/${AGENT}-connection.txt"
- if [ "$AGENT" = "pi" ]; then
- write_pi_config
- run_timed "$OUT" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$PROMPT"
- else
- parse_connect
- crosscheck_contract
- # claude/codex run in print mode via the flags connect.py emits
- # (claude -p / codex exec). For agents whose default subcommand prints
- # to stdout we pass the prompt through ctx.args.
- case "$AGENT" in
- claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
- codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
- opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
- hermes) patch_hermes_tools none
- invoke_via_connect "$OUT" -z "$PROMPT" ;;
- openclaw) patch_openclaw_agent notools
- invoke_via_connect "$OUT" agent --local --agent ci \
- --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
- *) invoke_via_connect "$OUT" "$PROMPT" ;;
- esac
- fi
+ parse_connect
+ crosscheck_contract
+ # claude/codex run in print mode via the flags start.py emits
+ # (claude -p / codex exec). For agents whose default subcommand prints
+ # to stdout we pass the prompt through ctx.args.
+ case "$AGENT" in
+ claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
+ codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
+ opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
+ pi) invoke_via_connect "$OUT" -p "$PROMPT" ;;
+ hermes) patch_hermes_tools none
+ invoke_via_connect "$OUT" -z "$PROMPT" ;;
+ openclaw) patch_openclaw_agent notools
+ CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \
+ --model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
+ *) invoke_via_connect "$OUT" "$PROMPT" ;;
+ esac
# A non-zero exit from the documented launch command is drift even if it
# printed something: a benign-looking "command not found" / usage dump would
# otherwise slip past assert_reply (which only flags empty/error-keyword text).
@@ -371,22 +398,21 @@ case "$MODE" in
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.'
T2='Run hello.py with python and show me the exact output.'
- # The connect.py recipe writers + crosscheck must see the repo; run them
- # from the repo root BEFORE cd-ing into the scratch work dir.
- if [ "$AGENT" != "pi" ]; then
- parse_connect
- crosscheck_contract
- # File-edit needs real tools, so we cannot zero them as in connection.
- # hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
- # bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
- # dir is empty, so no project context files are auto-loaded either.
- case "$AGENT" in
- hermes) patch_hermes_tools default ;;
- openclaw) patch_openclaw_agent tools ;;
- esac
- else
- write_pi_config
- fi
+ # The start.py recipe writers + crosscheck must see the repo; run them
+ # from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw
+ # gate tool approval through their config (prompting by default), so file-edit
+ # opts them into auto-approval to run edits/commands headlessly.
+ case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac
+ parse_connect
+ crosscheck_contract
+ # File-edit needs real tools, so we cannot zero them as in connection.
+ # hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
+ # bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
+ # dir is empty, so no project context files are auto-loaded either.
+ case "$AGENT" in
+ hermes) patch_hermes_tools default ;;
+ openclaw) patch_openclaw_agent tools ;;
+ esac
# Drive from inside the work dir so the agent edits files there. All log
# writes use absolute $LOGS_DIR, so cwd does not matter for them.
@@ -395,7 +421,14 @@ case "$MODE" in
invoke_turn() { # $1=outfile $2=continue? $3=prompt
local out="$1" cont="$2" prompt="$3"
case "$AGENT" in
- pi) run_timed "$out" pi -p --provider unsloth --model "$UNSLOTH_MODEL_ID" "$prompt" ;;
+ pi)
+ # Pi continues the previous session with -c; provider/model come from
+ # the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here.
+ if [ "$cont" = "continue" ]; then
+ invoke_via_connect "$out" -p --continue "$prompt"
+ else
+ invoke_via_connect "$out" -p "$prompt"
+ fi ;;
claude)
# --dangerously-skip-permissions lets headless claude actually use the
# Write/Bash tools (otherwise it blocks on an approval prompt and emits
@@ -416,7 +449,7 @@ case "$MODE" in
fi ;;
opencode) invoke_via_connect "$out" run "$prompt" ;;
hermes) invoke_via_connect "$out" -z "$prompt" ;;
- openclaw) invoke_via_connect "$out" agent --local --agent ci \
+ openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
*) invoke_via_connect "$out" "$prompt" ;;
esac
@@ -466,33 +499,180 @@ case "$MODE" in
# right before the measured turn, so an earlier turn's reuse can't leak in.
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
export LLAMA_LOG_DIR
- parse_connect # writes ~/.claude/settings.json (header=0) + env
+ parse_connect # prints session env + suppression flags (no ~/.claude write)
crosscheck_contract
PROMPT='Reply with exactly the single word: pong'
- # Phase A: header DISABLED (=0, the documented setting) -> expect a HIT on
- # the continued turn. connect.py's ensure_claude_attribution_header() set 0.
+ # Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 +
+ # --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a
+ # HIT on the continued turn, since the system-prompt prefix is stable.
invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes
FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2
invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT
- # Phase B: header ENABLED -> expect a MISS. The header prepends a
- # per-request-changing attribution line to the system prompt, so the shared
- # prefix changes every turn and the KV cache is invalidated (~90% slower);
- # this is exactly what the guide flag prevents.
- python3 - <<'PY'
-import json, os
-p = os.path.expanduser("~/.claude/settings.json")
-s = json.load(open(p)) if os.path.exists(p) else {}
-s.setdefault("env", {})["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "1"
-json.dump(s, open(p, "w"), indent=2)
-PY
+ # Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip
+ # the env var to 1 and strip the suppression flags from the launch command
+ # (without them the dynamic attribution line is included and changes every
+ # turn, so the shared prefix moves and the KV cache is invalidated, ~90%
+ # slower). This is session-only: nothing is written to ~/.claude.
+ CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1'
+ CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \
+ | sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")"
invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT"
FROM_MISS="$(bash "$CACHE_HELPER" mark)"
invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again"
CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS
- echo "[claude] attribution A/B OK (header=0 HIT, header=1 MISS)"
+ unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE
+ echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
+ ;;
+
+ # ── resume: does a launched agent's session survive exit and resume? ────
+ # Unlike the other modes, this drives the real LAUNCH path (`unsloth start
+ # ...`, the interactive default), not the --no-launch recipe. That
+ # path relocates each agent's home to a throwaway temp dir wiped on exit, so
+ # a session cannot be resumed -- unless --persist routes it to the stable
+ # Unsloth agents dir instead. We run one headless turn per pass and check
+ # whether the turn left a session in a persistent store (deterministic, no
+ # reliance on the model recalling anything), for a baseline pass and a
+ # --persist pass, and assert the expected split for this agent.
+ resume)
+ CODEWORD="PLATYPUS7"
+ T1="Remember this codeword for later: ${CODEWORD}. Reply with just the word OK."
+ T2="What codeword did I ask you to remember? Reply with just that word."
+ WORK="$WORKDIR_BASE/${AGENT}-resume"
+
+ # STABLE_HOME: the stable dir that --no-launch (and --persist) relocate to.
+ # Read it from a --no-launch probe (which also writes the agent's config
+ # there). codex/pi relocate their whole home/HOME here; opencode/claude keep
+ # their session data in a fixed user dir, so STABLE_HOME stays empty for them.
+ parse_connect
+ case "$AGENT" in
+ codex) STABLE_HOME="$(raw_env CODEX_HOME)" ;;
+ pi) STABLE_HOME="$(raw_env HOME)" ;;
+ *) STABLE_HOME="" ;;
+ esac
+
+ # The persistent stores a session would land in if it were NOT wiped. We
+ # count files here before/after each turn; a positive delta means the
+ # session persisted (is resumable), zero means it went to a wiped temp dir.
+ resume_tracked_dirs() {
+ case "$AGENT" in
+ codex) printf '%s\n' "$HOME/.codex" ;;
+ opencode) printf '%s\n' "$HOME/.local/share/opencode" "$HOME/.config/opencode" ;;
+ claude) printf '%s\n' "$HOME/.claude" ;;
+ pi) printf '%s\n' "$HOME/.pi" ;;
+ *) : ;;
+ esac
+ [ -n "$STABLE_HOME" ] && printf '%s\n' "$STABLE_HOME"
+ }
+ count_session_files() {
+ local total=0 d n
+ while IFS= read -r d; do
+ [ -n "$d" ] && [ -d "$d" ] || continue
+ n="$(find "$d" -type f 2>/dev/null | wc -l)"; total=$((total + n))
+ done < <(resume_tracked_dirs)
+ echo "$total"
+ }
+
+ # The headless first-turn subcommand per agent (mirrors file-edit's map),
+ # forwarded verbatim through the launch path as passthrough args.
+ set_t1_cmd() {
+ case "$AGENT" in
+ claude) T1_CMD=("${CLAUDE_CONNECT_FLAGS[@]}" -p "$T1") ;;
+ codex) T1_CMD=(exec "$T1") ;;
+ opencode) T1_CMD=(run "$T1") ;;
+ pi) T1_CMD=(-p "$T1") ;;
+ *) guide_fail "resume mode does not cover agent '$AGENT'" ;;
+ esac
+ }
+
+ # Run one headless turn through the launch path. $1=outfile, $2="" or
+ # "--persist", rest = the agent subcommand. --yolo auto-approves so no tool
+ # prompt can hang; --api-key attaches to the already-served CI model.
+ launch_turn() {
+ local out="$1" rflag="$2"; shift 2
+ local flag=(); [ -n "$rflag" ] && flag=("$rflag")
+ run_timed "$out" unsloth start "$AGENT" "${flag[@]}" --yolo \
+ --api-key "$UNSLOTH_API_KEY" "$@"
+ local rc=$?
+ redact "$out"
+ return "$rc"
+ }
+
+ # One pass: fresh work dir, one planting turn, set RESULT to PERSISTED/WIPED
+ # from the session-store delta. Runs in the main shell (not a command
+ # substitution) so a hang's guide_fail actually fails the job and the
+ # progress lines reach the CI log. $1 = "" (baseline) or "--persist".
+ RESULT=""
+ run_pass() {
+ local rflag="$1" label="baseline"
+ [ -n "$rflag" ] && label="resume"
+ rm -rf "$WORK"; mkdir -p "$WORK"
+ set_t1_cmd
+ local out="$LOGS_DIR/${AGENT}-resume-${label}.txt"
+ local before after rc
+ before="$(count_session_files)"
+ pushd "$WORK" >/dev/null || guide_fail "could not enter work dir $WORK"
+ launch_turn "$out" "$rflag" "${T1_CMD[@]}"; rc=$?
+ popd >/dev/null || true
+ after="$(count_session_files)"
+ echo "[$AGENT] ${label}: session files ${before} -> ${after} (rc=${rc})"
+ # The turn must succeed for the delta to mean anything: an agent that writes a
+ # session file then errors would otherwise be misread as PERSISTED. Mirror the
+ # file-edit mode and fail the pass on a non-zero launch (the flagship codex recall
+ # below stays WARN-only, driven by its own launch_turn calls).
+ [ "$rc" -eq 0 ] || { echo "[$AGENT] ${label} transcript (tail):"; tail -30 "$out" 2>/dev/null || true; \
+ guide_fail "resume ${label} turn for ${AGENT} exited non-zero (rc=${rc})"; }
+ if [ "$after" -gt "$before" ]; then RESULT="PERSISTED"; else RESULT="WIPED"; fi
+ }
+
+ run_pass ""; BASELINE="$RESULT"
+ # Only the temp-dir agents (codex/pi) need the --persist pass to prove the fix.
+ # opencode/claude persist either way, so the baseline already proves it and a
+ # second full CPU turn only risks a timeout; skip it for them.
+ case "$AGENT" in
+ codex|pi) run_pass "--persist"; RESUME="$RESULT" ;;
+ *) RESUME="n/a (persists either way)" ;;
+ esac
+
+ # Expected: codex/pi relocate their whole home to the temp dir, so a plain
+ # launch is WIPED and only --persist PERSISTS. opencode/claude keep their
+ # session data in a fixed user dir, so the baseline already PERSISTS.
+ case "$AGENT" in
+ codex|pi) EXPECT_BASELINE="WIPED" ;;
+ opencode|claude) EXPECT_BASELINE="PERSISTED" ;;
+ esac
+
+ echo "──────────────────────────────────────────────"
+ echo "[$AGENT] RESUME EXPERIMENT"
+ echo " baseline (unsloth start ${AGENT}): ${BASELINE} (expected ${EXPECT_BASELINE})"
+ echo " with --persist (unsloth start ${AGENT} --persist): ${RESUME}"
+ echo "──────────────────────────────────────────────"
+
+ [ "$BASELINE" = "$EXPECT_BASELINE" ] \
+ || guide_fail "baseline resume behavior for ${AGENT} was ${BASELINE}, expected ${EXPECT_BASELINE}"
+ case "$AGENT" in
+ codex|pi)
+ [ "$RESUME" = "PERSISTED" ] \
+ || guide_fail "--persist did not persist ${AGENT}'s session (got ${RESUME}); the session dir is still not stable" ;;
+ esac
+
+ # Flagship behavioral proof (codex only, WARN-only): after a --persist plant,
+ # resume the session and check the model actually recalls the codeword. A
+ # miss is not a failure (the CI model is small); the mechanism gate above is
+ # the real assertion.
+ if [ "$AGENT" = "codex" ]; then
+ rm -rf "$WORK"; mkdir -p "$WORK"
+ ( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-plant.txt" "--persist" exec "$T1" ) || true
+ ( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-recall.txt" "--persist" exec resume --last "$T2" ) || true
+ if grep -q "$CODEWORD" "$LOGS_DIR/codex-resume-recall.txt" 2>/dev/null; then
+ echo "[codex] behavioral recall HIT: resumed session remembered ${CODEWORD}"
+ else
+ echo "::warning::[codex] behavioral recall MISS (small CI model); mechanism gate still passed"
+ fi
+ fi
+ echo "[$AGENT] resume OK"
;;
*)
diff --git a/.github/scripts/agent-guides-install.sh b/.github/scripts/agent-guides-install.sh
index dfab8aec80..daf4bacd3e 100755
--- a/.github/scripts/agent-guides-install.sh
+++ b/.github/scripts/agent-guides-install.sh
@@ -7,7 +7,7 @@
# is the single biggest source of false reds, so installs retry with
# backoff and the only ::error:: this script can emit is class (b). The
# install recipes mirror the install_hint strings in
-# unsloth_cli/commands/connect.py at HEAD.
+# unsloth_cli/commands/start.py at HEAD.
#
# Usage: agent-guides-install.sh
# agent in: claude codex hermes openclaw opencode pi
@@ -25,13 +25,14 @@ install_fail() {
}
# npm registry flakiness is common in CI; retry 3x with linear backoff.
+# Extra npm flags may precede the package (e.g. npm_retry --ignore-scripts pkg).
npm_retry() {
- local pkg="$1" i
+ local i
for i in 1 2 3; do
- if npm install -g "$pkg" >> "$LOG" 2>&1; then
+ if npm install -g "$@" >> "$LOG" 2>&1; then
return 0
fi
- echo "[install] npm install -g $pkg attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
+ echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
sleep "$((i * 10))"
done
return 1
@@ -60,30 +61,30 @@ curl_bash() {
echo "[install] agent=$AGENT (log=$LOG)"
case "$AGENT" in
claude)
- # connect.py install_hint: curl -fsSL https://claude.ai/install.sh | bash
+ # start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash
curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed"
# The installer drops the binary under ~/.local/bin.
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;;
codex)
- # connect.py install_hint: npm install -g @openai/codex
+ # start.py install_hint: npm install -g @openai/codex
npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed"
;;
opencode)
- # connect.py install_hint: npm install -g opencode-ai
+ # start.py install_hint: npm install -g opencode-ai
npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed"
;;
openclaw)
- # connect.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash
+ # start.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash
# npm is the more deterministic path in CI and matches the agent's docs;
- # fall back to the connect.py curl installer if the npm tag is missing.
+ # fall back to the start.py curl installer if the npm tag is missing.
if ! npm_retry "openclaw@latest"; then
curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
fi
;;
hermes)
- # connect.py install_hint:
+ # start.py install_hint:
# curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash
curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \
--non-interactive --skip-setup --skip-browser --no-skills \
@@ -91,11 +92,13 @@ case "$AGENT" in
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
;;
pi)
- # No connect.py recipe; the agent's documented package name. The CLI moved
- # from the now-deprecated @mariozechner scope to @earendil-works (the old
- # scope is frozen, so installing it would test a stale Pi against the API).
- npm_retry "@earendil-works/pi-coding-agent" \
- || install_fail "npm install -g @earendil-works/pi-coding-agent failed"
+ # start.py install_hint: npm install -g --ignore-scripts @earendil-works/pi-coding-agent
+ # (--ignore-scripts matches Pi's documented recipe; exercising the exact hint
+ # catches guide drift). The CLI moved from the now-deprecated @mariozechner
+ # scope to @earendil-works (the old scope is frozen, so installing it would
+ # test a stale Pi against the API).
+ npm_retry --ignore-scripts "@earendil-works/pi-coding-agent" \
+ || install_fail "npm install -g --ignore-scripts @earendil-works/pi-coding-agent failed"
;;
*)
install_fail "unknown agent '$AGENT'"
diff --git a/.github/scripts/serve-unsloth-run.sh b/.github/scripts/serve-unsloth-run.sh
index 34b8b962c6..6ac98ded7c 100755
--- a/.github/scripts/serve-unsloth-run.sh
+++ b/.github/scripts/serve-unsloth-run.sh
@@ -27,7 +27,7 @@
#
# Outputs written to $GITHUB_ENV (and echoed):
# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
-# UNSLOTH_STUDIO_URL http://127.0.0.1: (so `unsloth connect`
+# UNSLOTH_STUDIO_URL http://127.0.0.1: (so `unsloth start`
# finds THIS server, not the hardcoded :8888)
# UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity)
# UNSLOTH_MODEL_ID the canonical id reported by /v1/models
diff --git a/.github/workflows/consolidated-tests-ci.yml b/.github/workflows/consolidated-tests-ci.yml
index 7978a200c0..fa84471d36 100644
--- a/.github/workflows/consolidated-tests-ci.yml
+++ b/.github/workflows/consolidated-tests-ci.yml
@@ -272,6 +272,7 @@ jobs:
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
+ tests/saving/test_gguf_single_pass_export.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py
@@ -361,9 +362,13 @@ jobs:
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
+ tests/saving/test_gguf_single_pass_export.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py \
+ tests/test_bad_mappings_redirect.py \
+ tests/test_prefetch_snapshot_scope.py \
+ tests/test_gemma_2b_mapper_key.py \
--deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
# The deselected test monkeypatches flash_attn_varlen_func, which is
# only bound on the module when `flash_attn` is importable. flash_attn
diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml
index 4632794587..bb7dcbf8e4 100644
--- a/.github/workflows/cross-platform-parity-ci.yml
+++ b/.github/workflows/cross-platform-parity-ci.yml
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
-# Runs tests/python/test_cross_platform_parity.py on Windows and macOS.
+# Runs installer parity and autostart opt-out tests on Windows and macOS.
#
# Why: that test is the guard that install.sh and install.ps1 stay in
# sync, but today it only runs on ubuntu-latest (auto-discovered by
@@ -21,6 +21,7 @@ on:
paths:
- 'install.sh'
- 'install.ps1'
+ - 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- '.github/workflows/cross-platform-parity-ci.yml'
push:
@@ -28,6 +29,7 @@ on:
paths:
- 'install.sh'
- 'install.ps1'
+ - 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch:
@@ -57,5 +59,11 @@ jobs:
python-version: '3.12'
cache: 'pip'
- run: python -m pip install -U pip pytest
- - name: Cross-platform parity test
- run: python -m pytest tests/python/test_cross_platform_parity.py -q
+ - name: Cross-platform parity tests
+ env:
+ UNSLOTH_NO_TORCH: '1'
+ run: >-
+ python -m pytest
+ tests/python/test_cross_platform_parity.py
+ tests/test_installer_skip_autostart.py
+ -q
diff --git a/.github/workflows/local-agent-guides-ci.yml b/.github/workflows/local-agent-guides-ci.yml
index 299ee3f18b..25796bd5cf 100644
--- a/.github/workflows/local-agent-guides-ci.yml
+++ b/.github/workflows/local-agent-guides-ci.yml
@@ -6,29 +6,27 @@
# Detects when our local-agent setup recipes drift out of sync with
# `unsloth run`. Boots a real `unsloth run --disable-tools` server and
# drives the coding agents end to end through the *exact* recipes defined
-# in unsloth_cli/commands/connect.py (the in-repo source of truth -- there
-# is no docs/ tree). Wherever connect.py has a recipe we drive the agent
-# via `unsloth connect --no-launch` and execute what it prints, so
-# the test self-updates against connect.py and catches silent recipe drift.
+# in unsloth_cli/commands/start.py (the in-repo source of truth -- there
+# is no docs/ tree). Wherever start.py has a recipe we drive the agent
+# via `unsloth start --no-launch` and execute what it prints, so
+# the test self-updates against start.py and catches silent recipe drift.
#
# Source-of-truth files this workflow guards:
-# unsloth_cli/commands/connect.py the `unsloth connect ` recipes
+# unsloth_cli/commands/start.py the `unsloth start ` recipes
# unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line)
#
# Failure taxonomy (each surfaced with a distinct ::error:: + the agent name
-# + the connect.py location, so a red X is immediately triageable):
+# + the start.py location, so a red X is immediately triageable):
# (a) Unsloth server/API regression -- the dialect HTTP preflight fails
# BEFORE the agent runs (or the server never becomes healthy).
# (b) Agent package install failed -- npm/curl install of the CLI failed.
# (c) Guide drift -- preflight passed + install ok, but
-# the documented `unsloth connect` flow produced no/garbled output.
+# the documented `unsloth start` flow produced no/garbled output.
#
# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
-# - claude/codex/hermes/openclaw/opencode have a connect.py recipe.
-# - pi has NO `unsloth connect pi` command in connect.py at HEAD; it is
-# driven by a hand-written recipe and the matrix cell asserts that the
-# missing connect recipe is the (known) reason, so the day connect.py
-# grows a `pi` command this cell flips to the self-updating path.
+# - All six have a `unsloth start ` recipe, so each cell obtains its
+# env + command from `unsloth start --no-launch` and runs THAT
+# (self-updating: a recipe change is exercised automatically).
name: Local Agent Guides CI
@@ -83,7 +81,7 @@ jobs:
# ═════════════════════════════════════════════════════════════════════
# Job 1: connection
# Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect,
- # install the agent, run `unsloth connect --no-launch`, execute
+ # install the agent, run `unsloth start --no-launch`, execute
# the emitted recipe with a trivial prompt, assert a non-empty reply.
# Runs on PR + weekly + dispatch. Each matrix cell is its own runner so
# it serves exactly one model on its own port.
@@ -103,7 +101,9 @@ jobs:
env:
# gemma-4-E4B (128K context, capable enough to drive every agent for a
# trivial reply; the 270m model produced empty/failed responses for
- # codex/openclaw and is below hermes' 64K context floor). Served as a flat
+ # codex/openclaw). Hermes' 64K context floor no longer constrains the model
+ # choice: write_hermes_config claims the floor for smaller windows and
+ # scales compaction back to the real window. Served as a flat
# GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B).
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
@@ -209,7 +209,7 @@ jobs:
;;
*)
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
- # OpenClaw's connect.py recipe writes an "openai-completions"
+ # OpenClaw's start.py recipe writes an "openai-completions"
# provider (write_openclaw_config), so it uses this path, not
# /v1/messages.
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
@@ -227,13 +227,13 @@ jobs:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
- # ── (c) drive the agent via connect.py and assert a reply ──────────
- # For the 5 agents with a connect.py recipe we run
- # `unsloth connect --no-launch`, eval its env/unset exports,
+ # ── (c) drive the agent via start.py and assert a reply ──────────
+ # For the 5 agents with a start.py recipe we run
+ # `unsloth start --no-launch`, eval its env/unset exports,
# then run the printed command with a hard timeout (no headless-TTY
# hang). Pi has no connect recipe, so it is driven by hand and the
# cell asserts that absence is the (known) reason.
- - name: Drive ${{ matrix.agent }} via unsloth connect (class-c isolation)
+ - name: Drive ${{ matrix.agent }} via unsloth start (class-c isolation)
env:
AGENT: ${{ matrix.agent }}
run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT"
@@ -248,8 +248,10 @@ jobs:
# `API Key: `) into logs/unsloth-run-.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
+ # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
+ # agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
- grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
done
fi
@@ -438,8 +440,10 @@ jobs:
# `API Key: `) into logs/unsloth-run-.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
+ # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
+ # agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
- grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
done
fi
@@ -467,6 +471,176 @@ jobs:
redacted-configs/
retention-days: 7
+ # ═════════════════════════════════════════════════════════════════════
+ # Job: resume
+ # Does a conversation started with `unsloth start ` survive exit
+ # and resume? This drives the REAL launch path (not the --no-launch
+ # recipe the other jobs use). A plain launch relocates the agent home to
+ # a temp dir wiped on exit, so codex/pi cannot resume; --persist routes the
+ # session to the stable Unsloth agents dir so it persists. opencode/claude
+ # keep their session data in a fixed user dir, so they persist either way.
+ # Dispatch-only: it is an end-to-end experiment, not a PR gate.
+ # ═════════════════════════════════════════════════════════════════════
+ resume:
+ name: resume (${{ matrix.agent }})
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ # codex/pi relocate their whole home (resume broken without --persist);
+ # opencode/claude keep session data in a fixed dir (resume already works).
+ # One agent from each class proves the split end to end; openclaw/hermes
+ # share codex's relocation mechanism and are covered by the unit tests.
+ agent: [codex, opencode, claude, pi]
+ env:
+ GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
+ GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
+ STUDIO_PORT: '18904'
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Linux deps for llama.cpp prebuilt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libcurl4-openssl-dev libssl-dev jq
+
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: '22'
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Restore GGUF model file
+ id: cache-gguf
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ continue-on-error: true
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Download GGUF if cache miss
+ id: download-gguf
+ if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ python -m pip install --upgrade huggingface_hub
+ mkdir -p gguf-cache
+ bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
+
+ - name: Save GGUF model file
+ if: always() && steps.download-gguf.outcome == 'success'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: gguf-cache
+ key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
+
+ - name: Install Studio (--local, --no-torch)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ run: |
+ mkdir -p logs
+ set -o pipefail
+ bash install.sh --local --no-torch 2>&1 | tee logs/install.log
+
+ - name: Serve unsloth run --disable-tools (gemma-4-E4B)
+ run: |
+ unsloth studio reset-password
+ bash .github/scripts/serve-unsloth-run.sh \
+ --gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
+ --port "$STUDIO_PORT" --log-dir logs \
+ --extra "--seed $UNSLOTH_SEED --temp 0" \
+ --health-timeout 900
+
+ - name: Preflight the agent's API dialect (class-a isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: |
+ set -uo pipefail
+ B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
+ preflight_fail() {
+ echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). Endpoint contract lives in studio/backend/routes/**.";
+ exit 1
+ }
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
+ -H "Authorization: Bearer $K") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
+ case "$AGENT" in
+ claude)
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
+ ;;
+ codex)
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
+ ;;
+ *)
+ code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
+ -H "Authorization: Bearer $K" -H 'content-type: application/json' \
+ --max-time 120 \
+ -d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
+ [ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
+ ;;
+ esac
+ echo "preflight OK for $AGENT"
+
+ - name: Install agent CLI (class-b isolation)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-install.sh "$AGENT"
+
+ - name: Resume experiment (launch path)
+ env:
+ AGENT: ${{ matrix.agent }}
+ run: bash .github/scripts/agent-guides-drive.sh resume "$AGENT"
+
+ - name: Collect server logs (debug)
+ if: always()
+ run: |
+ mkdir -p logs/studio-logs
+ cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
+ if [ -n "${UNSLOTH_API_KEY:-}" ]; then
+ grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
+ sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
+ done
+ fi
+
+ - name: Stop Studio
+ if: always()
+ run: |
+ if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
+ kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
+ fi
+ sleep 2
+ ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
+
+ - name: Upload logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: resume-${{ matrix.agent }}-log
+ path: |
+ logs/
+ agent-workdir/
+ redacted-configs/
+ retention-days: 7
+
# ═════════════════════════════════════════════════════════════════════
# Job 3: prompt-cache
# (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0
@@ -582,8 +756,10 @@ jobs:
# `API Key: `) into logs/unsloth-run-.log, and the upload
# step publishes all of logs/, so scrubbing only studio-logs would leak
# the bearer token in the retained artifact.
+ # Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
+ # agent-workdir/ are published by the same upload step.
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
- grep -rlF "$UNSLOTH_API_KEY" logs 2>/dev/null | while IFS= read -r f; do
+ grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
sed -i "s#${UNSLOTH_API_KEY}##g" "$f" 2>/dev/null || true
done
fi
diff --git a/.github/workflows/lockfile-audit.yml b/.github/workflows/lockfile-audit.yml
index 9c28e21672..aaf258d615 100644
--- a/.github/workflows/lockfile-audit.yml
+++ b/.github/workflows/lockfile-audit.yml
@@ -60,11 +60,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
diff --git a/.github/workflows/ossf.yml b/.github/workflows/ossf.yml
new file mode 100644
index 0000000000..f9a270540f
--- /dev/null
+++ b/.github/workflows/ossf.yml
@@ -0,0 +1,78 @@
+# This workflow uses actions that are not certified by GitHub. They are provided
+# by a third-party and are governed by separate terms of service, privacy
+# policy, and support documentation.
+
+name: Scorecard supply-chain security
+on:
+ # For Branch-Protection check. Only the default branch is supported. See
+ # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
+ branch_protection_rule:
+ # To guarantee Maintained check is occasionally updated. See
+ # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
+ schedule:
+ - cron: '21 20 * * 0'
+ push:
+ branches: [ "main" ]
+
+# Declare default permissions as read only.
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
+ if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
+ permissions:
+ # Needed to upload the results to code-scanning dashboard.
+ security-events: write
+ # Needed to publish results and get a badge (see publish_results below).
+ id-token: write
+ # Uncomment the permissions below if installing in a private repository.
+ # contents: read
+ # actions: read
+
+ steps:
+ - name: "Checkout code"
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
+
+ - name: "Run analysis"
+ uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
+ # - you want to enable the Branch-Protection check on a *public* repository, or
+ # - you are installing Scorecard on a *private* repository
+ # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
+ # repo_token: ${{ secrets.SCORECARD_TOKEN }}
+
+ # Public repositories:
+ # - Publish results to OpenSSF REST API for easy access by consumers
+ # - Allows the repository to include the Scorecard badge.
+ # - See https://github.com/ossf/scorecard-action#publishing-results.
+ # For private repositories:
+ # - `publish_results` will always be set to `false`, regardless
+ # of the value entered here.
+ publish_results: true
+
+ # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
+ # file_mode: git
+
+ # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
+ # format to the repository Actions tab.
+ - name: "Upload artifact"
+ uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ # Upload the results to GitHub's code scanning dashboard (optional).
+ # Commenting out will disable upload of results to your repo's Code Scanning dashboard
+ - name: "Upload to code-scanning"
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 188e078f90..4daafae35d 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -19,6 +19,19 @@ on:
permissions:
contents: read
+env:
+ DESKTOP_RELEASE_NOTES: |
+ Desktop app for Unsloth Studio.
+
+ **macOS**: Download the Apple Silicon `.dmg`.
+ **Windows**: Download the `-setup.exe` installer.
+ **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
+
+ > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
+ > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
+ > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
+ > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
+
concurrency:
group: release-desktop-${{ github.repository }}
cancel-in-progress: false
@@ -295,14 +308,6 @@ jobs:
PY
build:
- # TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
- # with actions/upload-artifact handoff so the matrix build cannot
- # publish a Release on its own. The current matrix runs across
- # Linux/macOS/Windows in a single job, so the split needs artefact
- # collection across the OS matrix and is out of scope for this
- # hardening pass.
- permissions:
- contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
strategy:
fail-fast: false
max-parallel: 1
@@ -311,15 +316,21 @@ jobs:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
label: macOS (Apple Silicon)
+ artifact: macos-aarch64
+ release_arch: aarch64
# - platform: macos-latest
# args: '--target x86_64-apple-darwin'
# label: macOS (Intel)
- platform: ubuntu-22.04
args: ''
label: Linux (x64)
+ artifact: linux-x64
+ release_arch: x64
- platform: windows-latest
args: ''
label: Windows (x64)
+ artifact: windows-x64
+ release_arch: x64
name: Build ${{ matrix.label }}
needs: prepare-version
@@ -465,41 +476,18 @@ jobs:
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
}
- const releaseBodies = [];
- for (let i = 0; i < lines.length; i += 1) {
- const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
- if (!match) continue;
- const baseIndent = match[1].length;
- const bodyLines = [];
- i += 1;
- for (; i < lines.length; i += 1) {
- const line = lines[i];
- if (line.trim() === '') {
- bodyLines.push('');
- continue;
- }
- const indent = line.match(/^\s*/)[0].length;
- if (indent <= baseIndent) {
- i -= 1;
- break;
- }
- bodyLines.push(line.slice(baseIndent + 2));
- }
- releaseBodies.push(bodyLines.join('\n'));
+ const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
+ if (!releaseBody) {
+ throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
}
- if (releaseBodies.length === 0) {
- throw new Error('Expected at least one desktop release body');
+ if (/\brpm\b|\.rpm/i.test(releaseBody)) {
+ throw new Error('Desktop release body must not advertise RPM packages');
}
- for (const body of releaseBodies) {
- if (/\brpm\b|\.rpm/i.test(body)) {
- throw new Error('Desktop release body must not advertise RPM packages');
- }
- if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
- throw new Error('Desktop release body must not advertise AppImage as universal');
- }
- if (!/AppImage.*experimental/i.test(body)) {
- throw new Error('Desktop release body must mark AppImage as experimental');
- }
+ if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
+ throw new Error('Desktop release body must not advertise AppImage as universal');
+ }
+ if (!/AppImage.*experimental/i.test(releaseBody)) {
+ throw new Error('Desktop release body must mark AppImage as experimental');
}
JS
@@ -644,48 +632,33 @@ jobs:
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The
- # next step builds the AppImage with the Tauri signing key and a
- # contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
- # that ran here could exfiltrate signing material or tamper with
- # published release artifacts. Fail closed on any mismatch.
+ # next step builds the AppImage with the Tauri signing key, so a
+ # substituted linuxdeploy that ran here could exfiltrate signing
+ # material or tamper with release artifacts. Fail closed on any
+ # mismatch.
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
chmod +x "$dest"
- # ── Linux: build + sign + upload ──
+ # ── Linux: build + sign ──
- name: Build Linux app
+ id: build_linux
if: matrix.platform == 'ubuntu-22.04'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
with:
projectPath: studio
tauriScript: npx --prefix . tauri
- tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
- releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
- releaseBody: |
- Desktop app for Unsloth Studio.
-
- **macOS**: Download the Apple Silicon `.dmg`.
- **Windows**: Download the `-setup.exe` installer.
- **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
-
- > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
- > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
- > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
- > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
- releaseDraft: ${{ inputs.draft }}
- prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
- # ── macOS: build + sign + notarize + upload ──
+ # ── macOS: build + sign + notarize ──
- name: Build macOS app
+ id: build_macos
if: matrix.platform == 'macos-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
@@ -695,29 +668,14 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
- tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
- releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
- releaseBody: |
- Desktop app for Unsloth Studio.
-
- **macOS**: Download the Apple Silicon `.dmg`.
- **Windows**: Download the `-setup.exe` installer.
- **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
-
- > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
- > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
- > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
- > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
- releaseDraft: ${{ inputs.draft }}
- prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
- # ── Windows: build + sign + upload ──
+ # ── Windows: build + sign ──
- name: Build Windows app
+ id: build_windows
if: matrix.platform == 'windows-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -728,35 +686,83 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
- tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
- releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
- releaseBody: |
- Desktop app for Unsloth Studio.
-
- **macOS**: Download the Apple Silicon `.dmg`.
- **Windows**: Download the `-setup.exe` installer.
- **Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
-
- > Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
- > Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
- > Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
- > First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
- releaseDraft: ${{ inputs.draft }}
- prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
- # Release process note: only non-draft workflow runs advance the public
- # desktop-latest updater channel. Draft builds are for private review; if a
- # draft is manually published later, this channel intentionally remains
- # unchanged until a narrow manual channel-publish flow is added or a public
- # desktop release is created by running this workflow with draft=false.
- publish-updater-channel:
- name: Publish desktop updater channel
+ - name: Stage release assets
+ shell: bash
+ env:
+ ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
+ RELEASE_ARCH: ${{ matrix.release_arch }}
+ run: |
+ set -euo pipefail
+ if command -v python3 >/dev/null 2>&1; then
+ PYTHON=python3
+ else
+ PYTHON=python
+ fi
+ "$PYTHON" <<'PY'
+ import json
+ import os
+ import pathlib
+ import re
+ import shutil
+ import sys
+ import unicodedata
+
+ raw_paths = os.environ.get('ARTIFACT_PATHS', '')
+ try:
+ artifact_paths = json.loads(raw_paths)
+ except json.JSONDecodeError as error:
+ sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
+ if not isinstance(artifact_paths, list) or not artifact_paths:
+ sys.exit('tauri-action did not return any release artifacts')
+
+ destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
+ destination.mkdir(parents=True, exist_ok=True)
+ staged = []
+ for raw_path in artifact_paths:
+ source = pathlib.Path(raw_path)
+ if not source.is_file():
+ continue
+ name = source.name
+ for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
+ if name.endswith(extension):
+ name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
+ break
+ name = unicodedata.normalize('NFD', name)
+ name = ''.join(character for character in name if not unicodedata.combining(character))
+ name = re.sub(r'[ ()\[\]{}]', '.', name)
+ while '..' in name:
+ name = name.replace('..', '.')
+ target = destination / name
+ if target.exists():
+ sys.exit(f'Duplicate staged release asset name: {name}')
+ shutil.copy2(source, target)
+ staged.append(name)
+
+ if not staged:
+ sys.exit('No release files were staged')
+ print('Staged release assets:')
+ print('\n'.join(sorted(staged)))
+ PY
+
+ - name: Upload signed release assets
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: desktop-release-${{ matrix.artifact }}
+ path: ${{ runner.temp }}/desktop-release-assets/*
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 1
+
+ # Only this job gets write access; builds hand off signed files via artifacts.
+ # Draft runs do not advance the public desktop-latest channel.
+ publish-release:
+ name: Publish desktop release
needs: [prepare-version, build]
- if: ${{ !inputs.draft }}
runs-on: ubuntu-latest
permissions:
- contents: write
+ contents: write # create the versioned Release and replace updater-channel metadata
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
@@ -765,7 +771,164 @@ jobs:
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
+ - name: Harden runner (audit)
+ uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
+ with:
+ egress-policy: audit
+
+ - name: Download signed release assets
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: desktop-release-*
+ path: ${{ runner.temp }}/desktop-release-assets
+ merge-multiple: true
+
+ - name: Validate release asset set
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 <<'PY'
+ import pathlib
+ import os
+ import sys
+
+ asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
+ files = [path for path in asset_dir.iterdir() if path.is_file()]
+ required_suffixes = (
+ '.dmg',
+ '.app.tar.gz',
+ '.app.tar.gz.sig',
+ '.deb',
+ '.AppImage',
+ '.AppImage.sig',
+ '-setup.exe',
+ '-setup.exe.sig',
+ )
+ for suffix in required_suffixes:
+ matches = [path for path in files if path.name.endswith(suffix)]
+ if len(matches) != 1:
+ sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
+ if any(path.name == 'latest.json' for path in files):
+ sys.exit('Build artifacts must not supply latest.json')
+ print('\n'.join(sorted(path.name for path in files)))
+ PY
+
+ - name: Create or validate versioned release
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_DRAFT: ${{ inputs.draft }}
+ run: |
+ set -euo pipefail
+ notes_file="$RUNNER_TEMP/desktop-release-notes.md"
+ printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
+
+ release_json="$RUNNER_TEMP/versioned-release.json"
+ # REST tag lookup omits drafts; `gh release view` also checks pending tags.
+ if gh release view "$DESKTOP_RELEASE_TAG" \
+ --json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
+ python3 <<'PY'
+ import json
+ import os
+ import pathlib
+ import sys
+
+ release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
+ expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
+ expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
+ if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
+ sys.exit('Existing desktop release tag does not match the requested tag')
+ if bool(release.get('isDraft')) != expected_draft:
+ sys.exit('Existing desktop release draft state does not match the workflow input')
+ if bool(release.get('isPrerelease')) != expected_prerelease:
+ sys.exit('Existing desktop release prerelease state does not match the requested version')
+ PY
+ else
+ release_flags=(
+ --title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
+ --notes-file "$notes_file"
+ --target "$GITHUB_SHA"
+ )
+ if [ "$RELEASE_DRAFT" = "true" ]; then
+ release_flags+=(--draft)
+ fi
+ if [ "$DESKTOP_PRERELEASE" = "true" ]; then
+ release_flags+=(--prerelease)
+ fi
+ gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
+ fi
+
+ - name: Publish versioned release assets
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
+
+ - name: Generate and publish versioned updater metadata
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ python3 <<'PY'
+ import datetime
+ import json
+ import os
+ import pathlib
+ import sys
+ import urllib.parse
+
+ asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
+ files = [path for path in asset_dir.iterdir() if path.is_file()]
+
+ def exactly_one(suffix: str) -> pathlib.Path:
+ matches = [path for path in files if path.name.endswith(suffix)]
+ if len(matches) != 1:
+ sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
+ return matches[0]
+
+ def entry(signature_suffix: str) -> dict[str, str]:
+ signature_path = exactly_one(signature_suffix)
+ bundle_name = signature_path.name.removesuffix('.sig')
+ bundle_path = asset_dir / bundle_name
+ if not bundle_path.is_file():
+ sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
+ encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
+ encoded_name = urllib.parse.quote(bundle_name, safe='')
+ return {
+ 'signature': signature_path.read_text(),
+ 'url': (
+ f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
+ f'{encoded_tag}/{encoded_name}'
+ ),
+ }
+
+ darwin = entry('.app.tar.gz.sig')
+ linux = entry('.AppImage.sig')
+ windows = entry('.exe.sig')
+ notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
+ metadata = {
+ 'version': os.environ['APP_VERSION'],
+ 'notes': notes,
+ 'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
+ 'platforms': {
+ 'darwin-aarch64': darwin,
+ 'darwin-aarch64-app': darwin,
+ 'linux-x86_64': linux,
+ 'linux-x86_64-appimage': linux,
+ 'windows-x86_64': windows,
+ 'windows-x86_64-nsis': windows,
+ },
+ }
+ output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
+ output.write_text(json.dumps(metadata, indent=2) + '\n')
+ PY
+ gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
+
- name: Download versioned updater metadata
+ if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -790,6 +953,7 @@ jobs:
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata
+ if: ${{ !inputs.draft }}
shell: bash
run: |
python3 <<'PY'
@@ -849,6 +1013,7 @@ jobs:
PY
- name: Ensure desktop updater channel release
+ if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -881,6 +1046,7 @@ jobs:
PY
- name: Prevent updater channel downgrade
+ if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -971,6 +1137,7 @@ jobs:
PY
- name: Publish desktop updater channel metadata
+ if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml
index 0ef2ad1e9d..1275d12216 100644
--- a/.github/workflows/security-audit.yml
+++ b/.github/workflows/security-audit.yml
@@ -2,8 +2,8 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Multi-language supply-chain audit. Triggers:
-# - PRs touching any dependency manifest (Python / npm / Cargo) or
-# this workflow file,
+# - PRs touching any dependency manifest (Python / npm / Cargo), a
+# scanner or its allowlist baseline, or this workflow file,
# - push to main / pip,
# - nightly @ 04:13 UTC so newly-published advisories surface even
# when no PR opens,
@@ -57,7 +57,9 @@ on:
- 'studio/src-tauri/Cargo.lock'
- 'pyproject.toml'
- 'scripts/scan_packages.py'
+ - 'scripts/scan_packages_baseline.json'
- 'scripts/scan_npm_packages.py'
+ - 'scripts/scan_npm_packages_baseline.json'
- '.github/workflows/security-audit.yml'
push:
branches: [main, pip]
diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml
index bce355458a..3022127a2b 100644
--- a/.github/workflows/studio-backend-ci.yml
+++ b/.github/workflows/studio-backend-ci.yml
@@ -68,9 +68,10 @@ jobs:
pip install -r studio/backend/requirements/studio.txt
# Extras that studio.txt does not list but the import chain needs
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
- # for the auth DB, yaml/jinja2 for utils.models.model_config, etc.):
+ # for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for
+ # the orphan-cleanup process scan, etc.):
pip install \
- python-multipart aiofiles sqlalchemy cryptography \
+ python-multipart aiofiles sqlalchemy cryptography psutil \
pyyaml jinja2 mammoth unpdf requests \
'numpy<3' pytest pytest-asyncio httpx
# Torch CPU + transformers are required by a chunk of the backend test
@@ -133,7 +134,7 @@ jobs:
python -m pip install --upgrade pip
pip install -r studio/backend/requirements/studio.txt
pip install \
- python-multipart aiofiles sqlalchemy cryptography \
+ python-multipart aiofiles sqlalchemy cryptography psutil \
pyyaml jinja2 mammoth unpdf requests typer \
'numpy<3' pytest pytest-asyncio httpx
# torchvision: unsloth_zoo.vision_utils imports it at module scope.
@@ -229,7 +230,9 @@ jobs:
tests/sh/test_resolve_cuda_archs.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh \
- tests/sh/test_torch_flavor.sh; do
+ tests/sh/test_torch_flavor.sh \
+ tests/sh/test_with_llama_cpp_dir_flag.sh \
+ tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"
diff --git a/.github/workflows/studio-export-capability-ci.yml b/.github/workflows/studio-export-capability-ci.yml
new file mode 100644
index 0000000000..1ee6489209
--- /dev/null
+++ b/.github/workflows/studio-export-capability-ci.yml
@@ -0,0 +1,76 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
+
+# Runs studio/backend/tests/test_export_capability.py on Linux, Windows and macOS.
+#
+# export_capability() is per-OS (is_apple_silicon() and the PyTorch-import probe differ per
+# platform) and the export backend must import without PyTorch, so this confirms the gating and
+# import-safety on hosted Windows/macOS. Hosted runners have no GPU/MLX, so a real accelerator
+# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
+# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
+
+name: Studio export capability
+
+on:
+ pull_request:
+ paths:
+ - 'studio/backend/utils/hardware/hardware.py'
+ - 'studio/backend/core/export/export.py'
+ - 'studio/backend/routes/export.py'
+ - 'studio/backend/main.py'
+ - 'studio/backend/tests/test_export_capability.py'
+ - '.github/workflows/studio-export-capability-ci.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'studio/backend/utils/hardware/hardware.py'
+ - 'studio/backend/core/export/export.py'
+ - 'studio/backend/routes/export.py'
+ - 'studio/backend/main.py'
+ - 'studio/backend/tests/test_export_capability.py'
+ - '.github/workflows/studio-export-capability-ci.yml'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ capability:
+ name: capability (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ env:
+ # No accelerator on hosted runners; keep detection on the CPU path.
+ CUDA_VISIBLE_DEVICES: ""
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+ - name: Upgrade pip
+ run: python -m pip install --upgrade pip
+ - name: Install CPU PyTorch
+ # CPU wheel index so every OS gets a CPU build; keep PyPI as an extra index so torch's
+ # transitive deps still resolve (matching the other workflows in this repo).
+ run: python -m pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple "torch>=2.4,<2.13"
+ - name: Install backend import deps
+ # Enough to import utils.hardware and core.export.export; NOT unsloth (needs a GPU, and
+ # the import-safety test blocks it) or triton/llama.cpp (Linux-only / native builds).
+ run: python -m pip install
+ transformers peft accelerate safetensors huggingface_hub datasets
+ sentencepiece protobuf fastapi starlette structlog psutil
+ python-multipart pydantic httpx "numpy<3" pytest
+ - name: Export capability + import-safety tests
+ working-directory: studio/backend
+ run: python -m pytest tests/test_export_capability.py -q
diff --git a/.github/workflows/studio-inference-smoke.yml b/.github/workflows/studio-inference-smoke.yml
index aebf90380a..58ef2558f3 100644
--- a/.github/workflows/studio-inference-smoke.yml
+++ b/.github/workflows/studio-inference-smoke.yml
@@ -444,6 +444,8 @@ jobs:
python - <<'PY'
import json
import os
+ import time
+ import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@@ -464,10 +466,26 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
- def post_sse(path, body, *, timeout = 600):
+ def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
@@ -483,6 +501,22 @@ jobs:
invocation markers / tool output, since
`delta.content` alone is not evidence
that the tool path executed.
+
+ A shared CI runner can stall the stream transport (the
+ connection opening, or a mid-stream read) even when Studio
+ is healthy, so retry a stall once with a fresh request
+ capped at 300s. A stall means the stream did NOT complete,
+ so partial events are normally NOT returned (an early
+ tool_start with no tool_end is not proof the tool loop
+ finished). The one exception is `complete_on`: an optional
+ predicate over the events collected so far -- when a stall
+ happens after it is already satisfied (the tool ran and
+ produced its result before the trailing read timed out),
+ those events are returned rather than discarded, so the
+ stall-after-answer case still counts. HTTP status errors
+ surface immediately; a stall that yields no completed result
+ across all attempts re-raises so the caller can rotate to
+ the next seed.
"""
body = {**body, "stream": True}
data = json.dumps(body).encode()
@@ -495,26 +529,45 @@ jobs:
"Content-Type": "application/json",
},
)
- parts = []
- events = []
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- for raw in resp:
- line = raw.decode().strip()
- if not line.startswith("data: "):
- continue
- payload = line[6:]
- if payload == "[DONE]":
- break
- events.append(payload)
- try:
- chunk = json.loads(payload)
- except json.JSONDecodeError:
- continue
- for choice in chunk.get("choices", []):
- delta = choice.get("delta", {}) or {}
- if delta.get("content"):
- parts.append(delta["content"])
- return "".join(parts), events
+ for attempt in range(retries + 1):
+ parts = []
+ events = []
+ t = timeout if attempt == 0 else min(timeout, 300)
+ try:
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ for raw in resp:
+ line = raw.decode().strip()
+ if not line.startswith("data: "):
+ continue
+ payload = line[6:]
+ if payload == "[DONE]":
+ break
+ events.append(payload)
+ try:
+ chunk = json.loads(payload)
+ except json.JSONDecodeError:
+ continue
+ for choice in chunk.get("choices", []):
+ delta = choice.get("delta", {}) or {}
+ if delta.get("content"):
+ parts.append(delta["content"])
+ return "".join(parts), events
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ # A stall after the tool already produced its result is
+ # the case this probe exists to tolerate: keep those
+ # events. But a stall with only an early tool_start (no
+ # completed output) is not proof the tool loop finished,
+ # so it must not pass -- retry once, then raise so
+ # _run_tool_probe rotates to the next seed.
+ if complete_on is not None and complete_on(events):
+ print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True)
+ return "".join(parts), events
+ if attempt == retries:
+ raise
+ print(f"[retry-sse] {path}: {exc!r}", flush = True)
+ time.sleep(15)
_STUDIO_TOOL_TYPES = {
"tool_start", "tool_end", "tool_use", "tool_result",
@@ -651,17 +704,55 @@ jobs:
"""
attempts_log = []
best = None
+ # Cap the wall-clock spent rotating through stalled seeds so a
+ # persistent no-data wedge fails fast (clean assertion) instead
+ # of being killed by the job's timeout-minutes. A healthy or
+ # merely degenerate round answers in seconds, so all seeds still
+ # run in the normal case; only stalls consume the budget.
+ probe_deadline = time.monotonic() + 300
for attempt_i in range(max_attempts):
+ # Cap each read by the budget still remaining (not just a flat
+ # 180s) and skip an attempt too small to finish, so the whole
+ # rotation stays within ~300s -- two probes then fit the job's
+ # timeout-minutes even if every seed stalls.
+ remaining = int(probe_deadline - time.monotonic())
+ if attempt_i and remaining < 30:
+ print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True)
+ break
attempt_seed = SEED + attempt_i
- content, events = post_sse("/v1/chat/completions", {
- "messages": [{"role": "user", "content": prompt}],
- "enable_tools": True,
- "enabled_tools": enabled,
- "session_id": f"{session}-att{attempt_i}",
- "temperature": TOOL_PROBE_TEMP,
- "seed": attempt_seed,
- "max_tokens": 600,
- })
+ try:
+ # Bounded per-attempt timeout, no inner retry -- the seed
+ # loop IS the retry, so a stall raises quickly and rotates
+ # rather than spending post_sse's full 600+300s. complete_on
+ # keeps a stall that already produced the tool result (only
+ # the trailing read timed out) instead of discarding it.
+ content, events = post_sse("/v1/chat/completions", {
+ "messages": [{"role": "user", "content": prompt}],
+ "enable_tools": True,
+ "permission_mode": "full",
+ "enabled_tools": enabled,
+ "session_id": f"{session}-att{attempt_i}",
+ "temperature": TOOL_PROBE_TEMP,
+ "seed": attempt_seed,
+ "max_tokens": 600,
+ }, timeout = min(180, remaining), retries = 0,
+ complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles))
+ except urllib.error.HTTPError:
+ # HTTPError subclasses URLError, so re-raise a real 4xx/5xx
+ # here instead of letting the transport-stall handler below
+ # swallow it and rotate seeds -- an endpoint status failure
+ # must surface, not be masked as missing tool evidence.
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ # A transport stall that outlived post_sse's own retry:
+ # log it as a failed attempt and rotate to the next seed
+ # rather than sinking the whole probe on one bad stream.
+ attempts_log.append({
+ "attempt": attempt_i, "seed": attempt_seed,
+ "transport_error": repr(exc),
+ })
+ print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True)
+ continue
invoked = _tool_invoked(events)
produced = _tool_output_contains(events, *needles)
attempts_log.append({
@@ -722,15 +813,19 @@ jobs:
# enough that requiring a tool_call marker would create
# red-herring failures from infra rather than from Studio.
try:
+ # Best-effort and bounded: a single 180s attempt keeps a stall
+ # from eating the job's timeout-minutes (it already WARNs, so a
+ # retry buys nothing).
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 400,
- })
+ }, timeout = 180, retries = 0)
print(
f"[tools] PASS web_search stream ({len(content)} chars in content, "
f"{len(events)} raw events)"
@@ -938,6 +1033,8 @@ jobs:
import base64
import json
import os
+ import time
+ import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@@ -956,8 +1053,24 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON
diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml
index d562294d42..946681706a 100644
--- a/.github/workflows/studio-mac-inference-smoke.yml
+++ b/.github/workflows/studio-mac-inference-smoke.yml
@@ -430,6 +430,8 @@ jobs:
python - <<'PY'
import json
import os
+ import time
+ import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@@ -450,14 +452,41 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
- def post_sse(path, body, *, timeout = 600):
+ def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
- call with enable_tools=true must use this helper."""
+ call with enable_tools=true must use this helper.
+
+ A shared CI runner can stall the stream transport (the
+ connection opening, or a mid-stream read) even when Studio
+ is healthy, so harden the read three ways: retry a stall
+ once with a fresh request capped at 300s; return any text
+ already streamed before a stall (a stall on the trailing
+ tokens, after the answer arrived, still counts); and when
+ every attempt yields nothing, a hard call re-raises while a
+ soft call (the best-effort server-side tool probes) returns
+ None so the caller can WARN instead of sinking the whole
+ job. HTTP status errors always surface immediately."""
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@@ -469,24 +498,43 @@ jobs:
"Content-Type": "application/json",
},
)
- parts = []
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- for raw in resp:
- line = raw.decode().strip()
- if not line.startswith("data: "):
- continue
- payload = line[6:]
- if payload == "[DONE]":
- break
- try:
- chunk = json.loads(payload)
- except json.JSONDecodeError:
- continue
- for choice in chunk.get("choices", []):
- delta = choice.get("delta", {}) or {}
- if delta.get("content"):
- parts.append(delta["content"])
- return "".join(parts)
+ for attempt in range(retries + 1):
+ parts = []
+ t = timeout if attempt == 0 else min(timeout, 300)
+ try:
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ for raw in resp:
+ line = raw.decode().strip()
+ if not line.startswith("data: "):
+ continue
+ payload = line[6:]
+ if payload == "[DONE]":
+ break
+ try:
+ chunk = json.loads(payload)
+ except json.JSONDecodeError:
+ continue
+ for choice in chunk.get("choices", []):
+ delta = choice.get("delta", {}) or {}
+ if delta.get("content"):
+ parts.append(delta["content"])
+ return "".join(parts)
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ # Text already streamed is a valid signal -- keep it
+ # rather than re-running a heavy generation.
+ if parts:
+ joined = "".join(parts)
+ print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
+ return joined
+ if attempt == retries:
+ if soft:
+ print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
+ return None
+ raise
+ print(f"[retry-sse] {path}: {exc!r}", flush = True)
+ time.sleep(15)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@@ -557,16 +605,23 @@ jobs:
# macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL;
# cap max_tokens tightly so each SSE round stays under ~30s
# even when the model stalls in a degenerate output state.
+ # retries=0 on the best-effort probes: this job's 25-minute cap
+ # allows a 10-minute model load, so a no-data stall must be a
+ # single 180s attempt (not 180+15+180s) to leave room for the
+ # thinking checks. A soft/best-effort probe only WARNs anyway.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 128,
- }, timeout = 180)
- if "56088" in content or "56,088" in content:
+ }, timeout = 180, retries = 0, soft = True)
+ if content is None:
+ print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
+ elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
# Empty stream is a known Mac-quant degeneracy too; log
@@ -593,12 +648,13 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 96,
- }, timeout = 180)
+ }, timeout = 180, retries = 0)
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@@ -825,6 +881,8 @@ jobs:
import base64
import json
import os
+ import time
+ import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@@ -848,8 +906,24 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON
diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml
index 8186c07211..63a7e9dc8f 100644
--- a/.github/workflows/studio-windows-inference-smoke.yml
+++ b/.github/workflows/studio-windows-inference-smoke.yml
@@ -634,6 +634,8 @@ jobs:
python - <<'PY'
import json
import os
+ import time
+ import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@@ -656,10 +658,41 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
- def post_sse(path, body, *, timeout = 600):
+ def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
+ # The server-side agentic loop always answers over SSE. A
+ # shared CI runner can stall the stream transport (the
+ # connection opening, or a mid-stream read) even when Studio
+ # is healthy, so harden the read three ways:
+ # * retry a transport stall once with a fresh request,
+ # capped at 300s (a healthy server answers a retry
+ # quickly, a wedged one never does);
+ # * return any text already streamed before a stall, so a
+ # stall on the trailing tokens -- after the answer
+ # arrived -- still counts;
+ # * when every attempt yields nothing, a hard call
+ # re-raises while a soft call (the best-effort
+ # server-side tool probes) returns None so the caller
+ # can WARN instead of sinking the whole job.
+ # HTTP status errors always surface immediately.
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@@ -671,24 +704,43 @@ jobs:
"Content-Type": "application/json",
},
)
- parts = []
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- for raw in resp:
- line = raw.decode().strip()
- if not line.startswith("data: "):
- continue
- payload = line[6:]
- if payload == "[DONE]":
- break
- try:
- chunk = json.loads(payload)
- except json.JSONDecodeError:
- continue
- for choice in chunk.get("choices", []):
- delta = choice.get("delta", {}) or {}
- if delta.get("content"):
- parts.append(delta["content"])
- return "".join(parts)
+ for attempt in range(retries + 1):
+ parts = []
+ t = timeout if attempt == 0 else min(timeout, 300)
+ try:
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ for raw in resp:
+ line = raw.decode().strip()
+ if not line.startswith("data: "):
+ continue
+ payload = line[6:]
+ if payload == "[DONE]":
+ break
+ try:
+ chunk = json.loads(payload)
+ except json.JSONDecodeError:
+ continue
+ for choice in chunk.get("choices", []):
+ delta = choice.get("delta", {}) or {}
+ if delta.get("content"):
+ parts.append(delta["content"])
+ return "".join(parts)
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ # Text already streamed is a valid signal -- keep it
+ # rather than re-running a heavy generation.
+ if parts:
+ joined = "".join(parts)
+ print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
+ return joined
+ if attempt == retries:
+ if soft:
+ print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
+ return None
+ raise
+ print(f"[retry-sse] {path}: {exc!r}", flush = True)
+ time.sleep(15)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@@ -731,16 +783,24 @@ jobs:
)
# ── 2. Server-side python tool ───────────────────────────────
+ # Bound each soft probe to a single 180s attempt (timeout=180,
+ # retries=0): this job runs two of them back-to-back under a
+ # 30-minute cap, so the default 600+15+300s per stall could hit
+ # the workflow timeout before the thinking checks run. A soft
+ # probe only WARNs anyway, so a retry buys nothing.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
- })
- if "56088" in content or "56,088" in content:
+ }, timeout = 180, retries = 0, soft = True)
+ if content is None:
+ print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
+ elif "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
assert content, "python tool: SSE stream empty"
@@ -757,13 +817,16 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["terminal"],
"session_id": "ci-tool-calling-bash",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
- })
- if "hello-bash-tool" in content:
+ }, timeout = 180, retries = 0, soft = True)
+ if content is None:
+ print("[tools] WARN terminal tool: SSE transport stalled after retries -- non-blocking")
+ elif "hello-bash-tool" in content:
print(f"[tools] PASS terminal tool ({len(content)} chars)")
else:
assert content, "terminal tool: SSE stream empty"
@@ -779,12 +842,13 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
+ "permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 400,
- })
+ }, timeout = 180, retries = 0)
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@@ -1063,6 +1127,8 @@ jobs:
import base64
import json
import os
+ import time
+ import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@@ -1082,8 +1148,24 @@ jobs:
"Content-Type": "application/json",
},
)
- with urllib.request.urlopen(req, timeout = timeout) as resp:
- return resp.status, json.loads(resp.read().decode())
+ # Shared CI runners stall sporadically, so retry transport-level
+ # failures only; HTTP status errors surface immediately. Bounded
+ # to fit the job's timeout-minutes: short probes get 3 full
+ # attempts, long probes one retry capped at 300s (a healthy
+ # server answers a retry quickly; a stalled one never does).
+ attempts = 3 if timeout <= 300 else 2
+ for attempt in range(attempts):
+ try:
+ t = timeout if attempt == 0 else min(timeout, 300)
+ with urllib.request.urlopen(req, timeout = t) as resp:
+ return resp.status, json.loads(resp.read().decode())
+ except urllib.error.HTTPError:
+ raise
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
+ if attempt == attempts - 1:
+ raise
+ print(f"[retry] {path}: {exc!r}", flush = True)
+ time.sleep(15)
# ── 1. response_format = json_object (JSON mode) ─────────────
status, data = post("/v1/chat/completions", {
@@ -1334,42 +1416,75 @@ jobs:
try { Add-MpPreference -ExclusionPath $p -ErrorAction Stop } catch { }
}
- - name: Hide Visual Studio + CMake (simulate a host with no build tools)
+ - name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
- # A Program Files dir can hold a transient handle (Defender / MSBuild node)
- # so Rename-Item intermittently fails with "Access is denied"; retry to ride it out.
- function Rename-WithRetry($Path, $NewName) {
- for ($i = 1; $i -le 6; $i++) {
- try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
- catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
+ $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
+ $pf = Join-Path $root 'ProgramFiles'
+ $pfx86 = Join-Path $root 'ProgramFilesx86'
+ New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
+
+ $blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach ($tool in @('cmake', 'cl.exe')) {
+ foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
+ if ($cmd.Source) {
+ $dir = Split-Path -Parent $cmd.Source
+ if ($dir) {
+ [void] $blocked.Add(
+ [Environment]::ExpandEnvironmentVariables($dir).Trim().Trim('"').TrimEnd('\'))
+ }
+ }
}
}
- # Rename the Visual Studio install roots (incl. the Installer that holds
- # vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
- foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
- if (Test-Path -LiteralPath $d) {
- Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff')
- Write-Host "Hid VS: $d"
- }
+ # Normalized comparison so registry spellings (trailing slash,
+ # unexpanded %VAR%) still match.
+ function Test-Blocked([string]$p) {
+ $n = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd('\')
+ return $blocked.Contains($n)
}
- # Surgically rename each cmake executable on PATH (not its parent dir --
- # cmake can share a dir with other shims) so Get-Command cmake fails.
- $hidden = @()
- foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
- if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
- Rename-WithRetry $c.Source ((Split-Path $c.Source -Leaf) + '.off')
- $hidden += $c.Source
- Write-Host "Hid cmake: $($c.Source)"
- }
+
+ $pathParts = $env:Path -split [IO.Path]::PathSeparator |
+ Where-Object { $_ -and -not (Test-Blocked $_) }
+ $noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
+
+ # install.ps1's Refresh-SessionPath and setup.ps1's Refresh-Environment
+ # rebuild the session Path from these scopes mid-install, so filter
+ # them too. Originals are saved for the cleanup step.
+ foreach ($scope in @('Machine', 'User')) {
+ $orig = [Environment]::GetEnvironmentVariable('Path', $scope)
+ if (-not $orig) { continue }
+ Set-Content -LiteralPath (Join-Path $root "orig-path-$scope.txt") -Value $orig -NoNewline
+ $kept = ($orig -split ';' | Where-Object { $_ -and -not (Test-Blocked $_) }) -join ';'
+ [Environment]::SetEnvironmentVariable('Path', $kept, $scope)
+ Write-Host "Filtered $scope Path scope."
+ }
+
+ "NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
+ "NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
+ "NO_BUILD_TOOLS_PATH<&1 | Tee-Object -FilePath logs/install.log
@@ -1480,19 +1599,19 @@ jobs:
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
echo "Inference OK without Visual Studio: $CONTENT"
- - name: Restore Visual Studio + CMake
+ - name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
- foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
- $off = "$d.vsoff"
- if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
- }
- if ($env:HIDDEN_CMAKE) {
- foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) {
- if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) }
+ $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
+ foreach ($scope in @('Machine', 'User')) {
+ $saved = Join-Path $root "orig-path-$scope.txt"
+ if (Test-Path -LiteralPath $saved) {
+ [Environment]::SetEnvironmentVariable('Path', (Get-Content -LiteralPath $saved -Raw), $scope)
+ Write-Host "Restored $scope Path scope."
}
}
+ Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
- name: Stop Studio
if: always()
@@ -1540,21 +1659,34 @@ jobs:
with:
python-version: '3.12'
- - name: Hide Visual Studio
+ - name: Prepare no-build-tools simulation
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
- # Retry the rename: a Program Files dir can hold a transient handle that
- # makes Rename-Item intermittently fail with "Access is denied".
- function Rename-WithRetry($Path, $NewName) {
- for ($i = 1; $i -le 6; $i++) {
- try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
- catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
+ $root = Join-Path $env:GITHUB_WORKSPACE 'no-build-tools'
+ $pf = Join-Path $root 'ProgramFiles'
+ $pfx86 = Join-Path $root 'ProgramFilesx86'
+ New-Item -ItemType Directory -Force -Path $pf, $pfx86 | Out-Null
+
+ $blocked = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach ($tool in @('cmake', 'cl.exe')) {
+ foreach ($cmd in (Get-Command $tool -All -ErrorAction SilentlyContinue)) {
+ if ($cmd.Source) {
+ $dir = Split-Path -Parent $cmd.Source
+ if ($dir) { [void] $blocked.Add($dir) }
+ }
}
}
- foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
- if (Test-Path -LiteralPath $d) { Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
- }
+
+ $pathParts = $env:Path -split [IO.Path]::PathSeparator |
+ Where-Object { $_ -and -not $blocked.Contains($_) }
+ $noBuildToolsPath = $pathParts -join [IO.Path]::PathSeparator
+
+ "NO_BUILD_TOOLS_PROGRAMFILES=$pf" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
+ "NO_BUILD_TOOLS_PROGRAMFILES_X86=$pfx86" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
+ "NO_BUILD_TOOLS_PATH< /tmp/resolve.json || {
- echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; }
- cat /tmp/resolve.json
- echo "Prebuilt resolver ran with no Visual Studio present."
+ if ($LASTEXITCODE -ne 0) { Write-Host "::error::pip install huggingface_hub failed"; exit 1 }
+ python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > resolve.json
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "::error::resolver exited non-zero"
+ if (Test-Path resolve.json) { Get-Content resolve.json }
+ exit 1
+ }
+ Get-Content resolve.json
+ Write-Host "Prebuilt resolver ran with no Visual Studio present."
- - name: Restore Visual Studio
+ - name: Clean no-build-tools simulation
if: always()
shell: pwsh
run: |
- foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
- $off = "$d.vsoff"
- if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
- }
+ Remove-Item -LiteralPath (Join-Path $env:GITHUB_WORKSPACE 'no-build-tools') -Recurse -Force -ErrorAction SilentlyContinue
# ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ──
pester:
@@ -1610,6 +1751,13 @@ jobs:
- name: Install Pester v5
shell: pwsh
run: |
+ # PSGallery is intermittently absent from the repository list on GitHub's Windows
+ # runners, which makes `Set-PSRepository PSGallery` fail with "No repository with the
+ # name 'PSGallery' was found." Re-register the default gallery first so the policy
+ # change and module install below always have a repository to target.
+ if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
+ Register-PSRepository -Default -ErrorAction SilentlyContinue
+ }
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
Import-Module Pester -MinimumVersion 5.5.0
diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml
index 888b3d70a3..5b92f1a3e0 100644
--- a/.github/workflows/studio-windows-update-smoke.yml
+++ b/.github/workflows/studio-windows-update-smoke.yml
@@ -6,9 +6,9 @@
# windows-latest runner:
#
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
-# the prebuilt llama.cpp Windows binary (llama-bNNNN-bin-win-cpu-
-# x64 from ggml-org/llama.cpp). Hitting the source-build fallback
-# is treated as an Unsloth bug -- Studio must always pick the
+# the prebuilt llama.cpp Windows binary (app--windows-x64-cpu
+# from unslothai/llama.cpp). Hitting the source-build fallback is
+# treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Windows.
# 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no
diff --git a/.github/workflows/version-compat-ci.yml b/.github/workflows/version-compat-ci.yml
index e492d21e99..6becccc90a 100644
--- a/.github/workflows/version-compat-ci.yml
+++ b/.github/workflows/version-compat-ci.yml
@@ -285,6 +285,92 @@ jobs:
tests/vllm_compat/test_extended_module_imports.py \
-v --tb=short
+ # Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
+ # the static symbol/source greps above, this drives unsloth's actual
+ # source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
+ # runner under the tests/conftest.py spoof harness -- no GPU, no training.
+ # Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple
+ # per-token-logps return, restructured PEFT ref-adapter block) by asserting
+ # the generated Unsloth trainer still satisfies the transform contracts.
+ grpo-fake-run:
+ name: GRPO fake-run (latest + main TRL, CPU spoof)
+ runs-on: ubuntu-latest
+ timeout-minutes: 18
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ path: unsloth
+ - name: Clone unsloth-zoo @ main
+ run: |
+ for attempt in 1 2 3; do
+ rm -rf "$RUNNER_TEMP/unsloth-zoo"
+ if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
+ "$RUNNER_TEMP/unsloth-zoo"; then
+ break
+ fi
+ if [ "$attempt" -eq 3 ]; then
+ echo "::error::git clone unsloth-zoo failed after 3 attempts"
+ exit 1
+ fi
+ delay=$((5 * attempt))
+ echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
+ sleep "$delay"
+ done
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+ - name: Install CPU torch + ecosystem + TRL latest
+ run: |
+ python -m pip install --upgrade pip
+ pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
+ 'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
+ # Ecosystem floors unsloth needs; TRL itself is installed last so it
+ # can pull the transformers/peft it requires.
+ pip install \
+ 'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \
+ 'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \
+ 'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
+ pip install --upgrade trl
+ pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
+ pip install --no-deps -e ./unsloth
+ - name: Fake-run vs TRL latest
+ env:
+ UNSLOTH_IS_PRESENT: '1'
+ UNSLOTH_COMPILE_DISABLE: '1'
+ # Disable dynamo/inductor at the process level, before conftest.py's early
+ # `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner
+ # (defense in depth; the CPU fake-train also flips this at runtime).
+ TORCHDYNAMO_DISABLE: '1'
+ TORCH_COMPILE_DISABLE: '1'
+ PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
+ run: |
+ cd unsloth
+ python -c "import trl; print('Resolved TRL', trl.__version__)"
+ PYTHONPATH=. python -m pytest \
+ tests/version_compat/test_trl_grpo_fake_run.py \
+ tests/version_compat/test_trl_fake_train_cpu.py \
+ -v --tb=short
+ # `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge
+ # TRL break does not red every PR. github.event_name is valid in a step if.
+ - name: Fake-run vs TRL main (scheduled / dispatch only)
+ if: ${{ github.event_name != 'pull_request' }}
+ env:
+ UNSLOTH_IS_PRESENT: '1'
+ UNSLOTH_COMPILE_DISABLE: '1'
+ TORCHDYNAMO_DISABLE: '1'
+ TORCH_COMPILE_DISABLE: '1'
+ PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
+ run: |
+ pip install --upgrade "git+https://github.com/huggingface/trl"
+ cd unsloth
+ python -c "import trl; print('Resolved TRL', trl.__version__)"
+ PYTHONPATH=. python -m pytest \
+ tests/version_compat/test_trl_grpo_fake_run.py \
+ tests/version_compat/test_trl_fake_train_cpu.py \
+ -v --tb=short
+
# Daily-only: same suites but with --strict on importable upstream
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
daily-fresh-fetch:
diff --git a/.gitignore b/.gitignore
index 9f7d4b8c60..39ca2226ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ outputs/
exports/
/datasets/
studio/backend/assets/datasets/
+# Generated async worker / reviewer transcripts (never part of the product).
+studio/backend/async_task_outputs/
unsloth_training_checkpoints/
*.gguf
*.safetensors
diff --git a/README.md b/README.md
index e3fd4e6980..ef45b91430 100644
--- a/README.md
+++ b/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. Only use this on a trusted network.
+- `-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.
@@ -230,6 +243,14 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
```
+Skip the post-install prompt that starts Studio (useful for automated installs):
+```bash
+curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
+```
+```powershell
+$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
+```
+
Pin the Python version:
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh
diff --git a/install.ps1 b/install.ps1
index f7f9540970..c25d7e7b7f 100644
--- a/install.ps1
+++ b/install.ps1
@@ -6,6 +6,7 @@
# irm | iex cannot forward arguments, so web installs take options as env vars set
# before the pipe (flags still work via .\install.ps1):
# $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only)
+# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch
# $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version
# $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
# .\install.ps1 --no-torch # equivalent flag
@@ -90,6 +91,7 @@ function Install-UnslothStudio {
if ($TauriMode) {
exit $Code
}
+ throw $Message
}
# ── Parse flags ──
@@ -98,7 +100,9 @@ function Install-UnslothStudio {
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
+ $SkipAutostart = $false
$ShortcutsOnly = $false
+ $WithLlamaCppDir = ""
$argList = $args
for ($i = 0; $i -lt $argList.Count; $i++) {
switch ($argList[$i]) {
@@ -116,11 +120,20 @@ function Install-UnslothStudio {
}
$PackageName = $argList[$i]
}
+ "--with-llama-cpp-dir" {
+ $i++
+ if ($i -ge $argList.Count) {
+ Write-Host "[ERROR] --with-llama-cpp-dir requires a path argument." -ForegroundColor Red
+ return (Exit-InstallFailure "--with-llama-cpp-dir requires a path argument.")
+ }
+ $WithLlamaCppDir = $argList[$i]
+ }
}
}
# Env-var equivalent for web installs; an explicit flag still wins.
if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true }
+ if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true }
# Propagate to child processes so they also respect verbose mode.
# Process-scoped -- does not persist.
@@ -460,6 +473,17 @@ function Install-UnslothStudio {
param(
[Parameter(Mandatory = $true)][ScriptBlock]$Command
)
+ # Installer-pinned index installs (torch) must beat an inherited uv mirror
+ # (#6898): when the command pins an index, clear every uv index env var so
+ # it wins, then restore in finally. Other installs keep the user's mirror.
+ $savedUvIndex = $null
+ if ($Command.ToString() -match '--default-index') {
+ $savedUvIndex = @{}
+ foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL') {
+ $savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
+ Remove-Item "Env:$n" -ErrorAction SilentlyContinue
+ }
+ }
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
@@ -479,6 +503,7 @@ function Install-UnslothStudio {
return [int]$LASTEXITCODE
} finally {
$ErrorActionPreference = $prevEap
+ if ($savedUvIndex) { foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } }
}
}
@@ -2146,7 +2171,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
- $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
+ $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@@ -2160,7 +2185,7 @@ exit 0
}
}
} else {
- $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
+ $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@@ -2191,7 +2216,7 @@ exit 0
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
- $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
+ $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
if ($torchInstallExit -ne 0) {
# Transient AMD-index failure: fall back to a CPU base so the install
# still completes; Studio setup retries ROCm afterwards.
@@ -2200,7 +2225,7 @@ exit 0
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
# torch>= range, so without it uv would keep the ROCm build and only swap
# the companions -- a mismatched venv the flavor-repair block won't fix.
- $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
+ $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@@ -2214,7 +2239,7 @@ exit 0
} else {
Write-TauriLog "STEP" "Installing PyTorch"
substep "installing PyTorch ($TorchIndexUrl)..."
- $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
+ $torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@@ -2226,7 +2251,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
- $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
+ $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
@@ -2238,7 +2263,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
- $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7" }
+ $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@@ -2266,7 +2291,7 @@ exit 0
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
- $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto }
+ $baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
@@ -2297,7 +2322,7 @@ exit 0
# keeps a stale torch==X+cpu against a CUDA index and setup.ps1 then loops on
# "torch cpu != required cuXXX". Reinstall the right triplet when a GPU build is
# expected: CUDA from $TorchIndexUrl, ROCm from $ROCmIndexUrl (repo.amd.com gfx*
- # is a PEP 503 index uv resolves via --index-url, same URL the fresh ROCm install
+ # is a PEP 503 index uv resolves via --default-index, same URL the fresh ROCm install
# above uses). --no-torch / CPU-only hosts (expected cpu) are no-ops.
if (-not $SkipTorch) {
$expectedTorchTag = Get-ExpectedTorchFlavorTag -TorchIndexUrl $TorchIndexUrl -ROCmIndexUrl $ROCmIndexUrl
@@ -2313,7 +2338,7 @@ exit 0
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
- $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --index-url $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
+ $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit)
@@ -2322,7 +2347,7 @@ exit 0
} elseif ($expectedTorchTag -ne 'rocm') {
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
- $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
+ $torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit)
@@ -2430,6 +2455,13 @@ exit 0
}
$studioArgs = @('studio', 'setup')
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
+ if ($WithLlamaCppDir) {
+ if (-not (Test-Path -LiteralPath $WithLlamaCppDir -PathType Container)) {
+ Write-Host "[ERROR] --with-llama-cpp-dir path does not exist: $WithLlamaCppDir" -ForegroundColor Red
+ return (Exit-InstallFailure "--with-llama-cpp-dir path does not exist.")
+ }
+ $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR = (Resolve-Path -LiteralPath $WithLlamaCppDir).Path
+ }
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
# Hand the venv interpreter to setup.ps1 so it reuses the Python we already
# resolved and built the venv with, instead of re-probing the system (which
@@ -2445,6 +2477,7 @@ exit 0
} else {
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
}
+ Remove-Item Env:UNSLOTH_LOCAL_LLAMA_CPP_DIR -ErrorAction SilentlyContinue
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
Remove-Item Env:UNSLOTH_SETUP_PYTHON -ErrorAction SilentlyContinue
}
@@ -2583,9 +2616,10 @@ exit 0
# Diagnostic only; never block install on a probe failure.
}
- # In interactive terminals, ask the user before starting Studio.
+ # In interactive terminals, ask the user before starting Studio unless the
+ # caller explicitly disabled the post-install prompt.
# In non-interactive environments (CI, Docker) just print instructions.
- $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
+ $IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
if ($IsInteractive) {
Write-Host ""
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
@@ -2594,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 {
@@ -2615,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 ""
}
}
diff --git a/install.sh b/install.sh
index 7a9f0be87f..4a3c4471fa 100755
--- a/install.sh
+++ b/install.sh
@@ -8,8 +8,9 @@
#
# Piped installs take options as env vars after the pipe (a bare `| sh --no-torch`
# makes sh reject --no-torch as its own option). Flags still work via ./install.sh:
-# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only)
-# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
+# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only)
+# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh # do not prompt to launch
+# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
# Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch)
#
@@ -49,10 +50,16 @@ PACKAGE_NAME="unsloth"
TAURI_MODE=false
_USER_PYTHON=""
_NO_TORCH_FLAG=false
+_SKIP_AUTOSTART=false
_VERBOSE=false
_SHORTCUTS_ONLY=false
_next_is_package=false
_next_is_python=false
+_next_is_llama_cpp_dir=false
+# Seed from the environment so a caller who exports UNSLOTH_LOCAL_LLAMA_CPP_DIR
+# (the documented piped-install style) is honored; the --with-llama-cpp-dir
+# flag below overrides it when given.
+_WITH_LLAMA_CPP_DIR="${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}"
for arg in "$@"; do
if [ "$_next_is_package" = true ]; then
PACKAGE_NAME="$arg"
@@ -64,6 +71,11 @@ for arg in "$@"; do
_next_is_python=false
continue
fi
+ if [ "$_next_is_llama_cpp_dir" = true ]; then
+ _WITH_LLAMA_CPP_DIR="$arg"
+ _next_is_llama_cpp_dir=false
+ continue
+ fi
case "$arg" in
--local) STUDIO_LOCAL_INSTALL=true ;;
--package) _next_is_package=true ;;
@@ -72,11 +84,13 @@ for arg in "$@"; do
--no-torch) _NO_TORCH_FLAG=true ;;
--verbose|-v) _VERBOSE=true ;;
--shortcuts-only) _SHORTCUTS_ONLY=true ;;
+ --with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;;
esac
done
# Env-var equivalents for piped installs; an explicit flag still wins.
case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;; esac
+case "${UNSLOTH_SKIP_AUTOSTART:-}" in 1|true|TRUE|yes|YES|on|ON) _SKIP_AUTOSTART=true ;; esac
[ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON"
if [ "$_VERBOSE" = true ]; then
@@ -148,6 +162,12 @@ run_maybe_quiet() {
run_install_cmd() {
_label="$1"
shift
+ # Installer-pinned index installs (torch) must beat an inherited uv mirror
+ # (#6898): when we pass --default-index, neutralize every uv index env var so
+ # the pinned index wins. Other installs keep the user's mirror.
+ case " $* " in
+ *" --default-index "*) set -- env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL "$@" ;;
+ esac
if _is_verbose; then
"$@" && return 0
_rc=$?
@@ -255,6 +275,10 @@ if [ "$_next_is_python" = true ]; then
echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2
exit 1
fi
+if [ "$_next_is_llama_cpp_dir" = true ]; then
+ echo "❌ ERROR: --with-llama-cpp-dir requires a path argument." >&2
+ exit 1
+fi
# Validate --package to prevent injection into shell/Python commands.
# Must start with a letter/digit (rejects leading dashes that uv would parse as flags).
@@ -1427,8 +1451,14 @@ if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
SKIP_TORCH=true
fi
+# Apple Silicon: exclude broken mlx-lm 0.31.3 (QK-norm load regression for
+# gemma4 / qwen3_5; mlx-lm #1242). A curl-piped install has no overrides file
+# and skips the guarded MLX step (SKIP_STUDIO_BASE=1), so this is the only cover.
+_MLX_LM_EXCLUDE_ARG=""
+
# Apple Silicon: override mlx-vlm / mlx-lm's transformers pin (see overrides file).
if [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
+ _MLX_LM_EXCLUDE_ARG="mlx-lm!=0.31.3"
_OVERRIDES_FILE="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)/studio/backend/requirements/single-env/overrides-darwin-arm64.txt"
if [ -f "$_OVERRIDES_FILE" ]; then
# uv splits UV_OVERRIDE on whitespace, so a repo path with whitespace
@@ -1462,6 +1492,81 @@ elif [ "$OS" = "macos" ]; then
fi
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
+# AMD GPU name from the Windows host via WMI, or empty. Discrete cards aren't in
+# /proc/cpuinfo, so ask Windows. Cached ("-" = negative), self-contained, bounded
+# to 10s. Defined here so the reroute below can use it before _run_bounded exists.
+_WSL_AMD_GPU_NAME_CACHE=""
+_wsl_amd_gpu_name() {
+ if [ -n "$_WSL_AMD_GPU_NAME_CACHE" ]; then
+ [ "$_WSL_AMD_GPU_NAME_CACHE" = "-" ] && return 1
+ printf '%s' "$_WSL_AMD_GPU_NAME_CACHE"; return 0
+ fi
+ command -v powershell.exe >/dev/null 2>&1 || { _WSL_AMD_GPU_NAME_CACHE="-"; return 1; }
+ _wag_ps="(Get-CimInstance Win32_VideoController | Where-Object { \$_.Name -match 'AMD|Radeon' } | Select-Object -First 1).Name"
+ if command -v timeout >/dev/null 2>&1; then
+ _wag_n="$(timeout 10 powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
+ else
+ _wag_n="$(powershell.exe -NoProfile -Command "$_wag_ps" 2>/dev/null | tr -d '\r\n\000')"
+ fi
+ if [ -n "$_wag_n" ]; then _WSL_AMD_GPU_NAME_CACHE="$_wag_n"; printf '%s' "$_wag_n"; return 0; fi
+ _WSL_AMD_GPU_NAME_CACHE="-"; return 1
+}
+
+# ── Bounded command runner ──
+# Runs a command under a 10s timeout when the `timeout` binary is available,
+# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
+# driver init or after a reset) from hanging the installer: a timed-out probe
+# exits nonzero and is treated exactly like a failed probe. No-op semantics on
+# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
+_run_bounded() {
+ if command -v timeout >/dev/null 2>&1; then
+ timeout 10 "$@"
+ else
+ "$@"
+ fi
+}
+
+# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
+# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
+# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
+# var, so the probes below cannot see the distinction on their own.
+_cvd_hides_nvidia() {
+ [ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
+ _cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
+ [ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
+}
+
+# ── NVIDIA usable-GPU helper ──
+# Returns 0 (true) if an NVIDIA GPU is present and usable.
+# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
+# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
+# -- handles PATH gaps, subprocess timeouts, and driver init races that
+# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
+# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
+# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
+_has_usable_nvidia_gpu() {
+ if _cvd_hides_nvidia; then
+ return 1
+ fi
+ _nvsmi=""
+ if command -v nvidia-smi >/dev/null 2>&1; then
+ _nvsmi="nvidia-smi"
+ elif [ -x "/usr/bin/nvidia-smi" ]; then
+ _nvsmi="/usr/bin/nvidia-smi"
+ fi
+ if [ -n "$_nvsmi" ]; then
+ if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
+ return 0
+ fi
+ fi
+ # Fallback: NVIDIA driver exposes one subdir per GPU under this path.
+ if [ -d /proc/driver/nvidia/gpus ] && \
+ [ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
+ return 0
+ fi
+ return 1
+}
+
# Strix Halo ROCm-on-WSL only targets Ubuntu 24.04. On a newer distro (e.g. 26.04)
# with a 24.04 distro present, re-run the install there and stop; else fall through
# to CPU + the `wsl --install` hint below (never auto-create a distro). Runs before
@@ -1472,7 +1577,15 @@ _maybe_reroute_strixhalo_to_2404() {
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
[ "${UNSLOTH_WSL_REROUTED:-0}" = "1" ] && return 0
[ -e /dev/dxg ] || return 0
- grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0
+ # A usable NVIDIA GPU (common on hybrid AMD+NVIDIA hosts) means the CUDA path works on
+ # this distro, so don't reroute for AMD. _has_usable_nvidia_gpu (moved above) honors
+ # CUDA_VISIBLE_DEVICES=""/-1 and the /proc/driver/nvidia fallback for PATH/timeout gaps.
+ if _has_usable_nvidia_gpu; then return 0; fi
+ # Strix APUs show in /proc/cpuinfo; discrete cards don't, so also try WMI. Either reroutes.
+ if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
+ && ! _wsl_amd_gpu_name >/dev/null 2>&1; then
+ return 0
+ fi
# Already ROCm-on-WSL? leave a working GPU alone, whatever the version.
if [ -e /opt/rocm/lib/librocdxg.so ] || [ -e /opt/rocm/lib64/librocdxg.so ]; then
return 0
@@ -1521,6 +1634,7 @@ _maybe_reroute_strixhalo_to_2404() {
# Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the
# GPU instead of falling back to the desktop-app prompt path.
[ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1"
+ [ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1"
_rr_args=""
[ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")"
[ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")"
@@ -1938,61 +2052,6 @@ _has_amd_rocm_gpu() {
return 1
}
-# ── Bounded command runner ──
-# Runs a command under a 10s timeout when the `timeout` binary is available,
-# otherwise runs it unbounded. Keeps a wedged nvidia-smi (blocking during
-# driver init or after a reset) from hanging the installer: a timed-out probe
-# exits nonzero and is treated exactly like a failed probe. No-op semantics on
-# hosts without `timeout` (e.g. macOS) or when the probe is healthy.
-_run_bounded() {
- if command -v timeout >/dev/null 2>&1; then
- timeout 10 "$@"
- else
- "$@"
- fi
-}
-
-# Returns 0 (true) when CUDA_VISIBLE_DEVICES is set to "" or "-1", i.e. every
-# NVIDIA device is deliberately hidden (mixed AMD+NVIDIA hosts steering work to
-# the AMD card). Unset means all devices visible. nvidia-smi ignores this env
-# var, so the probes below cannot see the distinction on their own.
-_cvd_hides_nvidia() {
- [ "${CUDA_VISIBLE_DEVICES+set}" = "set" ] || return 1
- _cvd_trim=$(printf '%s' "$CUDA_VISIBLE_DEVICES" | tr -d '[:space:]')
- [ -z "$_cvd_trim" ] || [ "$_cvd_trim" = "-1" ]
-}
-
-# ── NVIDIA usable-GPU helper ──
-# Returns 0 (true) if an NVIDIA GPU is present and usable.
-# Primary probe: nvidia-smi -L. Fallback: /proc/driver/nvidia/gpus/ sysfs,
-# which the NVIDIA driver populates on Linux regardless of nvidia-smi state
-# -- handles PATH gaps, subprocess timeouts, and driver init races that
-# could otherwise cause nvidia-smi to fail and silence NVIDIA detection.
-# A GPU hidden via CUDA_VISIBLE_DEVICES=""/-1 counts as NOT usable (matches
-# install_llama_prebuilt.py has_usable_nvidia), so AMD/CPU routing still runs.
-_has_usable_nvidia_gpu() {
- if _cvd_hides_nvidia; then
- return 1
- fi
- _nvsmi=""
- if command -v nvidia-smi >/dev/null 2>&1; then
- _nvsmi="nvidia-smi"
- elif [ -x "/usr/bin/nvidia-smi" ]; then
- _nvsmi="/usr/bin/nvidia-smi"
- fi
- if [ -n "$_nvsmi" ]; then
- if _run_bounded "$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'; then
- return 0
- fi
- fi
- # Fallback: NVIDIA driver exposes one subdir per GPU under this path.
- if [ -d /proc/driver/nvidia/gpus ] && \
- [ -n "$(ls -A /proc/driver/nvidia/gpus 2>/dev/null)" ]; then
- return 0
- fi
- return 1
-}
-
# ── Detect GPU and choose PyTorch index URL ──
# Mirrors Get-TorchIndexUrl in install.ps1.
# On CPU-only machines this returns the cpu index, avoiding the solver
@@ -2141,9 +2200,9 @@ _expected_torch_flavor_tag() {
esac
}
-# Whether index ($1) supports a plain --index-url reinstall. pytorch.org cuXXX /
+# Whether index ($1) supports a plain --default-index reinstall. pytorch.org cuXXX /
# rocmX.Y AND the repo.amd.com gfx* indexes are all PEP 503 simple indexes that uv
-# resolves (torch + every transitive dep) via --index-url -- the same URLs the
+# resolves (torch + every transitive dep) via --default-index -- the same URLs the
# fresh-install paths above already use -- so a stale wheel is auto-repairable.
# Unknown/odd-mirror leaves -> no, so we warn rather than risk a wrong reinstall.
_torch_index_repairable() {
@@ -2306,19 +2365,19 @@ _persist_rocm_wsl_dropin() {
fi
}
+# _wsl_amd_gpu_name is defined earlier so both the reroute and this bootstrap can use it.
_maybe_bootstrap_rocm_wsl() {
[ "${OS:-}" = "wsl" ] || return 0
[ "${SKIP_TORCH:-false}" = "false" ] || return 0
[ "${UNSLOTH_SKIP_ROCM_WSL_SETUP:-0}" = "1" ] && return 0
# Leave any already-usable GPU completely alone (NVIDIA, or working ROCm).
if _has_usable_nvidia_gpu; then return 0; fi
- # "Usable ROCm" here = rocminfo enumerates the gfx1151 agent. Don't use the
- # generic _has_amd_rocm_gpu: its broad gfx match accepts "gfx11-generic" and
- # would skip this bootstrap while the real GPU is still unusable. awk consumes
- # all input, so rocminfo isn't SIGPIPE'd like `grep -q` would under pipefail.
+ # Usable ROCm = rocminfo enumerates a real GPU agent: gfx[1-9] (excludes gfx000,
+ # the CPU agent) and not the "gfx11-generic" fallback. awk consumes all input so
+ # rocminfo isn't SIGPIPE'd like `grep -q` under pipefail.
_ensure_rocm_probe_env
if command -v rocminfo >/dev/null 2>&1 && \
- rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx1151/{found=1} END{exit !found}'; then
+ rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[1-9]/ && !/generic/{found=1} END{exit !found}'; then
# rocminfo may work only via the transient env _ensure_rocm_probe_env
# just set, which dies with the installer. Persist the drop-in so login
# shells (Studio, llama.cpp) inherit it -- else a reinstall over an
@@ -2328,9 +2387,12 @@ _maybe_bootstrap_rocm_wsl() {
fi
# WSL GPU passthrough device must exist (present on any WSL2 GPU host).
[ -e /dev/dxg ] || return 0
- # Only Strix Halo (gfx1151): rocminfo can't tell us the arch yet, so match
- # the CPU model string WSL exposes (e.g. "AMD Ryzen AI Max+ ... Radeon 8060S").
- grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null || return 0
+ # Strix APUs show in /proc/cpuinfo (the CPU model); discrete cards don't, so also
+ # ask the Windows host. Either signal suffices; the bootstrap detects arch from rocminfo.
+ if ! grep -qiE 'Ryzen AI Max|Radeon 80[0-9]0S|Strix Halo' /proc/cpuinfo 2>/dev/null \
+ && ! _wsl_amd_gpu_name >/dev/null 2>&1; then
+ return 0
+ fi
command -v bash >/dev/null 2>&1 || return 0
# Fast path: already configured (librocdxg present) but launched from a
@@ -2348,7 +2410,8 @@ _maybe_bootstrap_rocm_wsl() {
fi
echo ""
- substep "Detected AMD Strix Halo (Radeon 8000S) in WSL with no ROCm runtime yet." "$C_WARN"
+ _rw_gpu="$(_wsl_amd_gpu_name 2>/dev/null || true)"; [ -n "$_rw_gpu" ] || _rw_gpu="an AMD GPU"
+ substep "Detected ${_rw_gpu} in WSL with no ROCm runtime yet." "$C_WARN"
substep "Setting up ROCm-on-WSL (ROCm 7.2 + librocdxg) automatically to enable this GPU."
substep "One-time, uses sudo and a large download. (skip: re-run with UNSLOTH_SKIP_ROCM_WSL_SETUP=1)"
@@ -2653,7 +2716,7 @@ if [ "$_MIGRATED" = true ]; then
# to prevent transitive torch resolution.
run_install_cmd_retry "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
- "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
+ "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
# Resolve pydantic WITH deps so pip pins pydantic-core to the
# matching version (no-torch-runtime.txt below is --no-deps).
# All transitive deps are torch-free.
@@ -2664,9 +2727,11 @@ if [ "$_MIGRATED" = true ]; then
run_install_cmd_retry "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
fi
else
+ # Pin mlx-lm away from 0.31.3 here too: a curl-piped migration has no
+ # overrides file, so UV_OVERRIDE is unset and this positional is the only cover.
run_install_cmd_retry "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
--reinstall-package unsloth --reinstall-package unsloth-zoo \
- "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
+ "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3" ${_MLX_LM_EXCLUDE_ARG:-}
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "overlaying local repo (editable)..."
@@ -2689,7 +2754,7 @@ if [ "$_MIGRATED" = true ]; then
substep "repairing ROCm torch (overwritten by dependency resolution)..."
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL" \
+ --default-index "$TORCH_INDEX_URL" \
--force-reinstall
fi
;;
@@ -2815,7 +2880,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL"
+ --default-index "$TORCH_INDEX_URL"
else
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
# Pass explicit wheel URLs so the matched trio is
@@ -2838,18 +2903,18 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL"
+ --default-index "$TORCH_INDEX_URL"
fi
else
substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN"
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL"
+ --default-index "$TORCH_INDEX_URL"
fi
else
substep "installing PyTorch ($TORCH_INDEX_URL)..."
run_install_cmd_retry "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL"
+ --default-index "$TORCH_INDEX_URL"
fi
# AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths).
# Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm
@@ -2870,7 +2935,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
run_install_cmd_retry "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
--upgrade-package unsloth --upgrade-package unsloth-zoo \
- "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
+ "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
# Same pydantic-with-deps trick as the migrated branch.
run_install_cmd_retry "install pydantic (with deps for compatible core)" \
uv pip install --python "$_VENV_PY" pydantic
@@ -2888,7 +2953,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd_retry "install unsloth (local)" uv pip install --python "$_VENV_PY" \
- --upgrade-package unsloth "unsloth>=2026.6.9" "unsloth-zoo>=2026.6.7"
+ --upgrade-package unsloth "unsloth>=2026.7.3" "unsloth-zoo>=2026.7.3"
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@@ -2897,7 +2962,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
else
run_install_cmd_retry "install unsloth" uv pip install --python "$_VENV_PY" \
- --upgrade-package unsloth -- "$PACKAGE_NAME"
+ --upgrade-package unsloth -- "$PACKAGE_NAME" ${_MLX_LM_EXCLUDE_ARG:-}
fi
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
@@ -2909,7 +2974,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
substep "repairing ROCm torch (overwritten by dependency resolution)..."
run_install_cmd_retry "repair ROCm torch" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL" \
+ --default-index "$TORCH_INDEX_URL" \
--force-reinstall
fi
;;
@@ -2920,7 +2985,7 @@ else
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
- run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.6.7" "unsloth>=2026.6.9" --torch-backend=auto
+ run_install_cmd_retry "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "unsloth-zoo>=2026.7.3" "unsloth>=2026.7.3" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
substep "overlaying unsloth-zoo from git main..."
@@ -2944,14 +3009,14 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
_installed_torch_tag=""
[ -n "$_installed_torch_ver" ] && _installed_torch_tag=$(_torch_flavor_tag "$_installed_torch_ver")
- # Repair when flavor is wrong AND the index is plain --index-url reinstallable
+ # Repair when flavor is wrong AND the index is plain --default-index reinstallable
# (cuXXX / rocmX.Y / repo.amd.com gfx*); an unknown mirror leaf -> warn only.
if [ -n "$_installed_torch_tag" ] && [ "$_installed_torch_tag" != "$_expected_torch_tag" ] \
&& [ "$(_torch_index_repairable "$TORCH_INDEX_URL")" = "yes" ]; then
substep "PyTorch flavor mismatch (installed $_installed_torch_tag, need $_expected_torch_tag) -- reinstalling correct build..."
run_install_cmd "reinstall PyTorch ($_expected_torch_tag)" uv pip install --python "$_VENV_PY" \
"$TORCH_CONSTRAINT" torchvision torchaudio \
- --index-url "$TORCH_INDEX_URL" \
+ --default-index "$TORCH_INDEX_URL" \
--reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio
_installed_torch_ver=$("$_VENV_PY" -c "import torch; print(torch.__version__)" 2>/dev/null || true)
_installed_torch_tag=""
@@ -2962,7 +3027,7 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
substep "[WARN] PyTorch is CPU-only but a $_expected_torch_tag GPU build was expected for this machine." "$C_WARN"
substep "[WARN] Training and GPU inference will run on CPU until this is fixed." "$C_WARN"
substep "[WARN] Re-run this installer, or reinstall the GPU build manually:" "$C_WARN"
- substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --index-url $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
+ substep "[WARN] uv pip install --python \"$_VENV_PY\" \"$TORCH_CONSTRAINT\" torchvision torchaudio --default-index $TORCH_INDEX_URL --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio" "$C_WARN"
fi
fi
fi
@@ -3023,6 +3088,13 @@ _run_setup_with_studio_home() {
"$@"
fi
}
+if [ -n "$_WITH_LLAMA_CPP_DIR" ]; then
+ if [ ! -d "$_WITH_LLAMA_CPP_DIR" ]; then
+ echo "[ERROR] --with-llama-cpp-dir path does not exist: $_WITH_LLAMA_CPP_DIR" >&2
+ exit 1
+ fi
+ _WITH_LLAMA_CPP_DIR="$(CDPATH= cd -P -- "$_WITH_LLAMA_CPP_DIR" && pwd -P)"
+fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
_run_setup_with_studio_home env \
SKIP_STUDIO_BASE="$_SKIP_BASE" \
@@ -3031,6 +3103,7 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
STUDIO_LOCAL_INSTALL=1 \
STUDIO_LOCAL_REPO="$_REPO_ROOT" \
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
+ UNSLOTH_LOCAL_LLAMA_CPP_DIR="$_WITH_LLAMA_CPP_DIR" \
bash "$SETUP_SH" =2026.6.7",
+ "unsloth_zoo>=2026.7.3",
"wheel>=0.42.0",
"packaging",
"numpy",
@@ -94,7 +95,7 @@ huggingfacenotorch = [
]
huggingface = [
"unsloth[huggingfacenotorch]",
- "unsloth_zoo>=2026.6.7",
+ "unsloth_zoo>=2026.7.3",
"torchvision",
"unsloth[triton]",
]
@@ -579,7 +580,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
- "unsloth_zoo>=2026.6.7",
+ "unsloth_zoo>=2026.7.3",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
diff --git a/scripts/install_rocm_wsl_strixhalo.sh b/scripts/install_rocm_wsl_strixhalo.sh
index 5ef9ee386a..aa560fc432 100644
--- a/scripts/install_rocm_wsl_strixhalo.sh
+++ b/scripts/install_rocm_wsl_strixhalo.sh
@@ -3,13 +3,14 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# ──────────────────────────────────────────────────────────────────────────────
-# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151)
+# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX
+# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
# ──────────────────────────────────────────────────────────────────────────────
-# install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime
-# is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG
-# bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04
-# WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via
-# /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies).
+# install.sh routes the detected arch to the right ROCm wheels once a runtime exists;
+# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg).
+# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by
+# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the
+# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent.
#
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
@@ -34,10 +35,12 @@ set -euo pipefail
# ── Tunables (override via env) ──────────────────────────────────────────────
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
-GFX="gfx1151"
+# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200).
+# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
+GFX="${UNSLOTH_WSL_GFX:-}"
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
-# AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test.
-TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/"
+# AMD's wheel index for the (optional) smoke test; resolved after arch detection.
+TORCH_INDEX=""
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
# torch itself into the real venv right after, so a duplicate download is wasteful.
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
@@ -220,12 +223,12 @@ $SUDO ldconfig
say "Persisting ROCm-on-WSL environment"
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
$SUDO tee "$_envfile" >/dev/null <>> Unsloth ROCm-on-WSL (gfx1151) >>>
+# >>> Unsloth ROCm-on-WSL >>>
export HSA_ENABLE_DXG_DETECTION=1
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
export PATH="${ROCM_DIR}/bin:\${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
-# <<< Unsloth ROCm-on-WSL (gfx1151) <<<
+# <<< Unsloth ROCm-on-WSL <<<
EOF
# also drop into ~/.bashrc for interactive shells
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
@@ -237,32 +240,50 @@ export PATH="${ROCM_DIR}/bin:${PATH}"
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
# ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
-say "Verifying rocminfo sees ${GFX}"
+say "Verifying rocminfo enumerates the GPU over DXG"
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
# rocminfo on first match, which under `set -o pipefail` turns a successful match
-# into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a
-# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass.
+# into a pipeline failure.
_rocminfo_out="$(rocminfo 2>/dev/null || true)"
-if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then
+# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU
+# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
+_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
+if [ -z "$_detected_gfx" ]; then
printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true
- die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
+ die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
fi
+# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
+# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
+if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
+ die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
+fi
+GFX="${GFX:-$_detected_gfx}"
# Display-only summary: best-effort (|| true) so head's early pipe-close under
# `set -o pipefail` can't fail the bootstrap after verification already passed.
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
note "ROCm-on-WSL runtime is live for ${GFX}."
-# ── Step 6 (optional): torch smoke test from the gfx1151 index ───────────────
+# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ───────
if [ "$SMOKE_TEST" = "1" ]; then
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
+ # Map the detected arch to AMD's repo.amd.com wheel family index.
+ case "$GFX" in
+ gfx1200|gfx1201) _fam="gfx120X-all" ;;
+ gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
+ *) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
+ esac
+ TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
_venv="${HOME}/.unsloth/rocm-smoketest"
rm -rf "$_venv"; python3 -m venv "$_venv"
"$_venv/bin/pip" install --quiet --upgrade pip
- # gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py
+ # AMD arch index is primary (torch + triton); PyPI only an extra for pure-py
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
die "torch install from ${TORCH_INDEX} failed."
+ # WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
+ _tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
+ [ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
"$_venv/bin/python" - <<'PY'
import torch
ok = torch.cuda.is_available()
diff --git a/scripts/scan_packages_baseline.json b/scripts/scan_packages_baseline.json
index 046566d148..929cc37bda 100644
--- a/scripts/scan_packages_baseline.json
+++ b/scripts/scan_packages_baseline.json
@@ -39,7 +39,7 @@
"file": "botocore/utils.py",
"check": "Reads credential paths AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Creds: L3551: CACHE_DIR = os.path.expanduser(os.path.join('~', '.aws', 'boto', 'cache')) | L3721: return os.path.expanduser(os.path.join('~', '.aws', 'login', 'cache'))\nNetwork: L32: from urllib.request import getproxies, proxy_bypass",
+ "evidence": "Creds: L3551: CACHE_DIR = os.path.expanduser(os.path.join('~', '.aws', 'boto', 'cache')) | L3719: return os.path.expanduser(os.path.join('~', '.aws', 'login', 'cache'))\nNetwork: L32: from urllib.request import getproxies, proxy_bypass",
"evidence_hash": "2d691bc373ab872aad23c744104596ba6d0d9f3b35aa101c7edbff4429b174c1"
},
{
@@ -55,23 +55,23 @@
"file": "datasets/utils/file_utils.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L443: while True: sha256:feba37d77721aa658e1786d2e4b67de76fefe1ceeb3ce8529d361c5241778eea",
- "evidence_hash": "2e458563dec752d0a9896c9685d368d9906867110db315ab751e3eb6ec63f51c"
+ "evidence": "L441: while True: sha256:ce92e38c17c524815e1f9055be77235028c1e68e41b45cbfe9c8f1b867a205da",
+ "evidence_hash": "cb36281d28a975d101121c0702ee05eeee470879520d39a8be552129333f514d"
},
{
"package": "datasets",
"file": "datasets/utils/file_utils.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L441: while True: sha256:ce92e38c17c524815e1f9055be77235028c1e68e41b45cbfe9c8f1b867a205da",
- "evidence_hash": "cb36281d28a975d101121c0702ee05eeee470879520d39a8be552129333f514d"
+ "evidence": "L443: while True: sha256:feba37d77721aa658e1786d2e4b67de76fefe1ceeb3ce8529d361c5241778eea",
+ "evidence_hash": "2e458563dec752d0a9896c9685d368d9906867110db315ab751e3eb6ec63f51c"
},
{
"package": "diffusers",
"file": "diffusers/utils/import_utils.py",
"check": "Downloads and executes remote code",
"severity": "CRITICAL",
- "evidence": "L1015: return importlib.import_module(\".\" + module_name, self.__name__)",
+ "evidence": "L1052: return importlib.import_module(\".\" + module_name, self.__name__)",
"evidence_hash": "e584ecfdb097d9482bb19cd3992813bc1a119cfd4c40af14748bafe22900d91e"
},
{
@@ -79,7 +79,7 @@
"file": "diffusers/utils/testing_utils.py",
"check": "Harvests environment variables/secrets AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Env: L233: value = os.environ[key]\nNetwork: L688: response = requests.get(arry, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L709: response = requests.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L728: image = PIL.Image.open(requests.get(image, stream=True, timeout=DIFFUSERS_REQUEST_TIMEOUT).raw)",
+ "evidence": "Env: L236: value = os.environ[key]\nNetwork: L691: response = requests.get(arry, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L712: response = requests.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT) | L731: image = PIL.Image.open(requests.get(image, stream=True, timeout=DIFFUSERS_REQUEST_TIMEOUT).raw)",
"evidence_hash": "671190a6106c6ee9674e5e5942dc0940e1d2f8c78d5faf674413c2345b783fd9"
},
{
@@ -90,12 +90,20 @@
"evidence": "Archive: L317: a['TarFileType'] = tarfile.open(fileobj=_fileW,mode='w')\nNetwork: L330: x['SocketType'] = _socket = socket.socket()",
"evidence_hash": "894862e547cf91b90cd6e4b495db3fb05b7490ef0d63de7e795a7e3d9447d850"
},
+ {
+ "package": "fastapi",
+ "file": "fastapi/routing.py",
+ "check": "C2 polling/beaconing loop detected",
+ "severity": "CRITICAL",
+ "evidence": "L586: while True: sha256:251135b5ebfdd1248916449f32262575e003ef64382501c65b7e4061d67bda45",
+ "evidence_hash": "365aef4449c8089753d9398417cd76ab762cef547d75db70d87bca9c0b550ab5"
+ },
{
"package": "fastmcp-slim",
"file": "fastmcp/cli/apps_dev.py",
"check": "Creates archive with sensitive data AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Archive: L1340: with tarfile.open(fileobj=io.BytesIO(data), mode=\"r:gz\") as tar:\nNetwork: L1291: with httpx.Client(timeout=30.0) as client: | L1305: with httpx.Client(timeout=30.0) as client: | L1335: with httpx.Client(timeout=30.0) as client: | L1537: client = httpx.AsyncClient(\nL1538: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1539: ) | L1701: async with httpx.AsyncClient(trust_env=False) as client: | L1769: with socket.socket(family, socket.SOCK_STREAM) as s:",
+ "evidence": "Archive: L1353: with tarfile.open(fileobj=io.BytesIO(data), mode=\"r:gz\") as tar:\nNetwork: L1304: with httpx.Client(timeout=30.0) as client: | L1318: with httpx.Client(timeout=30.0) as client: | L1348: with httpx.Client(timeout=30.0) as client: | L1549: client = httpx.AsyncClient(\nL1550: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1551: ) | L1713: async with httpx.AsyncClient(trust_env=False) as client: | L1781: with socket.socket(family, socket.SOCK_STREAM) as s:",
"evidence_hash": "73a7a72013e9f800627ea07e6dbc3beeb8c905a6a5480c8fd896f0063173d25c"
},
{
@@ -103,8 +111,8 @@
"file": "fastmcp/cli/apps_dev.py",
"check": "Enumerates filesystem AND makes network calls",
"severity": "CRITICAL",
- "evidence": "FS: L624: history.replaceState(null, \"\", url); sha256:fd8dbfa8af4dea2ce43f4d441f3f81239de341b76a2eb0a33c446f6757ce5f43\nNetwork: L1291: with httpx.Client(timeout=30.0) as client: | L1305: with httpx.Client(timeout=30.0) as client: | L1335: with httpx.Client(timeout=30.0) as client: | L1537: client = httpx.AsyncClient(\nL1538: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1539: ) | L1701: async with httpx.AsyncClient(trust_env=False) as client: | L1769: with socket.socket(family, socket.SOCK_STREAM) as s:",
- "evidence_hash": "6ada4a9111213bdee5ea24c70a72ec4acdc8ffe0de4a01fd9835bc261ccab8f8"
+ "evidence": "FS: L637: history.replaceState(null, \"\", url); sha256:17068ba5bfed62c3a3007ec8bf3e0ea41ef6529b9e6112064d9afb3be9231436\nNetwork: L1304: with httpx.Client(timeout=30.0) as client: | L1318: with httpx.Client(timeout=30.0) as client: | L1348: with httpx.Client(timeout=30.0) as client: | L1549: client = httpx.AsyncClient(\nL1550: timeout=httpx.Timeout(60.0, read=None), trust_env=False\nL1551: ) | L1713: async with httpx.AsyncClient(trust_env=False) as client: | L1781: with socket.socket(family, socket.SOCK_STREAM) as s:",
+ "evidence_hash": "e5325edfada6499540e6f0c24a0868979d275522e2b6a180aa9b5dd3280681b4"
},
{
"package": "fonttools",
@@ -132,19 +140,35 @@
},
{
"package": "huggingface-hub",
- "file": "huggingface_hub/hf_api.py",
+ "file": "huggingface_hub/_sandbox.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L3746: while True: sha256:0c73ed1a7447120b112c063b14e720c6695bc11d00eb6b912cd0f10dc3e29b31",
- "evidence_hash": "22f50b930e44146c5350bb99e6e6ebb09feea9bf1e899e407bedc4ffaf06721b"
+ "evidence": "L1179: while True: sha256:33ceddf9e42aae207e891e97808c518e92a0b27ab60e4326256717bfb25a3a38",
+ "evidence_hash": "802fd41d8bb17bf425e99d128c0351c820103a5efb74690a4086e542a71437b8"
+ },
+ {
+ "package": "huggingface-hub",
+ "file": "huggingface_hub/_sandbox.py",
+ "check": "Writes to /tmp and executes (staged dropper)",
+ "severity": "CRITICAL",
+ "evidence": "L83: d=/tmp/.sbx-server\nL84: if command -v wget >/dev/null 2>&1; then wget -q --header \"Authorization: Bearer $SBX_DL_TOKEN\" -O \"$d\" \"$SBX_SERVER_URL\"\nL85: elif command -v curl >/dev/null 2>&1; then curl -fsSL -H \"Authorization: Bearer $SBX_DL_TOKEN\" -o \"$d\" \"$SBX_SERVER_URL\"\nL86: else cp \"$SBX_SERVER_MOUNT/sbx-server\" \"$d\"; fi\nL87: chmod +x \"$d\"",
+ "evidence_hash": "6908a3fe328fa94ee22a119998d6ad07cfa1ba4efa2628acf240f4204fd76e22"
},
{
"package": "huggingface-hub",
"file": "huggingface_hub/hf_api.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L4600: while True: sha256:f4a851312a1832efe1b435aa1275a82184e19cc3f47e2cd244373d56c11de272",
- "evidence_hash": "dc8fcf44788e32f42d1cc2eb0e2deb55eb2dbf2c3a55909a7d503e450f45e602"
+ "evidence": "L4677: while True: sha256:04afb38843e4125d1476f3f04bdad0edf1f63f8d75ad49a713b13e4bc68612fb",
+ "evidence_hash": "18877a2502c862b46a5d7e33fa7c39ab4ef32da7e1b07f596fd455f4376770c6"
+ },
+ {
+ "package": "huggingface-hub",
+ "file": "huggingface_hub/hf_api.py",
+ "check": "C2 polling/beaconing loop detected",
+ "severity": "CRITICAL",
+ "evidence": "L3746: while True: sha256:0c73ed1a7447120b112c063b14e720c6695bc11d00eb6b912cd0f10dc3e29b31",
+ "evidence_hash": "22f50b930e44146c5350bb99e6e6ebb09feea9bf1e899e407bedc4ffaf06721b"
},
{
"package": "huggingface-hub",
@@ -159,8 +183,8 @@
"file": "huggingface_hub/utils/_http.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L443: while True: sha256:0ab4fed32d3af10f361963371f681923481377508a405b5d8770cef75f859168",
- "evidence_hash": "1484f6b92f41c427ba8cbc7c4695a94975fea683dfa83aa510b4b0e982be4721"
+ "evidence": "L462: while True: sha256:c75d1ee228cf7703a8c28551d649395a1f89f69a3aba69413f5bbcbd10c31958",
+ "evidence_hash": "d4d5f83fed39b87898cf776d5dad0bf1a6388a932f5fb7997d1070b50e46213e"
},
{
"package": "huggingface-hub",
@@ -218,6 +242,22 @@
"evidence": "L5: import socket sha256:915068303029fa5806199f256fb74504c65f253f9aee8ea23d8e384bb772b1c7",
"evidence_hash": "30be130f165f418dfd37b144c5ae333de184b95f828ab8bd4010a67b84a5f814"
},
+ {
+ "package": "multiprocess",
+ "file": "multiprocess/tests/__init__.py",
+ "check": "Reverse shell / bind shell pattern",
+ "severity": "CRITICAL",
+ "evidence": "L3355: os.dup2(conn.fileno(), i) | L3387: \"test needs os.dup2()\") | L3405: os.dup2(fd, newfd) | L19: import socket sha256:26a745abdc7e89da28ab943394234d8ccb415e805477c3cc1f7d4766341a4c4c",
+ "evidence_hash": "a6b9bb85e9bb6682ab0dea4f95fd9266e8802f118c76d86dd87f7ab5864872cf"
+ },
+ {
+ "package": "multiprocess",
+ "file": "multiprocess/tests/__init__.py",
+ "check": "Reverse shell / bind shell pattern",
+ "severity": "CRITICAL",
+ "evidence": "L3521: os.dup2(conn.fileno(), i) | L3553: \"test needs os.dup2()\") | L3571: os.dup2(fd, newfd) | L20: import socket sha256:07d2933301c0dbeeb6e42381687827d8dd7cfd7471986c559ca64283d5ae6e24",
+ "evidence_hash": "db1f4ca69865ec3911d7450fe11d212b817139deda21cd7a4ee32d547a8dc452"
+ },
{
"package": "numba",
"file": "numba/pycc/decorators.py",
@@ -231,7 +271,7 @@
"file": "numba/tests/support.py",
"check": "Reverse shell / bind shell pattern",
"severity": "CRITICAL",
- "evidence": "L1021: os.dup2(w, fd) | L1026: os.dup2(save, fd)",
+ "evidence": "L1016: os.dup2(w, fd) | L1021: os.dup2(save, fd)",
"evidence_hash": "fea7aa03d48bf0f4386302fa444984c4f5dfc772cfec3f1df199fd33a52eec10"
},
{
@@ -298,13 +338,21 @@
"evidence": "Env: L105: token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\") | L150: environment_token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\")\nNetwork: L415: http_client: httpx.Client | None = None, | L531: http_client: httpx.Client | None = None, | L649: http_client: httpx.AsyncClient | None = None, | L767: http_client: httpx.AsyncClient | None = None,",
"evidence_hash": "92dbec8ccd79c1e0bc41e93cdd0bdbb091220616c6a1352873196e9dda6bd85c"
},
+ {
+ "package": "openai",
+ "file": "openai/resources/beta/responses/responses.py",
+ "check": "C2 polling/beaconing loop detected",
+ "severity": "CRITICAL",
+ "evidence": "L3999: while True: sha256:df298b6eaf3416589b79f4ef283f8fb76e54d505bfda8840673f8e6419117e2e",
+ "evidence_hash": "10ce5cb5a7097fcff4042ddcfb4802edda60aa4b7b113c8b926a52ddb76f78c2"
+ },
{
"package": "openai",
"file": "openai/resources/beta/threads/runs/runs.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L1074: while True: sha256:ef6d59a4a10b73a5af491f10af2885b7a309fda9468eb0f9572d19558d3ceb9f",
- "evidence_hash": "43c03b55fedcbc980e5e6649c3c4493729128d280cc868349ab9590908ea5f99"
+ "evidence": "L1053: while True: sha256:973bb1aeca2e17e022872dc343a1bf5d8fe33bfa59fe01e2f8fe875522db5bce",
+ "evidence_hash": "24626e4aa53047a515ead563b42c07c43f73a2c5b82978fa59f58ffc2859e19b"
},
{
"package": "openai",
@@ -319,7 +367,7 @@
"file": "openai/resources/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L3803: while True: sha256:1ce0b5a388c747945cdfda1a71b77afdfd03ae840d7aa9fa62f02eb00aa5e29f",
+ "evidence": "L3950: while True: sha256:1ce0b5a388c747945cdfda1a71b77afdfd03ae840d7aa9fa62f02eb00aa5e29f",
"evidence_hash": "6de300ebb5e6e17cb51c89cbcdf08515a44655182f0776f0908a9d1043ebbcd7"
},
{
@@ -495,16 +543,16 @@
"file": "sklearn/datasets/_openml.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L100: while True: sha256:270363bb66980201e477f9b94886e4023f7a3d21b5ce026b7603a8c249a50c5b",
- "evidence_hash": "53edbe07c312d459068d38e537b5114e65685ac3d4487b0423fa4542b5df20fe"
+ "evidence": "L100: while True: sha256:1f05a1b4fdd843b309634f583cb5e919866ef38ec5aa0b7d8a66ac8820655594",
+ "evidence_hash": "69597a64e5670a0f9a3c2aafc0bde4160f6170a9e2dc38f2c413cfa8d22ad193"
},
{
"package": "scikit-learn",
"file": "sklearn/datasets/_openml.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L100: while True: sha256:1f05a1b4fdd843b309634f583cb5e919866ef38ec5aa0b7d8a66ac8820655594",
- "evidence_hash": "69597a64e5670a0f9a3c2aafc0bde4160f6170a9e2dc38f2c413cfa8d22ad193"
+ "evidence": "L100: while True: sha256:270363bb66980201e477f9b94886e4023f7a3d21b5ce026b7603a8c249a50c5b",
+ "evidence_hash": "53edbe07c312d459068d38e537b5114e65685ac3d4487b0423fa4542b5df20fe"
},
{
"package": "scikit-learn",
@@ -586,6 +634,14 @@
"evidence": "L1221: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L1226: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)",
"evidence_hash": "bba233b67f8ea4f0723b2fecaabf56528531bccd77ace836165bf38b47246bcc"
},
+ {
+ "package": "sentencepiece",
+ "file": "sentencepiece/__init__.py",
+ "check": "Reverse shell / bind shell pattern",
+ "severity": "CRITICAL",
+ "evidence": "L772: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L777: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)",
+ "evidence_hash": "65b5a11cce128fe09b3f238c01bed7c883d1740d7d46d659118f67940f6c17dc"
+ },
{
"package": "setuptools",
"file": "distutils-precedence.pth",
@@ -642,6 +698,14 @@
"evidence": "Base64: L1211: content = base64.b64decode(data)\nSubprocess: L2692: subprocess.run(\nL2693: cmd.split(), capture_output=True, text=True, check=True\nL2694: ) | L2995: cmd_output = subprocess.run(\nL2996: (\"openssl\", \"sha512\", filename), capture_output=True, text=True\nL2997: ) | L3707: out = subprocess.check_output(\nL3708: [\"ldd\", os.path.join(search, file)]\nL3709: ) | L3791: jobs.append(functools.partial(subprocess.check_call, cmd)) | L3876: subprocess.check_call(\nL3877: shlex.split(halide_cmd_gen.get_command_line())\nL3878: ) | L4336: subprocess.check_output(\nL4337: cmd_parts, stderr=subprocess.STDOUT, env=os.environ\nL4338: ) | L4591: output = subprocess.check_output(\nL4592: cmd_parts,\nL4593: stderr=subprocess.STDOUT,\nL4594: text=True,\nL4595: env=os.environ,\nL4596: )",
"evidence_hash": "c09774087b702a6c5d6e2e85d9239c7c241ec938fbe9c0153e8f0b5c0710389b"
},
+ {
+ "package": "torch",
+ "file": "torch/_inductor/codecache.py",
+ "check": "base64 decode + subprocess execution (staged payload)",
+ "severity": "CRITICAL",
+ "evidence": "Base64: L1727: content = base64.b64decode(data)\nSubprocess: L3270: subprocess.run(\nL3271: cmd, capture_output=True, text=True, check=True\nL3272: ) | L3583: cmd_output = subprocess.run(\nL3584: (\"openssl\", \"sha512\", filename), capture_output=True, text=True\nL3585: ) | L4338: out = subprocess.check_output(\nL4339: [\"ldd\", os.path.join(search, file)]\nL4340: ) | L4422: jobs.append(functools.partial(subprocess.check_call, cmd)) | L4507: subprocess.check_call(\nL4508: shlex.split(halide_cmd_gen.get_command_line())\nL4509: ) | L4992: subprocess.check_output(\nL4993: cmd_parts, stderr=subprocess.STDOUT, env=os.environ\nL4994: ) | L5247: output = subprocess.check_output(\nL5248: cmd_parts,\nL5249: stderr=subprocess.STDOUT,\nL5250: text=True,\nL5251: env=os.environ,\nL5252: )",
+ "evidence_hash": "87f77b5f51cb84fe9950fdeeb90fe8710e1b863100e90b5e2cfb228a725bee06"
+ },
{
"package": "torch",
"file": "torch/ao/__init__.py",
@@ -695,7 +759,7 @@
"file": "torch/testing/_internal/common_utils.py",
"check": "Harvests environment variables/secrets AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Env: L4770: env = os.environ.copy()\nNetwork: L4832: with request.urlopen(url, timeout=15) as f1, open(path, 'wb' if binary else 'w') as f2: | L4850: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:",
+ "evidence": "Env: L4900: env = os.environ.copy()\nNetwork: L4962: with request.urlopen(url, timeout=15) as f1, open(path, 'wb' if binary else 'w') as f2: | L4980: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:",
"evidence_hash": "704a851b9d68c9b885b9e15538bd7e96f03875503b618fe6f126c4438edd7386"
},
{
@@ -706,6 +770,14 @@
"evidence": "L32: import socket sha256:89faaaa8bc908e02dad73fd59b2b481fa91189c84b39b556c2766e71d2783bf3",
"evidence_hash": "3d23d77ace91812a07cb9508cf352185d154176e8e8c8b9b28fa92cdbcfe0d53"
},
+ {
+ "package": "torch",
+ "file": "torch/testing/_internal/common_utils.py",
+ "check": "Reverse shell / bind shell pattern",
+ "severity": "CRITICAL",
+ "evidence": "L32: import socket sha256:ba439cbf568b194872f1d974c02b0487e51f677b67e379400522d0992600bd2d",
+ "evidence_hash": "88e98b227573997f86eedea8e885a407b0dd549d46d4a3f0b840ec5aafe66865"
+ },
{
"package": "torchvision",
"file": "torchvision/datasets/utils.py",
@@ -743,8 +815,8 @@
"file": "transformers/testing_utils.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L1663: while True: sha256:969e911d30c37a279ad915fb8c3d2d0a3f5705a7eb82ae6e00687388b68bbe65",
- "evidence_hash": "2aa8e94baa805d599720a16afee6f08976482e301333e619e6c343389498ad15"
+ "evidence": "L1577: while True: sha256:2c6152f9da685f728e58d39dfc1827bc794f52606f56983bf38b5c6d0857cd5b",
+ "evidence_hash": "cdada67f3327237f00838a6750a4908dfaf76b9ab30c1352495c340d4fbd15c9"
},
{
"package": "transformers",
@@ -759,15 +831,15 @@
"file": "transformers/testing_utils.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L1577: while True: sha256:2c6152f9da685f728e58d39dfc1827bc794f52606f56983bf38b5c6d0857cd5b",
- "evidence_hash": "cdada67f3327237f00838a6750a4908dfaf76b9ab30c1352495c340d4fbd15c9"
+ "evidence": "L1699: while True: sha256:969e911d30c37a279ad915fb8c3d2d0a3f5705a7eb82ae6e00687388b68bbe65",
+ "evidence_hash": "2aa8e94baa805d599720a16afee6f08976482e301333e619e6c343389498ad15"
},
{
"package": "transformers",
"file": "transformers/testing_utils.py",
"check": "Harvests environment variables/secrets AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Env: L284: value = os.environ[key] | L300: value = os.environ[key] | L2129: env = os.environ.copy() | L2251: for k in list(os.environ.keys()):\nNetwork: L2561: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:",
+ "evidence": "Env: L288: value = os.environ[key] | L304: value = os.environ[key] | L2165: env = os.environ.copy() | L2287: for k in list(os.environ.keys()):\nNetwork: L2597: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:",
"evidence_hash": "73ff16aee09cf163fb3a7a04dfa2cf610595bde2f19460a579397695f728e3f4"
},
{
@@ -799,16 +871,16 @@
"file": "trl/extras/vllm_client.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L146: while True: sha256:2beedc742e1f085eaa10fd3bc40be97d2331d21887ef1b9ccdfa2150a184edfe",
- "evidence_hash": "1540dffaaa053780e953e04c11d9c6b9c74b91cb60f3e6d87451ba7fe7db46db"
+ "evidence": "L152: while True: sha256:93e7d409e300af445376e6defbe2d0241aa19ecf63ed41b780fbb91c7d09856f",
+ "evidence_hash": "208838617172de61bca201d2a1bbeb5aa5aaa55feb1a1069cf39214673a7d6d1"
},
{
"package": "trl",
"file": "trl/extras/vllm_client.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
- "evidence": "L152: while True: sha256:93e7d409e300af445376e6defbe2d0241aa19ecf63ed41b780fbb91c7d09856f",
- "evidence_hash": "208838617172de61bca201d2a1bbeb5aa5aaa55feb1a1069cf39214673a7d6d1"
+ "evidence": "L146: while True: sha256:2beedc742e1f085eaa10fd3bc40be97d2331d21887ef1b9ccdfa2150a184edfe",
+ "evidence_hash": "1540dffaaa053780e953e04c11d9c6b9c74b91cb60f3e6d87451ba7fe7db46db"
},
{
"package": "trl",
@@ -866,6 +938,14 @@
"evidence": "Crypto: L294: r\"|\\b(?:xprv|xpub|bc1|0x[a-fA-F0-9]{40})\\b\",\nNetwork: L53: import urllib.request | L1757: req = urllib.request.Request(url, headers = {\"Accept\": \"application/json\"}) | L1758: with urllib.request.urlopen(req, timeout = 30) as resp:",
"evidence_hash": "278ff15b0b702d37d7f0b30a1e55a31bf2b11883685718a47478fbb5ce7f5212"
},
+ {
+ "package": "unsloth-zoo",
+ "file": "scripts/scan_packages.py",
+ "check": "Writes to /tmp and executes (staged dropper)",
+ "severity": "CRITICAL",
+ "evidence": "L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", | L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", sha256:78268349021e21bedcd2eaaa5b4a71b0de1d52e023ada914dfdc09515ee1aad8",
+ "evidence_hash": "590fe1c96c442fbea5eb8642650257bc0b0199e919b9bacdb11dfa767b6fe839"
+ },
{
"package": "unsloth-zoo",
"file": "tests/security/fixtures/_build.py",
@@ -919,16 +999,16 @@
"file": "tests/test_mlx_save_export_regressions.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
- "evidence": "L164: temporary_location=\"/tmp/ignored\", sha256:78837e80d48e872ef191aaacfe5e1c621a98a20df486a70a41d1a932d074a5b3",
- "evidence_hash": "dd11376e664d0d7e7f4cc4baf57eacd4b7ae7b03222dce3912ce68b63dbfca1e"
+ "evidence": "L165: temporary_location=\"/tmp/ignored\", sha256:9f8502377b19666288b28399633dfc6740a64d0cb70ad1615e38b1269f94bf37",
+ "evidence_hash": "b7262d6e58f2ebad961dd3e64ca6c32bba356b5044d7a642d7dbd36a58cb6c81"
},
{
"package": "unsloth-zoo",
"file": "tests/test_quantize_gguf_q2_k_l.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
- "evidence": "L67: input_gguf=\"/tmp/in.gguf\", sha256:32532cadc357beee1009f4e86481bdbe60a0b7bf47f6bb022b05ec1b8e15aed0",
- "evidence_hash": "49f5b67379de17178f21a9bc93b79d6b94a70ecbdd16de86574934aac30a071d"
+ "evidence": "L67: input_gguf=\"/tmp/in.gguf\", sha256:06789b55e8f31426c233f37ff7d3729cc9e1f61c0829abd2c00c39216c63c7ad",
+ "evidence_hash": "ad4913d9099eb9b70e09d6860b242eb5f48c67e46d9bf4ae35c1c38a267d753b"
},
{
"package": "unsloth-zoo",
@@ -951,7 +1031,7 @@
"file": "unsloth_zoo/llama_cpp.py",
"check": "Creates archive with sensitive data AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Archive: L938: with tarfile.open(archive_path, \"r:gz\") as archive:\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2862: check = requests.get(llama_cpp_chat_file, timeout = 5)",
+ "evidence": "Archive: L938: with tarfile.open(archive_path, \"r:gz\") as archive:\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2873: check = requests.get(llama_cpp_chat_file, timeout = 5)",
"evidence_hash": "b9f3b1652349fa8ef9ac2d1715978aca1e1632165851a00a2698dd47189e410c"
},
{
@@ -959,7 +1039,7 @@
"file": "unsloth_zoo/llama_cpp.py",
"check": "Harvests environment variables/secrets AND makes network calls",
"severity": "CRITICAL",
- "evidence": "Env: L125: keynames = \"\\n\" + \"\\n\".join(os.environ.keys()) | L683: token = os.environ.get(\"GH_TOKEN\") or os.environ.get(\"GITHUB_TOKEN\")\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2862: check = requests.get(llama_cpp_chat_file, timeout = 5)",
+ "evidence": "Env: L125: keynames = \"\\n\" + \"\\n\".join(os.environ.keys()) | L683: token = os.environ.get(\"GH_TOKEN\") or os.environ.get(\"GITHUB_TOKEN\")\nNetwork: L691: response = requests.get(url, timeout = timeout, headers = headers, stream = stream) | L1699: response = requests.get(\nL1700: LLAMA_CPP_CONVERT_FILE, timeout = (10, 120)\nL1701: ) | L2873: check = requests.get(llama_cpp_chat_file, timeout = 5)",
"evidence_hash": "9cd0b1bb59c7eb1d814d7636dfd167c34f265eb7c4521a9d88b2bdcfd535b926"
},
{
@@ -1002,6 +1082,14 @@
"evidence": "Obfusc: L87: __import__(name)\nExec: L735: exec(\"\"\"exec _code_ in _globs_, _locs_\"\"\")",
"evidence_hash": "3cb7d8247dea7dd3d7b21ededc0181c58c50099aeb73c9138a286f3d1ad92d4f"
},
+ {
+ "package": "cffi",
+ "file": "cffi/_cffi_gen_src.py",
+ "check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
+ "severity": "HIGH",
+ "evidence": "Obfusc: L52: compiled = compile(source=pysrc, filename=filename, mode='exec')\nExec: L53: exec(compiled, globs, globs)",
+ "evidence_hash": "c429e4c977a61db6b7c717b5a552fce74eda622213e49eb5467a3782fd746fb9"
+ },
{
"package": "cffi",
"file": "cffi/setuptools_ext.py",
@@ -1127,7 +1215,7 @@
"file": "numba/tests/support.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L879: __import__(modname)\nExec: L813: eval(co, globs, ns)",
+ "evidence": "Obfusc: L874: __import__(modname)\nExec: L808: eval(co, globs, ns)",
"evidence_hash": "649a7d750f903478243b0bcb9e8020521b505fc7fedc5b696ec01f4efc096109"
},
{
@@ -1159,7 +1247,7 @@
"file": "numba/tests/test_np_functions.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L7106: exec(compile(funcstr, '', 'exec'), globals(), dct)\nExec: L7106: exec(compile(funcstr, '', 'exec'), globals(), dct)",
+ "evidence": "Obfusc: L7118: exec(compile(funcstr, '', 'exec'), globals(), dct)\nExec: L7118: exec(compile(funcstr, '', 'exec'), globals(), dct)",
"evidence_hash": "9e81164131d16056fb56ad3cd11b8d129d1ff4f5855031e8b501e0335d5c14ed"
},
{
@@ -1175,16 +1263,16 @@
"file": "numpy/testing/_private/utils.py",
"check": "Anti-analysis/sandbox evasion + suspicious behavior",
"severity": "HIGH",
- "evidence": "Anti: L2777: original_trace = sys.gettrace() | L2779: sys.settrace(None) | L2782: sys.settrace(original_trace)\nSubprocess: L1478: output = subprocess.run(cmd, capture_output=True, text=True)\nExec: L1346: exec(astr, dict) | L1632: exec(code, globs, locs)",
- "evidence_hash": "27468a6828101c6c026ae25aca8aa90ef485fd62b2c8f0967479edae9c965844"
+ "evidence": "Anti: L2788: original_trace = sys.gettrace() | L2790: sys.settrace(None) | L2793: sys.settrace(original_trace)\nSubprocess: L1486: output = subprocess.run(cmd, capture_output=True, text=True) | L2889: res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,\nL2890: errors=\"replace\", **kwargs)\nExec: L1352: exec(astr, dict) | L1640: exec(code, globs, locs)",
+ "evidence_hash": "9c6961817e5b1751e572dfe0858286703bb835870ecdfd6a7a9fdd8372a5dd2b"
},
{
"package": "numpy",
"file": "numpy/testing/_private/utils.py",
"check": "Anti-analysis/sandbox evasion + suspicious behavior",
"severity": "HIGH",
- "evidence": "Anti: L2788: original_trace = sys.gettrace() | L2790: sys.settrace(None) | L2793: sys.settrace(original_trace)\nSubprocess: L1486: output = subprocess.run(cmd, capture_output=True, text=True) | L2889: res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,\nL2890: errors=\"replace\", **kwargs)\nExec: L1352: exec(astr, dict) | L1640: exec(code, globs, locs)",
- "evidence_hash": "9c6961817e5b1751e572dfe0858286703bb835870ecdfd6a7a9fdd8372a5dd2b"
+ "evidence": "Anti: L2777: original_trace = sys.gettrace() | L2779: sys.settrace(None) | L2782: sys.settrace(original_trace)\nSubprocess: L1478: output = subprocess.run(cmd, capture_output=True, text=True)\nExec: L1346: exec(astr, dict) | L1632: exec(code, globs, locs)",
+ "evidence_hash": "27468a6828101c6c026ae25aca8aa90ef485fd62b2c8f0967479edae9c965844"
},
{
"package": "numpy",
@@ -1199,7 +1287,7 @@
"file": "PIL/Image.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L422: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), []) | L490: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), [])\nExec: L3772: def eval(image: Image, *args: Callable[[int], float]) -> Image:",
+ "evidence": "Obfusc: L422: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), []) | L490: __import__(f\"{__spec__.parent}.{plugin}\", globals(), locals(), [])\nExec: L3776: def eval(image: Image, *args: Callable[[int], float]) -> Image:",
"evidence_hash": "c2c1e7ae44e15862caf8de549d09db7b35e93282450f07ef61aaf5450a408c13"
},
{
@@ -1255,7 +1343,7 @@
"file": "setuptools/_distutils/compilers/C/base.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L1286: __import__(module_name)\nExec: L1113: if lib_type not in eval(expected):",
+ "evidence": "Obfusc: L1287: __import__(module_name)\nExec: L1114: if lib_type not in eval(expected):",
"evidence_hash": "368651e9818ed2d1bb009027d3bcfbf94ae30639c0882a6c2bddde97b8c4f1e5"
},
{
@@ -1271,7 +1359,7 @@
"file": "setuptools/tests/config/test_pyprojecttoml.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L364: \"setup.py\": \"__import__('setuptools').setup(include_package_data=False)\",\nExec: L98: \"__main__.py\": \"def exec(): print('hello')\",",
+ "evidence": "Obfusc: L387: \"setup.py\": \"__import__('setuptools').setup(include_package_data=False)\",\nExec: L98: \"__main__.py\": \"def exec(): print('hello')\",",
"evidence_hash": "067d41014f72a61d8b4adf25f3659d1f66a0e909f732223f48837aa7684df4e6"
},
{
@@ -1279,7 +1367,7 @@
"file": "setuptools/tests/test_editable_install.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L120: SETUP_SCRIPT_STUB = \"__import__('setuptools').setup()\"\nExec: L449: exec(finder, loc, loc)",
+ "evidence": "Obfusc: L120: SETUP_SCRIPT_STUB = \"__import__('setuptools').setup()\"\nExec: L447: exec(finder, loc, loc)",
"evidence_hash": "a78d7f5af7eb4ba92656cda258c195b92f6337c585c97d0823e47a9d4a2eb15d"
},
{
@@ -1322,12 +1410,20 @@
"evidence": "Obfusc: L919: c = compile(funcstr, filename, 'exec')\nExec: L163: module = eval(import_command) | L170: exec(import_command, {}, namespace) | L903: exec(ln, {}, namespace) | L909: exec(ln, {}, namespace) | L920: exec(c, namespace, funclocals)",
"evidence_hash": "ab4f5819576a70038301668b8f3e4a781c4b757b146117d5d93eab1896a5a6cd"
},
+ {
+ "package": "tensorboard",
+ "file": "tensorboard/plugins/projector/tf_projector_plugin/projector_binary.js",
+ "check": "Python wheel ships large JS bundle (uncommon; manually review)",
+ "severity": "HIGH",
+ "evidence": "sha256: 53c38430766be25dc672a30846ac3b9eba86aee35eb0746785ec012647c7d9a2",
+ "evidence_hash": "2c6384e8115a6d5dacf1f84d8f724832d8dc59feb442bb98ffae0857c0ccb381"
+ },
{
"package": "torch",
"file": "torch/_dynamo/bytecode_debugger.py",
"check": "Anti-analysis/sandbox evasion + suspicious behavior",
"severity": "HIGH",
- "evidence": "Anti: L1048: self._old_trace = sys.gettrace() | L1049: sys.settrace(self._settrace_callback) | L1106: sys.settrace(self._old_trace)\nExec: L683: result = eval(arg, frame_globals, eval_locals) | L708: result = eval(cmd, frame_globals, eval_locals) | L716: exec(cmd, frame_globals, eval_locals)",
+ "evidence": "Anti: L1052: self._old_trace = sys.gettrace() | L1053: sys.settrace(self._settrace_callback) | L1113: sys.settrace(self._old_trace)\nExec: L684: result = eval(arg, frame_globals, eval_locals) | L709: result = eval(cmd, frame_globals, eval_locals) | L717: exec(cmd, frame_globals, eval_locals)",
"evidence_hash": "dc2afd1769d357c15b69802bd2799fafa059c0b1dcdd4937528fb5b601962f1b"
},
{
@@ -1343,7 +1439,7 @@
"file": "torch/fx/experimental/rewriter.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L46: code = compile(dest_ast, \"\", \"exec\")\nExec: L49: exec(code, globals_dict)",
+ "evidence": "Obfusc: L44: code = compile(dest_ast, \"\", \"exec\")\nExec: L47: exec(code, globals_dict)",
"evidence_hash": "76374f96feed416eec390458843621f33524cfb8d93ef0f3eb4cb1b47d0ad748"
},
{
@@ -1359,7 +1455,7 @@
"file": "torch/package/package_importer.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L602: def __import__(self, name, globals=None, locals=None, fromlist=(), level=0):\nExec: L412: exec(code, ns)",
+ "evidence": "Obfusc: L599: def __import__(self, name, globals=None, locals=None, fromlist=(), level=0):\nExec: L412: exec(code, ns)",
"evidence_hash": "c7c0650f0c74a086d224112f77ee76634b8f47afc047ce27fee8c7fc45560512"
},
{
@@ -1391,7 +1487,7 @@
"file": "tests/test_mlx_trainer_internals.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L430: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L408: def eval(self):",
+ "evidence": "Obfusc: L1158: assert ppl == pytest.approx(__import__(\"math\").exp(2.5))\nExec: L1136: def eval(self):",
"evidence_hash": "c409327ef6420cc0c7224506fcb82b11bbc9838a6f2f97c9c2cfc00a40c4cdbf"
},
{
@@ -1407,7 +1503,7 @@
"file": "unsloth_zoo/compiler.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L1013: _mod = __import__(model_location, fromlist=items) | L4291: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4292: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4293: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4294: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4292: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4293: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4294: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4291: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4292: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4293: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nExec: L612: if eval(_dtype) is not None: | L613: dtype = eval(_dtype) | L955: _modeling_file = eval(model_location) | L1255: f = eval(f\"{model_location}.{module}\") | L1563: exec(f\"def raise_{j}(*args, **kwargs): print('{function}')\", globals(), locals()) | L1564: try: exec(f\"EMPTY_LOGITS.{function} = raise_{j}\", globals(), locals()) | L2699: exec(f\"import {parent}\", locals(), globals()) | L2830: dir(eval(parent)), | L2834: exec(f\"{parent}.{child}.forward = forward\", globals(), locals()) | L2908: module = eval(f\"modeling_file.{module}\") | L2935: inner_class = eval(f\"modeling_file.{inner_class}\") | L3065: exec(f\"from timm.layers.norm_act import {norm}\") | L3073: forward = eval(norm).forward | L3079: exec(f\"timm.layers.norm_act.{norm}.forward = forward\") | L3096: exec(f\"from timm.models._efficientnet_blocks import {block}\") | L3104: forward = eval(block).forward | L3110: exec(f\"timm.models._efficientnet_blocks.{block}.forward = forward\") | L3385: exec(f\"import {model_location}\", globals()) | L3388: modeling_file = eval(model_location) | L3401: exec(\nL3402: \"model_logger.addFilter(HideLoggingMessage('`use_cache`'))\", globals(), locals()\nL3403: ) | L3405: exec(\nL3406: \"model_logger.addFilter(HideLoggingMessage('compile_config'))\",\nL3407: globals(),\nL3408: locals(),\nL3409: ) | L3560: source = eval(f\"modeling_file.{module}\") | L3574: source = eval(f\"modeling_file.{module}\") | L3675: source = eval(f\"modeling_file.{module}\") | L3713: source = eval(f\"{model_location}.{module}\") | L3784: source = eval(f\"{model_location}.{module}\") | L3832: source = eval(f\"{model_location}.{module}\") | L4054: source = eval(f\"{model_location}.{module}\") | L4065: exec(\nL4066: f\"{model_location}.{module}._update_causal_mask = no_update_causal_mask\",\nL4067: globals(),\nL4068: ) | L4131: source = eval(f\"{model_location}.{module}\") | L4172: module_cls = eval(f\"{model_location}.{module}\") | L4209: module_cls = eval(f\"{model_location}.{module}\") | L4276: exec(\nL4277: \"from transformers.trainer import (\" + \", \".join(x for x in good_items) + \")\",\nL4278: globals(),\nL4279: ) | L4341: exec(inner_training_loop, globals()) | L4349: function = eval(f\"{model_location}.{module}\") | L4427: function = eval(f\"{model_location}.{module}\") | L4562: source = eval(f\"{model_location}.torch\") | L4569: function = eval(f\"source.nn.{module}\") | L4628: exec(\nL4629: f\"{model_location}.torch.nn.{module}.forward = forward\",\nL4630: globals(),\nL4631: locals(),\nL4632: ) | L4634: exec(\nL4635: f\"{model_location}.nn.{module}.forward = forward\",\nL4636: globals(),\nL4637: locals(),\nL4638: ) | L4642: exec(\nL4643: f\"combined_module.torch.nn.{module}.forward = forward\",\nL4644: globals(),\nL4645: locals(),\nL4646: ) | L4648: exec(\nL4649: f\"combined_module.nn.{module}.forward = forward\",\nL4650: globals(),\nL4651: locals(),\nL4652: ) | L4669: exec(\nL4670: f\"{model_location}.{module} = combined_module.{module}\",\nL4671: globals(),\nL4672: locals(),\nL4673: ) | L4683: check_dicts = dir(eval(f\"{model_location}\")) | L4685: item = eval(f\"{model_location}.{check}\") | L4695: exec(\nL4696: f\"{model_location}.{check}['{key}'] = combined_module.{replaced_class}\",\nL4697: globals(),\nL4698: locals(),\nL4699: )",
+ "evidence": "Obfusc: L1013: _mod = __import__(model_location, fromlist=items) | L4295: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4298: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nL4298: f' \"-____-\" Trainable parameters = {get_model_param_count(model, trainable_only=True):,} of {get_model_param_count(model):,} ({get_model_param_count(model, trainable_only=True)/get_model_p sha256:9e832c1e7b2815aa44dc42638d821cb9df22550db88d85bd4bfb29c13f984b53 | L4295: f\" {chr(92)}{chr(92)} /| Num examples = {num_examples:,} | Num Epochs = {num_train_epochs:,} | Total steps = {max_steps:,}\\\\n\"\\\\\nL4296: f\"O^O/ {chr(92)}_/ {chr(92)} Batch size per device = {self._train_batch_size:,} | Gradient accumulation steps = {args.gradient_accumulation_steps}\\\\n\"\\\\\nL4297: f\"{chr(92)} / Data Parallel GPUs = {args.world_size} | Total batch size ({self._train_batch_size} x {args.gradient_accumulation_steps} x {args.world_size}) = {total_train_batch_size: sha256:b7ea9cdbe360ad323911014caf12a9d4ec87cb36195a511080e4e484c2fb80d2\nExec: L612: if eval(_dtype) is not None: | L613: dtype = eval(_dtype) | L955: _modeling_file = eval(model_location) | L1255: f = eval(f\"{model_location}.{module}\") | L1563: exec(f\"def raise_{j}(*args, **kwargs): print('{function}')\", globals(), locals()) | L1564: try: exec(f\"EMPTY_LOGITS.{function} = raise_{j}\", globals(), locals()) | L2699: exec(f\"import {parent}\", locals(), globals()) | L2830: dir(eval(parent)), | L2834: exec(f\"{parent}.{child}.forward = forward\", globals(), locals()) | L2908: module = eval(f\"modeling_file.{module}\") | L2935: inner_class = eval(f\"modeling_file.{inner_class}\") | L3065: exec(f\"from timm.layers.norm_act import {norm}\") | L3073: forward = eval(norm).forward | L3079: exec(f\"timm.layers.norm_act.{norm}.forward = forward\") | L3096: exec(f\"from timm.models._efficientnet_blocks import {block}\") | L3104: forward = eval(block).forward | L3110: exec(f\"timm.models._efficientnet_blocks.{block}.forward = forward\") | L3389: exec(f\"import {model_location}\", globals()) | L3392: modeling_file = eval(model_location) | L3405: exec(\nL3406: \"model_logger.addFilter(HideLoggingMessage('`use_cache`'))\", globals(), locals()\nL3407: ) | L3409: exec(\nL3410: \"model_logger.addFilter(HideLoggingMessage('compile_config'))\",\nL3411: globals(),\nL3412: locals(),\nL3413: ) | L3564: source = eval(f\"modeling_file.{module}\") | L3578: source = eval(f\"modeling_file.{module}\") | L3679: source = eval(f\"modeling_file.{module}\") | L3717: source = eval(f\"{model_location}.{module}\") | L3788: source = eval(f\"{model_location}.{module}\") | L3836: source = eval(f\"{model_location}.{module}\") | L4058: source = eval(f\"{model_location}.{module}\") | L4069: exec(\nL4070: f\"{model_location}.{module}._update_causal_mask = no_update_causal_mask\",\nL4071: globals(),\nL4072: ) | L4135: source = eval(f\"{model_location}.{module}\") | L4176: module_cls = eval(f\"{model_location}.{module}\") | L4213: module_cls = eval(f\"{model_location}.{module}\") | L4280: exec(\nL4281: \"from transformers.trainer import (\" + \", \".join(x for x in good_items) + \")\",\nL4282: globals(),\nL4283: ) | L4345: exec(inner_training_loop, globals()) | L4353: function = eval(f\"{model_location}.{module}\") | L4431: function = eval(f\"{model_location}.{module}\") | L4566: source = eval(f\"{model_location}.torch\") | L4573: function = eval(f\"source.nn.{module}\") | L4632: exec(\nL4633: f\"{model_location}.torch.nn.{module}.forward = forward\",\nL4634: globals(),\nL4635: locals(),\nL4636: ) | L4638: exec(\nL4639: f\"{model_location}.nn.{module}.forward = forward\",\nL4640: globals(),\nL4641: locals(),\nL4642: ) | L4646: exec(\nL4647: f\"combined_module.torch.nn.{module}.forward = forward\",\nL4648: globals(),\nL4649: locals(),\nL4650: ) | L4652: exec(\nL4653: f\"combined_module.nn.{module}.forward = forward\",\nL4654: globals(),\nL4655: locals(),\nL4656: ) | L4673: exec(\nL4674: f\"{model_location}.{module} = combined_module.{module}\",\nL4675: globals(),\nL4676: locals(),\nL4677: ) | L4687: check_dicts = dir(eval(f\"{model_location}\")) | L4689: item = eval(f\"{model_location}.{check}\") | L4699: exec(\nL4700: f\"{model_location}.{check}['{key}'] = combined_module.{replaced_class}\",\nL4701: globals(),\nL4702: locals(),\nL4703: )",
"evidence_hash": "ec1875fd32d00fe885e566ebda75163e46e838ca31020abb57e0991892c2bdf7"
},
{
@@ -1423,8 +1519,8 @@
"file": "unsloth_zoo/mlx/loader.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L2218: _mod = __import__(module_name, fromlist=[\"_\"])\nExec: L140: mx.eval(model.parameters()) | L176: mx.eval(model.parameters()) | L2022: model.eval() | L2605: mx.eval(model.parameters()) | L2721: mx.eval(module.weight) | L4030: mx.eval(model.parameters()) | L4058: mx.eval(model.parameters()) | L4178: mx.eval(model.parameters())",
- "evidence_hash": "9b29dade82912216c8b4808aa293b79749aa80ef1d2be35edd93bec7632810f1"
+ "evidence": "Obfusc: L2869: _mod = __import__(module_name, fromlist=[\"_\"])\nExec: L148: mx.eval(model.parameters()) | L180: mx.eval(model.parameters()) | L732: mx.eval(model.parameters()) | L733: mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu)) | L799: mx.eval(model.parameters()) | L802: mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu)) | L2673: model.eval() | L3256: mx.eval(model.parameters()) | L3372: mx.eval(module.weight) | L5666: mx.eval(model.parameters()) | L5716: mx.eval(model.parameters()) | L5859: mx.eval(model.parameters())",
+ "evidence_hash": "7b44760032c5df6d379ccfdd0bff3d23f857f64e08210fa0fba8d2881d457634"
},
{
"package": "unsloth-zoo",
@@ -1439,7 +1535,7 @@
"file": "unsloth_zoo/saving_utils.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
- "evidence": "Obfusc: L3241: module = __import__('transformers', fromlist=[model_class_name])\nExec: L3123: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3169: exec(save_pretrained, globals(), functions)",
+ "evidence": "Obfusc: L4015: module = __import__('transformers', fromlist=[model_class_name])\nExec: L3897: exec(f\"from transformers.modeling_utils import ({', '.join(functions)})\", locals(), globals()) | L3943: exec(save_pretrained, globals(), functions)",
"evidence_hash": "530b2383acd9fe8330aa65cd0bf86164aaacd47770e7c8d0752195bee36396ec"
},
{
@@ -1449,46 +1545,6 @@
"severity": "HIGH",
"evidence": "Obfusc: L836: code = compile(module, \"\", \"exec\")\nExec: L736: exec(code, globs, locs)",
"evidence_hash": "5c0992c90f05c772abd94d00784f157de337e1f8567f8b3aee1b15e46c96cd5d"
- },
- {
- "package": "multiprocess",
- "file": "multiprocess/tests/__init__.py",
- "check": "Reverse shell / bind shell pattern",
- "severity": "CRITICAL",
- "evidence": "L3355: os.dup2(conn.fileno(), i) | L3387: \"test needs os.dup2()\") | L3405: os.dup2(fd, newfd) | L19: import socket sha256:26a745abdc7e89da28ab943394234d8ccb415e805477c3cc1f7d4766341a4c4c",
- "evidence_hash": "a6b9bb85e9bb6682ab0dea4f95fd9266e8802f118c76d86dd87f7ab5864872cf"
- },
- {
- "package": "unsloth-zoo",
- "file": "scripts/scan_packages.py",
- "check": "Writes to /tmp and executes (staged dropper)",
- "severity": "CRITICAL",
- "evidence": "L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", | L308: r\"/tmp/\\S+.*(?:subprocess|os\\.system|os\\.popen|Popen|chmod.*\\+x)\", sha256:78268349021e21bedcd2eaaa5b4a71b0de1d52e023ada914dfdc09515ee1aad8",
- "evidence_hash": "590fe1c96c442fbea5eb8642650257bc0b0199e919b9bacdb11dfa767b6fe839"
- },
- {
- "package": "multiprocess",
- "file": "multiprocess/tests/__init__.py",
- "check": "Reverse shell / bind shell pattern",
- "severity": "CRITICAL",
- "evidence": "L3521: os.dup2(conn.fileno(), i) | L3553: \"test needs os.dup2()\") | L3571: os.dup2(fd, newfd) | L20: import socket sha256:07d2933301c0dbeeb6e42381687827d8dd7cfd7471986c559ca64283d5ae6e24",
- "evidence_hash": "db1f4ca69865ec3911d7450fe11d212b817139deda21cd7a4ee32d547a8dc452"
- },
- {
- "package": "fastapi",
- "file": "fastapi/routing.py",
- "check": "C2 polling/beaconing loop detected",
- "severity": "CRITICAL",
- "evidence": "L586: while True: sha256:bef9ea429314fad39e063895a37dc5cfe9b04561f3d1acbb3c99abb4e92e6cfe",
- "evidence_hash": "b15773e1bc249713156a349278ea60f7c0e3dd7d537affe929ab51089e1942bb"
- },
- {
- "package": "tensorboard",
- "file": "tensorboard/plugins/projector/tf_projector_plugin/projector_binary.js",
- "check": "Python wheel ships large JS bundle (uncommon; manually review)",
- "severity": "HIGH",
- "evidence": "sha256: 53c38430766be25dc672a30846ac3b9eba86aee35eb0746785ec012647c7d9a2",
- "evidence_hash": "2c6384e8115a6d5dacf1f84d8f724832d8dc59feb442bb98ffae0857c0ccb381"
}
]
}
diff --git a/scripts/verify_import_hoist.py b/scripts/verify_import_hoist.py
index b4c908b0cb..22a21a2ebc 100644
--- a/scripts/verify_import_hoist.py
+++ b/scripts/verify_import_hoist.py
@@ -564,6 +564,12 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
for n, tids in b["module_import_targets"].items():
if tids & after_used:
continue # resolved -> fine
+ # `from __future__ import ...` is a compiler directive, not a runtime
+ # binding: the name (`annotations`, ...) is never loaded, so it can never
+ # "resolve" to a use. Skip it so a legitimately-added future import
+ # (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
+ if all(t.startswith("from:__future__:") for t in tids):
+ continue
newly_added = bool(tids - before_module_targets)
was_used_before = bool(tids & before_used)
if newly_added or was_used_before:
@@ -588,9 +594,23 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
# package object and only *add* submodule attributes (e.g. adding
# `import urllib.error` next to `import urllib.request`). Nothing the name
# resolved to before is lost, so no reference is re-pointed -- skip it.
+ #
+ # A deliberate *relocation* is also benign and must not block: when a name
+ # keeps its spelling but its import source is moved A -> B in THIS diff (the
+ # old `from A import x` is removed at module level and a new `from B import x`
+ # is added), the swap is intentional, not a silent re-point to a pre-existing
+ # different object. This mirrors the relocation tolerance already applied to
+ # TARGET-MISSING. The dangerous case -- the name now resolving to a target
+ # that already existed before (shadow/clash) -- is NOT exempted.
+ removed_module_targets = before_module_targets - after_module_targets
for key, tafter in b["target_by_use"].items():
tbefore = a["target_by_use"].get(key)
if tbefore and tbefore != tafter and (tbefore - tafter):
+ lost = tbefore - tafter
+ gained = tafter - tbefore
+ relocated = lost <= removed_module_targets and gained <= added_module_targets
+ if relocated:
+ continue
findings.append(
(
"BLOCKER",
diff --git a/studio/MCP.md b/studio/MCP.md
new file mode 100644
index 0000000000..91b39fcc69
--- /dev/null
+++ b/studio/MCP.md
@@ -0,0 +1,34 @@
+# Unsloth Studio MCP server
+
+Studio can expose a local MCP server so an MCP client can inspect models and
+GPU state, validate recipes, start or stop training, inspect recipe output, and
+export a loaded model.
+
+The server is disabled by default. Enable it for a local Studio process with:
+
+```bash
+UNSLOTH_STUDIO_ENABLE_MCP=1 \
+UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \
+unsloth studio
+```
+
+The endpoint is `http://127.0.0.1:8888/mcp/` when Studio uses its default port
+(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Studio
+port when it is configured differently.
+
+The high-impact tools are:
+
+- `studio_status` and `list_local_models` for discovery
+- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs`
+- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset`
+- `load_checkpoint` and `export_gguf`
+
+`start_training` accepts the same fields as the Studio `TrainingStartRequest`.
+The request is validated by the existing Pydantic model before a subprocess is
+started. Export paths use the existing Studio validation as well.
+
+The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact
+Bearer token for both HTTP and WebSocket connections. Keep it on localhost
+unless the deployment has an authenticated reverse proxy. The MCP endpoint is
+intentionally opt-in because tools can consume GPU memory, write model
+artifacts, and stop active work.
\ No newline at end of file
diff --git a/studio/backend/assets/configs/inference_defaults.json b/studio/backend/assets/configs/inference_defaults.json
index 1c7a409bc1..0633f80bbc 100644
--- a/studio/backend/assets/configs/inference_defaults.json
+++ b/studio/backend/assets/configs/inference_defaults.json
@@ -235,6 +235,13 @@
"min_p": 0.1,
"repetition_penalty": 1.0
},
+ "deepseek-v4": {
+ "temperature": 1.0,
+ "top_p": 1.0,
+ "top_k": -1,
+ "min_p": 0.0,
+ "repetition_penalty": 1.0
+ },
"deepseek-r1": {
"temperature": 0.6,
"top_p": 0.95,
@@ -394,7 +401,7 @@
"phi-4", "phi-3",
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",
"devstral", "pixtral",
- "deepseek-r1", "deepseek-v3", "deepseek-ocr",
+ "deepseek-v4", "deepseek-r1", "deepseek-v3", "deepseek-ocr",
"glm-5", "glm-4",
"nemotron",
"minimax-m2.7", "minimax-m2.5", "minimax",
diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py
index a0da2b2096..9bb3ab5735 100644
--- a/studio/backend/auth/storage.py
+++ b/studio/backend/auth/storage.py
@@ -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()
diff --git a/studio/backend/auth/terminal_prompt.py b/studio/backend/auth/terminal_prompt.py
new file mode 100644
index 0000000000..8491019ae9
--- /dev/null
+++ b/studio/backend/auth/terminal_prompt.py
@@ -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
diff --git a/studio/backend/colab.py b/studio/backend/colab.py
index dd274399bc..e04543b3aa 100644
--- a/studio/backend/colab.py
+++ b/studio/backend/colab.py
@@ -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,
diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py
index 9d8ca5cfcc..4647dc098d 100644
--- a/studio/backend/core/data_recipe/service.py
+++ b/studio/backend/core/data_recipe/service.py
@@ -9,6 +9,8 @@ import os
from pathlib import Path
from typing import Any
+from utils.paths import recipe_datasets_root
+
from .jsonable import to_jsonable
from .local_callable_validators import (
register_oxc_local_callable_validators,
@@ -277,6 +279,11 @@ def create_data_designer(recipe: dict[str, Any], *, artifact_path: str | None =
_apply_data_designer_image_context_patch()
from data_designer.interface.data_designer import DataDesigner # pyright: ignore[reportMissingImports]
+ if artifact_path is None:
+ # DataDesigner defaults to cwd/artifacts; packaged Studio can run with
+ # cwd=/, so keep default callers on Studio's writable recipe artifact root.
+ artifact_path = str(recipe_datasets_root())
+
recipe = _strip_frontend_model_config_metadata(recipe)
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)
diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py
index 4b0ecb0840..e03ff0786b 100644
--- a/studio/backend/core/export/export.py
+++ b/studio/backend/core/export/export.py
@@ -13,7 +13,18 @@ import shutil
import contextlib
from pathlib import Path
from typing import Optional, Tuple, List
-from unsloth import FastLanguageModel, FastVisionModel, _IS_MLX
+
+# unsloth imports torch on non-MLX hosts, so a --no-torch install raises here. Stay importable
+# (null the classes) so exports return a clean "PyTorch is not installed" error, not an import crash.
+try:
+ from unsloth import FastLanguageModel, FastVisionModel, _IS_MLX
+ _UNSLOTH_IMPORT_ERROR = None
+except Exception as _unsloth_exc: # ImportError (e.g. missing torch) or a broken native load
+ FastLanguageModel = None
+ FastVisionModel = None
+ _IS_MLX = False
+ _UNSLOTH_IMPORT_ERROR = _unsloth_exc
+
from huggingface_hub import HfApi, ModelCard
from utils.hardware import clear_gpu_cache
@@ -27,14 +38,46 @@ from utils.paths import (
)
from core.inference import get_inference_backend
-# GPU-only imports — guarded for Apple Silicon where these aren't needed
+# GPU/PyTorch-only imports, skipped on MLX and on a --no-torch install so the module stays
+# importable; export then degrades to a clear "PyTorch is not installed" error.
+torch = None
+_TORCH_IMPORT_ERROR: Optional[BaseException] = None
if not _IS_MLX:
- from peft import PeftModel, PeftModelForCausalLM
- from transformers.modeling_utils import PushToHubMixin
- import torch
+ try:
+ from peft import PeftModel, PeftModelForCausalLM
+ from transformers.modeling_utils import PushToHubMixin
+ import torch
+ except Exception as _torch_exc: # ImportError, or a broken native torch load
+ _TORCH_IMPORT_ERROR = _torch_exc
logger = get_logger(__name__)
+
+def _export_runtime_available() -> bool:
+ """True if export can run: MLX active, or Unsloth imported (only succeeds on a GPU host)."""
+ return bool(_IS_MLX) or (FastLanguageModel is not None)
+
+
+def _export_runtime_message() -> str:
+ """Precise reason the export runtime is unavailable, mirroring hardware.export_capability()."""
+ if torch is None:
+ return (
+ "PyTorch is not installed. Model export requires PyTorch with a supported accelerator "
+ "(NVIDIA, AMD, or Intel GPU) or Apple Silicon (MLX). Install PyTorch to enable export."
+ )
+ return (
+ "Export requires an NVIDIA, AMD, or Intel GPU, or Apple Silicon (MLX). No supported "
+ "accelerator was found on this host. (PyTorch is installed, but Unsloth cannot export on "
+ "CPU only.)"
+ )
+
+
+# Kept for call sites / tests referencing the PyTorch-missing text.
+_PYTORCH_MISSING_MESSAGE = (
+ "PyTorch is not installed. Model export requires PyTorch with a supported accelerator "
+ "(NVIDIA, AMD, or Intel GPU) or Apple Silicon (MLX). Install PyTorch to enable export."
+)
+
_LLAMA_CPP_SCRIPTS_WARNING_EMITTED = False
@@ -89,6 +132,28 @@ def _compressed_export_supported():
return False
+def _torchao_export_supported():
+ """True if the installed unsloth build has the portable torchao FP8/INT8 export path."""
+ try:
+ import unsloth.save as _us
+ return hasattr(_us, "_normalize_torchao_method")
+ except Exception:
+ return False
+
+
+def _has_nvidia_gpu():
+ """True only on a real NVIDIA CUDA box (not ROCm/XPU/CPU/MLX); compressed-tensors needs it."""
+ try:
+ from utils.hardware import hardware as _hw
+ return _hw.DEVICE == _hw.DeviceType.CUDA and not _hw.IS_ROCM
+ except Exception:
+ try:
+ import torch
+ return bool(torch.cuda.is_available()) and getattr(torch.version, "hip", None) is None
+ except Exception:
+ return False
+
+
def _hf_offline(timeout = 3):
"""True if export should avoid the Hub: honors the HF offline env vars, else does one
cheap TCP reachability probe so a network-down load uses local files / the HF cache
@@ -436,13 +501,17 @@ class ExportBackend:
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
private: bool = False,
+ compressed_method: Optional[str] = None,
) -> Tuple[bool, str, Optional[str]]:
"""
Export merged model (for PEFT models).
Args:
save_directory: Local directory to save model
- format_type: "16-bit (FP16)" or "4-bit (FP4)"
+ format_type: "16-bit (FP16)", "4-bit (FP4)", or a compressed-tensors label
+ compressed_method: Optional compressed-tensors scheme alias (e.g. "fp8",
+ "fp8_static", "w8a8", "w4a16", "mxfp4", "mxfp8", "nvfp4"). Overrides
+ format_type and is resolved against unsloth.save COMPRESSED_EXPORT_SCHEMES.
push_to_hub: Whether to push to Hugging Face Hub
repo_id: Hub repository ID (username/model-name)
hf_token: Hugging Face token
@@ -451,38 +520,108 @@ class ExportBackend:
Returns:
Tuple of (success: bool, message: str, output_path: Optional[str])
"""
+ if not _export_runtime_available():
+ return False, _export_runtime_message(), None
if not self.current_model or not self.current_tokenizer:
return False, "No model loaded. Please select a checkpoint first.", None
- if not self.is_peft:
- return (
- False,
- "This is not a PEFT model. Use 'Export Base Model' instead.",
- None,
- )
+ # Merged export works for PEFT adapters and non-PEFT Local/HF base models alike
+ # (save_pretrained_merged is a no-op merge that just saves the base).
output_path: Optional[str] = None
- # compressed-tensors formats run save_pretrained_merged with an FP8/FP4 save_method and
- # write to a sibling "-" directory (for vLLM).
- _COMPRESSED = {
- "FP8 (compressed-tensors)": ("fp8", "fp8"),
- "NVFP4 (compressed-tensors)": ("nvfp4", "nvfp4"),
+ # Quantized formats save to a sibling "-". Two backends: compressed-tensors
+ # (llm-compressor, NVIDIA-only) and portable torchao FP8/INT8 (device-agnostic). The alias
+ # comes from `compressed_method` (the "all formats" dropdown) or the `format_type` label.
+ _LABEL_TO_ALIAS = {
+ "FP8 (compressed-tensors)": "fp8",
+ "NVFP4 (compressed-tensors)": "nvfp4",
}
- is_compressed = format_type in _COMPRESSED
+ compressed_alias = compressed_method or _LABEL_TO_ALIAS.get(format_type)
+ compressed_suffix: Optional[str] = None
+ # Classify the alias: torchao-portable vs compressed-tensors.
+ torchao_info = None
+ if compressed_alias and _torchao_export_supported():
+ try:
+ import unsloth.save as _us_t
+ torchao_info = _us_t._normalize_torchao_method(compressed_alias)
+ except Exception:
+ torchao_info = None
+ is_torchao = torchao_info is not None
+ is_compressed = compressed_alias is not None and not is_torchao
try:
- if _IS_MLX:
- if is_compressed:
- return False, "Compressed-tensors export is not supported on macOS/MLX.", None
- mlx_save_method = "merged_4bit" if format_type == "4-bit (FP4)" else "merged_16bit"
- elif is_compressed:
+ if _IS_MLX and (is_compressed or is_torchao):
+ return (
+ False,
+ "Quantized (FP8/FP4/INT) export is not supported on macOS/MLX. "
+ "Use 16-bit or GGUF.",
+ None,
+ )
+
+ if is_torchao:
+ # Portable torchao: no NVIDIA GPU, no calibration.
+ compressed_suffix = torchao_info[1]
+
+ if is_compressed:
+ # compressed-tensors needs CUDA; enforce in the backend even if the UI gate is bypassed.
+ if not _has_nvidia_gpu():
+ return (
+ False,
+ "Compressed-tensors (FP8/FP4) export requires an NVIDIA GPU. On other "
+ "hardware use the portable FP8/INT8 (torchao) formats or 16-bit.",
+ None,
+ )
if not _compressed_export_supported():
return (
False,
- "Compressed-tensors (FP8/NVFP4) export requires an Unsloth build with "
+ "Compressed-tensors (FP8/FP4) export requires an Unsloth build with "
"compressed-tensors support. Upgrade unsloth, or choose 16-bit.",
None,
)
- save_method = _COMPRESSED[format_type][0]
+ import unsloth.save as _us
+
+ # Prefer the llm-compressor-main shadow (transformers 5.x): it quantizes newer models
+ # (Qwen3.5, Gemma-4, ...) the shipped 0.10.x cannot. Route all compressed exports
+ # through it when available; else fall back to the workspace 0.10.x path below.
+ _shadow_pp = None
+ try:
+ from utils.transformers_version import llmcompressor_shadow_pythonpath
+ _shadow_pp = llmcompressor_shadow_pythonpath()
+ except Exception as e:
+ logger.warning(f"llm-compressor-main shadow unavailable: {e}")
+ if _shadow_pp:
+ os.environ[_us._COMPRESSED_QUANTIZE_PYTHONPATH_ENV] = _shadow_pp
+ else:
+ # No shadow (disabled/offline/failed): the workspace 0.10.x cannot exceed its
+ # transformers ceiling, so fail fast for sidecar models; default-tier still works.
+ os.environ.pop(_us._COMPRESSED_QUANTIZE_PYTHONPATH_ENV, None)
+ _exceeds, _tf_ver = _us._transformers_exceeds_llm_compressor_ceiling()
+ if _exceeds:
+ return (
+ False,
+ "FP8/FP4 compressed-tensors export is not available for this model: it "
+ f"runs under transformers {_tf_ver}, but the installed llm-compressor "
+ f"supports transformers <= {_us._LLM_COMPRESSOR_MAX_TRANSFORMERS} and the "
+ "llm-compressor-main runtime could not be provisioned (offline or "
+ "UNSLOTH_DISABLE_LLMCOMPRESSOR_MAIN). Export to GGUF or 16-bit instead.",
+ None,
+ )
+
+ try:
+ info = _us._normalize_compressed_method(compressed_alias)
+ except Exception as e:
+ return False, f"Unsupported compressed export '{compressed_alias}': {e}", None
+ if info is None:
+ return (
+ False,
+ f"'{compressed_alias}' is not a recognized compressed-tensors export.",
+ None,
+ )
+ compressed_suffix = info[2]
+
+ if _IS_MLX:
+ mlx_save_method = "merged_4bit" if format_type == "4-bit (FP4)" else "merged_16bit"
+ elif is_compressed or is_torchao:
+ save_method = compressed_alias
elif format_type == "4-bit (FP4)":
save_method = "merged_4bit_forced"
elif self._audio_type == "whisper":
@@ -506,10 +645,10 @@ class ExportBackend:
save_directory, self.current_tokenizer, save_method = save_method
)
- # Compressed export writes to the "-" sibling; report that as output.
+ # Compressed / torchao writes to the "-" sibling; report that as output.
final_dir = (
- f"{save_directory}-{_COMPRESSED[format_type][1]}"
- if is_compressed
+ f"{save_directory}-{compressed_suffix}"
+ if (is_compressed or is_torchao)
else save_directory
)
self._write_export_metadata(final_dir)
@@ -549,10 +688,9 @@ class ExportBackend:
token = hf_token,
private = private,
)
- elif is_compressed and output_path and Path(output_path).is_dir():
- # The compressed model was already built locally in output_path; upload it
- # directly so we do not re-run the (expensive, OOM-prone) compression that
- # push_to_hub_merged(save_method=fp8/nvfp4) would otherwise do a second time.
+ elif (is_compressed or is_torchao) and output_path and Path(output_path).is_dir():
+ # Already built in output_path; upload it directly instead of re-running the
+ # expensive quantization that push_to_hub_merged(save_method=...) would redo.
hf_api = HfApi(token = hf_token)
repo_id = PushToHubMixin._create_repo(
PushToHubMixin,
@@ -564,7 +702,7 @@ class ExportBackend:
username = repo_id.split("/")[0],
base_model = getattr(self.current_model.config, "_name_or_path", "unknown"),
model_type = getattr(self.current_model.config, "model_type", "llm"),
- method = format_type,
+ method = compressed_alias or format_type,
extra = "unsloth",
)
ModelCard(content).push_to_hub(
@@ -610,6 +748,8 @@ class ExportBackend:
Returns:
Tuple of (success: bool, message: str, output_path: Optional[str])
"""
+ if not _export_runtime_available():
+ return False, _export_runtime_message(), None
if not self.current_model or not self.current_tokenizer:
return False, "No model loaded. Please select a checkpoint first.", None
@@ -728,7 +868,7 @@ class ExportBackend:
def export_gguf(
self,
save_directory: str,
- quantization_method: str = "Q4_K_M",
+ quantization_method = "Q4_K_M",
push_to_hub: bool = False,
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
@@ -739,7 +879,9 @@ class ExportBackend:
Args:
save_directory: Local directory to save model
- quantization_method: GGUF quantization method (e.g., "Q4_K_M")
+ quantization_method: A single GGUF quant method (e.g., "Q4_K_M") or a list of them
+ (e.g., ["Q4_K_M", "Q8_0"]). A list produces one GGUF per quant from a single
+ model load (unsloth save_to_gguf loops internally).
push_to_hub: Whether to push to Hugging Face Hub
repo_id: Hub repository ID
hf_token: Hugging Face token
@@ -747,11 +889,13 @@ class ExportBackend:
Returns:
Tuple of (success: bool, message: str, output_path: Optional[str])
"""
+ if not _export_runtime_available():
+ return False, _export_runtime_message(), None
if not self.current_model or not self.current_tokenizer:
return False, "No model loaded. Please select a checkpoint first.", None
- # Only forward imatrix_file to an unsloth build that accepts it; otherwise even a plain
- # no-imatrix export would fail with an unexpected-keyword error against an older unsloth.
+ # Only forward imatrix_file to an unsloth build that accepts it, else older builds raise
+ # an unexpected-keyword error even for a plain no-imatrix export.
if imatrix_file is not None and not _supports_kwarg(
self.current_model.save_pretrained_gguf, "imatrix_file"
):
@@ -766,8 +910,14 @@ class ExportBackend:
output_path: Optional[str] = None
model_tmp_to_cleanup: Optional[str] = None
try:
- # unsloth expects lowercase quant method
- quant_method = quantization_method.lower()
+ # Normalize to a lowercased list so multiple quants come from one model load.
+ if isinstance(quantization_method, (list, tuple)):
+ quant_methods = [str(q).lower() for q in quantization_method if str(q).strip()]
+ else:
+ quant_methods = [str(quantization_method).lower()]
+ if not quant_methods:
+ quant_methods = ["q4_k_m"]
+ quant_method = quant_methods if len(quant_methods) > 1 else quant_methods[0]
# Pin convert_hf_to_gguf.py to setup.sh's tagged llama.cpp ref so it
# can't drift past the pinned llama-quantize binary's gguf API.
@@ -889,7 +1039,7 @@ class ExportBackend:
return (
True,
- f"GGUF model exported successfully ({quantization_method})",
+ f"GGUF model exported successfully ({', '.join(quant_methods)})",
output_path,
)
@@ -909,19 +1059,56 @@ class ExportBackend:
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
private: bool = False,
+ gguf: bool = False,
+ gguf_outtype: str = "q8_0",
) -> Tuple[bool, str, Optional[str]]:
"""
Export LoRA adapter only (not merged).
+ Args:
+ gguf: If True, also convert the adapter to a GGUF LoRA file (llama.cpp
+ convert_lora_to_gguf.py), loadable with `llama-cli --lora ...`.
+ gguf_outtype: GGUF LoRA output float type; one of q8_0/f16/bf16/f32.
+
Returns:
Tuple of (success: bool, message: str, output_path: Optional[str])
"""
+ if not _export_runtime_available():
+ return False, _export_runtime_message(), None
if not self.current_model or not self.current_tokenizer:
return False, "No model loaded. Please select a checkpoint first.", None
if not self.is_peft:
return False, "This is not a PEFT model. No adapter to export.", None
+ _GGUF_LORA_OUTTYPES = ("q8_0", "f16", "bf16", "f32")
+ if gguf:
+ if _IS_MLX:
+ return (
+ False,
+ "GGUF LoRA adapter export is not supported on macOS/MLX. "
+ "Use the safetensors adapter instead.",
+ None,
+ )
+ outtype = str(gguf_outtype).lower()
+ if outtype not in _GGUF_LORA_OUTTYPES:
+ return (
+ False,
+ f"Invalid GGUF LoRA outtype '{gguf_outtype}'. "
+ f"Choose one of {', '.join(_GGUF_LORA_OUTTYPES)}.",
+ None,
+ )
+ # getattr so an older build without save_pretrained_gguf returns a clean message
+ # instead of an AttributeError (a generic 500).
+ _save_gguf_fn = getattr(self.current_model, "save_pretrained_gguf", None)
+ if _save_gguf_fn is None or not _supports_kwarg(_save_gguf_fn, "save_method"):
+ return (
+ False,
+ "This Unsloth build does not support GGUF LoRA adapter export. "
+ "Upgrade unsloth and unsloth_zoo, or export the safetensors adapter.",
+ None,
+ )
+
output_path: Optional[str] = None
try:
if save_directory:
@@ -929,7 +1116,24 @@ class ExportBackend:
logger.info(f"Saving LoRA adapter locally to: {save_directory}")
ensure_dir(Path(save_directory))
- if _IS_MLX:
+ if gguf:
+ # Writes the adapter files plus "-lora-.gguf".
+ _apply_wsl_sudo_patch()
+ self.current_model.save_pretrained_gguf(
+ save_directory,
+ self.current_tokenizer,
+ save_method = "lora",
+ quantization_method = outtype,
+ # Forward the token so convert_lora_to_gguf.py can fetch a gated base's config.
+ token = hf_token or None,
+ )
+ final_ggufs = sorted(glob.glob(os.path.join(save_directory, "*.gguf")))
+ logger.info(
+ "LoRA GGUF export complete. Files in %s:\n %s",
+ save_directory,
+ "\n ".join(os.path.basename(f) for f in final_ggufs) or "(none)",
+ )
+ elif _IS_MLX:
# MLX: save adapters.safetensors + tokenizer files
self.current_model.save_lora_adapters(save_directory)
self.current_tokenizer.save_pretrained(save_directory)
@@ -949,7 +1153,24 @@ class ExportBackend:
logger.info(f"Pushing LoRA adapter to Hub: {repo_id}")
- if _IS_MLX:
+ if gguf:
+ # Upload the locally-built GGUF folder; needs a local save_directory so the
+ # conversion is not re-run.
+ if not (output_path and Path(output_path).is_dir()):
+ return (
+ False,
+ "GGUF LoRA Hub upload requires a local save directory; set one and "
+ "retry.",
+ None,
+ )
+ hf_api = HfApi(token = hf_token)
+ hf_api.create_repo(repo_id, private = private, exist_ok = True)
+ hf_api.upload_folder(
+ folder_path = output_path,
+ repo_id = repo_id,
+ repo_type = "model",
+ )
+ elif _IS_MLX:
with tempfile.TemporaryDirectory() as tmp_dir:
self.current_model.save_lora_adapters(tmp_dir)
self.current_tokenizer.save_pretrained(tmp_dir)
diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py
index 052a47dd80..6d1a928f2e 100644
--- a/studio/backend/core/export/orchestrator.py
+++ b/studio/backend/core/export/orchestrator.py
@@ -132,6 +132,11 @@ class ExportOrchestrator:
"""True while an export / load / cleanup command is running."""
return self._export_active
+ def is_worker_alive(self) -> bool:
+ """True while the persistent export subprocess is running (op or idle)."""
+ proc = self._proc
+ return proc is not None and proc.is_alive()
+
def was_cancelled(self) -> bool:
"""True if the in-flight (or most recent) run was cancelled by the user."""
return self._cancel_requested
@@ -204,6 +209,23 @@ class ExportOrchestrator:
def _spawn_subprocess(self, config: dict) -> None:
"""Spawn a new export subprocess."""
+ # Last-resort recheck for spawns outside an active op. Inside an op, _export_active is set and
+ # load_checkpoint already rechecked, so a reservation here is an install about to observe
+ # is_export_active() and abort; raising would kill this export for an install that never proceeds.
+ from utils.transformers_version import sidecar_swap_in_progress
+
+ from utils.transformers_version import sidecar_swap_kind
+
+ _swap_kind = sidecar_swap_kind()
+ # Inside an active op an INSTALL reservation is about to abort on the
+ # is_export_active check, but a lazy REPAIR has no such check and can be
+ # rebuilding the sidecar right now, so it must always refuse the spawn.
+ if _swap_kind == "repair" or (_swap_kind is not None and not self._export_active):
+ from utils.transformers_version import SidecarSwapInProgress
+ raise SidecarSwapInProgress(
+ "A transformers installation is replacing the latest sidecar; "
+ "retry when it completes."
+ )
from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
@@ -231,11 +253,17 @@ class ExportOrchestrator:
adopt_pid(self._proc.pid) # bind to parent lifetime (Windows job / sweep)
logger.info("Export subprocess started (pid=%s)", self._proc.pid)
- def _shutdown_subprocess(self, timeout: float = 10.0) -> None:
- """Gracefully shut down the export subprocess."""
+ def _shutdown_subprocess(self, timeout: float = 10.0) -> bool:
+ """Gracefully shut down the export subprocess.
+
+ Returns True only once the worker is confirmed dead. If it survives
+ terminate/kill (e.g. wedged in an uninterruptible CUDA syscall that outlives
+ SIGKILL) the live handle is KEPT, not nulled, so is_worker_alive() and the
+ pre-swap liveness guard can still observe the survivor instead of a cleared
+ handle and refuse the destructive sidecar swap."""
if self._proc is None or not self._proc.is_alive():
self._proc = None
- return
+ return True
self._drain_queue()
@@ -265,10 +293,20 @@ class ExportOrchestrator:
except Exception:
pass
+ if self._proc is not None and self._proc.is_alive():
+ # Survived SIGKILL (uninterruptible syscall): keep the handle so callers
+ # and the pre-swap guard see a live worker rather than a nulled one.
+ logger.error(
+ "Export subprocess still alive after terminate/kill; "
+ "preserving its handle for the pre-swap liveness check"
+ )
+ return False
+
self._proc = None
self._cmd_queue = None
self._resp_queue = None
logger.info("Export subprocess shut down")
+ return True
def _cleanup(self):
"""atexit handler."""
@@ -339,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",
@@ -409,14 +448,44 @@ class ExportOrchestrator:
self._export_active = True
op_success, op_message = False, ""
try:
+ # Handshake with the sidecar install route: _export_active is set above, so either this
+ # recheck refuses BEFORE tearing down the old worker (keeping the loaded checkpoint), or
+ # the install sees is_export_active() and 409s. The spawn-time recheck stays as a last resort.
+ from utils.transformers_version import sidecar_swap_in_progress
+
+ if sidecar_swap_in_progress():
+ from utils.transformers_version import SidecarSwapInProgress
+ op_message = (
+ "A transformers installation is replacing the latest "
+ "sidecar; retry when it completes."
+ )
+ raise SidecarSwapInProgress(op_message)
# Always kill any existing subprocess and spawn fresh.
if self._ensure_subprocess_alive():
- self._shutdown_subprocess()
+ if self._shutdown_subprocess() is False:
+ # Survivor still holds GPU memory (a wedged CUDA syscall outliving
+ # SIGKILL); its handle is kept so is_worker_alive() and the pre-swap
+ # guard still see it. Do not spawn a second worker over it -- fail so
+ # the load can retry once it exits.
+ op_message = (
+ "The current export worker did not exit and still holds GPU "
+ "memory; not starting a new checkpoint load over it. Retry shortly."
+ )
+ return False, op_message
elif self._proc is not None:
self._shutdown_subprocess(timeout = 2)
logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path)
- self._spawn_subprocess(sub_config)
+ try:
+ self._spawn_subprocess(sub_config)
+ except Exception:
+ # The old worker is already gone; a stale current_checkpoint
+ # would make the Export page claim a loaded checkpoint that
+ # the next op then fails on with "no subprocess running".
+ self.current_checkpoint = None
+ self.is_vision = False
+ self.is_peft = False
+ raise
try:
resp = self._wait_response("loaded")
@@ -456,6 +525,7 @@ class ExportOrchestrator:
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
private: bool = False,
+ compressed_method: Optional[str] = None,
) -> Tuple[bool, str, Optional[str]]:
"""Export merged PEFT model."""
return self._run_export(
@@ -467,6 +537,7 @@ class ExportOrchestrator:
"repo_id": repo_id,
"hf_token": hf_token,
"private": private,
+ "compressed_method": compressed_method,
},
)
@@ -495,13 +566,13 @@ class ExportOrchestrator:
def export_gguf(
self,
save_directory: str,
- quantization_method: str = "Q4_K_M",
+ quantization_method = "Q4_K_M",
push_to_hub: bool = False,
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
imatrix_file = None,
) -> Tuple[bool, str, Optional[str]]:
- """Export model in GGUF format."""
+ """Export model in GGUF format. `quantization_method` may be a single method or a list."""
return self._run_export(
"gguf",
{
@@ -521,8 +592,10 @@ class ExportOrchestrator:
repo_id: Optional[str] = None,
hf_token: Optional[str] = None,
private: bool = False,
+ gguf: bool = False,
+ gguf_outtype: str = "q8_0",
) -> Tuple[bool, str, Optional[str]]:
- """Export LoRA adapter only."""
+ """Export LoRA adapter only (optionally also as a GGUF LoRA file)."""
return self._run_export(
"lora",
{
@@ -531,6 +604,8 @@ class ExportOrchestrator:
"repo_id": repo_id,
"hf_token": hf_token,
"private": private,
+ "gguf": gguf,
+ "gguf_outtype": gguf_outtype,
},
)
@@ -554,12 +629,28 @@ class ExportOrchestrator:
self._export_active = True
op_success, op_message, op_output_path = False, "", None
try:
+ # Handshake with the sidecar install route (see load_checkpoint): _export_active is set
+ # above, so this recheck refuses before the command is sent, or the install sees the active
+ # op and 409s. Without it, an install would block in cleanup_memory behind a long export op.
+ from utils.transformers_version import sidecar_swap_in_progress
+
+ if sidecar_swap_in_progress():
+ from utils.transformers_version import SidecarSwapInProgress
+ op_message = (
+ "A transformers installation is replacing the latest "
+ "sidecar; retry when it completes."
+ )
+ raise SidecarSwapInProgress(op_message)
cmd = {"type": "export", "export_type": export_type, **params}
try:
self._send_cmd(cmd)
+ # GGUF for 30B+ models can take 30+ min per quant; a multi-quant list runs them
+ # all in one op off a single merge, so scale the timeout by the quant count.
+ _qm = params.get("quantization_method")
+ _n = len(_qm) if isinstance(_qm, (list, tuple)) and _qm else 1
resp = self._wait_response(
f"export_{export_type}_done",
- timeout = 3600, # GGUF for 30B+ models can take 30+ min
+ timeout = 3600 * max(1, _n),
)
op_success = resp.get("success", False)
op_message = resp.get("message", "")
diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py
index d473dcb54f..9ecfa73eee 100644
--- a/studio/backend/core/export/worker.py
+++ b/studio/backend/core/export/worker.py
@@ -236,6 +236,17 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None:
checkpoint_path = cmd["checkpoint_path"]
max_seq_length = cmd.get("max_seq_length", 2048)
load_in_4bit = cmd.get("load_in_4bit", True)
+ # Latest-sidecar checkpoints load 16-bit here too: bnb 4-bit feeds quantized
+ # expert weights into unvalidated paths (same flip as the chat worker).
+ if load_in_4bit:
+ from utils.transformers_version import latest_tier_active_for
+ if latest_tier_active_for(checkpoint_path, cmd.get("hf_token")):
+ load_in_4bit = False
+ logger.info(
+ "Latest-transformers sidecar active for %s - forcing a 16-bit "
+ "export load (4-bit is disabled for brand-new architectures)",
+ checkpoint_path,
+ )
trust_remote_code = cmd.get("trust_remote_code", False)
# Auto-enable trust_remote_code for NemotronH/Nano models.
@@ -387,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":
@@ -397,6 +421,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
repo_id = cmd.get("repo_id"),
hf_token = cmd.get("hf_token"),
private = cmd.get("private", False),
+ compressed_method = cmd.get("compressed_method"),
)
elif export_type == "base":
success, message, output_path = backend.export_base_model(
@@ -423,6 +448,8 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
repo_id = cmd.get("repo_id"),
hf_token = cmd.get("hf_token"),
private = cmd.get("private", False),
+ gguf = cmd.get("gguf", False),
+ gguf_outtype = cmd.get("gguf_outtype", "q8_0"),
)
else:
success, message = False, f"Unknown export type: {export_type}"
diff --git a/studio/backend/core/inference/__init__.py b/studio/backend/core/inference/__init__.py
index 2faf70bb79..ad78157418 100644
--- a/studio/backend/core/inference/__init__.py
+++ b/studio/backend/core/inference/__init__.py
@@ -7,13 +7,16 @@ Inference submodule - backend for model loading and generation.
The default get_inference_backend() returns an InferenceOrchestrator that
delegates to a subprocess. The original InferenceBackend runs inside the
subprocess and can be imported directly from .inference when needed.
+
+Public names are resolved lazily (PEP 562): importing this package -- or a
+dependency-light leaf like ``core.inference.chat_eos`` -- must NOT eagerly pull
+the orchestrator / llama_cpp import chain (httpx, subprocess plumbing, the ML
+backend and its Studio dependencies). Those load only when a public name is
+actually accessed, so standalone helpers stay unit-testable without the full
+inference stack.
"""
-from .orchestrator import InferenceOrchestrator, get_inference_backend
-from .llama_cpp import LlamaCppBackend
-
-# Expose InferenceOrchestrator as InferenceBackend for backward compat.
-InferenceBackend = InferenceOrchestrator
+from typing import TYPE_CHECKING
__all__ = [
"InferenceBackend",
@@ -21,3 +24,33 @@ __all__ = [
"get_inference_backend",
"LlamaCppBackend",
]
+
+# name -> (submodule, attribute); InferenceBackend aliases InferenceOrchestrator.
+_LAZY_ATTRS = {
+ "InferenceOrchestrator": ("orchestrator", "InferenceOrchestrator"),
+ "InferenceBackend": ("orchestrator", "InferenceOrchestrator"),
+ "get_inference_backend": ("orchestrator", "get_inference_backend"),
+ "LlamaCppBackend": ("llama_cpp", "LlamaCppBackend"),
+}
+
+
+def __getattr__(name):
+ try:
+ submodule, attr = _LAZY_ATTRS[name]
+ except KeyError:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
+ from importlib import import_module
+
+ value = getattr(import_module(f"{__name__}.{submodule}"), attr)
+ globals()[name] = value # cache so later access skips __getattr__
+ return value
+
+
+def __dir__():
+ return sorted(set(globals()) | set(__all__))
+
+
+if TYPE_CHECKING: # keep static analysers / IDEs aware of the lazy names
+ from .llama_cpp import LlamaCppBackend
+ from .orchestrator import InferenceOrchestrator, get_inference_backend
+ InferenceBackend = InferenceOrchestrator
diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py
index e7de4a5312..92471b9866 100644
--- a/studio/backend/core/inference/_html_to_md.py
+++ b/studio/backend/core/inference/_html_to_md.py
@@ -7,6 +7,11 @@ Minimal HTML-to-Markdown converter using only the standard library.
Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line
``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic,
lists, tables, blockquotes, code blocks, and entity decoding.
+
+``main_content=True`` also applies a readability-style heuristic: scope
+conversion to the page's ```` (else ````) subtree when it
+carries substantial text, and strip known boilerplate fragments (skip-links,
+error placeholders, session banners, cookie prompts) from the result.
"""
from __future__ import annotations
@@ -27,8 +32,138 @@ _SKIP_TAGS = frozenset(
"math",
"nav",
"footer",
+ # Never-rendered / form-chrome elements, not page content.
+ "template",
+ "dialog",
+ "button",
+ "select",
+ "datalist",
}
)
+#
/
close cannot leave the renderer stuck hidden.
+ self._open_tags: list[str] = []
+ self._hidden_marks: list[int] = []
+
# Link state
self._link_href: str | None = None
self._link_text_parts: list[str] = []
@@ -150,16 +305,95 @@ class _MarkdownRenderer(HTMLParser):
# ------------------------------------------------------------------
# Tag handlers
# ------------------------------------------------------------------
+ # Structural bookkeeping shared by every start tag (skip/hidden/scope).
+ def _close_implicit(self, tag: str) -> None:
+ """HTML5 optional-end-tag recovery for a start tag about to open.
+
+ Pops each implicitly-closed ancestor (and its hidden marks), scanning the
+ whole stack so an open ``
``/``
`` still closes under an unclosed inline
+ ````. Stops at a ``_CLOSE_BARRIERS`` container so recovery never crosses
+ a nested list/table/dl and leaks the outer item's hidden content. Runs even
+ for skipped ``