Merge branch 'main' into dh/test-5106-windows-gpu-ci-mock

This commit is contained in:
Daniel Han 2026-05-14 04:35:39 -07:00 committed by GitHub
commit 8c6de7e0ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
127 changed files with 16305 additions and 1872 deletions

7
.github/CODEOWNERS vendored
View file

@ -53,3 +53,10 @@
/studio/backend/tests/ @rolandtannous @danielhanchen
/tests/ @rolandtannous @danielhanchen
/scripts/ @rolandtannous @danielhanchen
# Snapshot data for the notebook linter / Colab oracle. Drift in these
# files changes the pin floor for every Unsloth notebook, so refreshes
# must be reviewed by the notebook owners directly. CODEOWNERS later
# wins, so this overrides the broader /scripts/ rule above.
/scripts/data/colab_*.txt @danielhanchen @shimmyshimmer
/scripts/data/colab_*.json @danielhanchen @shimmyshimmer

View file

@ -5,6 +5,12 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
# github-actions refs are git tags / SHAs, not semver -- the
# `semver-minor-days` / `semver-patch-days` knobs are rejected
# by Dependabot's validator for this ecosystem. Only the
# `default-days` floor applies.
default-days: 7
groups:
actions:
patterns: ["*"]
@ -12,21 +18,24 @@ updates:
applies-to: security-updates
patterns: ["*"]
- package-ecosystem: "bun"
directory: "/studio/frontend"
schedule:
interval: "weekly"
groups:
bun-frontend:
patterns: ["*"]
bun-frontend-security:
applies-to: security-updates
patterns: ["*"]
# Removed a stray `package-ecosystem: "bun"` entry for
# /studio/frontend: that path has no bun.lock / bun.lockb, so
# Dependabot's bun ecosystem silently no-ops on it. The actual
# lockfile committed at /studio/frontend is package-lock.json
# (npm), and the npm entry further below already catches
# npm_and_yarn security advisories for that directory. Version
# updates for /studio/frontend stay suppressed (open-pull-
# requests-limit: 0 in that entry) -- security PRs flow through
# regardless. Add a real bun entry IF and WHEN bun.lock lands.
- package-ecosystem: "npm"
directory: "/studio/backend/core/data_recipe/oxc-validator"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
npm-oxc-validator:
patterns: ["*"]
@ -41,6 +50,8 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 5
cooldown:
default-days: 7
groups:
python:
patterns: ["*"]
@ -52,6 +63,10 @@ updates:
directory: "/studio/src-tauri"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
cargo-tauri:
patterns: ["*"]
@ -59,15 +74,25 @@ updates:
applies-to: security-updates
patterns: ["*"]
# bun owns version updates for /studio/frontend (above); GitHub
# fires npm-package advisories under npm_and_yarn, so this entry
# catches and groups them. limit: 0 suppresses version-update
# PRs, security updates flow through regardless.
# /studio/frontend npm dependencies. Version-update PRs are
# deliberately suppressed (open-pull-requests-limit: 0) -- the
# frontend dep tree is large, the lockfile is the authoritative
# pin, and `min-release-age=7` in studio/frontend/.npmrc already
# blocks fresh tarballs at install time. Security advisories
# arrive via GitHub's npm_and_yarn channel and are NOT capped by
# `open-pull-requests-limit` per Dependabot's documented
# behaviour; they flow through this entry, group together, and
# still respect the cooldown below so we never ingest a tarball
# that was hot-published less than 3 days ago.
- package-ecosystem: "npm"
directory: "/studio/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
groups:
npm-frontend-security:
applies-to: security-updates

View file

@ -121,6 +121,8 @@ jobs:
UNSLOTH_IS_PRESENT: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -234,9 +236,23 @@ jobs:
# tests/conftest.py spoof which handles that.
run: |
set -euxo pipefail
git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
https://github.com/unslothai/unsloth-zoo \
"$RUNNER_TEMP/unsloth-zoo"
# github.com occasionally 500s on the git fetch; retry so a
# single upstream blip does not fail CI.
for attempt in 1 2 3; do
rm -rf "$RUNNER_TEMP/unsloth-zoo"
if git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
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
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
pip show unsloth_zoo
@ -255,6 +271,39 @@ jobs:
tests/utils/test_trunc_normal_patch.py
python -m pytest --collect-only -q "$RUNNER_TEMP/unsloth-zoo/tests/"
- name: import_fixes drift detectors (18 tests, HARD GATE)
# One drift detector per fix_* / patch_* function in
# unsloth/import_fixes.py. The detectors assert the *healthy*
# upstream shape that the fix expects ABSENT the regression;
# ANY DRIFT DETECTED -> pytest.fail (NEVER skip) so the
# matrix cell goes red and the maintainer triages on the
# next PR, not in a downstream user's crash report.
#
# Pathologies covered by the suite (each maps to one fix
# function with the line range cited in the test docstring):
# * protobuf MessageFactory GetPrototype / GetMessageClass
# * datasets 4.4.x recursion range
# * TRL tuple-vs-bool _*_available caching
# * transformers PreTrainedModel.enable_input_require_grads
# source pattern flip
# * transformers torchcodec / causal_conv1d availability
# flags
# * transformers + accelerate is_wandb_available
# * peft.utils.transformers_weight_conversion importability
# + build_peft_weight_mapping signature
# * triton 3.6+ CompiledKernel num_ctas / cluster_dims
# * torch / torchvision pinned compatibility table
# * vllm guided_decoding_params / structured_outputs +
# aimv2 ovis config version
# * huggingface_hub is_offline_mode / HF_HUB_OFFLINE
# * torch.nn.init.trunc_normal_ presence (patch site for
# patch_trunc_normal_precision_issue)
# * xformers post-num_splits-key fix version
# HARD GATE: a red cell here is a real upstream regression
# without a corresponding zoo / unsloth-side workaround.
run: |
python -m pytest -v --tb=short tests/test_import_fixes_drift.py
- name: unsloth Bucket-A — CPU tests not in Repo tests (CPU)
# 16 tests across 5 files. They live inside tests/saving/ and
# tests/utils/, both of which Repo tests (CPU) excludes via --ignore
@ -1999,6 +2048,8 @@ jobs:
UNSLOTH_IS_PRESENT: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -2040,9 +2091,23 @@ jobs:
# main-branch fixes flow into the smoke without a release).
run: |
set -euxo pipefail
git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
https://github.com/unslothai/unsloth-zoo \
"$RUNNER_TEMP/unsloth-zoo"
# github.com occasionally 500s on the git fetch; retry so a
# single upstream blip does not fail CI.
for attempt in 1 2 3; do
rm -rf "$RUNNER_TEMP/unsloth-zoo"
if git clone --depth=1 --branch="$UNSLOTH_ZOO_REF" \
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
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
pip show unsloth_zoo

View file

@ -44,6 +44,8 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View file

@ -89,7 +89,19 @@ jobs:
# Silicon (~5-7 min), so we budget headroom.
timeout-minutes: 25
steps:
# harden-runner audit mode: macOS runners cannot use blocking mode
# today (eBPF egress enforcement is Linux-only), but audit mode is
# supported cross-platform and surfaces the egress destinations in
# the runner log. This produces the data needed to graduate this
# job to a block-mode allowlist once macOS support lands.
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -153,7 +165,20 @@ jobs:
'httpx==0.28.1'
pip install --index-url https://download.pytorch.org/whl/cpu \
'torch==2.10.0'
pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"
# github.com occasionally 500s on the git fetch; retry the
# zoo install so a single upstream blip does not fail CI.
for attempt in 1 2 3; do
if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::pip install unsloth_zoo failed after 3 attempts"
exit 1
fi
delay=$((5 * attempt))
echo "::warning::unsloth_zoo install failed (attempt $attempt/3), retrying in ${delay}s..."
sleep "$delay"
done
pip install -e . --no-deps
# Real Apple Silicon sanity: confirm _IS_MLX activates on real

View file

@ -67,10 +67,28 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Validate the dispatched ref before it reaches actions/checkout's `ref:`
# input. Reading via env (NOT direct ${{ ... }} interpolation in the
# regex test) closes the GitHub-Actions-injection class where a
# client_payload.ref like `main"; rm -rf / #` would be embedded into the
# shell command. NOTEBOOKS_REF defaults to 'main' on non-dispatch
# events, but only repository_dispatch can supply attacker-controlled
# values, so we gate this check on that event type.
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- name: Checkout unsloth (this PR)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: unsloth
persist-credentials: false
- name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -79,6 +97,7 @@ jobs:
ref: ${{ env.NOTEBOOKS_REF }}
path: notebooks
fetch-depth: 0 # drift check needs git status / diff
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -166,13 +185,28 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# See `static.Validate client_payload.ref shape` for rationale. This
# job's `if:` excludes repository_dispatch today, so the validation
# step is a defence-in-depth no-op until that gate ever relaxes.
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: unslothai/notebooks
ref: ${{ env.NOTEBOOKS_REF }}
path: notebooks
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: { python-version: '3.12', cache: 'pip' }
- name: Install
@ -200,13 +234,25 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: unslothai/notebooks
ref: ${{ env.NOTEBOOKS_REF }}
path: notebooks
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: { python-version: '3.12', cache: 'pip' }
@ -294,13 +340,25 @@ jobs:
- 'nb/Whisper.ipynb' # installation_whisper_content
- 'nb/Synthetic_Data_Hackathon.ipynb' # installation_synthetic_data_content
steps:
- name: Validate client_payload.ref shape
if: github.event_name == 'repository_dispatch'
env:
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
run: |
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
echo "::error::client_payload.ref contains disallowed characters" >&2
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
with: { path: unsloth }
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: unslothai/notebooks
ref: ${{ env.NOTEBOOKS_REF }}
path: notebooks
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: { python-version: '3.12' }

View file

@ -3,16 +3,306 @@ name: Release Desktop App
on:
workflow_dispatch:
inputs:
studio_version:
description: 'Studio version tag to release (for example, v0.1.39-beta)'
type: string
required: true
pypi_version:
description: 'Exact PyPI unsloth version just published/stamped (for example, 2026.5.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
type: string
required: false
draft:
description: 'Create as draft release'
description: 'Create as draft release; draft runs do not advance desktop-latest updater channel'
type: boolean
default: true
permissions:
contents: write
contents: read
concurrency:
group: release-desktop-${{ github.repository }}
cancel-in-progress: false
jobs:
prepare-version:
name: Prepare release versions
runs-on: ubuntu-latest
outputs:
studio_version: ${{ steps.prepare.outputs.studio_version }}
app_version: ${{ steps.prepare.outputs.app_version }}
desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
prerelease: ${{ steps.prepare.outputs.prerelease }}
pypi_version: ${{ steps.prepare.outputs.pypi_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- name: Validate release versions
id: prepare
shell: bash
env:
INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
run: |
python3 <<'PY'
import os
import pathlib
import re
import sys
studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
if not studio_version:
sys.exit('studio_version is required, for example v0.1.39-beta')
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
semver_tag = re.compile(
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$'
)
if not semver_tag.fullmatch(studio_version):
sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.39-beta: {studio_version}')
app_version = studio_version.removeprefix('v')
desktop_release_tag = f'desktop-v{app_version}'
prerelease = 'true' if '-' in app_version.split('+', 1)[0] else 'false'
def parse_backend_version(version):
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
version,
)
if not match:
return None
major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
if suffix_name:
normalized = suffix_name.lower().lstrip('.')
order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
if order is None:
return None
number = int(suffix_number or '0')
elif suffix_text:
order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
number = 0
else:
order = 4
number = 0
return (int(major), int(minor), int(patch), order, number)
preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
if not match:
sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
min_backend_version = match.group(1)
input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
parsed_min_backend = parse_backend_version(min_backend_version)
if parsed_min_backend is None:
sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')
pypi_version = input_pypi_version or min_backend_version
parsed_pypi = parse_backend_version(pypi_version)
if parsed_pypi is None:
sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
if parsed_pypi < parsed_min_backend:
sys.exit(
f'pypi_version {pypi_version} is lower than desktop minimum '
f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
)
if input_pypi_version:
print(
'Using exact PyPI unsloth version from pypi_version input: '
f'{pypi_version} (desktop minimum: {min_backend_version})'
)
else:
print(
'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
f'{pypi_version}'
)
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
print(f'studio_version={studio_version}', file=output)
print(f'app_version={app_version}', file=output)
print(f'desktop_release_tag={desktop_release_tag}', file=output)
print(f'prerelease={prerelease}', file=output)
print(f'pypi_version={pypi_version}', file=output)
PY
- name: Verify PyPI package and Studio stamp
shell: bash
env:
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
pypi_version = os.environ['PYPI_VERSION']
dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
dist_dir.mkdir(parents=True, exist_ok=True)
metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'
last_error = None
for attempt in range(1, 6):
try:
with urllib.request.urlopen(metadata_url, timeout=30) as response:
metadata = json.load(response)
break
except Exception as exc:
last_error = exc
if attempt < 5:
time.sleep(10 * attempt)
else:
sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')
files = metadata.get('urls') or []
if not files:
sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')
for file_info in files:
filename = file_info.get('filename')
url = file_info.get('url')
if not filename or '/' in filename or not url:
sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
target = dist_dir / filename
for attempt in range(1, 4):
try:
with urllib.request.urlopen(url, timeout=60) as response:
target.write_bytes(response.read())
break
except Exception as exc:
last_error = exc
if attempt < 3:
time.sleep(5 * attempt)
else:
sys.exit(f'Could not download {filename} from PyPI ({last_error})')
PY
if [ -f scripts/stamp_studio_release.py ]; then
mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
if [ "${#dists[@]}" -eq 0 ]; then
echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
exit 1
fi
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
else
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
exit 1
fi
- name: Guard public updater channel version
if: ${{ !inputs.draft }}
shell: bash
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
APP_VERSION: ${{ steps.prepare.outputs.app_version }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = os.environ['APP_VERSION']
if not isinstance(current, str):
sys.exit('desktop-latest latest.json has missing version')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to publish {next_version}; desktop-latest currently points at newer version {current}.'
)
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
@ -32,14 +322,31 @@ jobs:
label: Windows (x64)
name: Build ${{ matrix.label }}
needs: prepare-version
runs-on: ${{ matrix.platform }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
# harden-runner in audit mode: surfaces every egress destination in
# the runner log so the allowlist for a future `egress-policy: block`
# promotion can be derived from observed traffic. Audit mode is
# cross-platform (Linux / macOS / Windows runners); blocking mode is
# currently Linux-only, so we deliberately stay in audit until the
# macOS + Windows codesign paths have been observed.
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
# ── Linux dependencies ──
- name: Install Linux dependencies
@ -50,12 +357,18 @@ jobs:
# ── Node.js ──
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 24
- name: Install pinned Tauri CLI
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
- name: Verify pinned Tauri CLI
shell: bash
@ -67,38 +380,152 @@ jobs:
exit 1
fi
- name: Install frontend dependencies
working-directory: studio/frontend
run: npm install
- name: Verify backend package is published
- name: Verify desktop updater and Linux package config
shell: bash
run: |
node <<'JS'
const { readFileSync } = require('node:fs');
(async () => {
const cargo = readFileSync('studio/src-tauri/Cargo.toml', 'utf8');
const match = cargo.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error('Could not read desktop app version');
const expected = 'https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json';
const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
const endpoints = config.plugins?.updater?.endpoints;
if (!Array.isArray(endpoints) || endpoints.length !== 1) {
throw new Error('Expected exactly one desktop updater endpoint');
}
if (endpoints[0] !== expected) {
throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
}
if (endpoints.some((endpoint) => endpoint.includes('/releases/latest/'))) {
throw new Error('Desktop updater endpoint must not use repo-wide /releases/latest/');
}
const appVersion = match[1];
const response = await fetch(`https://pypi.org/pypi/unsloth/${appVersion}/json`);
if (!response.ok) {
const message = 'Publish unsloth=={app_version} to PyPI before the desktop release';
throw new Error(`${message.replace('{app_version}', appVersion)} (HTTP ${response.status})`);
const targets = config.bundle?.targets;
if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
throw new Error('Desktop release must not target RPM packages');
}
if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/);
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'));
}
if (releaseBodies.length === 0) {
throw new Error('Expected at least one desktop release body');
}
for (const body of releaseBodies) {
if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
}
JS
- name: Install frontend dependencies
working-directory: studio/frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --no-fund --no-audit
# ── Rust ──
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Patch desktop app version
shell: bash
working-directory: studio/src-tauri
run: |
set -euo pipefail
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
"$PYTHON" <<'PY'
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
if not app_version:
sys.exit('APP_VERSION is required')
cargo_toml = pathlib.Path('Cargo.toml')
lines = cargo_toml.read_text().splitlines(keepends=True)
in_package = False
patched = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped == '[package]':
in_package = True
continue
if stripped.startswith('[') and stripped.endswith(']'):
in_package = False
if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
lines[index] = f'version = "{app_version}"\n'
patched = True
break
if not patched:
sys.exit('Could not patch [package] version in Cargo.toml')
cargo_toml.write_text(''.join(lines))
cargo_lock = pathlib.Path('Cargo.lock')
lock_text = cargo_lock.read_text()
lock_text, count = re.subn(
r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
lock_text,
)
if count != 1:
sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
cargo_lock.write_text(lock_text)
PY
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
"$PYTHON" <<'PY'
import json
import os
import pathlib
import sys
app_version = os.environ['APP_VERSION']
metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
if versions != [app_version]:
sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
PY
git diff -- Cargo.toml Cargo.lock
- name: Rust cache
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: 'studio/src-tauri -> target'
@ -146,8 +573,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -159,7 +586,7 @@ jobs:
> 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: false
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize + upload ──
@ -177,8 +604,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -190,7 +617,7 @@ jobs:
> 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: false
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── Windows: build + sign + upload ──
@ -209,8 +636,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -222,5 +649,254 @@ jobs:
> 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: false
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
needs: [prepare-version, build]
if: ${{ !inputs.draft }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- name: Download versioned updater metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-updater"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
expected_tag = os.environ['DESKTOP_RELEASE_TAG']
if source.get('tag_name') != expected_tag:
sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
if source.get('draft'):
sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
PY
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata
shell: bash
run: |
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
release_tag = os.environ['DESKTOP_RELEASE_TAG']
latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
data = json.loads(latest_path.read_text())
if not isinstance(data, dict):
sys.exit('latest.json must be a JSON object')
version = data.get('version')
if not isinstance(version, str) or not version:
sys.exit('latest.json missing version')
if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
sys.exit(f'latest.json version is not SemVer-like: {version}')
if version.removeprefix('v') != app_version:
sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')
platforms = data.get('platforms')
if not isinstance(platforms, dict) or not platforms:
sys.exit('latest.json missing platforms')
required_families = {
'darwin-aarch64': False,
'linux-x86_64': False,
'windows-x86_64': False,
}
expected_prefix = f'https://github.com/unslothai/unsloth/releases/download/{release_tag}/'
forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')
for platform, entry in platforms.items():
if not isinstance(entry, dict):
sys.exit(f'Platform {platform} must be an object')
url = entry.get('url')
signature = entry.get('signature')
if not isinstance(url, str) or not url.strip():
sys.exit(f'Platform {platform} missing url')
if not isinstance(signature, str) or not signature.strip():
sys.exit(f'Platform {platform} missing signature')
if any(fragment in url for fragment in forbidden_fragments):
sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
if not url.startswith(expected_prefix):
sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
for family in required_families:
if platform == family or platform.startswith(family + '-'):
required_families[family] = True
missing = [family for family, found in required_families.items() if not found]
if missing:
sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
PY
- name: Ensure desktop updater channel release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
channel_json="$RUNNER_TEMP/desktop-latest-release.json"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json" 2>/dev/null; then
gh release create desktop-latest \
--title "Unsloth Studio Desktop updater channel" \
--notes "Machine-managed desktop updater channel; latest.json is replaced by release-desktop.yml." \
--prerelease \
--latest=false \
--target "$GITHUB_SHA"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json"
fi
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
if channel.get('draft'):
sys.exit('desktop-latest release is draft; refusing to publish updater channel')
if channel.get('immutable'):
sys.exit('desktop-latest release is immutable; cannot replace latest.json')
if not channel.get('prerelease'):
sys.exit('desktop-latest release must be a prerelease so it cannot compete with repo-wide latest')
PY
- name: Prevent updater channel downgrade
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
next_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = json.loads(next_path.read_text()).get('version')
if not isinstance(current, str) or not isinstance(next_version, str):
sys.exit('Could not compare desktop-latest channel versions')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to move desktop-latest from {current} to older version {next_version}.'
)
PY
- name: Publish desktop updater channel metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload desktop-latest "$RUNNER_TEMP/desktop-updater/latest.json" --clobber
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$RUNNER_TEMP/desktop-latest-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
assets = [asset for asset in channel.get('assets', []) if asset.get('name') == 'latest.json']
if len(assets) != 1:
sys.exit(f'Expected exactly one desktop-latest latest.json asset, found {len(assets)}')
expected_url = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/desktop-latest/latest.json'
actual_url = assets[0].get('browser_download_url')
if actual_url != expected_url:
sys.exit(f'desktop-latest latest.json URL mismatch: expected {expected_url}, got {actual_url}')
PY

View file

@ -57,6 +57,7 @@ on:
- 'studio/src-tauri/Cargo.lock'
- 'pyproject.toml'
- 'scripts/scan_packages.py'
- 'scripts/scan_npm_packages.py'
- '.github/workflows/security-audit.yml'
push:
branches: [main, pip]
@ -97,17 +98,36 @@ jobs:
# char SHA freezes this action at known-good code; Dependabot's
# github-actions ecosystem will auto-bump the SHA.
# v2.19.1 commit:
- name: Harden runner (egress audit)
# Per-job allowlist: advisory-audit hits PyPI, npm registry,
# crates.io advisories, GitHub release artefacts (osv-scanner
# binary), Semgrep registry, and TruffleHog's own GitHub action.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
release-assets.githubusercontent.com:443
registry.npmjs.org:443
pypi.org:443
files.pythonhosted.org:443
static.rust-lang.org:443
index.crates.io:443
static.crates.io:443
crates.io:443
semgrep.dev:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history so TruffleHog can diff base..head; without
# this it sees only the latest commit and reports nothing.
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -122,7 +142,7 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1
- uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: studio/src-tauri -> target
@ -244,6 +264,27 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Pre-install lockfile supply-chain audit (npm + cargo).
# Catches structural anomalies (non-registry resolved URLs,
# missing integrity hashes, known IOC strings) BEFORE `npm
# audit` or OSV-Scanner consult the advisory DB. The advisory
# path is reactive -- there is a window between a malicious
# publication and the GHSA landing. This step fires on the
# injection pattern itself so it catches the same class of
# attack the moment the lockfile shape becomes wrong.
# ─────────────────────────────────────────────────────────────
- name: Lockfile supply-chain audit (pre-install scan)
run: |
python3 scripts/lockfile_supply_chain_audit.py
{
echo "## Lockfile supply-chain audit"
echo
echo "Scanned: studio/frontend/package-lock.json + studio/src-tauri/Cargo.lock"
echo
echo "No structural anomalies or known IOC strings."
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# npm: Studio frontend
# ─────────────────────────────────────────────────────────────
@ -437,7 +478,7 @@ jobs:
# ─────────────────────────────────────────────────────────────
- name: TruffleHog (secrets in diff)
continue-on-error: true
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
with:
path: ./
base: ${{ github.event.pull_request.base.sha || '' }}
@ -662,17 +703,28 @@ jobs:
id: extras
files: 'extras'
steps:
# Egress audit on every shard. Each shard pulls hundreds of
# Egress block on every shard. Each shard pulls hundreds of
# PyPI archives -- if a malicious wheel ever phones home from
# within the scanner sandbox (it shouldn't; we never execute
# the archive), harden-runner's audit log records the host.
- name: Harden runner (egress audit)
# the archive), harden-runner now rejects the connect outright.
# Per-job allowlist: pip-scan-packages only fetches PyPI archives
# via scan_packages.py + pip download. No npm or cargo traffic.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -749,7 +801,6 @@ jobs:
# transitive set (no point fetching the same transformers
# wheel five times). Across shards we accept some redundant
# downloads in exchange for wall-clock parallelism.
continue-on-error: true
env:
SHARD_FILES: ${{ matrix.shard.files }}
run: |
@ -794,3 +845,286 @@ jobs:
logs-scan-packages-${{ matrix.shard.id }}.txt
audit-reqs/
retention-days: 30
# ─────────────────────────────────────────────────────────────────────
# npm: pre-install tarball content scan.
# ─────────────────────────────────────────────────────────────────────
npm-scan-packages:
# Counterpart to pip-scan-packages for the npm side. Reads
# studio/frontend/package-lock.json, downloads each resolved
# tarball DIRECTLY from registry.npmjs.org (never via `npm
# install` -- no lifecycle scripts ever run), verifies the
# lockfile integrity hash, unpacks each tarball into a sandboxed
# temp dir behind size / count / path-escape / symlink guards,
# and pattern-scans the extracted file contents for the
# signatures common to npm supply-chain attacks:
#
# - lifecycle (preinstall / install / postinstall / prepare)
# scripts in any package.json that fetch + execute external
# code,
# - C2 / exfiltration hosts (getsession.org, AWS IMDS,
# Kubernetes ServiceAccount token paths, GitHub Actions OIDC,
# HashiCorp Vault endpoints),
# - credential-stealing references (.npmrc, .aws/credentials,
# GITHUB_TOKEN / NPM_TOKEN in JS sources),
# - known IOC filenames (router_init.js, tanstack_runner.js,
# router_runtime.js),
# - obfuscation shapes (Function/eval against base64 blobs).
#
# Threat model: every tarball is hostile. Safety guarantees are
# documented at scripts/scan_npm_packages.py top-of-file. The
# script is stdlib-only so adding it does not increase the
# transitive supply-chain surface.
name: npm scan-packages (Studio frontend tarballs)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: []
steps:
# Per-job allowlist: npm-scan-packages only fetches tarballs from
# registry.npmjs.org. GitHub endpoints retained for checkout +
# setup-python action machinery.
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Sanity-check scan_npm_packages.py
run: |
test -f scripts/scan_npm_packages.py
python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())"
- name: Scan npm tarballs (declared + transitive, no install)
# The script exits 1 on HIGH/CRITICAL findings; we capture the
# full log and surface it in the step summary either way. It
# never runs `npm install`, never executes anything from a
# downloaded tarball, and only fetches from registry.npmjs.org.
# Initially non-blocking so the baseline can settle; drop
# continue-on-error once the baseline is clean for a week.
run: |
set -o pipefail
LOG=logs-scan-npm.txt
python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG"
{
echo "## scan_npm_packages"
echo
echo '### Findings (tail)'
echo '```'
tail -300 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: scan-npm-packages-log
path: logs-scan-npm.txt
retention-days: 30
# ─────────────────────────────────────────────────────────────────────
# Workflow-trigger lint. Refuses two patterns that together powered the
# TanStack GHSA-g7cv-rxg3-hmpx supply-chain compromise:
#
# 1. `pull_request_target` -- runs a fork's workflow YAML against
# the base repository's secrets. There is no safe use of this
# trigger for a public open-source project.
#
# 2. Shared cache keys between PR-triggered workflows and the
# publish workflow. A fork PR can poison the cache; the publish
# workflow then restores the poisoned cache on next run.
#
# Cheap pure-Python lint, runs in seconds. Fail-closed.
# ─────────────────────────────────────────────────────────────────────
workflow-trigger-lint:
name: workflow-trigger lint (pull_request_target / cache-poisoning)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install PyYAML
run: pip install pyyaml
- name: Lint workflow triggers + cache keys
run: python3 scripts/lint_workflow_triggers.py
# ─────────────────────────────────────────────────────────────────────
# Regression tests: pin scanner IOC tables and pre-install fixtures.
# Hard gate (no continue-on-error) so future drift in the IOC tables
# or scanner exit semantics fails this PR at review time.
# ─────────────────────────────────────────────────────────────────────
tests-security:
name: pytest tests/security
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install pytest + PyYAML
# PyYAML is imported by scripts/lint_workflow_triggers.py, which the
# `tests/security/test_lint_workflow_triggers.py` regression suite
# exercises as a subprocess. Without it the lint script bails with
# `ERROR: PyYAML is required` (exit 2) and the 5 lint regression
# tests fail. Pinned the same way pytest is pinned.
run: pip install pytest==9.0.3 pyyaml==6.0.2
- name: Run security regression tests
run: python3 -m pytest tests/security -v
# ─────────────────────────────────────────────────────────────────────
# npm provenance + new install-script diff. Catches the two npm
# supply-chain levers we don't yet gate on:
#
# 1. `npm audit signatures` validates the registry-signed
# provenance of every tarball laid down in node_modules. Pulled
# from the public npm transparency log; surfaces unsigned or
# mis-signed deps. Informational for now (continue-on-error)
# while the baseline settles.
#
# 2. `check_new_install_scripts.py` diffs the PR's lockfile
# against the base ref and refuses any newly-added dep that
# ships a postinstall hook. Every recent npm supply-chain
# compromise leveraged a postinstall as the execution lever, so
# blocking new ones at PR time is a small, high-signal gate.
# ─────────────────────────────────────────────────────────────────────
npm-provenance-and-install-scripts:
name: npm provenance + new install-script diff
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden runner (egress block)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
disable-sudo: true
allowed-endpoints: >
api.github.com:443
github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
registry.npmjs.org:443
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Need the base commit accessible for `git show
# <base-sha>:studio/frontend/package-lock.json` below.
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install Studio frontend deps (--ignore-scripts)
# `npm audit signatures` requires node_modules to be populated.
# `--ignore-scripts` is mandatory: this is exactly the lever the
# new-install-script gate below protects against, and we must
# not run any third-party hook to set up the audit.
working-directory: studio/frontend
run: npm ci --ignore-scripts
- name: npm audit signatures (informational)
# Surfaces unsigned / mis-signed packages from the npm
# transparency log. continue-on-error during baseline-build
# phase; promote to hard gate once the lockfile is fully
# signed (most major maintainers signed by mid-2025).
working-directory: studio/frontend
continue-on-error: true
run: |
set -o pipefail
LOG=logs-audit-signatures.txt
npm audit signatures 2>&1 | tee "$LOG"
{
echo "## npm audit signatures"
echo
echo '```'
tail -200 "$LOG"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Extract base-ref lockfile (PR triggers only)
if: github.event_name == 'pull_request'
run: |
set -e
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git show "$BASE_SHA:studio/frontend/package-lock.json" \
> /tmp/base-package-lock.json
- name: Diff for newly-added install-script deps
if: github.event_name == 'pull_request'
run: |
python3 scripts/check_new_install_scripts.py \
--base /tmp/base-package-lock.json \
--head studio/frontend/package-lock.json
- name: Skip install-script diff (non-PR trigger)
if: github.event_name != 'pull_request'
run: |
echo "Not a pull_request event; install-script diff requires a base ref."
echo "This step is intentionally a no-op outside PR triggers."
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: npm-audit-signatures-log
path: studio/frontend/logs-audit-signatures.txt
if-no-files-found: ignore
retention-days: 30

View file

@ -51,6 +51,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps
run: |
@ -69,9 +71,10 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
# Same key as studio-ui-smoke.yml so the two jobs share a
@ -79,7 +82,8 @@ jobs:
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -88,6 +92,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -53,6 +53,8 @@ jobs:
python: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -106,6 +108,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View file

@ -36,6 +36,8 @@ jobs:
working-directory: studio/frontend
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# FIXME: drop this step once @assistant-ui/* and assistant-stream
# leave 0.x -- on 1.x, caret ranges are conventional. Until then,
@ -58,7 +60,21 @@ jobs:
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
# Run the structural lockfile scan BEFORE npm ci. A compromised
# tarball runs its `prepare` / `postinstall` during `npm ci`,
# so any catch has to fire upstream of that. The scanner is
# pure-Python read-only; safe to call ahead of every install.
- name: Lockfile supply-chain audit (pre-install scan)
working-directory: ${{ github.workspace }}
run: python3 scripts/lockfile_supply_chain_audit.py
- name: Lockfile must agree with package.json (npm ci is strict)
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm ci --no-fund --no-audit
- name: npm ci must not have modified the working tree

View file

@ -67,6 +67,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
@ -85,15 +87,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -102,6 +106,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -306,6 +317,8 @@ jobs:
STUDIO_PORT: '18889'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
@ -324,15 +337,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache GGUF model file
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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
if: steps.cache-gguf.outputs.cache-hit != 'true'
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -341,6 +356,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir 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 }}
@ -614,6 +636,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |
@ -632,15 +656,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
- name: Restore HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
- name: Prime HF_HOME with the GGUF + mmproj
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -651,6 +677,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$MMPROJ_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -44,6 +44,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -56,15 +58,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -73,6 +77,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -4,8 +4,8 @@
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes HF_HOME via actions/cache, and shares
# the install.sh --local --no-torch bootstrap.
# behaviour under test, primes a model cache via actions/cache, and
# shares the install.sh --local --no-torch bootstrap.
#
# 1. OpenAI, Anthropic API tests
# gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
@ -40,7 +40,7 @@ on:
- '.github/workflows/studio-mac-inference-smoke.yml'
push:
branches: [main, pip]
# Manual trigger for pre-warming HF_HOME caches on main, or re-running
# Manual trigger for pre-warming model caches on main, or re-running
# against an arbitrary branch without pushing a no-op commit.
workflow_dispatch:
@ -67,6 +67,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -79,15 +81,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -96,6 +100,15 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
# Save partial caches on cancel/timeout -- hf download resumes by
# content hash. `outcome != skipped` keeps cache-hit a no-op.
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome != 'skipped' && hashFiles('hf-cache/**/*.gguf') != ''
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -306,6 +319,8 @@ jobs:
STUDIO_PORT: '18898'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -318,15 +333,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache GGUF model file
- name: Restore GGUF model file
id: cache-gguf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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
if: steps.cache-gguf.outputs.cache-hit != 'true'
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -335,6 +352,14 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
# Save partial caches on cancel; next run resumes via content hash.
- name: Save GGUF model file
if: always() && steps.download-gguf.outcome != 'skipped' && hashFiles('gguf-cache/**/*.gguf') != ''
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 }}
@ -659,9 +684,10 @@ jobs:
GGUF_FILE: gemma-4-E2B-it-UD-Q4_K_XL.gguf
MMPROJ_FILE: mmproj-F16.gguf
STUDIO_PORT: '18899'
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -674,33 +700,47 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
# Cache flat .gguf + mmproj (Job 2's pattern). HF_HOME inflates
# ~3.6x via xet/blobs/snapshots, which made macOS saves never land.
# mmproj is auto-detected as a sibling via detect_mmproj_file
# (studio/backend/utils/models/model_config.py).
- name: Restore GGUF + mmproj files
id: cache-gguf
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v1
- name: Prime HF_HOME with the GGUF + mmproj
if: steps.cache-hf.outputs.cache-hit != 'true'
- name: Download GGUF + mmproj if cache miss
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
# Authenticated + parallel: shared macos-14 NAT egress stalls
# multi-GB anonymous downloads.
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p hf-cache
mkdir -p gguf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" &
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache &
MODEL_PID=$!
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$MMPROJ_FILE" &
hf download "$GGUF_REPO" "$MMPROJ_FILE" --local-dir gguf-cache &
MMPROJ_PID=$!
wait "$MODEL_PID"
wait "$MMPROJ_PID"
# Fail loud on a partial download instead of in the next step.
find hf-cache -name "$GGUF_FILE" -o -name "$MMPROJ_FILE" \
| xargs -I{} ls -lhL {}
ls -lh "gguf-cache/$GGUF_FILE" "gguf-cache/$MMPROJ_FILE"
# Save partial caches on cancel. hashFiles guard avoids a hard
# save failure when the download step exits with no files.
- name: Save GGUF + mmproj files
if: always() && steps.download-gguf.outcome != 'skipped' && hashFiles('gguf-cache/**/*.gguf') != ''
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v1
- name: Install Studio (--local, --no-torch)
env:
@ -755,12 +795,17 @@ jobs:
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
# Load the GGUF (mmproj is auto-detected via the HF repo
# lookup, the cached file is pulled out of HF_HOME).
# Load via local file path; mmproj sibling auto-detected by
# detect_mmproj_file (model_config.py). gguf_variant omitted
# -- it routes through _find_local_gguf_by_variant which
# expects a directory, not a file path.
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
MMPROJ_PATH="$GITHUB_WORKSPACE/gguf-cache/${MMPROJ_FILE}"
ls -lh "$GGUF_PATH" "$MMPROJ_PATH"
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name, is_vision}'
- name: JSON schema decoding + image input

View file

@ -44,6 +44,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -56,15 +58,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -73,6 +77,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -46,6 +46,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:

View file

@ -40,6 +40,8 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux native deps for Tauri / WebKit2GTK
run: |
@ -56,12 +58,18 @@ jobs:
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
- uses: swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1
- uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: studio/src-tauri -> target
- name: Install pinned Tauri CLI (matches release-desktop.yml)
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
- name: Verify pinned Tauri CLI version
run: |
@ -69,8 +77,17 @@ jobs:
echo "$out"
[ "$out" = "tauri-cli 2.10.1" ] || { echo "::error::expected tauri-cli 2.10.1, got $out"; exit 1; }
- name: Lockfile supply-chain audit (pre-install scan)
run: python3 scripts/lockfile_supply_chain_audit.py
- name: Frontend build (npm ci, vite)
working-directory: studio/frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: |
npm ci --no-fund --no-audit
npm run build

View file

@ -52,6 +52,8 @@ jobs:
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps
run: |
@ -70,15 +72,17 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -87,6 +91,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -40,6 +40,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Linux deps for llama.cpp prebuilt
run: |

View file

@ -52,6 +52,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -63,15 +65,17 @@ jobs:
with:
python-version: '3.12'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -80,6 +84,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
# See studio-windows-update-smoke.yml for the full rationale.

View file

@ -62,6 +62,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -73,15 +75,29 @@ jobs:
with:
python-version: '3.12'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
# Split restore + save (rather than the one-step actions/cache) so a
# transient restore-side failure does not kill the whole job. v5 has a
# known flake where it logs "Cache hit for: <key>" and then exits
# non-zero without actually extracting the archive (see
# actions/cache#1621 and github community discussion #163260).
# continue-on-error on restore masks that failure so the Prime step
# below can re-download from HF and the job keeps running. Save then
# populates the cache key on a real miss only; cache keys are
# immutable, so a corrupted cached entry persists until the -v1
# suffix below is bumped.
- name: Restore HF_HOME cache for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
# Run on a real cache miss AND on the silent-restore-failure mode
# described above (outcome != success).
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -90,6 +106,16 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME cache for ${{ env.GGUF_REPO }}
# Only write a fresh cache entry when we actually rebuilt the
# directory (Prime ran and succeeded). Skipping when Prime is
# skipped avoids "already exists" save warnings on the happy path.
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
# See studio-windows-update-smoke.yml for the full rationale.
@ -364,6 +390,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -375,15 +403,20 @@ jobs:
with:
python-version: '3.12'
- name: Cache GGUF model file
# Split restore + save so a transient restore-side failure does not
# kill the whole job. See the matching block in the tool-calling job
# above for the full rationale (actions/cache#1621).
- name: Restore GGUF model cache
id: cache-gguf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
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
if: steps.cache-gguf.outputs.cache-hit != 'true'
id: download-gguf
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -392,6 +425,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
- name: Save GGUF model cache
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: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
# See studio-windows-update-smoke.yml for the full rationale.
@ -760,6 +800,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -771,15 +813,23 @@ jobs:
with:
python-version: '3.12'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
# Split restore + save so a transient restore-side failure does not
# kill the whole job. See the matching block in the tool-calling job
# for the full rationale (actions/cache#1621). This is the block that
# actually broke in run 25713577488: "Cache hit for: <key>" was
# logged, the step exited non-zero in ~0.3 s without extracting the
# 3.4 GiB archive, and steps 6-15 were skipped.
- name: Restore HF_HOME cache for ${{ env.GGUF_REPO }} (model + mmproj)
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
- name: Prime HF_HOME with the GGUF + mmproj
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -790,6 +840,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$MMPROJ_FILE"
- name: Save HF_HOME cache for ${{ env.GGUF_REPO }} (model + mmproj)
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
# See studio-windows-update-smoke.yml for the full rationale.

View file

@ -57,6 +57,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -72,15 +74,17 @@ jobs:
# then fatal-errors with "Cache folder path is retrieved
# for pip but doesn't exist on disk".
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
continue-on-error: true
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
id: prime-hf
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
@ -89,6 +93,13 @@ jobs:
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
if: always() && steps.prime-hf.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
shell: pwsh
# See studio-windows-update-smoke.yml for the full rationale.

View file

@ -58,6 +58,8 @@ jobs:
PYTHONUTF8: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:

View file

@ -58,6 +58,8 @@ jobs:
timeout-minutes: 12
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'
@ -83,6 +85,8 @@ jobs:
timeout-minutes: 10
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'
@ -107,6 +111,8 @@ jobs:
timeout-minutes: 8
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'
@ -129,6 +135,8 @@ jobs:
timeout-minutes: 8
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'
@ -151,6 +159,8 @@ jobs:
timeout-minutes: 8
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'
@ -173,6 +183,8 @@ jobs:
timeout-minutes: 12
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'
@ -200,11 +212,27 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
with: { path: unsloth }
- name: Clone unsloth-zoo @ main
run: |
git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
"$RUNNER_TEMP/unsloth-zoo"
# github.com occasionally 500s on the git fetch; retry so a
# single upstream blip does not fail CI.
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'
@ -265,6 +293,8 @@ jobs:
timeout-minutes: 20
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'

View file

@ -42,6 +42,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@ -53,7 +55,16 @@ jobs:
with:
python-version: '3.12'
- name: Lockfile supply-chain audit (pre-install scan)
run: python3 scripts/lockfile_supply_chain_audit.py
- name: Build frontend
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
# required for `vite build`. The pre-install lockfile structural
# audit (lockfile_supply_chain_audit.py) is the practical defence
# against the npm postinstall-dropper class -- it fires BEFORE any
# tarball runs, on the injection pattern itself rather than an
# advisory-DB lookup.
run: |
cd studio/frontend
npm ci --no-fund --no-audit

View file

@ -1,183 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Custom Semgrep rules for unsloth + studio backend. The off-the-shelf
# rule packs (p/python, p/javascript, p/supply-chain, p/security-audit)
# wired into the security-audit workflow already cover the common
# patterns. These rules add catches for the *specific* shape of recent
# CVEs in the broader Python ML / dev-tools stack -- so if we ever
# introduce a similar bug ourselves, CI lights up.
#
# Run locally:
# pip install 'semgrep>=1.95'
# semgrep --config .semgrep/unsloth-rules.yml studio/backend unsloth scripts
#
# Wired into CI via .github/workflows/security-audit.yml's Semgrep step.
rules:
# ─────────────────────────────────────────────────────────────────
# langchain-core CVE-2025-68664 shape:
# `dumps()` / `dumpd()` over a user-controlled dict that may carry
# the `lc` marker key -> deserialization injection on the round
# trip. Catch any json.dumps / pickle.dumps / yaml.dump on data
# that flowed through a Request/WebSocket payload.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-deserialize-roundtrip
message: >-
Serializing user-controlled data with langchain-style `dumps`
can re-instantiate arbitrary classes when deserialized. See
langchain-core CVE-2025-68664. Sanitize / strip `lc` marker keys
before dumping, or use a strict schema (Pydantic) instead.
severity: WARNING
languages: [python]
patterns:
- pattern-either:
- pattern: langchain_core.load.dumps($DATA, ...)
- pattern: langchain_core.load.dumpd($DATA, ...)
- pattern: dumps($DATA)
- pattern: dumpd($DATA)
- metavariable-pattern:
metavariable: $DATA
patterns:
- pattern-either:
- pattern: request.$F
- pattern: payload
- pattern: body
- pattern: data
- pattern: input
# ─────────────────────────────────────────────────────────────────
# n8n CVE-2025-68668 shape:
# `_pyodide._base.eval_code(...)` or any private/underscore call
# into pyodide internals that escapes the public sandbox API.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-pyodide-private-eval
message: >-
Calling `_pyodide._base.eval_code` (or any `_pyodide.<private>`)
bypasses the public Pyodide sandbox -- this is how n8n
CVE-2025-68668 (CVSS 9.9) escaped the Code Node's blocklist.
Use the documented sandbox API (`pyodide.runPython`) and rely
on web-worker isolation for untrusted input.
severity: ERROR
languages: [python, javascript, typescript]
patterns:
- pattern-either:
- pattern: _pyodide._base.eval_code(...)
- pattern: $X._pyodide.$Y(...)
# ─────────────────────────────────────────────────────────────────
# marimo CVE-2026-39987 shape:
# FastAPI / Starlette WebSocket route that accepts connections
# without checking auth -- in marimo this dropped a PTY shell to
# any unauthenticated attacker.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-websocket-no-auth
message: >-
WebSocket route accepts connections without an auth check.
marimo CVE-2026-39987 was a pre-auth WebSocket on
`/terminal/ws` that handed a full PTY shell to any
unauthenticated peer. Add a Depends(get_current_user) /
`await websocket.headers.get("authorization")` gate before
`await websocket.accept()`.
severity: WARNING
languages: [python]
patterns:
- pattern: |
@$APP.websocket("...")
async def $F(websocket: WebSocket, ...):
...
await websocket.accept()
...
- pattern-not-inside: |
@$APP.websocket("...")
async def $F(websocket: WebSocket, ..., $USER = Depends(...)):
...
- pattern-not-inside: |
@$APP.websocket("...")
async def $F(websocket: WebSocket, ...):
...
if not $AUTH:
...
await websocket.accept()
# ─────────────────────────────────────────────────────────────────
# litellm 1.82.7 shape:
# `subprocess.Popen` of a child Python interpreter that reads
# stdin from a network response (the C2-fetch-then-exec dropper
# pattern). Catches both `Popen([sys.executable, ...], stdin=...)`
# and `Popen("python ...", stdin=...)` variants.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-popen-network-stdin
message: >-
Spawning a Python interpreter that reads its program from a
network call is the canonical fetch-and-exec dropper (litellm
1.82.7 used this exact shape). Almost never legitimate inside a
package's import path.
severity: ERROR
languages: [python]
pattern-either:
- pattern: |
subprocess.Popen([..., $PY, ...], stdin=$NET, ...)
- pattern: |
subprocess.run([..., $PY, ...], input=$NET, ...)
# ─────────────────────────────────────────────────────────────────
# Shai-Hulud / ForceMemo shape:
# programmatic write of a `.github/workflows/*.yml` file from
# inside our own Python source. We never write workflows
# programmatically; if a contributor ever does, they're probably
# re-implementing the worm pattern.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-write-github-workflow
message: >-
Code that programmatically writes into `.github/workflows/`
from within unsloth itself is the Shai-Hulud / ForceMemo
self-propagation pattern. If you legitimately need a workflow
template, ship it under examples/ or templates/ instead.
severity: ERROR
languages: [python]
patterns:
- pattern-either:
- pattern: open("$P", ...)
- pattern: Path("$P").write_text(...)
- pattern: open("$P", "w", ...)
- metavariable-regex:
metavariable: $P
regex: \.github/workflows/.*\.ya?ml
# ─────────────────────────────────────────────────────────────────
# Pickle-from-network shape: classic deserialization sink that
# several recent ML pipeline CVEs hit (mlflow, pyzmq, ray serve).
# ─────────────────────────────────────────────────────────────────
- id: unsloth-pickle-from-network
message: >-
`pickle.loads` on bytes that flowed from a network response is
arbitrary code execution. Use `safetensors` or a strict
schema (Pydantic / msgspec) instead. ML frameworks have shipped
multiple CVEs of this exact shape (mlflow, ray serve, pyzmq).
severity: ERROR
languages: [python]
pattern-either:
- pattern: pickle.loads($X.content)
- pattern: pickle.loads($X.text.encode(...))
- pattern: pickle.loads(requests.get(...).content)
- pattern: pickle.load(urllib.request.urlopen(...))
# ─────────────────────────────────────────────────────────────────
# Subprocess shell=True with f-string / format / concat -- command
# injection if any interpolated value comes from user input.
# ─────────────────────────────────────────────────────────────────
- id: unsloth-shell-true-interpolation
message: >-
`subprocess` call with `shell=True` and an interpolated command
string is command injection if any input is user-controlled.
Pass argv list instead, or use shlex.quote on each part.
severity: WARNING
languages: [python]
pattern-either:
- pattern: subprocess.run(f"...", shell=True, ...)
- pattern: subprocess.Popen(f"...", shell=True, ...)
- pattern: subprocess.call(f"...", shell=True, ...)
- pattern: os.system(f"...")
- pattern: subprocess.run("..." + $X, shell=True, ...)
- pattern: subprocess.run("...{}...".format(...), shell=True, ...)

View file

@ -2,6 +2,10 @@
set -euo pipefail
# PyPI/Studio release publishing must use `./build.sh publish` (or an
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
# artifacts include the display-only Studio release version.
# 1. Build frontend (Vite outputs to dist/)
cd studio/frontend
@ -70,10 +74,33 @@ cd ../..
# 2. Clean old artifacts
rm -rf build dist *.egg-info
# 3. Build wheel
# 3. Stamp display-only Studio release metadata for packaged builds.
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
_restore_studio_build_info() {
cp "$_STUDIO_BUILD_INFO_BACKUP" "$_STUDIO_BUILD_INFO" 2>/dev/null || true
rm -f "$_STUDIO_BUILD_INFO_BACKUP"
}
trap _restore_studio_build_info EXIT
if [ "${1:-}" = "publish" ]; then
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py --require-release)"
else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
# 4. Build wheel/sdist
python -m build
# 4. Optionally publish
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi
_restore_studio_build_info
trap - EXIT
# 5. Optionally publish
if [ "${1:-}" = "publish" ]; then
python -m twine upload dist/*
fi

View file

@ -1185,3 +1185,10 @@ ignore = [
]
[tool.ruff.format]
[tool.pytest.ini_options]
# Narrow the default test discovery so `pytest` from the repo root
# does NOT pick up the GPU-heavy tests under tests/python, tests/qlora,
# etc. The CI security job runs `pytest tests/security` explicitly.
testpaths = ["tests/security"]
pythonpath = ["."]

View file

@ -0,0 +1,292 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Diff two `package-lock.json` files and flag NEW install-script deps.
A package with `"hasInstallScript": true` runs `preinstall` / `install` /
`postinstall` lifecycle hooks every time `npm ci` lays it down. Every
npm supply-chain compromise of the last 18 months (Shai-Hulud,
TanStack, axios-style, ArmorCode hijacks) leveraged exactly this lever:
the attacker publishes a new malicious version of a dep we already
trust, and the post-install hook runs the next time CI installs.
This scanner refuses to allow a newly-introduced install-script dep to
land without a maintainer eyeball on the lifecycle script body.
Existing install-script deps are NOT re-flagged -- if `node-gyp` has
been in the lockfile since day one, it's not part of this PR's threat
model. Only new entries are surfaced.
Supports lockfileVersion 1 (`dependencies` key, recursive), 2 and 3
(flat `packages` key with `node_modules/<a>/node_modules/<b>` nesting
for transitive entries). For each NEW install-script package we
attempt a stdlib-only fetch of
`https://registry.npmjs.org/<name>/<version>` to recover the actual
postinstall command body. If the network is blocked we still emit the
finding -- the lifecycle command body is informational, not
load-bearing.
Exit codes
==========
0 no newly-added install-script deps
1 one or more newly-added install-script deps; listed on stderr
2 internal error (missing lockfile, malformed JSON, etc.)
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
REGISTRY_BASE = "https://registry.npmjs.org/"
REGISTRY_TIMEOUT_SECS = 5
CRITICAL = "CRITICAL"
HIGH = "HIGH"
class Finding:
__slots__ = ("severity", "name", "version", "kind", "detail")
def __init__(
self, severity: str, name: str, version: str, kind: str, detail: str
) -> None:
self.severity = severity
self.name = name
self.version = version
self.kind = kind
self.detail = detail
def __str__(self) -> str:
return (
f" [{self.severity}] {self.name}@{self.version}\n"
f" kind: {self.kind}\n"
f" detail: {self.detail}"
)
# ─────────────────────────────────────────────────────────────────────
# Lockfile parsing.
# ─────────────────────────────────────────────────────────────────────
def _strip_nm_prefix(key: str) -> str:
"""Convert a v2/v3 `packages` key into a bare package name.
`node_modules/foo` -> `foo`; `node_modules/foo/node_modules/bar` ->
`bar`. The empty key (`""`) is the project root and returns "".
"""
if not key:
return ""
# Use the LAST `node_modules/` segment so transitives map to their
# leaf name, matching how npm install resolves a postinstall.
marker = "node_modules/"
idx = key.rfind(marker)
if idx == -1:
return key
return key[idx + len(marker) :]
def _collect_install_script_entries(lock: dict) -> dict[str, str]:
"""Walk a parsed lockfile and return {package_name: version} for
every entry with `hasInstallScript: true` (v2/v3) OR a
non-empty `scripts.preinstall|install|postinstall` (v1).
The same package may appear at multiple versions in a single
lockfile (de-duplicated copies under different parents); we key by
`name@version` so we don't lose either copy. Returns a dict keyed
by `name@version` -> the same string for convenience.
"""
seen: dict[str, str] = {}
version = lock.get("lockfileVersion")
# v2 / v3: flat `packages` map.
packages = lock.get("packages") or {}
for key, entry in packages.items():
if key == "" or not isinstance(entry, dict):
continue
if entry.get("link"):
continue
if not entry.get("hasInstallScript"):
continue
name = _strip_nm_prefix(key)
if not name:
continue
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
# v1 also embeds a `dependencies` tree; v2/v3 carry both for
# backwards-compat but `packages` is canonical for them. For v1
# there is no `hasInstallScript` flag, so look for a non-empty
# `scripts.preinstall|install|postinstall` directly.
def _walk_v1(deps: dict, depth: int = 0) -> None:
if depth > 64 or not isinstance(deps, dict):
return
for name, entry in deps.items():
if not isinstance(entry, dict):
continue
scripts = entry.get("scripts") or {}
lifecycle = any(
isinstance(scripts, dict) and scripts.get(hook)
for hook in ("preinstall", "install", "postinstall")
)
# v1 also sets `requires` only on the parent, no flag, so
# the lifecycle-script presence is the only signal.
if lifecycle:
ver = entry.get("version") or "<unversioned>"
seen[f"{name}@{ver}"] = name
_walk_v1(entry.get("dependencies"), depth = depth + 1)
if version == 1 or "dependencies" in lock:
_walk_v1(lock.get("dependencies") or {})
return seen
def _load_lockfile(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"lockfile not found: {path}")
try:
return json.loads(path.read_text(encoding = "utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"{path}: not valid JSON: {exc}") from exc
# ─────────────────────────────────────────────────────────────────────
# Registry lookup for the postinstall command body (best-effort).
# ─────────────────────────────────────────────────────────────────────
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
"""Return {hook: command} for any of preinstall / install /
postinstall published in the registry metadata for this name@ver.
Returns None on any error (network blocked, 404, malformed JSON).
Never raises; the caller treats absence as "could not enrich, emit
finding anyway".
"""
safe_name = urllib.parse.quote(name, safe = "@/")
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
try:
with urllib.request.urlopen(url, timeout = REGISTRY_TIMEOUT_SECS) as resp:
body = resp.read()
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
return None
try:
meta = json.loads(body)
except json.JSONDecodeError:
return None
scripts = meta.get("scripts") or {}
if not isinstance(scripts, dict):
return None
keep = {}
for hook in ("preinstall", "install", "postinstall"):
cmd = scripts.get(hook)
if isinstance(cmd, str) and cmd.strip():
keep[hook] = cmd
return keep or None
# ─────────────────────────────────────────────────────────────────────
# Diff.
# ─────────────────────────────────────────────────────────────────────
def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
base = _collect_install_script_entries(base_lock)
head = _collect_install_script_entries(head_lock)
findings: list[Finding] = []
for key in sorted(head):
if key in base:
continue # pre-existing install-script dep; not in scope
name = head[key]
# key is "name@version"; rsplit("@", 1) handles scoped names.
version = (
key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
)
scripts = _fetch_registry_scripts(name, version)
if scripts:
detail = "; ".join(f"{h}={cmd!r}" for h, cmd in scripts.items())
else:
detail = (
"newly added with hasInstallScript=true; registry "
"metadata unreachable -- inspect the package's "
"scripts.{preinstall,install,postinstall} manually"
)
findings.append(
Finding(
severity = CRITICAL,
name = name,
version = version,
kind = "new-install-script",
detail = detail,
)
)
return findings
# ─────────────────────────────────────────────────────────────────────
# CLI.
# ─────────────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description = (
"Diff two package-lock.json files and refuse any newly-"
"added install-script dep."
),
)
parser.add_argument(
"--base",
required = True,
help = "Path to the BASE package-lock.json (e.g. main branch).",
)
parser.add_argument(
"--head",
required = True,
help = "Path to the HEAD package-lock.json (this PR).",
)
args = parser.parse_args(argv)
try:
base_lock = _load_lockfile(Path(args.base))
head_lock = _load_lockfile(Path(args.head))
except (FileNotFoundError, ValueError) as exc:
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
return 2
findings = diff_new_install_scripts(base_lock, head_lock)
if not findings:
print(
"[install-script-diff] OK: no newly-added install-script "
"dependencies between base and head",
flush = True,
)
return 0
print(
f"\n[install-script-diff] FAIL: {len(findings)} newly-added "
f"install-script dependency(ies):\n",
file = sys.stderr,
)
for f in findings:
print(str(f), file = sys.stderr)
print(file = sys.stderr)
print(
"[install-script-diff] Refusing to proceed. Every new "
"install-script dep is a postinstall lifecycle hook that "
"would run on the next `npm ci`. Review each finding above, "
"confirm the maintainer + version, and re-run.",
file = sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -6,12 +6,38 @@ from __future__ import annotations
import ast
import argparse
import io
import os
import sys
import tempfile
import tokenize
from collections import defaultdict
from pathlib import Path
def _atomic_write_text(path: Path, data: str, encoding: str) -> None:
"""Write ``data`` to ``path`` atomically.
Stages a tmp file in the same directory (so it's on the same
filesystem as the destination), fsyncs, then `os.replace`s into
place. A crash mid-write therefore leaves either the previous
content or the fully new content -- never a truncated source file.
"""
dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath)
try:
with os.fdopen(fd, "w", encoding=encoding) as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def enforce_spacing(text: str) -> tuple[str, bool]:
"""Return updated text with keyword '=' padded by spaces, plus change flag."""
lines = text.splitlines(keepends=True)
@ -146,7 +172,7 @@ def process_file(path: Path) -> bool:
updated, changed = enforce_spacing(original)
updated, removed = remove_redundant_passes(updated)
if changed or removed:
path.write_text(updated, encoding=encoding)
_atomic_write_text(path, updated, encoding)
return True
return False

View file

@ -1,9 +1,15 @@
#!/bin/bash
set -e
set -euo pipefail
# ============================================================
# Gemma 4 MLX — One-command setup + inference
#
# Supply-chain hardening: the uv installer payload is pinned by
# SHA-256. Rotate by running:
# curl -sSLf https://astral.sh/uv/install.sh | shasum -a 256
# and updating _UV_INSTALLER_SHA256 below.
# ============================================================
#
# Usage:
# bash install_gemma4_mlx.sh [--venv-dir DIR]
#
@ -104,10 +110,17 @@ else
fi
# ── Install uv ───────────────────────────────────────────────
_UV_INSTALLER_SHA256="48cd5aca5d5671a3b3d5f61538cc8622e4434af63319115159990d8b0dd02416"
if ! command -v uv >/dev/null 2>&1; then
step "uv" "installing uv package manager..."
_uv_tmp=$(mktemp)
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
_uv_actual=$(shasum -a 256 "$_uv_tmp" | awk '{print $1}')
if [ "$_uv_actual" != "$_UV_INSTALLER_SHA256" ]; then
rm -f "$_uv_tmp"
fail "uv installer SHA-256 mismatch: got $_uv_actual expected $_UV_INSTALLER_SHA256 (refusing to execute)"
fi
sh "$_uv_tmp" </dev/null >/dev/null 2>&1
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then

View file

@ -1,9 +1,18 @@
#!/bin/bash
set -e
set -euo pipefail
# ============================================================
# Qwen3.6 MLX — One-command setup + inference
#
# Supply-chain hardening:
# - All third-party downloads (uv installer, mlx_vlm qwen3_5
# patches) are pinned to an immutable git commit SHA and verified
# against a hardcoded SHA-256. Any mismatch aborts the install
# before the bytes are copied into site-packages.
# - To rotate any pin, fetch the new file with `curl`, run
# `shasum -a 256`, and update the corresponding constant below.
# ============================================================
#
# Usage:
# bash install_qwen3_6_mlx.sh [--venv-dir DIR]
#
@ -104,10 +113,21 @@ else
fi
# ── Install uv ───────────────────────────────────────────────
# Pin the uv installer payload by SHA-256. Rotate by running:
# curl -sSLf https://astral.sh/uv/install.sh | shasum -a 256
# and updating the constant below. We fetch into a temp file, verify
# the digest, and only then execute. Mismatch aborts.
_UV_INSTALLER_SHA256="48cd5aca5d5671a3b3d5f61538cc8622e4434af63319115159990d8b0dd02416"
if ! command -v uv >/dev/null 2>&1; then
step "uv" "installing uv package manager..."
_uv_tmp=$(mktemp)
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
_uv_actual=$(shasum -a 256 "$_uv_tmp" | awk '{print $1}')
if [ "$_uv_actual" != "$_UV_INSTALLER_SHA256" ]; then
rm -f "$_uv_tmp"
fail "uv installer SHA-256 mismatch: got $_uv_actual expected $_UV_INSTALLER_SHA256 (refusing to execute)"
fi
sh "$_uv_tmp" </dev/null
rm -f "$_uv_tmp"
if [ -f "$HOME/.local/bin/env" ]; then
@ -150,21 +170,55 @@ else
fi
# ── Apply patches for multi-turn image chat ──────────────────
_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/fix/ui-fix/unsloth/models/patches/mlx_vlm_qwen3_5"
#
# Pin every patch to an immutable commit SHA and verify the body
# against a hardcoded SHA-256. The mlx_vlm_qwen3_5 patch tree
# currently only exists on the upstream `fix/ui-fix` branch; we pin
# to the branch HEAD commit, NOT the floating ref, so a forced push
# on `fix/ui-fix` cannot swap the bytes under us.
#
# Rotate by:
# _PATCH_COMMIT=<new SHA>
# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/qwen3_5.py" | shasum -a 256
# curl -sSLf "https://raw.githubusercontent.com/unslothai/unsloth/$_PATCH_COMMIT/unsloth/models/patches/mlx_vlm_qwen3_5/generate.py" | shasum -a 256
_PATCH_COMMIT="013c99e51bbb8c4b83d88f3b150a1e53251a19d2"
_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/${_PATCH_COMMIT}/unsloth/models/patches/mlx_vlm_qwen3_5"
_PATCH_SHA_QWEN35="4b6fbbcc59b1d6b935e7204351aae1476836d25542a11c7885402b672d2efa64"
_PATCH_SHA_GENERATE="50c4cbb8c3d94c0c74a4d209db6d2b23b102944c147c6421f2eded427b8edaf7"
_SITE_PKGS=$("$_VENV_PY" -c "import site; print(site.getsitepackages()[0])")
step "patch" "fixing multi-turn image chat..."
if curl -sSLf "${_PATCH_BASE}/qwen3_5.py" -o "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then
# Stage all downloads in an isolated tmpdir; we only copy into
# site-packages after every checksum has matched.
_PATCH_TMP=$(mktemp -d)
trap 'rm -rf "$_PATCH_TMP"' EXIT
apply_pinned_patch() {
# apply_pinned_patch <remote_basename> <expected_sha256> <dest_abspath>
_name="$1"; _expected="$2"; _dest="$3"
_staged="$_PATCH_TMP/$_name"
if ! curl -sSLf "${_PATCH_BASE}/${_name}" -o "$_staged"; then
step "warning" "failed to download ${_name} patch — multi-turn image chat may not work" "$C_WARN"
return 1
fi
_actual=$(shasum -a 256 "$_staged" | awk '{print $1}')
if [ "$_actual" != "$_expected" ]; then
step "warning" "${_name} SHA-256 mismatch (got $_actual expected $_expected) — refusing to install patch" "$C_WARN"
return 1
fi
mkdir -p "$(dirname "$_dest")"
cp "$_staged" "$_dest"
return 0
}
if apply_pinned_patch "qwen3_5.py" "$_PATCH_SHA_QWEN35" "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then
substep "patched qwen3_5.py (MRoPE position reset)"
else
step "warning" "failed to download qwen3_5.py patch — multi-turn image chat may not work" "$C_WARN"
fi
if curl -sSLf "${_PATCH_BASE}/generate.py" -o "${_SITE_PKGS}/mlx_vlm/generate.py"; then
if apply_pinned_patch "generate.py" "$_PATCH_SHA_GENERATE" "${_SITE_PKGS}/mlx_vlm/generate.py"; then
substep "patched generate.py (mask trim on cache reuse)"
else
step "warning" "failed to download generate.py patch — multi-turn image chat may not work" "$C_WARN"
fi
# Clear pycache so patches take effect

View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Refuse dangerous GitHub Actions trigger patterns at PR time.
Two patterns are banned outright, both of which powered the TanStack
GHSA-g7cv-rxg3-hmpx supply-chain compromise:
1. `pull_request_target` -- runs a fork's workflow YAML against the
BASE repository's secrets and permissions. The fork can inject
arbitrary code into the base context. The TanStack worm used this
to land base-context execution from a fork PR. There is essentially
no safe use of this trigger for a public open-source project;
`pull_request` is the safe alternative.
2. `workflow_run` chained to a PR-triggered workflow -- carries the
same trust boundary problem one hop later. If a PR-triggered
workflow can poison artifacts/caches and a `workflow_run` trigger
fires off the result with elevated permissions, the attacker still
reaches the trusted context.
3. Shared cache keys between PR-triggered workflows and publish /
release / push-triggered workflows. The TanStack worm poisoned the
Actions cache from a fork PR and the legitimate release workflow
then restored the poisoned cache. Cache keys must be partitioned
so that nothing a PR can write is ever read by a workflow that
holds secrets.
Exit codes
==========
0 no findings
1 one or more findings; stderr lists each with file path
Run from repo root:
python3 scripts/lint_workflow_triggers.py
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print(
"ERROR: PyYAML is required. Install with 'pip install pyyaml'", file = sys.stderr
)
sys.exit(2)
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
BANNED_TRIGGERS: tuple[str, ...] = ("pull_request_target",)
RESTRICTED_TRIGGERS: tuple[str, ...] = ("workflow_run",)
PUBLISH_WORKFLOW_NAMES: tuple[str, ...] = ("release-desktop.yml",)
def _normalise_on(on_field):
if isinstance(on_field, str):
return {on_field}
if isinstance(on_field, list):
return set(on_field)
if isinstance(on_field, dict):
return set(on_field.keys())
return set()
def _load_workflow(path: Path):
try:
return yaml.safe_load(path.read_text())
except Exception as exc:
print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr)
sys.exit(2)
def _extract_cache_keys(path: Path) -> list[str]:
text = path.read_text()
keys: list[str] = []
for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text):
keys.append(m.group(1).strip())
return keys
def _trigger_set(yaml_doc) -> set[str]:
on = yaml_doc.get(True)
if on is None:
on = yaml_doc.get("on")
return _normalise_on(on)
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__)
parser.add_argument(
"--workflows-dir",
type = Path,
default = DEFAULT_WORKFLOWS_DIR,
help = "Override the workflows directory (used by tests).",
)
args = parser.parse_args()
workflows_dir = args.workflows_dir
findings: list[str] = []
workflows = sorted(workflows_dir.glob("*.yml"))
pr_triggered: list[tuple[Path, list[str]]] = []
publish_triggered: list[tuple[Path, list[str]]] = []
for path in workflows:
doc = _load_workflow(path)
triggers = _trigger_set(doc)
for t in BANNED_TRIGGERS:
if t in triggers:
findings.append(
f"{path.name}: BANNED trigger '{t}' (GHSA-g7cv-rxg3-hmpx "
"pattern: fork PRs run in base-repo context). Switch to "
"'pull_request' and use a deploy-on-merge workflow for "
"any privileged step."
)
for t in RESTRICTED_TRIGGERS:
if t in triggers:
text = path.read_text()
if "lint:workflow_triggers-allow-workflow_run" not in text:
findings.append(
f"{path.name}: RESTRICTED trigger '{t}' requires an "
"explicit `# lint:workflow_triggers-allow-workflow_run` "
"comment somewhere in the file, with a justification."
)
if "pull_request" in triggers:
pr_triggered.append((path, _extract_cache_keys(path)))
is_dispatch_only = "workflow_dispatch" in triggers and not (
"push" in triggers or "pull_request" in triggers
)
if path.name in PUBLISH_WORKFLOW_NAMES or is_dispatch_only:
publish_triggered.append((path, _extract_cache_keys(path)))
pr_keys = {key for _, keys in pr_triggered for key in keys}
for pub_path, pub_keys in publish_triggered:
for k in pub_keys:
if k in pr_keys:
findings.append(
f"{pub_path.name}: cache key {k!r} is also declared in a "
"PR-triggered workflow. A fork PR could poison this cache "
"and the publish workflow would restore it on next run. "
"Add a unique suffix (e.g. '-publish-only') to partition "
"the namespaces."
)
if findings:
print(
"Workflow trigger lint failed with the following issues:", file = sys.stderr
)
for f in findings:
print(f" - {f}", file = sys.stderr)
return 1
print(
f"OK: scanned {len(workflows)} workflow file(s); "
f"no pull_request_target, no unjustified workflow_run, "
f"no PR/publish cache-key collision."
)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,754 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Lockfile supply-chain audit for the Studio frontend and Tauri shell.
Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a
lockfile contains patterns that indicate the kind of supply-chain
injection seen in the npm Shai-Hulud waves and the cargo
crates.io brand-squat attempts.
What it checks
==============
studio/frontend/package-lock.json (lockfileVersion 2 or 3):
1. `resolved` URL origin. Every entry must resolve through
`https://registry.npmjs.org/`. Direct GitHub-hosted dependencies
(`git+ssh://`, `git+https://`, `github:owner/repo#sha`,
`file:`, `http://`) are refused -- npm's TanStack incident used
exactly this vector to land an unaudited GitHub commit hash as
an optional dependency.
2. `integrity` field presence. Every non-workspace entry must carry
an `integrity` SHA. A missing integrity means the registry can
swap the tarball after lockfile generation and CI will not
notice.
3. Known IOC strings. A hardcoded set of indicator-of-compromise
substrings is grepped across the entire lockfile body (file
names, dependency keys, URLs). The list is updated as new
campaigns surface. Catching one means the local install was
about to pull a publicly-known malicious release.
studio/src-tauri/Cargo.lock:
4. `source` field origin. Every entry with a `source` must point at
`registry+https://github.com/rust-lang/crates.io-index`. Direct
git sources (`git+https://...`) and `path+...` for cross-crate
paths warrant manual review and are flagged.
5. Known cargo IOC strings. Same idea as (3), separate list.
Exit codes
==========
0 no findings, or an opt-out env var (UNSLOTH_LOCKFILE_AUDIT_SKIP)
is set to a justification string (>=5 chars, not '1'/'true'/etc).
A value like '1' or 'true' is now REJECTED loudly and the audit
runs normally
1 one or more findings; stderr lists them with file path and line
number where derivable
2 internal error (missing dependency, malformed JSON, etc.)
Operational stance
==================
This scanner only PARSES the lockfiles -- it never executes anything
in them, never resolves anything against the network. Safe to run
ahead of every `npm ci`. The IOC list is short by design; this
complements (not replaces) `npm audit`, OSV-Scanner, and the
advisory-DB pipeline in `.github/workflows/security-audit.yml`. The
shape of the catch is "we refuse to proceed because the lockfile
itself is shaped wrong", which fires before any third-party install
script gets a chance to run on the runner.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
# ─────────────────────────────────────────────────────────────────────
# Known IOC strings (case-sensitive substring match).
# ─────────────────────────────────────────────────────────────────────
#
# Keep these short and FACTUAL. Each entry is tied to a public advisory
# and is the literal string an attacker would have to embed for the
# attack to work. Adding speculative or generic patterns here would
# generate false positives on dependency upgrades.
NPM_IOC_STRINGS: tuple[str, ...] = (
# Shai-Hulud TanStack wave -- May 11, 2026 (GHSA-g7cv-rxg3-hmpx).
"router_init.js",
"tanstack_runner.js",
"router_runtime.js",
"@tanstack/setup",
"github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c",
# Exfiltration endpoints observed across both Shai-Hulud waves.
"filev2.getsession.org",
"getsession.org/file/",
# Campaign markers; the worm tarballs print this to stdout on run.
"A Mini Shai-Hulud has Appeared",
# Mini Shai-Hulud May-12 2026 wave.
"git-tanstack.com",
"transformers.pyz",
"/tmp/transformers.pyz",
"With Love TeamPCP",
# Aikido (May-12 wave): payload SHA-256 hashes + Bun marker.
"ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c",
"2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96",
"bun run tanstack_runner.js",
"We've been online over 2 hours",
)
# Hard pin-blocks for publicly confirmed malicious versions.
# keep in sync with scripts/scan_npm_packages.py
BLOCKED_NPM_VERSIONS: dict[str, set[str]] = {
# GHSA-g7cv-rxg3-hmpx -- TanStack May-11 2026 (84 versions).
"@tanstack/arktype-adapter": {"1.166.12", "1.166.15"},
"@tanstack/eslint-plugin-router": {"1.161.9", "1.161.12"},
"@tanstack/eslint-plugin-start": {"0.0.4", "0.0.7"},
"@tanstack/history": {"1.161.9", "1.161.12"},
"@tanstack/nitro-v2-vite-plugin": {"1.154.12", "1.154.15"},
"@tanstack/react-router": {"1.169.5", "1.169.8"},
"@tanstack/react-router-devtools": {"1.166.16", "1.166.19"},
"@tanstack/react-router-ssr-query": {"1.166.15", "1.166.18"},
"@tanstack/react-start": {"1.167.68", "1.167.71"},
"@tanstack/react-start-client": {"1.166.51", "1.166.54"},
"@tanstack/react-start-rsc": {"0.0.47", "0.0.50"},
"@tanstack/react-start-server": {"1.166.55", "1.166.58"},
"@tanstack/router-cli": {"1.166.46", "1.166.49"},
"@tanstack/router-core": {"1.169.5", "1.169.8"},
"@tanstack/router-devtools": {"1.166.16", "1.166.19"},
"@tanstack/router-devtools-core": {"1.167.6", "1.167.9"},
"@tanstack/router-generator": {"1.166.45", "1.166.48"},
"@tanstack/router-plugin": {"1.167.38", "1.167.41"},
"@tanstack/router-ssr-query-core": {"1.168.3", "1.168.6"},
"@tanstack/router-utils": {"1.161.11", "1.161.14"},
"@tanstack/router-vite-plugin": {"1.166.53", "1.166.56"},
"@tanstack/solid-router": {"1.169.5", "1.169.8"},
"@tanstack/solid-router-devtools": {"1.166.16", "1.166.19"},
"@tanstack/solid-router-ssr-query": {"1.166.15", "1.166.18"},
"@tanstack/solid-start": {"1.167.65", "1.167.68"},
"@tanstack/solid-start-client": {"1.166.50", "1.166.53"},
"@tanstack/solid-start-server": {"1.166.54", "1.166.57"},
"@tanstack/start-client-core": {"1.168.5", "1.168.8"},
"@tanstack/start-fn-stubs": {"1.161.9", "1.161.12"},
"@tanstack/start-plugin-core": {"1.169.23", "1.169.26"},
"@tanstack/start-server-core": {"1.167.33", "1.167.36"},
"@tanstack/start-static-server-functions": {"1.166.44", "1.166.47"},
"@tanstack/start-storage-context": {"1.166.38", "1.166.41"},
"@tanstack/valibot-adapter": {"1.166.12", "1.166.15"},
"@tanstack/virtual-file-routes": {"1.161.10", "1.161.13"},
"@tanstack/vue-router": {"1.169.5", "1.169.8"},
"@tanstack/vue-router-devtools": {"1.166.16", "1.166.19"},
"@tanstack/vue-router-ssr-query": {"1.166.15", "1.166.18"},
"@tanstack/vue-start": {"1.167.61", "1.167.64"},
"@tanstack/vue-start-client": {"1.166.46", "1.166.49"},
"@tanstack/vue-start-server": {"1.166.50", "1.166.53"},
"@tanstack/zod-adapter": {"1.166.12", "1.166.15"},
# Mini Shai-Hulud May-12 wave: OpenSearch JS client.
"@opensearch-project/opensearch": {"3.5.3", "3.6.2", "3.7.0", "3.8.0"},
# Mini Shai-Hulud May-12 wave: @squawk/* (22 packages, 5 versions each;
# https://safedep.io/mass-npm-supply-chain-attack-tanstack-mistral/).
"@squawk/airport-data": {"0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.8"},
"@squawk/airports": {"0.6.2", "0.6.3", "0.6.4", "0.6.5", "0.6.6"},
"@squawk/airspace": {"0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5"},
"@squawk/airspace-data": {"0.5.3", "0.5.4", "0.5.5", "0.5.6", "0.5.7"},
"@squawk/airway-data": {"0.5.4", "0.5.5", "0.5.6", "0.5.7", "0.5.8"},
"@squawk/airways": {"0.4.2", "0.4.3", "0.4.4", "0.4.5", "0.4.6"},
"@squawk/fix-data": {"0.6.4", "0.6.5", "0.6.6", "0.6.7", "0.6.8"},
"@squawk/fixes": {"0.3.2", "0.3.3", "0.3.4", "0.3.5", "0.3.6"},
"@squawk/flight-math": {"0.5.4", "0.5.5", "0.5.6", "0.5.7", "0.5.8"},
"@squawk/flightplan": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"},
"@squawk/geo": {"0.4.4", "0.4.5", "0.4.6", "0.4.7", "0.4.8"},
"@squawk/icao-registry": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"},
"@squawk/icao-registry-data": {"0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8"},
"@squawk/mcp": {"0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5"},
"@squawk/navaid-data": {"0.6.4", "0.6.5", "0.6.6", "0.6.7", "0.6.8"},
"@squawk/navaids": {"0.4.2", "0.4.3", "0.4.4", "0.4.5", "0.4.6"},
"@squawk/notams": {"0.3.6", "0.3.7", "0.3.8", "0.3.9", "0.3.10"},
"@squawk/procedure-data": {"0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7"},
"@squawk/procedures": {"0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6"},
"@squawk/types": {"0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5"},
"@squawk/units": {"0.4.3", "0.4.4", "0.4.5", "0.4.6", "0.4.7"},
"@squawk/weather": {"0.5.6", "0.5.7", "0.5.8", "0.5.9", "0.5.10"},
# Mini Shai-Hulud May-12 wave: @uipath/* (64 packages, single version each;
# https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised).
"@uipath/apollo-react": {"4.24.5"},
"@uipath/apollo-wind": {"2.16.2"},
"@uipath/cli": {"1.0.1"},
"@uipath/rpa-tool": {"0.9.5"},
"@uipath/apollo-core": {"5.9.2"},
"@uipath/filesystem": {"1.0.1"},
"@uipath/solutionpackager-tool-core": {"0.0.34"},
"@uipath/solution-tool": {"1.0.1"},
"@uipath/maestro-tool": {"1.0.1"},
"@uipath/codedapp-tool": {"1.0.1"},
"@uipath/agent-tool": {"1.0.1"},
"@uipath/orchestrator-tool": {"1.0.1"},
"@uipath/integrationservice-tool": {"1.0.2"},
"@uipath/rpa-legacy-tool": {"1.0.1"},
"@uipath/vertical-solutions-tool": {"1.0.1"},
"@uipath/flow-tool": {"1.0.2"},
"@uipath/codedagent-tool": {"1.0.1"},
"@uipath/common": {"1.0.1"},
"@uipath/resource-tool": {"1.0.1"},
"@uipath/auth": {"1.0.1"},
"@uipath/docsai-tool": {"1.0.1"},
"@uipath/case-tool": {"1.0.1"},
"@uipath/api-workflow-tool": {"1.0.1"},
"@uipath/test-manager-tool": {"1.0.2"},
"@uipath/robot": {"1.3.4"},
"@uipath/traces-tool": {"1.0.1"},
"@uipath/agent-sdk": {"1.0.2"},
"@uipath/integrationservice-sdk": {"1.0.2"},
"@uipath/maestro-sdk": {"1.0.1"},
"@uipath/data-fabric-tool": {"1.0.2"},
"@uipath/tasks-tool": {"1.0.1"},
"@uipath/insights-tool": {"1.0.1"},
"@uipath/insights-sdk": {"1.0.1"},
"@uipath/uipath-python-bridge": {"1.0.1"},
"@uipath/ap-chat": {"1.5.7"},
"@uipath/project-packager": {"1.1.16"},
"@uipath/packager-tool-case": {"0.0.9"},
"@uipath/packager-tool-workflowcompiler-browser": {"0.0.34"},
"@uipath/packager-tool-connector": {"0.0.19"},
"@uipath/packager-tool-workflowcompiler": {"0.0.16"},
"@uipath/packager-tool-webapp": {"1.0.6"},
"@uipath/packager-tool-apiworkflow": {"0.0.19"},
"@uipath/packager-tool-functions": {"0.1.1"},
"@uipath/widget.sdk": {"1.2.3"},
"@uipath/resources-tool": {"0.1.11"},
"@uipath/agent.sdk": {"0.0.18"},
"@uipath/codedagents-tool": {"0.1.12"},
"@uipath/aops-policy-tool": {"0.3.1"},
"@uipath/solution-packager": {"0.0.35"},
"@uipath/packager-tool-bpmn": {"0.0.9"},
"@uipath/packager-tool-flow": {"0.0.19"},
"@uipath/telemetry": {"0.0.7"},
"@uipath/tool-workflowcompiler": {"0.0.12"},
"@uipath/vss": {"0.1.6"},
"@uipath/solutionpackager-sdk": {"1.0.11"},
"@uipath/ui-widgets-multi-file-upload": {"1.0.1"},
"@uipath/access-policy-tool": {"0.3.1"},
"@uipath/context-grounding-tool": {"0.1.1"},
"@uipath/gov-tool": {"0.3.1"},
"@uipath/admin-tool": {"0.1.1"},
"@uipath/identity-tool": {"0.1.1"},
"@uipath/llmgw-tool": {"1.0.1"},
"@uipath/resourcecatalog-tool": {"0.1.1"},
"@uipath/functions-tool": {"1.0.1"},
"@uipath/access-policy-sdk": {"0.3.1"},
"@uipath/platform-tool": {"1.0.1"},
# Mini Shai-Hulud May-12 wave: @mistralai/* (npm) — separate from PyPI mistralai
# (https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised).
"@mistralai/mistralai": {"2.2.2", "2.2.3", "2.2.4"},
"@mistralai/mistralai-gcp": {"1.7.1", "1.7.2", "1.7.3"},
"@mistralai/mistralai-azure": {"1.7.1", "1.7.2", "1.7.3"},
# Mini Shai-Hulud May-12 wave: @tallyui/* (30 entries, 10 packages)
# (Aikido enumeration).
"@tallyui/components": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/connector-medusa": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/connector-shopify": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/connector-vendure": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/connector-woocommerce": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/core": {"0.2.1", "0.2.2", "0.2.3"},
"@tallyui/database": {"1.0.1", "1.0.2", "1.0.3"},
"@tallyui/pos": {"0.1.1", "0.1.2", "0.1.3"},
"@tallyui/storage-sqlite": {"0.2.1", "0.2.2", "0.2.3"},
"@tallyui/theme": {"0.2.1", "0.2.2", "0.2.3"},
# Mini Shai-Hulud May-12 wave: @beproduct/nestjs-auth (18 versions)
# (Aikido enumeration).
"@beproduct/nestjs-auth": {
"0.1.2",
"0.1.3",
"0.1.4",
"0.1.5",
"0.1.6",
"0.1.7",
"0.1.8",
"0.1.9",
"0.1.10",
"0.1.11",
"0.1.12",
"0.1.13",
"0.1.14",
"0.1.15",
"0.1.16",
"0.1.17",
"0.1.18",
"0.1.19",
},
# Mini Shai-Hulud May-12 wave: @draftlab/* + @draftauth/*
# (Aikido enumeration).
"@draftauth/client": {"0.2.1", "0.2.2"},
"@draftauth/core": {"0.13.1", "0.13.2"},
"@draftlab/auth": {"0.24.1", "0.24.2"},
"@draftlab/auth-router": {"0.5.1", "0.5.2"},
"@draftlab/db": {"0.16.1"},
# Mini Shai-Hulud May-12 wave: @taskflow-corp/cli + @tolka/cli
# (Aikido enumeration).
"@taskflow-corp/cli": {"0.1.24", "0.1.25", "0.1.26", "0.1.27", "0.1.28", "0.1.29"},
"@tolka/cli": {"1.0.2", "1.0.3", "1.0.4", "1.0.5", "1.0.6"},
# Mini Shai-Hulud May-12 wave: @ml-toolkit-ts/* + @mesadev/* + @dirigible-ai/sdk + @supersurkhet/*
# (Aikido enumeration).
"@dirigible-ai/sdk": {"0.6.2", "0.6.3"},
"@mesadev/rest": {"0.28.3"},
"@mesadev/saguaro": {"0.4.22"},
"@mesadev/sdk": {"0.28.3"},
"@ml-toolkit-ts/preprocessing": {"1.0.2", "1.0.3"},
"@ml-toolkit-ts/xgboost": {"1.0.3", "1.0.4"},
"@supersurkhet/cli": {"0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"},
"@supersurkhet/sdk": {"0.0.2", "0.0.3", "0.0.4", "0.0.5", "0.0.6", "0.0.7"},
# Mini Shai-Hulud May-12 wave: Unscoped packages (10 entries)
# (Aikido enumeration).
"safe-action": {"0.8.3", "0.8.4"},
"ts-dna": {"3.0.1", "3.0.2", "3.0.3", "3.0.4"},
"cross-stitch": {"1.1.3", "1.1.4", "1.1.5", "1.1.6"},
"cmux-agent-mcp": {"0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7", "0.1.8"},
"agentwork-cli": {"0.1.4", "0.1.5"},
"git-branch-selector": {"1.3.3", "1.3.4", "1.3.5", "1.3.6", "1.3.7"},
"wot-api": {"0.8.1", "0.8.2", "0.8.3", "0.8.4"},
"git-git-git": {"1.0.8", "1.0.9", "1.0.10", "1.0.11", "1.0.12"},
"nextmove-mcp": {"0.1.3", "0.1.4", "0.1.5", "0.1.6", "0.1.7"},
"ml-toolkit-ts": {"1.0.4", "1.0.5"},
# Cross-ecosystem Mini Shai-Hulud (Apr-30 wave): npm counterpart of
# PyPI lightning 2.6.2/2.6.3. Same threat actor (TeamPCP) per Semgrep,
# Aikido, OX Security, Resecurity. Safe version: 7.0.3 and earlier.
"intercom-client": {"7.0.4"},
}
CARGO_IOC_STRINGS: tuple[str, ...] = (
# Reserved for future cargo-side incidents. Empty by default --
# `source` origin check below catches the structural pattern.
)
# ─────────────────────────────────────────────────────────────────────
# Allowed lockfile origins.
# ─────────────────────────────────────────────────────────────────────
NPM_REGISTRY_PREFIX = "https://registry.npmjs.org/"
# Tarballs are also fetched from this mirror on some GH Actions cached
# runs (npm rewrites the resolved URL on cache hit). Allow either.
NPM_REGISTRY_PREFIXES_ALLOWED: tuple[str, ...] = (NPM_REGISTRY_PREFIX,)
CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
# ─────────────────────────────────────────────────────────────────────
# Cargo non-registry source allowlist.
# ─────────────────────────────────────────────────────────────────────
#
# Each entry is `(crate_name, exact_source_string)`. The crate must
# match by name AND the source must match the full pinned-SHA string
# verbatim. Bumping the commit SHA forces a re-review here: the
# scanner fires until the new SHA is appended.
#
# Studio's Tauri shell pulls `fix-path-env` directly from
# tauri-apps/fix-path-env-rs because the crate is not published to
# crates.io. The pinned commit (c4c45d5) was reviewed at the time it
# landed; future bumps need explicit approval.
CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
(
"fix-path-env",
"git+https://github.com/tauri-apps/fix-path-env-rs#"
"c4c45d503ea115a839aae718d02f79e7c7f0f673",
),
)
# ─────────────────────────────────────────────────────────────────────
# Finding container.
# ─────────────────────────────────────────────────────────────────────
class Finding:
__slots__ = ("path", "package", "kind", "detail")
def __init__(self, path: str, package: str, kind: str, detail: str) -> None:
self.path = path
self.package = package
self.kind = kind
self.detail = detail
def __str__(self) -> str:
return (
f" [{self.kind}] {self.path}\n"
f" package: {self.package}\n"
f" detail: {self.detail}"
)
# ─────────────────────────────────────────────────────────────────────
# package-lock.json audit.
# ─────────────────────────────────────────────────────────────────────
def audit_npm_lockfile(path: Path) -> list[Finding]:
findings: list[Finding] = []
if not path.exists():
return findings
raw = path.read_text(encoding = "utf-8")
try:
lock = json.loads(raw)
except json.JSONDecodeError as exc:
findings.append(
Finding(
path = str(path),
package = "<root>",
kind = "malformed-lockfile",
detail = f"could not parse as JSON: {exc}",
)
)
return findings
lockfile_version = lock.get("lockfileVersion")
if lockfile_version not in (2, 3):
findings.append(
Finding(
path = str(path),
package = "<root>",
kind = "unsupported-lockfile-version",
detail = (f"only lockfileVersion 2 or 3 audited; got {lockfile_version}"),
)
)
packages = lock.get("packages") or {}
for key, entry in packages.items():
# The empty key "" is the project root; workspace entries use
# keys like "node_modules/foo" or "studio/frontend/sub-pkg".
# Skip the project root (it has no `resolved`).
if key == "":
continue
if entry.get("link"):
# Workspace symlink; no tarball to resolve.
continue
resolved = entry.get("resolved")
# Entries living inside another package's `node_modules/`
# tree are bundled fold-ins -- the parent's tarball ships
# their source verbatim and the parent's `integrity` covers
# the whole subtree. npm represents them in lockfileVersion 3
# as nested entries with no `resolved` and no `integrity` of
# their own. Treat them as transparent to this audit.
nested = key.count("/node_modules/") >= 1
# 1. resolved-URL origin.
if resolved is None:
if nested or entry.get("bundled"):
# Bundled / fold-in entry; covered by parent integrity.
pass
elif entry.get("version"):
# Top-level entry without a resolved URL is suspicious.
findings.append(
Finding(
path = str(path),
package = key,
kind = "missing-resolved-url",
detail = (
f"version={entry['version']!r} but no `resolved` "
"field; lockfile is incomplete"
),
)
)
else:
if not any(resolved.startswith(p) for p in NPM_REGISTRY_PREFIXES_ALLOWED):
findings.append(
Finding(
path = str(path),
package = key,
kind = "non-registry-resolved-url",
detail = (
f"resolved={resolved!r}; only "
f"{NPM_REGISTRY_PREFIX} is permitted. Direct "
"GitHub / git / file references are the "
"Shai-Hulud injection vector."
),
)
)
# 2. integrity-hash presence.
if resolved is not None and not entry.get("integrity"):
findings.append(
Finding(
path = str(path),
package = key,
kind = "missing-integrity-hash",
detail = (
"no `integrity` field; npm cannot verify the "
"tarball SHA against the registry-published hash"
),
)
)
# 3. Blocked malicious version list.
nm_prefix = "node_modules/"
pkg_name = key[len(nm_prefix) :] if key.startswith(nm_prefix) else key
version = entry.get("version")
blocked = BLOCKED_NPM_VERSIONS.get(pkg_name, set())
if version and version in blocked:
findings.append(
Finding(
path = str(path),
package = key,
kind = "blocked-known-malicious",
detail = (
f"{pkg_name}@{version} is on the " "BLOCKED_NPM_VERSIONS list"
),
)
)
# 4. Known IOC strings: scan the raw file body so we hit fields the
# structural pass above doesn't enumerate (scripts, optional
# dependencies, etc.). Cheap and complete.
for ioc in NPM_IOC_STRINGS:
if ioc in raw:
# Best-effort line number lookup.
line_no = _first_line_containing(raw, ioc)
findings.append(
Finding(
path = f"{path}:{line_no}" if line_no else str(path),
package = "<ioc-match>",
kind = "known-ioc-string",
detail = (
f"matched known IOC substring {ioc!r}; this is "
"a public indicator of a recent supply-chain "
"compromise. Refuse to install."
),
)
)
return findings
def _first_line_containing(text: str, needle: str) -> int | None:
for i, line in enumerate(text.splitlines(), start = 1):
if needle in line:
return i
return None
# ─────────────────────────────────────────────────────────────────────
# Cargo.lock audit.
# ─────────────────────────────────────────────────────────────────────
# Cargo.lock is TOML; parse with stdlib tomllib (Python 3.11+). The
# studio's Tauri shell already requires a modern toolchain so this is
# always available where CI runs.
_PACKAGE_HEADER = re.compile(r"^\[\[package\]\]\s*$")
def audit_cargo_lockfile(path: Path) -> list[Finding]:
findings: list[Finding] = []
if not path.exists():
return findings
raw = path.read_text(encoding = "utf-8")
try:
import tomllib # type: ignore[import-not-found]
except ImportError:
# Python <3.11; fall back to a tomli shim if importable.
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError:
findings.append(
Finding(
path = str(path),
package = "<root>",
kind = "missing-toml-parser",
detail = (
"Python 3.11+ tomllib or tomli is required to "
"parse Cargo.lock; install tomli or upgrade "
"Python before re-running this audit"
),
)
)
return findings
try:
lock = tomllib.loads(raw)
except Exception as exc:
findings.append(
Finding(
path = str(path),
package = "<root>",
kind = "malformed-lockfile",
detail = f"could not parse as TOML: {exc}",
)
)
return findings
for entry in lock.get("package", []):
name = entry.get("name") or "<unnamed>"
version = entry.get("version") or "<unversioned>"
source = entry.get("source")
# Workspace-local crates have no `source` field; skip them.
if source is None:
continue
if source != CARGO_REGISTRY_SOURCE:
if (name, source) in CARGO_SOURCE_ALLOWLIST:
# Pre-approved non-registry source pinned by SHA.
pass
else:
findings.append(
Finding(
path = str(path),
package = f"{name}@{version}",
kind = "non-registry-cargo-source",
detail = (
f"source={source!r}; only "
f"{CARGO_REGISTRY_SOURCE!r} is permitted "
"by default, and no allowlist entry covers "
"this crate. If the source is legitimate, "
"add `(name, source)` to "
"CARGO_SOURCE_ALLOWLIST after reviewing the "
"pinned commit."
),
)
)
if not entry.get("checksum") and source == CARGO_REGISTRY_SOURCE:
findings.append(
Finding(
path = str(path),
package = f"{name}@{version}",
kind = "missing-cargo-checksum",
detail = (
"registry crate without checksum; cargo cannot "
"verify the downloaded source against the "
"registry-published SHA"
),
)
)
for ioc in CARGO_IOC_STRINGS:
if ioc in raw:
line_no = _first_line_containing(raw, ioc)
findings.append(
Finding(
path = f"{path}:{line_no}" if line_no else str(path),
package = "<ioc-match>",
kind = "known-ioc-string",
detail = f"matched known IOC substring {ioc!r}",
)
)
return findings
# ─────────────────────────────────────────────────────────────────────
# CLI.
# ─────────────────────────────────────────────────────────────────────
DEFAULT_NPM_LOCKFILES = ("studio/frontend/package-lock.json",)
DEFAULT_CARGO_LOCKFILES = ("studio/src-tauri/Cargo.lock",)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description = "Pre-install lockfile supply-chain audit.",
)
parser.add_argument(
"--root",
default = str(REPO_ROOT),
help = "Repo root (default: parent of this script).",
)
parser.add_argument(
"--npm-lockfile",
action = "append",
default = None,
help = (
"Path to a package-lock.json (repeatable). "
"Default: studio/frontend/package-lock.json."
),
)
parser.add_argument(
"--cargo-lockfile",
action = "append",
default = None,
help = (
"Path to a Cargo.lock (repeatable). "
"Default: studio/src-tauri/Cargo.lock."
),
)
args = parser.parse_args(argv)
# SF4: require a real justification (e.g. JIRA ticket id) for the
# skip env var. Treat the trivially-set values ("1", "true", "yes",
# "on", empty) as INVALID -- they look like accidental flips and
# silently bypassed the supply-chain audit. A valid value is a
# non-empty string >=5 chars after stripping that does not match
# any of the boolean-shaped tokens above. An invalid value emits a
# loud GitHub Actions warning to stderr and FALLS THROUGH to run
# the audit normally (fail-safe). A valid value emits a warning
# naming the reason and skips with rc=0 (compat).
_skip_raw = os.environ.get("UNSLOTH_LOCKFILE_AUDIT_SKIP")
if _skip_raw is not None:
_skip = _skip_raw.strip()
_invalid_tokens = {"", "1", "0", "true", "false", "yes", "no", "on", "off"}
if _skip.lower() in _invalid_tokens or len(_skip) < 5:
print(
"::warning::Lockfile audit skip REQUIRES a justification "
f"value (>=5 chars, not '{_skip_raw}'). Proceeding with "
"audit. Use e.g. UNSLOTH_LOCKFILE_AUDIT_SKIP=ticket-1234.",
file = sys.stderr,
flush = True,
)
else:
print(
f"::warning::Lockfile audit skipped: reason='{_skip}'",
file = sys.stderr,
flush = True,
)
return 0
root = Path(args.root).resolve()
npm_paths = [root / p for p in (args.npm_lockfile or DEFAULT_NPM_LOCKFILES)]
cargo_paths = [root / p for p in (args.cargo_lockfile or DEFAULT_CARGO_LOCKFILES)]
all_findings: list[Finding] = []
for p in npm_paths:
print(f"[lockfile-audit] npm: {p}", flush = True)
all_findings.extend(audit_npm_lockfile(p))
for p in cargo_paths:
print(f"[lockfile-audit] cargo: {p}", flush = True)
all_findings.extend(audit_cargo_lockfile(p))
if not all_findings:
print(
f"[lockfile-audit] OK: 0 findings across "
f"{len(npm_paths)} npm + {len(cargo_paths)} cargo lockfile(s)",
flush = True,
)
return 0
print(
f"\n[lockfile-audit] FAIL: {len(all_findings)} finding(s):\n",
file = sys.stderr,
)
for f in all_findings:
print(str(f), file = sys.stderr)
print(file = sys.stderr)
print(
"[lockfile-audit] Refusing to proceed. Each finding above is "
"either a structural lockfile anomaly or a public indicator-of-"
"compromise. Investigate before running `npm ci` or `cargo fetch`.",
file = sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -14,6 +14,7 @@ Converts IPython magics to plain Python:
import nbformat
import re
import shlex
import sys
import os
import urllib.request
@ -21,6 +22,22 @@ import urllib.parse
from pathlib import Path
# Hosts we are willing to fetch raw notebook JSON from. Anything else
# is rejected before `urlopen` so a typoed / hostile URL cannot pull
# code from arbitrary infrastructure.
_ALLOWED_NOTEBOOK_HOSTS = {
"raw.githubusercontent.com",
"gist.githubusercontent.com",
}
# Shell metacharacters that imply the cell's `!cmd` line cannot be
# parsed as a flat argv. If any of these appears, `shlex.split` would
# either fail or, worse, silently strip the operator -- so we keep
# `shell=True` for that command and emit a review marker.
_SHELL_METACHARS_RE = re.compile(r"\$\(|`|\|\||\||&&|>>?|<<?|\*|\?|;")
def needs_fstring(cmd: str) -> bool:
"""Check if command has Python variable interpolation like {var_name}."""
pattern = r"(?<!\$)\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
@ -53,6 +70,18 @@ def download_notebook(url: str) -> tuple[str, str]:
parsed = urllib.parse.urlparse(raw_url)
filename = os.path.basename(urllib.parse.unquote(parsed.path))
# Host allowlist. Refuse to fetch from anywhere the campaign IOC
# tables flag (or just anywhere we don't recognise). The blob->raw
# conversion above only emits `raw.githubusercontent.com`, so a
# rejection here means the caller hand-typed a URL pointing
# somewhere we don't trust.
host = parsed.hostname
if host not in _ALLOWED_NOTEBOOK_HOSTS:
raise ValueError(
f"Refused notebook fetch from {host!r}: not in allowlist "
f"{sorted(_ALLOWED_NOTEBOOK_HOSTS)}"
)
# Download
print(f"Downloading {url}...")
with urllib.request.urlopen(raw_url, timeout = 60) as response:
@ -74,7 +103,52 @@ def replace_colab_paths(source: str) -> str:
return source
def convert_cell_to_python(source: str) -> str:
def _emit_shell_command(indent: str, full_cmd: str, *, allow_shell: bool) -> list[str]:
"""Render a `!cmd` notebook line as one or more Python statements.
When the command body is f-string-interpolated, contains shell
metacharacters, or spans multiple lines, falling back to
`shell=True` is the only correct option -- `shlex.split` would
either drop operators or fail outright. We surface that with a
`# WARNING: shell=True; reviewed for hostile input` comment so a
reviewer cannot miss it.
Otherwise we emit `subprocess.run(shlex.split(cmd), shell=False)`
so the converted script is not a re-injection vector if the
notebook ever interpolates user-controlled data.
`allow_shell` defaults to True at the CLI for backwards
compatibility. Setting it to False makes `shell=True` emission a
hard error (no surprise behaviour).
"""
needs_f = needs_fstring(full_cmd)
has_meta = bool(_SHELL_METACHARS_RE.search(full_cmd))
multiline = "\n" in full_cmd
must_use_shell = needs_f or has_meta or multiline
if must_use_shell:
if not allow_shell:
raise ValueError(
"Cell uses shell metacharacters / interpolation but "
"--no-allow-shell was set; refusing to emit shell=True"
)
warn = f"{indent}# WARNING: shell=True; reviewed for hostile input"
f_prefix = "f" if needs_f else ""
if multiline:
escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
if escaped_cmd.rstrip().endswith('"'):
escaped_cmd = escaped_cmd.rstrip() + " "
stmt = f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
else:
stmt = f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
return [warn, stmt]
# Shell-safe argv form.
return [f"{indent}subprocess.run(shlex.split({full_cmd!r}), shell=False)"]
def convert_cell_to_python(source: str, *, allow_shell: bool = True) -> str:
"""Convert a cell's IPython magics to plain Python."""
lines = source.split("\n")
result = []
@ -112,18 +186,9 @@ def convert_cell_to_python(source: str) -> str:
cmd_lines.append(lines[i].strip())
full_cmd = "\n".join(cmd_lines)
f_prefix = "f" if needs_fstring(full_cmd) else ""
if "\n" in full_cmd:
escaped_cmd = full_cmd.replace('"""', r"\"\"\"")
if escaped_cmd.rstrip().endswith('"'):
escaped_cmd = escaped_cmd.rstrip() + " "
result.append(
f'{indent}subprocess.run({f_prefix}"""{escaped_cmd}""", shell=True)'
)
else:
result.append(
f"{indent}subprocess.run({f_prefix}{full_cmd!r}, shell=True)"
)
result.extend(
_emit_shell_command(indent, full_cmd, allow_shell = allow_shell)
)
# %cd path -> os.chdir(path)
elif stripped.startswith("%cd "):
@ -154,7 +219,12 @@ def convert_cell_to_python(source: str) -> str:
return "\n".join(result)
def convert_notebook(notebook_content: str, source_name: str = "notebook") -> str:
def convert_notebook(
notebook_content: str,
source_name: str = "notebook",
*,
allow_shell: bool = True,
) -> str:
"""Convert notebook JSON content to Python script."""
# Parse notebook
if isinstance(notebook_content, str):
@ -167,6 +237,7 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
"# coding: utf-8",
f"# Converted from: {source_name}",
"",
"import shlex",
"import subprocess",
"import os",
"import sys",
@ -189,7 +260,7 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
continue
if cell.cell_type == "code":
converted = convert_cell_to_python(source)
converted = convert_cell_to_python(source, allow_shell = allow_shell)
converted = replace_colab_paths(converted)
lines.append(converted)
lines.append("")
@ -215,13 +286,20 @@ def convert_notebook(notebook_content: str, source_name: str = "notebook") -> st
return "\n".join(lines)
def convert_notebook_to_script(source: str, output_dir: str | None = None):
def convert_notebook_to_script(
source: str,
output_dir: str | None = None,
*,
allow_shell: bool = True,
):
"""
Convert a notebook to Python script.
Args:
source: Local file path or URL to notebook
output_dir: Output directory (optional, defaults to current directory)
allow_shell: When False, refuse to emit `shell=True` for any
`!cmd` cell that uses metacharacters / interpolation.
"""
if is_url(source):
content, filename = download_notebook(source)
@ -246,7 +324,7 @@ def convert_notebook_to_script(source: str, output_dir: str | None = None):
output_path = output_filename
# Convert
script = convert_notebook(content, source_name)
script = convert_notebook(content, source_name, allow_shell = allow_shell)
# Write output
with open(output_path, "w", encoding = "utf-8") as f:
@ -281,19 +359,56 @@ Examples:
parser.add_argument(
"-o", "--output", dest = "output_dir", default = ".", help = "Output directory."
)
# Default True for backwards compatibility: existing Colab notebooks
# routinely use pipes / redirection / interpolation in `!cmd` lines
# and the converted script needs to keep working. Operators who
# convert untrusted notebooks should pass --no-allow-shell to force
# a hard error on every metacharacter-bearing cell.
parser.add_argument(
"--allow-shell",
dest = "allow_shell",
action = "store_true",
default = True,
help = "Allow emitting subprocess.run(..., shell=True) for cells "
"that use shell metacharacters or interpolation (default).",
)
parser.add_argument(
"--no-allow-shell",
dest = "allow_shell",
action = "store_false",
help = "Refuse to emit shell=True; cells with metacharacters error out.",
)
args = parser.parse_args()
# Create output directory if needed
os.makedirs(args.output_dir, exist_ok = True)
# SF2: track per-notebook failures so a CI invocation that converts
# 10 notebooks but silently fails on 3 is no longer reported as
# success. Each failure is collected and the loop continues so the
# caller sees the full set; final exit status is 1 if anything
# failed.
failures: list[tuple[str, str]] = []
ok = 0
total = len(args.notebooks)
for source in args.notebooks:
try:
convert_notebook_to_script(
source, output_dir = args.output_dir if args.output_dir != "." else None
source,
output_dir = args.output_dir if args.output_dir != "." else None,
allow_shell = args.allow_shell,
)
ok += 1
except Exception as e:
print(f"ERROR converting {source}: {e}")
failures.append((source, f"{type(e).__name__}: {e}"))
print(
f"converted {ok}/{total}, {len(failures)} failed",
file = sys.stderr if failures else sys.stdout,
)
sys.exit(1 if failures else 0)
if __name__ == "__main__":

View file

@ -40,12 +40,38 @@ import re
import shlex
import subprocess
import sys
import tempfile
import textwrap
import time
import urllib.error
import urllib.request
from typing import Any, Iterable, Iterator
def _atomic_write_bytes(path: pathlib.Path, data: bytes) -> None:
"""Atomic write helper. See `scripts/scan_packages.py::update_req_file`.
A crash between `mkstemp` and `os.replace` leaves the prior file
untouched, so a half-downloaded PyPI metadata cache file cannot
poison subsequent runs of the validator.
"""
path.parent.mkdir(parents = True, exist_ok = True)
dirpath = str(path.parent) or "."
fd, tmp_path = tempfile.mkstemp(prefix = ".nb_val.", dir = dirpath)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
HERE = pathlib.Path(__file__).resolve().parent
DATA_DIR = HERE / "data"
PYPI_CACHE_DIR = DATA_DIR / "pypi_cache"
@ -388,7 +414,7 @@ def pypi_metadata(name: str, version: str) -> dict[str, Any] | None:
data = json.loads(r.read())
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
return None
path.write_text(json.dumps(data))
_atomic_write_bytes(path, json.dumps(data).encode("utf-8"))
return data
@ -863,47 +889,72 @@ def cmd_drift(args: argparse.Namespace) -> int:
check = False,
capture_output = True,
)
try:
proc = subprocess.run(
[sys.executable, str(update_script)],
cwd = nbdir,
capture_output = True,
text = True,
timeout = 600,
)
except subprocess.TimeoutExpired:
print("FAIL: update_all_notebooks.py timed out (>600s)", file = sys.stderr)
return 2
if proc.returncode != 0:
print(
f"FAIL: update_all_notebooks.py exited {proc.returncode}", file = sys.stderr
)
sys.stderr.write(proc.stderr[-2000:])
return 2
diff_proc = subprocess.run(
["git", "-C", str(nbdir), "diff", "--stat"], capture_output = True, text = True
)
# SF3: the restore MUST run even on SystemExit / KeyboardInterrupt /
# segfault-propagated exception, otherwise the user's working tree
# silently stays rolled back into the stash. A bare try/finally
# (NOT try/except/finally) preserves the original exception and
# still runs the cleanup. The pre-existing try/except around
# `subprocess.run` of the updater is folded inside the new outer
# try so its early returns still happen, but the stash pop is
# protected.
findings: list[Finding] = []
if diff_proc.stdout.strip():
for line in diff_proc.stdout.splitlines():
findings.append(
Finding(
rule = "R-DRIFT-001",
file = line.strip(),
severity = "error",
message = "generator-vs-checked-in drift",
hint = "run `python update_all_notebooks.py` and commit the diff",
)
rc: int
try:
try:
proc = subprocess.run(
[sys.executable, str(update_script)],
cwd = nbdir,
capture_output = True,
text = True,
timeout = 600,
)
# Restore.
subprocess.run(
["git", "-C", str(nbdir), "checkout", "."], check = False, capture_output = True
)
subprocess.run(
["git", "-C", str(nbdir), "stash", "pop"], check = False, capture_output = True
)
except subprocess.TimeoutExpired:
print(
"FAIL: update_all_notebooks.py timed out (>600s)",
file = sys.stderr,
)
rc = 2
else:
if proc.returncode != 0:
print(
f"FAIL: update_all_notebooks.py exited {proc.returncode}",
file = sys.stderr,
)
sys.stderr.write(proc.stderr[-2000:])
rc = 2
else:
diff_proc = subprocess.run(
["git", "-C", str(nbdir), "diff", "--stat"],
capture_output = True,
text = True,
)
if diff_proc.stdout.strip():
for line in diff_proc.stdout.splitlines():
findings.append(
Finding(
rule = "R-DRIFT-001",
file = line.strip(),
severity = "error",
message = "generator-vs-checked-in drift",
hint = "run `python update_all_notebooks.py` and commit the diff",
)
)
rc = 0 if not findings else 1
finally:
# Restore the working tree. Both commands MUST run regardless of
# how the try block exited (including SystemExit/KeyboardInterrupt).
subprocess.run(
["git", "-C", str(nbdir), "checkout", "."],
check = False,
capture_output = True,
)
subprocess.run(
["git", "-C", str(nbdir), "stash", "pop"],
check = False,
capture_output = True,
)
_emit(findings)
return 0 if not findings else 1
return rc
# ----- Convert ----- #
@ -1096,7 +1147,7 @@ def cmd_refresh_colab(args: argparse.Namespace) -> int:
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
print(f"FAIL: could not fetch {COLAB_PIP_FREEZE_URL}: {e}", file = sys.stderr)
return 2
out.write_bytes(data)
_atomic_write_bytes(out, data)
print(f"wrote {len(data)} bytes to {out}")
return 0

1457
scripts/scan_npm_packages.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,15 @@ MEDIUM = "MEDIUM"
SEVERITY_ORDER = {CRITICAL: 0, HIGH: 1, MEDIUM: 2}
# Hard pin-blocks for publicly confirmed malicious PyPI versions.
# Source: Socket.dev 2026-05-12 disclosure (Mini Shai-Hulud May-12 wave) and
# earlier Semgrep / Endor reports for the `lightning` entries.
BLOCKED_PYPI_VERSIONS: dict[str, set[str]] = {
"guardrails-ai": {"0.10.1"},
"mistralai": {"2.4.6"},
"lightning": {"2.6.2", "2.6.3"},
}
# ---------------------------------------------------------------------------
# Pattern definitions
# ---------------------------------------------------------------------------
@ -336,6 +345,15 @@ RE_TOKEN_REGEX = re.compile(
r"|\bglpat-[0-9A-Za-z_-]{20,}", # GitLab PAT
)
# Mini Shai-Hulud May-12 2026 wave indicators. The dropper artifact name
# `transformers.pyz` is high-confidence (no legit PyPI package ships a `.pyz`
# named after `transformers`); the host + slogans are CRITICAL.
RE_MAY12_IOC = re.compile(
r"(git-tanstack\.com|/tmp/transformers\.pyz|transformers\.pyz"
r"|With Love TeamPCP|We've been online over 2 hours)",
re.IGNORECASE,
)
# JavaScript-side obfuscation. The npm chalk/debug compromise and the
# Lightning router_runtime.js use the same minifier-style hex-var name
# pattern; a bundle full of `_0x1f2e3d` identifiers is a near-universal
@ -529,6 +547,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
has_openssl_cli = bool(RE_OPENSSL_CLI.search(content))
has_temp_exec = bool(RE_TEMP_EXEC.search(content))
has_c2_polling = bool(RE_C2_POLLING.search(content))
has_may12_ioc = bool(RE_MAY12_IOC.search(content))
# ---------------------------------------------------------------
# CRITICAL: combination patterns that strongly indicate malice
@ -572,6 +591,18 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
)
)
# May-12 Shai-Hulud IOC string in Python source.
if has_may12_ioc:
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in Python file",
_extract_evidence(content, RE_MAY12_IOC),
)
)
# C2 polling/beaconing loop
if has_c2_polling:
findings.append(
@ -1071,6 +1102,16 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
_extract_evidence(content, RE_WORKFLOW_INJECT),
)
)
if RE_MAY12_IOC.search(content):
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in shell script",
_extract_evidence(content, RE_MAY12_IOC),
)
)
return findings
@ -1111,6 +1152,16 @@ def check_workflow_file(content: str, filename: str, package: str) -> list[Findi
_extract_evidence(content, RE_SHELL_DROPPER),
)
)
if RE_MAY12_IOC.search(content):
findings.append(
Finding(
CRITICAL,
package,
filename,
"May-12 Shai-Hulud IOC string present in workflow file",
_extract_evidence(content, RE_MAY12_IOC),
)
)
return findings
@ -1118,33 +1169,154 @@ def check_workflow_file(content: str, filename: str, package: str) -> list[Findi
# Archive handling
# ---------------------------------------------------------------------------
# Tarbomb caps, mirrored from scripts/scan_npm_packages.py::safe_extract.
# Refuses zip-of-death / tar-of-death archives so a hostile sdist or
# wheel cannot exhaust memory or fill the temp dir before content
# scanning even starts. Keep these constants in sync with the npm side;
# we duplicate rather than import to keep `scan_packages.py` standalone.
HARD_MAX_FILE_BYTES = 64 * 1024 * 1024 # 64 MiB per member
HARD_MAX_TOTAL_BYTES = 512 * 1024 * 1024 # 512 MiB cumulative
HARD_MAX_MEMBERS = 50_000 # entries per archive
def _refuse_unsafe_member_name(name: str) -> str | None:
"""Return a refusal reason for a member name, or None if safe.
Mirrors `scan_npm_packages.py::safe_extract` semantics: no absolute
paths, no `..` traversal segments. The caller is responsible for
checking the resolved path lands inside the extract root, but for
iter_archive_files we never write to disk so the name-shape check
plus the in-memory size cap is sufficient.
"""
if name.startswith("/") or ".." in Path(name).parts:
return f"unsafe member name {name!r}"
return None
def iter_archive_files(archive_path: str):
"""Yield (filename, text_content) for every file in a wheel/sdist."""
"""Yield (filename, text_content) for every file in a wheel/sdist.
Streams members with size + count caps applied at the member level
so a tarbomb / zipbomb cannot blow up the scanner's memory budget.
On cap breach we emit a `[WARN]` log and short-circuit the archive.
"""
path = Path(archive_path)
if path.suffix == ".whl" or path.suffix == ".zip":
total = 0
count = 0
with zipfile.ZipFile(path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
count += 1
if count > HARD_MAX_MEMBERS:
print(
f" [WARN] {path.name}: refused; member count "
f"{count} exceeds cap {HARD_MAX_MEMBERS}",
file = sys.stderr,
)
return
reason = _refuse_unsafe_member_name(info.filename)
if reason is not None:
print(
f" [WARN] {path.name}: refused member ({reason})",
file = sys.stderr,
)
continue
# Declared (uncompressed) size cap.
if info.file_size > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: skipped {info.filename!r} "
f"(declared {info.file_size} > cap {HARD_MAX_FILE_BYTES})",
file = sys.stderr,
)
continue
if total + info.file_size > HARD_MAX_TOTAL_BYTES:
print(
f" [WARN] {path.name}: cumulative bytes cap "
f"{HARD_MAX_TOTAL_BYTES} hit at {info.filename!r}",
file = sys.stderr,
)
return
try:
data = zf.read(info.filename)
total += len(data)
text = data.decode("utf-8", errors = "replace")
yield info.filename, text
except Exception:
continue
elif path.name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
with tarfile.open(path) as tf:
for member in tf.getmembers():
total = 0
count = 0
# Streaming open so we never read the whole archive into memory.
with tarfile.open(path, mode = "r|*") as tf:
for member in tf:
count += 1
if count > HARD_MAX_MEMBERS:
print(
f" [WARN] {path.name}: refused; member count "
f"{count} exceeds cap {HARD_MAX_MEMBERS}",
file = sys.stderr,
)
return
# Refuse symlinks / hardlinks / devices outright -- the
# scanner never writes them anyway, but tar parsers
# have historically dereferenced them on extract.
if member.issym() or member.islnk():
print(
f" [WARN] {path.name}: refused link member "
f"{member.name!r}",
file = sys.stderr,
)
continue
if member.isdev() or member.isfifo():
print(
f" [WARN] {path.name}: refused special member "
f"{member.name!r}",
file = sys.stderr,
)
continue
if not member.isfile():
continue
reason = _refuse_unsafe_member_name(member.name)
if reason is not None:
print(
f" [WARN] {path.name}: refused member ({reason})",
file = sys.stderr,
)
continue
declared = max(member.size, 0)
if declared > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: skipped {member.name!r} "
f"(declared {declared} > cap {HARD_MAX_FILE_BYTES})",
file = sys.stderr,
)
continue
if total + declared > HARD_MAX_TOTAL_BYTES:
print(
f" [WARN] {path.name}: cumulative bytes cap "
f"{HARD_MAX_TOTAL_BYTES} hit at {member.name!r}",
file = sys.stderr,
)
return
try:
f = tf.extractfile(member)
if f is None:
continue
data = f.read()
# Bound the read so a tar header that lies about
# size cannot OOM us.
data = f.read(HARD_MAX_FILE_BYTES + 1)
if len(data) > HARD_MAX_FILE_BYTES:
print(
f" [WARN] {path.name}: body of "
f"{member.name!r} exceeded declared cap",
file = sys.stderr,
)
continue
total += len(data)
text = data.decode("utf-8", errors = "replace")
yield member.name, text
except Exception:
@ -1154,26 +1326,48 @@ def iter_archive_files(archive_path: str):
def scan_archive(archive_path: str, package: str) -> list[Finding]:
"""Scan all files in an archive for malicious patterns."""
findings = []
for filename, content in iter_archive_files(archive_path):
lower = filename.lower()
if lower.endswith(".pth"):
findings.extend(check_pth_file(content, filename, package))
elif lower.endswith(".py"):
findings.extend(check_py_file(content, filename, package))
elif lower.endswith((".js", ".mjs", ".cjs", ".ts")):
# Lightning 2.6.x hid its real payload in a 14.8 MB
# router_runtime.js inside a Python wheel. Without this
# branch we'd have only seen the small Python loader.
findings.extend(check_js_file(content, filename, package))
elif lower.endswith((".sh", ".bash")):
findings.extend(check_shell_file(content, filename, package))
elif "/.github/workflows/" in lower and lower.endswith((".yml", ".yaml")):
# Shai-Hulud / ForceMemo plant their own GHA workflow.
# A workflow file inside a *PyPI package* is on its own
# already a yellow flag; pattern-match the worm signatures.
findings.extend(check_workflow_file(content, filename, package))
"""Scan all files in an archive for malicious patterns.
A corrupted archive container (truncated wheel, bad gzip header,
etc.) used to be silently skipped by an ``except Exception: continue``
inside ``iter_archive_files``. Per the silent-failure hardening
(SF1) it now emits a CRITICAL ``archive_corrupted`` finding so the
main loop counts and surfaces it rather than reporting "0 findings".
"""
findings: list[Finding] = []
try:
for filename, content in iter_archive_files(archive_path):
lower = filename.lower()
if lower.endswith(".pth"):
findings.extend(check_pth_file(content, filename, package))
elif lower.endswith(".py"):
findings.extend(check_py_file(content, filename, package))
elif lower.endswith((".js", ".mjs", ".cjs", ".ts")):
# Lightning 2.6.x hid its real payload in a 14.8 MB
# router_runtime.js inside a Python wheel. Without this
# branch we'd have only seen the small Python loader.
findings.extend(check_js_file(content, filename, package))
elif lower.endswith((".sh", ".bash")):
findings.extend(check_shell_file(content, filename, package))
elif "/.github/workflows/" in lower and lower.endswith((".yml", ".yaml")):
# Shai-Hulud / ForceMemo plant their own GHA workflow.
# A workflow file inside a *PyPI package* is on its own
# already a yellow flag; pattern-match the worm signatures.
findings.extend(check_workflow_file(content, filename, package))
except (zipfile.BadZipFile, tarfile.TarError, EOFError, OSError) as exc:
# The archive cannot be opened or is structurally broken. A
# benign wheel/sdist always opens; a malformed one is either a
# transport corruption (treat as scan failure) or a deliberate
# attempt to bypass scanners that swallow archive errors.
findings.append(
Finding(
CRITICAL,
package,
os.path.basename(archive_path),
"archive_corrupted",
f"{type(exc).__name__}: {exc}"[:240],
)
)
return findings
@ -1182,33 +1376,120 @@ def scan_archive(archive_path: str, package: str) -> list[Finding]:
# ---------------------------------------------------------------------------
_RE_PYPI_SPEC_VERSION = re.compile(r"==\s*([A-Za-z0-9_.\-+!]+)")
def _check_blocked_pypi_versions(
specs: list[str],
) -> tuple[list[str], list[Finding]]:
"""Filter ``specs`` against ``BLOCKED_PYPI_VERSIONS``.
Returns ``(safe_specs, findings)``. Each blocked spec emits a CRITICAL
``Finding`` and is removed from the returned spec list so the caller
never fetches the malicious tarball. Specs without an ``==X.Y.Z`` pin
pass through unchanged -- pip will resolve them at download time and
the existing scanners will catch the payload via the IOC regexes.
"""
safe: list[str] = []
findings: list[Finding] = []
for spec in specs:
name = _extract_pkg_name(spec).lower()
blocked = BLOCKED_PYPI_VERSIONS.get(name, set())
if not blocked:
safe.append(spec)
continue
m = _RE_PYPI_SPEC_VERSION.search(spec)
version = m.group(1) if m else None
if version is not None and version in blocked:
findings.append(
Finding(
CRITICAL,
f"{name}=={version}",
"<spec>",
"blocked-known-malicious",
f"{name}=={version} is on the BLOCKED_PYPI_VERSIONS list",
)
)
# Drop the spec; do not download.
continue
safe.append(spec)
return safe, findings
def _pip_download_env() -> dict[str, str]:
"""Return a scrubbed environment for invoking `pip download`.
Hostile shells / CI configs can override the index with PIP_INDEX_URL,
PIP_EXTRA_INDEX_URL, or a user `pip.conf`. We strip every PIP_*
override and route the resolver explicitly at PyPI. PIP_CONFIG_FILE
is forced to /dev/null so a stray ~/.pip/pip.conf with an
extra-index-url cannot bypass the pin.
"""
env = {**os.environ}
# Drop any user override.
for key in [k for k in env if k.startswith("PIP_")]:
env.pop(key, None)
env["PIP_INDEX_URL"] = "https://pypi.org/simple"
env["PIP_EXTRA_INDEX_URL"] = ""
env["PIP_CONFIG_FILE"] = "/dev/null"
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
return env
# Pip resolver flags shared by both download branches. Pinning the
# index URL on the CLI is belt + braces with the env scrub above.
# `--no-build-isolation` is deliberately NOT set; we never invoke
# setup.py at all because of `--only-binary :all:`.
_PIP_DOWNLOAD_PIN_FLAGS = [
"--index-url",
"https://pypi.org/simple",
"--only-binary",
":all:",
]
# Strip any character that could escape `dest` via `os.path.join`. This
# is the last line of defence before `pkg_dir = os.path.join(dest, ...)`
# so a spec like `../../etc/foo==1.0` cannot land outside the temp tree.
_RE_PKG_NAME_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
def download_packages(
specs: list[str],
dest: str,
*,
with_deps: bool = False,
) -> list[tuple[str, str]]:
) -> tuple[list[tuple[str, str]], list[str]]:
"""Download packages to dest using pip download. NEVER installs.
Returns list of (spec_or_name, filepath) for every downloaded archive.
Returns ``(results, download_errors)`` where ``results`` is a list of
``(spec_or_name, filepath)`` for every downloaded archive and
``download_errors`` is a list of one-line transport-failure summaries.
A non-empty ``download_errors`` MUST cause the caller to exit non-zero
even if no findings were produced; a silent ``0 findings, scan
incomplete`` is the bug class this return-shape was widened to fix.
When with_deps=True, downloads the full transitive dependency tree
in a single pip invocation (all archives land in one flat dir).
When with_deps=False (default), downloads each spec individually
with --no-deps.
"""
results = []
results: list[tuple[str, str]] = []
download_errors: list[str] = []
env = _pip_download_env()
if with_deps:
# Single pip download call for all specs + their transitive deps.
# --no-build-isolation and --no-binary :none: are NOT used --
# pip download only fetches wheels/sdists, never executes them.
# `--only-binary :all:` refuses sdists so we never execute a
# setup.py just to learn dependency metadata; combined with the
# scrubbed env, pip is wired hard at pypi.org.
os.makedirs(dest, exist_ok = True)
cmd = [
sys.executable,
"-m",
"pip",
"download",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
dest,
] + specs
@ -1218,14 +1499,18 @@ def download_packages(
capture_output = True,
text = True,
timeout = 600, # transitive resolution can be slow
env = env,
)
if proc.returncode != 0:
print(
f" [ERROR] pip download (with deps) failed: {proc.stderr.strip()[:500]}",
file = sys.stderr,
msg = (
f"pip download (with deps) failed: " f"{proc.stderr.strip()[:500]}"
)
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
except subprocess.TimeoutExpired:
print(f" [ERROR] pip download (with deps) timed out", file = sys.stderr)
msg = "pip download (with deps) timed out"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
# Collect every archive that landed in dest
for fname in sorted(os.listdir(dest)):
@ -1236,9 +1521,11 @@ def download_packages(
results.append((pkg_name, fpath))
else:
for spec in specs:
pkg_dir = os.path.join(
dest, spec.split("==")[0].split(">=")[0].split("<=")[0].split("[")[0]
)
raw_name = _extract_pkg_name(spec)
# Sanitize before joining into `dest` so a hostile spec
# cannot path-traverse out of the destination directory.
safe_name = _RE_PKG_NAME_SANITIZE.sub("_", raw_name) or "_pkg"
pkg_dir = os.path.join(dest, safe_name)
os.makedirs(pkg_dir, exist_ok = True)
cmd = [
sys.executable,
@ -1246,6 +1533,7 @@ def download_packages(
"pip",
"download",
"--no-deps",
*_PIP_DOWNLOAD_PIN_FLAGS,
"--dest",
pkg_dir,
spec,
@ -1256,15 +1544,20 @@ def download_packages(
capture_output = True,
text = True,
timeout = 120,
env = env,
)
if proc.returncode != 0:
print(
f" [ERROR] pip download failed for {spec}: {proc.stderr.strip()}",
file = sys.stderr,
msg = (
f"pip download failed for {spec}: "
f"{proc.stderr.strip()[:500]}"
)
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
continue
except subprocess.TimeoutExpired:
print(f" [ERROR] pip download timed out for {spec}", file = sys.stderr)
msg = f"pip download timed out for {spec}"
print(f" [ERROR] {msg}", file = sys.stderr)
download_errors.append(msg)
continue
# Find downloaded file(s)
@ -1272,7 +1565,7 @@ def download_packages(
fpath = os.path.join(pkg_dir, fname)
if os.path.isfile(fpath):
results.append((spec, fpath))
return results
return results, download_errors
# ---------------------------------------------------------------------------
@ -1586,6 +1879,13 @@ def update_req_file(filepath: str, updates: dict[int, str]) -> None:
"""Apply line-level updates to a requirements file.
updates: {line_num (1-indexed): new_line_text}
Writes atomically: stage in a sibling tmp file on the same
filesystem, fsync, then `os.replace` over the original. A SIGKILL
or power loss mid-write therefore either leaves the original
intact or leaves the fully new file -- never a half-written
requirements file (which would silently re-introduce a malicious
pin).
"""
with open(filepath) as f:
lines = f.readlines()
@ -1597,8 +1897,24 @@ def update_req_file(filepath: str, updates: dict[int, str]) -> None:
ending = "\n" if lines[idx].endswith("\n") else ""
lines[idx] = new_text + ending
with open(filepath, "w") as f:
f.writelines(lines)
dirpath = os.path.dirname(os.path.abspath(filepath)) or "."
fd, tmp_path = tempfile.mkstemp(
prefix = ".req_fix.",
dir = dirpath,
)
try:
with os.fdopen(fd, "w") as f:
f.writelines(lines)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, filepath)
except Exception:
# Best effort cleanup; the destination was never touched.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _run_fix(
@ -1842,10 +2158,19 @@ def main() -> int:
all_findings: list[Finding] = []
# Hard pin-block: refuse to download known-malicious PyPI versions.
specs, blocked_findings = _check_blocked_pypi_versions(specs)
all_findings.extend(blocked_findings)
tmpdir = tempfile.mkdtemp(prefix = "pth_scan_")
atexit.register(lambda d = tmpdir: shutil.rmtree(d, ignore_errors = True))
download_errors: list[str] = []
try:
downloaded = download_packages(specs, tmpdir, with_deps = args.with_deps)
downloaded, download_errors = download_packages(
specs,
tmpdir,
with_deps = args.with_deps,
)
print(f" Downloaded {len(downloaded)} archive(s).")
for spec, archive_path in downloaded:
@ -1871,6 +2196,26 @@ def main() -> int:
)
_run_fix(critical_pkgs, entries, args.max_search)
# Surface any pip-download failures BEFORE the scan-result exit code so
# an empty / partial download cannot mask itself as "0 findings, all
# clean". This is item (4) of the silent-failure hardening: an
# unresolvable spec or PyPI timeout used to print to stderr and exit 0.
if download_errors:
print(
f"\n {'=' * 72}\n"
f" SCAN INCOMPLETE: {len(download_errors)} pip download "
f"failure(s):\n"
f" {'=' * 72}",
file = sys.stderr,
)
for err in download_errors:
print(f" [ERROR] {err}", file = sys.stderr)
print(
" Refusing to report 'all clean' on a partial scan; " "exiting 2.",
file = sys.stderr,
)
return 2
# Exit code: 1 if any CRITICAL or HIGH
if any(f.severity in (CRITICAL, HIGH) for f in all_findings):
return 1

View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Stamp and verify display-only Studio release metadata for builds."""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from pathlib import Path
def _atomic_write_text(path: Path, data: str, encoding: str = "utf-8") -> None:
"""Atomic version of ``Path.write_text``.
A crash or signal mid-write leaves the prior file intact; the
Studio build never reads a partial ``_studio_release_build.py``.
"""
dirpath = str(path.parent) or "."
path.parent.mkdir(parents = True, exist_ok = True)
fd, tmp_path = tempfile.mkstemp(prefix = ".stamp_studio.", dir = dirpath)
try:
with os.fdopen(fd, "w", encoding = encoding) as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILD_INFO_PATH = (
REPO_ROOT / "studio" / "backend" / "utils" / "_studio_release_build.py"
)
BUILD_INFO_SUFFIX = "studio/backend/utils/_studio_release_build.py"
VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
MAX_VERSION_LENGTH = 64
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
\"\"\"Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
accidentally report a stale release tag.
\"\"\"
STUDIO_RELEASE_VERSION = None
"""
def is_valid_version(value: object) -> bool:
if not isinstance(value, str):
return False
version = value.strip()
if not version or len(version) > MAX_VERSION_LENGTH:
return False
if version.endswith("-dirty") or GIT_DESCRIBE_SUFFIX_RE.search(version):
return False
return VERSION_RE.fullmatch(version) is not None
def _exact_git_tag() -> str | None:
try:
result = subprocess.run(
[
"git",
"describe",
"--tags",
"--exact-match",
"--match",
"v[0-9]*",
"HEAD",
],
cwd = REPO_ROOT,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 2.0,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
tag = result.stdout.strip()
return tag if is_valid_version(tag) else None
def _git_worktree_is_dirty() -> bool:
try:
result = subprocess.run(
["git", "status", "--porcelain"],
cwd = REPO_ROOT,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = 2.0,
)
except (OSError, subprocess.TimeoutExpired):
return True
if result.returncode != 0:
return True
return bool(result.stdout.strip())
def _github_tag() -> str | None:
if os.environ.get("GITHUB_REF_TYPE") != "tag":
return None
github_ref = os.environ.get("GITHUB_REF_NAME", "").strip()
return github_ref or None
def resolve_version() -> tuple[str | None, str]:
env_version = os.environ.get("UNSLOTH_STUDIO_RELEASE_VERSION", "").strip()
if env_version:
return (env_version, "UNSLOTH_STUDIO_RELEASE_VERSION")
github_ref = _github_tag()
if github_ref:
return (github_ref, "GITHUB_REF_NAME")
git_tag = _exact_git_tag()
if git_tag:
return (git_tag, "exact git tag")
return (None, "none")
def build_info_source(version: str | None) -> str:
literal = repr(version) if version is not None else "None"
return f'''# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Studio release metadata."""
STUDIO_RELEASE_VERSION = {literal}
'''
def _env_version_conflicts(version: str) -> list[tuple[str, str]]:
conflicts: list[tuple[str, str]] = []
github_ref = _github_tag()
if github_ref and is_valid_version(github_ref) and github_ref != version:
conflicts.append(("GITHUB_REF_NAME", github_ref))
git_tag = _exact_git_tag()
if git_tag and git_tag != version:
conflicts.append(("exact git tag", git_tag))
return conflicts
def stamp(require_release: bool) -> int:
version, source = resolve_version()
if version is not None and not is_valid_version(version):
print(
f"Invalid Studio release version from {source}: {version!r}",
file = sys.stderr,
)
return 2
if version is not None and source == "UNSLOTH_STUDIO_RELEASE_VERSION":
conflicts = _env_version_conflicts(version)
if conflicts:
details = ", ".join(f"{name}={value!r}" for name, value in conflicts)
print(
"UNSLOTH_STUDIO_RELEASE_VERSION does not match available "
f"release tag metadata: {details}",
file = sys.stderr,
)
return 2
if require_release and source == "exact git tag" and _git_worktree_is_dirty():
print(
"Refusing to publish from a dirty exact-tag checkout. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION explicitly from release automation "
"or publish from a clean tag checkout.",
file = sys.stderr,
)
return 2
if version is None:
if require_release:
print(
"No Studio release version available. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
"or run from an exact local Studio release tag.",
file = sys.stderr,
)
return 2
_atomic_write_text(BUILD_INFO_PATH, PLACEHOLDER, encoding = "utf-8")
print("dev")
return 0
_atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8")
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
print(version)
return 0
def _read_wheel_member(path: Path) -> str | None:
with zipfile.ZipFile(path) as archive:
for name in archive.namelist():
if name.endswith(BUILD_INFO_SUFFIX):
return archive.read(name).decode("utf-8")
return None
def _read_sdist_member(path: Path) -> str | None:
with tarfile.open(path) as archive:
for member in archive.getmembers():
if member.name.endswith(BUILD_INFO_SUFFIX):
extracted = archive.extractfile(member)
if extracted is None:
return None
return extracted.read().decode("utf-8")
return None
def verify_dist(expected: str, dist_dir: Path) -> int:
if not is_valid_version(expected):
print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
return 2
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
if not artifacts:
print(f"No wheel or sdist artifacts found in {dist_dir}", file = sys.stderr)
return 2
expected_line = f"STUDIO_RELEASE_VERSION = {expected!r}"
failures: list[str] = []
for artifact in artifacts:
if artifact.suffix == ".whl":
content = _read_wheel_member(artifact)
else:
content = _read_sdist_member(artifact)
if content is None:
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
elif expected_line not in content:
failures.append(f"{artifact.name}: Studio release version mismatch")
if failures:
for failure in failures:
print(failure, file = sys.stderr)
return 2
print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__)
parser.add_argument("--require-release", action = "store_true")
parser.add_argument("--verify-dist", type = Path)
parser.add_argument("--expected")
args = parser.parse_args()
if args.verify_dist is not None:
if not args.expected:
parser.error("--verify-dist requires --expected")
return verify_dist(args.expected, args.verify_dist)
return stamp(require_release = args.require_release)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -480,6 +480,37 @@ def save_refresh_token(
conn.close()
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""Atomically validate-and-delete a refresh token for single-use rotation.
DELETE RETURNING fuses validate and delete into one statement so two
concurrent refresh requests cannot both consume the same token.
"""
token_hash = _hash_token(token)
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
conn.execute(
"DELETE FROM refresh_tokens WHERE expires_at < ?",
(now,),
)
cur = conn.execute(
"""
DELETE FROM refresh_tokens
WHERE token_hash = ? AND expires_at >= ?
RETURNING username, is_desktop
""",
(token_hash, now),
)
row = cur.fetchone()
conn.commit()
if row is None:
return None
return row["username"], bool(row["is_desktop"])
finally:
conn.close()
def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""
Verify a refresh token and return the username plus desktop marker.

View file

@ -433,6 +433,8 @@ class LlamaCppBackend:
self._hf_variant: Optional[str] = None
self._is_vision: bool = False
self._healthy = False
# Set by _classify_gpu_offload after _wait_for_health.
self._gpu_offload_active: Optional[bool] = None
self._context_length: Optional[int] = None
self._effective_context_length: Optional[int] = None
self._max_context_length: Optional[int] = None
@ -956,6 +958,13 @@ class LlamaCppBackend:
logger.debug(f"torch GPU probe failed: {e}")
return []
# Free-VRAM fraction at which Studio pins the GPU directly instead
# of deferring to ``--fit on``. 5% headroom covers CUDA context +
# compute buffers; 0.90 was too conservative and dropped 91-94%
# fits to CPU offload (#5106). The fork's --fit on still catches
# the truly-too-large case.
_GPU_PIN_VRAM_FRACTION = 0.95
@staticmethod
def _windows_pip_nvidia_dll_dirs(prefix: str) -> list[str]:
"""Return DLL dirs from pip-installed CUDA wheels under
@ -1024,11 +1033,11 @@ class LlamaCppBackend:
"""Pick GPU(s) for a model based on estimated VRAM and free memory.
``model_size_bytes`` should include both model weights and estimated
KV cache. The 90% threshold provides headroom for compute buffers,
CUDA context, and other runtime overhead.
KV cache. The ``_GPU_PIN_VRAM_FRACTION`` threshold provides headroom
for compute buffers, CUDA context, and other runtime overhead.
Returns (gpu_indices, use_fit):
- ([1], False) model fits on 1 GPU at 90% of free
- ([1], False) model fits on 1 GPU at the headroom threshold
- ([1, 2], False) model needs 2 GPUs
- (None, True) model too large, let --fit handle it
"""
@ -1036,12 +1045,13 @@ class LlamaCppBackend:
return None, True
model_size_mib = model_size_bytes / (1024 * 1024)
usable_fraction = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
# Sort GPUs by free memory descending
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
# Try fitting on 1 GPU (90% of free memory threshold)
if ranked[0][1] * 0.90 >= model_size_mib:
# Try fitting on 1 GPU at the usable-VRAM threshold.
if ranked[0][1] * usable_fraction >= model_size_mib:
return [ranked[0][0]], False
# Try fitting on N GPUs (accumulate free memory from most-free)
@ -1049,7 +1059,7 @@ class LlamaCppBackend:
selected = []
for idx, free_mib in ranked:
selected.append(idx)
cumulative += free_mib * 0.90
cumulative += free_mib * usable_fraction
if cumulative >= model_size_mib:
return sorted(selected), False
@ -1282,10 +1292,11 @@ class LlamaCppBackend:
) -> int:
"""Return the largest context length that fits in GPU VRAM.
Uses 90% of available VRAM as the budget (matching _select_gpus
threshold -- 10% reserved for compute buffers, CUDA context,
scratch space, flash-attn workspace, etc.).
If the model weights alone don't fit, returns min_ctx unchanged.
Uses 90% of available VRAM as the ctx-fit budget. Tighter than
``_GPU_PIN_VRAM_FRACTION`` on purpose: over-promising context
OOMs at runtime, while pinning conservatively just defers to
--fit on. If the weights alone don't fit, returns
``requested_ctx`` unchanged.
``kv_on_gpu`` mirrors ``--kv-offload`` (default on). When False
the KV cache lives in CPU RAM and doesn't compete with weights
@ -2031,6 +2042,7 @@ class LlamaCppBackend:
# still has valid state to publish.
effective_ctx = n_ctx if n_ctx > 0 else (self._context_length or 0)
max_available_ctx = self._context_length or effective_ctx
gpus: list[tuple[int, int]] = []
try:
model_size = self._get_gguf_size_bytes(model_path)
gpus = self._get_gpu_free_memory()
@ -2114,8 +2126,11 @@ class LlamaCppBackend:
gpu_indices, use_fit = self._select_gpus(requested_total, gpus)
# No silent shrink: effective_ctx stays == n_ctx.
else:
# Auto context: prefer fewer GPUs, cap context to fit.
# Auto context: prefer fewer GPUs, cap context
# to fit. Same headroom threshold as
# _select_gpus (#5106).
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
pin_fraction = self._GPU_PIN_VRAM_FRACTION
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
@ -2130,18 +2145,31 @@ class LlamaCppBackend:
capped, cache_type_kv, n_parallel = n_parallel
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
if total_mib <= pool_mib * pin_fraction:
effective_ctx = capped
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
else:
# No subset can host the weights (weights alone
# exceed 90% of every pool). Per spec, default
# the UI-visible context to 4096 and let
# --fit on flex -ngl so llama-server offloads
# layers to CPU RAM.
# Native ctx doesn't fit. Drop to 4096 and
# re-check before deferring to --fit on:
# a model that overflows at 131k may pin
# comfortably with a 4096 KV cache (#5106).
effective_ctx = min(4096, effective_ctx)
if effective_ctx > 0:
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
kv = self._estimate_kv_cache_bytes(
effective_ctx,
cache_type_kv,
n_parallel = n_parallel,
)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * pin_fraction:
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
elif gpus:
# Can't estimate KV -- fall back to file-size-only check.
@ -2570,12 +2598,54 @@ class LlamaCppBackend:
self._healthy = True
# Catch silent CPU fallback when GPU was intended (#5106).
self._gpu_offload_active = self._classify_gpu_offload(
gpu_indices is not None or use_fit, gpus or []
)
if self._gpu_offload_active is False:
logger.warning(
"llama-server appears to have loaded the model entirely "
"on CPU even though Studio detected at least one GPU. "
"This usually means the prebuilt binary's GPU backend "
"failed to load -- on Windows, cudart64_X.dll / "
"cublas64_X.dll could not be resolved. Reinstall the "
"Studio llama.cpp prebuilt or install a matching CUDA "
"toolkit (issue unslothai/unsloth#5106).",
)
logger.info(
f"llama-server ready on port {self._port} "
f"for model '{model_identifier}'"
)
return True
def _classify_gpu_offload(
self,
expected_gpu: bool,
detected_gpus: list[tuple[int, int]],
) -> Optional[bool]:
"""True if a GPU model buffer was allocated, False if only CPU
buffers landed despite GPU intent, None when there's no signal
(no GPU detected, no buffer-size lines, etc.)."""
if not detected_gpus or not expected_gpu:
return None
# llama-server logs one ``... model buffer size = N MiB`` line
# per backend buffer; CUDA0 / ROCm0 / Metal / Vulkan0 /
# OpenCL0 / SYCL0 are GPU, CPU / CPU_Mapped are not.
gpu_markers = ("CUDA", "ROCm", "Metal", "Vulkan", "OpenCL", "SYCL")
saw_buffer_line = False
saw_gpu_buffer = False
for line in self._stdout_lines:
if "model buffer size" not in line:
continue
saw_buffer_line = True
if any(marker in line for marker in gpu_markers):
saw_gpu_buffer = True
break
if not saw_buffer_line:
return None
return saw_gpu_buffer
def unload_model(self) -> bool:
"""Terminate the llama-server subprocess and cancel any in-flight download."""
self._cancel_event.set()

View file

@ -10,6 +10,7 @@ Supports web search (DuckDuckGo), Python code execution, and terminal commands.
import ast
import http.client
import os
import signal
os.environ["UNSLOTH_IS_PRESENT"] = "1"
@ -58,21 +59,37 @@ _MAX_OUTPUT_CHARS = 8000 # truncate long output
_BLOCKED_COMMANDS_COMMON = frozenset(
{
"rm",
"sudo",
"su",
"dd",
"chmod",
"chown",
"mkfs",
"shutdown",
"reboot",
"passwd",
"mount",
"umount",
"fdisk",
"sudo",
"su",
"doas",
"pkexec",
"shutdown",
"reboot",
"halt",
"poweroff",
"kill",
"killall",
"pkill",
"passwd",
"curl",
"wget",
"nc",
"ncat",
"netcat",
"socat",
"ssh",
"scp",
"sftp",
"rsync",
"eval",
"source",
}
)
_BLOCKED_COMMANDS_WIN = frozenset(
@ -221,35 +238,67 @@ def _build_safe_env(workdir: str) -> dict[str, str]:
def _sandbox_preexec():
"""Pre-exec hook: drop privilege escalation ability and set resource limits.
"""Best-effort sandbox setup for sandboxed subprocesses.
On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the
kernel level. On Linux and macOS, sets RLIMIT_FSIZE.
No-op on Windows (use creationflags instead).
Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it
per real UID, not per process tree, so it would starve the Studio
server and other sessions sharing the same user account.
All modules and handles are resolved at import time (module level) so
this function does not trigger Python imports in the forked child,
avoiding potential deadlocks in multi-threaded servers.
Modules are resolved at import time so the forked child runs no imports.
"""
try:
os.setsid()
except OSError:
pass
try:
os.umask(0o077)
except OSError:
pass
if _libc is not None:
try:
# PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
_libc.prctl(38, 1, 0, 0, 0)
_libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
except (OSError, AttributeError):
pass # Not available (container, old kernel, etc.)
pass
try:
_libc.prctl(1, 9, 0, 0, 0) # PR_SET_PDEATHSIG = SIGKILL
except (OSError, AttributeError):
pass
# CLONE_NEWNET intentionally not applied: where userns is enabled it
# blocks all egress, including allowlisted hosts. Network policy is
# enforced by the AST host check and the bash blocklist.
if _resource is not None:
# RLIMIT_NPROC is per-real-UID, so the cap is well above normal usage.
try:
nproc = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", "10000"))
_resource.setrlimit(_resource.RLIMIT_NPROC, (nproc, nproc))
except (ValueError, OSError, AttributeError):
pass
try:
# Limit file size to 100MB (prevents disk filling)
_resource.setrlimit(
_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)
)
except (ValueError, OSError):
pass
try:
as_bytes = (
int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_AS_GB", "8"))
* 1024
* 1024
* 1024
)
_resource.setrlimit(_resource.RLIMIT_AS, (as_bytes, as_bytes))
except (ValueError, OSError, AttributeError):
pass
try:
cpu_s = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_CPU_S", "600"))
_resource.setrlimit(_resource.RLIMIT_CPU, (cpu_s, cpu_s))
except (ValueError, OSError, AttributeError):
pass
try:
_resource.setrlimit(_resource.RLIMIT_NOFILE, (1024, 1024))
except (ValueError, OSError, AttributeError):
pass
def _get_shell_cmd(command: str) -> list[str]:
@ -265,25 +314,36 @@ def _get_shell_cmd(command: str) -> list[str]:
_workdirs: dict[str, str] = {}
# Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes.
_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z")
def _get_workdir(session_id: str | None = None) -> str:
"""Return (and lazily create) a persistent working directory for tool execution."""
"""Return a per-session sandbox dir at mode 0o700."""
global _workdirs
key = session_id or "_default"
if key not in _workdirs or not os.path.isdir(_workdirs[key]):
home = os.path.expanduser("~")
sandbox_root = os.path.join(home, "studio_sandbox")
if session_id:
# Sanitize: strip path separators and parent-dir references
safe_id = os.path.basename(session_id.replace("..", ""))
if not safe_id:
safe_id = "_invalid"
workdir = os.path.join(sandbox_root, safe_id)
# Verify resolved path stays under sandbox root
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
if session_id and _SESSION_ID_RE.match(session_id):
workdir = os.path.join(sandbox_root, session_id)
if not os.path.realpath(workdir).startswith(
os.path.realpath(sandbox_root) + os.sep
):
workdir = os.path.join(sandbox_root, "_invalid")
elif session_id:
workdir = os.path.join(sandbox_root, "_invalid")
else:
workdir = os.path.join(sandbox_root, "_default")
os.makedirs(workdir, exist_ok = True)
try:
os.chmod(sandbox_root, 0o700)
except OSError:
pass
try:
os.chmod(workdir, 0o700)
except OSError:
pass
_workdirs[key] = workdir
return _workdirs[key]
@ -932,7 +992,12 @@ def _check_signal_escape_patterns(code: str):
isinstance(shell_node, ast.Constant)
and shell_node.value is False
)
if shell_func in _STRING_SHELL_FUNCS or not shell_safe:
# Dynamic shell-exec args (chr/format/concat bypasses).
if (
shell_func in _STRING_SHELL_FUNCS
or shell_func in _SHELL_EXEC_FUNCS
or not shell_safe
):
def _is_safe_literal(n):
if _extract_string_from_node(n) is not None:
@ -1006,15 +1071,418 @@ def _check_signal_escape_patterns(code: str):
if visitor.imports_signal and not signal_tampering:
warnings.append("Code imports 'signal' module - review manually for safety")
# Static host policy: block metadata hosts and any literal host outside
# the trusted allowlist; uploads blocked regardless of host. Dynamic hosts
# are caught by the bash blocklist instead.
network_calls: list[dict] = []
sensitive_file_reads: list[dict] = []
_NETWORK_FQ_PREFIXES = (
"socket.socket",
"socket.create_connection",
"socket.getaddrinfo",
"urllib.request.urlopen",
"urllib.request.urlretrieve",
"urllib3.",
"requests.get",
"requests.post",
"requests.put",
"requests.delete",
"requests.patch",
"requests.head",
"requests.request",
"requests.Session",
"http.client.HTTPConnection",
"http.client.HTTPSConnection",
"httpx.get",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.request",
"httpx.Client",
"httpx.AsyncClient",
"aiohttp.ClientSession",
)
_UPLOAD_HTTP_METHODS = (
"requests.post",
"requests.put",
"requests.patch",
"requests.delete",
"requests.request",
"httpx.post",
"httpx.put",
"httpx.patch",
"httpx.delete",
"httpx.request",
"urllib.request.urlopen",
"urllib.request.Request",
)
_UPLOAD_HF_FQ = (
"huggingface_hub.upload_file",
"huggingface_hub.upload_folder",
"huggingface_hub.upload_large_folder",
"huggingface_hub.create_commit",
)
_UPLOAD_HF_METHODS = frozenset(
{
"upload_file",
"upload_folder",
"upload_large_folder",
"create_commit",
}
)
# Cloud-metadata / link-local hosts.
_METADATA_HOST_LITERALS = {
"169.254.169.254",
"fd00:ec2::254",
"metadata.google.internal",
"metadata",
"metadata.tencentyun.com",
"100.100.100.200",
"100.100.100.110",
"169.254.170.2",
"169.254.170.23",
}
_METADATA_HOST_PREFIXES = (
"169.254.",
"100.64.",
)
# Allowlist kept explicit so each entry is auditable.
_TRUSTED_PUBLIC_HOST_LITERALS = frozenset(
{
# search
"www.google.com",
"google.com",
"www.bing.com",
"bing.com",
"duckduckgo.com",
"html.duckduckgo.com",
# encyclopedic / reference
"wikipedia.org",
"www.wikipedia.org",
"wikimedia.org",
"www.wikimedia.org",
"wikidata.org",
"www.wikidata.org",
"commons.wikimedia.org",
"www.britannica.com",
"openlibrary.org",
"www.openstreetmap.org",
# ML / dev / data
"huggingface.co",
"hf.co",
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"gist.github.com",
"docs.github.com",
"pypi.org",
"files.pythonhosted.org",
"www.npmjs.com",
"registry.npmjs.org",
"crates.io",
"static.crates.io",
# docs
"docs.python.org",
"python.org",
"www.python.org",
"developer.mozilla.org",
"developer.apple.com",
"learn.microsoft.com",
"docs.docker.com",
"pytorch.org",
"docs.pytorch.org",
"tensorflow.org",
"www.tensorflow.org",
"numpy.org",
"pandas.pydata.org",
"scipy.org",
"scikit-learn.org",
"matplotlib.org",
"fastapi.tiangolo.com",
"starlette.io",
# academic
"arxiv.org",
"export.arxiv.org",
"scholar.google.com",
"openreview.net",
"semanticscholar.org",
"www.semanticscholar.org",
"biorxiv.org",
"www.biorxiv.org",
"medrxiv.org",
"www.medrxiv.org",
"pubmed.ncbi.nlm.nih.gov",
"www.ncbi.nlm.nih.gov",
# Q&A / community
"stackoverflow.com",
"stackexchange.com",
"askubuntu.com",
"superuser.com",
"serverfault.com",
# standards
"www.w3.org",
"tools.ietf.org",
"datatracker.ietf.org",
"www.rfc-editor.org",
# reputable news
"www.bbc.com",
"www.bbc.co.uk",
"www.reuters.com",
"apnews.com",
"www.nature.com",
"www.science.org",
# government / open data
"data.gov",
"catalog.data.gov",
"www.census.gov",
"www.nasa.gov",
"data.nasa.gov",
"www.cdc.gov",
"www.nih.gov",
"www.who.int",
# weather / time
"api.weather.gov",
"worldtimeapi.org",
}
)
_TRUSTED_PUBLIC_HOST_SUFFIXES = (
".wikipedia.org",
".wikimedia.org",
".wiktionary.org",
".wikibooks.org",
".wikiquote.org",
".wikisource.org",
".wikiversity.org",
".wikivoyage.org",
".stackexchange.com",
".hf.co",
".huggingface.co",
".githubusercontent.com",
".github.io",
".arxiv.org",
".readthedocs.io",
".readthedocs.org",
)
_SENSITIVE_FILE_PREFIXES = (
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/ssh/",
)
_SENSITIVE_FILE_RE = re.compile(
r"^/proc/(?:self|\d+)/(?:environ|cmdline|task/\d+/environ)$"
)
def _normalize_host(host: str) -> str:
if not host:
return ""
h = host.strip().lower().rstrip(".")
if "@" in h:
h = h.split("@", 1)[1]
if h.startswith("[") and "]" in h:
h = h[1 : h.index("]")]
elif h.count(":") == 1:
h = h.split(":", 1)[0]
return h
def _is_metadata_host(host: str) -> bool:
h = _normalize_host(host)
if not h:
return False
if h in _METADATA_HOST_LITERALS:
return True
if any(h.startswith(p) for p in _METADATA_HOST_PREFIXES):
return True
return False
def _is_trusted_host(host: str) -> bool:
h = _normalize_host(host)
if not h:
return False
if h in _TRUSTED_PUBLIC_HOST_LITERALS:
return True
return any(h.endswith(s) for s in _TRUSTED_PUBLIC_HOST_SUFFIXES)
def _call_is_upload_shape(node: ast.Call, fq: str) -> bool:
"""True for statically obvious upload shapes (files=, data=open(), bytes literal)."""
if fq in _UPLOAD_HF_FQ:
return True
if fq not in _UPLOAD_HTTP_METHODS:
return False
for kw in node.keywords or []:
if kw.arg == "files":
return True
if kw.arg == "data":
v = kw.value
if (
isinstance(v, ast.Call)
and isinstance(v.func, ast.Name)
and v.func.id == "open"
):
return True
if isinstance(v, ast.Constant) and isinstance(
v.value, (bytes, bytearray)
):
return True
return False
def _method_call_is_hf_upload(node: ast.Call) -> bool:
"""True for HfApi upload method names on any receiver."""
return (
isinstance(node.func, ast.Attribute)
and node.func.attr in _UPLOAD_HF_METHODS
)
class NetworkAndIoVisitor(ast.NodeVisitor):
def visit_Call(self, node):
parts: list[str] = []
cur = node.func
while isinstance(cur, ast.Attribute):
parts.insert(0, cur.attr)
cur = cur.value
if isinstance(cur, ast.Name):
parts.insert(0, cur.id)
fq = ".".join(parts) if parts else ""
if _method_call_is_hf_upload(node):
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": ("Blocked: file upload disallowed in sandbox"),
}
)
# Direct sock.connect((host, port)) bypasses the FQ-prefix branch below.
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "connect"
and node.args
):
a0 = node.args[0]
host_lit = None
if isinstance(a0, ast.Tuple) and a0.elts:
e0 = a0.elts[0]
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
host_lit = e0.value
elif isinstance(a0, ast.Constant) and isinstance(a0.value, str):
host_lit = a0.value
if host_lit:
if _is_metadata_host(host_lit):
network_calls.append(
{
"type": "metadata_host_blocked",
"line": getattr(node, "lineno", -1),
"description": "Blocked: cloud-metadata host",
}
)
elif not _is_trusted_host(host_lit):
network_calls.append(
{
"type": "untrusted_host_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: host not in sandbox allowlist; "
"use an allowed informational source"
),
}
)
if fq and any(fq.startswith(p) for p in _NETWORK_FQ_PREFIXES):
# 1) Upload-shape check (host-independent).
if _call_is_upload_shape(node, fq):
network_calls.append(
{
"type": "upload_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: file upload disallowed in sandbox"
),
}
)
# 2) Extract literal host (URL string or (host, port) tuple).
host_arg = None
url_arg = None
if node.args:
a0 = node.args[0]
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
url_arg = a0.value
elif isinstance(a0, ast.Tuple) and a0.elts:
e0 = a0.elts[0]
if isinstance(e0, ast.Constant) and isinstance(e0.value, str):
host_arg = e0.value
if url_arg and host_arg is None:
m = re.match(r"^\w+://([^/?#]+)", url_arg)
if m:
host_arg = m.group(1)
if host_arg:
if _is_metadata_host(host_arg):
network_calls.append(
{
"type": "metadata_host_blocked",
"line": getattr(node, "lineno", -1),
"description": "Blocked: cloud-metadata host",
}
)
elif not _is_trusted_host(host_arg):
network_calls.append(
{
"type": "untrusted_host_blocked",
"line": getattr(node, "lineno", -1),
"description": (
"Blocked: host not in sandbox allowlist; "
"use an allowed informational source"
),
}
)
is_open_call = (
(isinstance(node.func, ast.Name) and node.func.id == "open")
or fq in ("io.open", "pathlib.Path.open")
or fq.endswith(".open")
)
if is_open_call and node.args:
a0 = node.args[0]
path_lit = None
if isinstance(a0, ast.Constant) and isinstance(a0.value, str):
path_lit = a0.value
if path_lit:
flagged = False
if any(path_lit.startswith(p) for p in _SENSITIVE_FILE_PREFIXES):
flagged = True
elif _SENSITIVE_FILE_RE.match(path_lit):
flagged = True
if flagged:
sensitive_file_reads.append(
{
"type": "sensitive_file_read",
"line": getattr(node, "lineno", -1),
"description": (
f"open({path_lit!r}) targets a host identity / "
"credential file; sandboxed code may not read it"
),
}
)
self.generic_visit(node)
NetworkAndIoVisitor().visit(tree)
is_safe = (
len(signal_tampering) == 0
and len(exception_catching) == 0
and len(shell_escapes) == 0
and len(network_calls) == 0
and len(sensitive_file_reads) == 0
)
return is_safe, {
"signal_tampering": signal_tampering,
"exception_catching": exception_catching,
"shell_escapes": shell_escapes,
"network_calls": network_calls,
"sensitive_file_reads": sensitive_file_reads,
"warnings": warnings,
}
@ -1041,7 +1509,21 @@ def _check_code_safety(code: str) -> str | None:
exception_reasons = [
item.get("description", "") for item in info.get("exception_catching", [])
]
all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r]
network_reasons = [
item.get("description", "") for item in info.get("network_calls", [])
]
file_reasons = [
item.get("description", "") for item in info.get("sensitive_file_reads", [])
]
all_reasons = [
r
for r in reasons
+ shell_reasons
+ exception_reasons
+ network_reasons
+ file_reasons
if r
]
if all_reasons:
return (
f"Error: unsafe code detected ({'; '.join(all_reasons)}). "
@ -1051,11 +1533,31 @@ def _check_code_safety(code: str) -> str | None:
return None
def _kill_process_tree(proc) -> None:
"""SIGKILL the setsid process group; fall back to single-pid kill."""
if proc.poll() is not None:
return
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, PermissionError):
pgid = None
if pgid is not None:
try:
os.killpg(pgid, signal.SIGKILL)
return
except (ProcessLookupError, PermissionError):
pass
try:
proc.kill()
except (ProcessLookupError, PermissionError):
pass
def _cancel_watcher(proc, cancel_event, poll_interval = 0.2):
"""Daemon thread that kills a process when cancel_event is set."""
while proc.poll() is None:
if cancel_event is not None and cancel_event.is_set():
proc.kill()
_kill_process_tree(proc)
return
cancel_event.wait(poll_interval) if cancel_event else None
@ -1126,8 +1628,11 @@ def _python_exec(
try:
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
_kill_process_tree(proc)
try:
proc.communicate(timeout = 5)
except subprocess.TimeoutExpired:
pass
return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():
@ -1211,8 +1716,11 @@ def _bash_exec(
try:
output, _ = proc.communicate(timeout = timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
_kill_process_tree(proc)
try:
proc.communicate(timeout = 5)
except subprocess.TimeoutExpired:
pass
return _truncate(f"Execution timed out after {timeout} seconds.")
if cancel_event is not None and cancel_event.is_set():

View file

@ -17,7 +17,9 @@ Pattern follows core/data_recipe/jobs/manager.py.
import json as _json
import math
import multiprocessing as mp
import os
import queue
import shutil
import threading
import time
import structlog
@ -33,9 +35,54 @@ from utils.native_path_leases import (
native_path_secret_removed_for_child_start,
run_without_native_path_secret,
)
from utils.paths import outputs_root
logger = get_logger(__name__)
def _cleanup_cancelled_checkpoints(output_dir: str | os.PathLike) -> None:
"""Remove ``checkpoint-<int>`` subdirs after a cancelled run.
Only paths whose realpath is under outputs_root are touched."""
out = Path(output_dir)
if not out.exists():
return
try:
out_real = out.resolve()
out_root_real = Path(outputs_root()).resolve()
except OSError:
return
try:
out_real.relative_to(out_root_real)
except ValueError:
# Refuse to delete anything outside the configured outputs root.
logger.warning(
"Skipping checkpoint cleanup - %s is not under outputs_root %s",
out_real,
out_root_real,
)
return
removed = 0
for entry in out.iterdir() if out.is_dir() else []:
if not entry.is_dir():
continue
name = entry.name
if not name.startswith("checkpoint-"):
continue
tail = name[len("checkpoint-") :]
if not tail.isdigit():
continue
try:
shutil.rmtree(entry, ignore_errors = False)
removed += 1
except OSError as exc:
logger.warning("Could not remove %s: %s", entry, exc)
logger.info(
"Cancelled-run cleanup removed %d checkpoint dir(s) under %s",
removed,
out,
)
_CTX = mp.get_context("spawn")
# Plot styling constants
@ -316,6 +363,8 @@ class TrainingBackend:
)
self._proc.terminate()
proc = self._proc
cancelled = self._cancel_requested
output_dir = self._output_dir
if proc is not None:
proc.join(timeout = 5.0)
@ -328,6 +377,17 @@ class TrainingBackend:
if self._pump_thread is not None and self._pump_thread.is_alive():
self._pump_thread.join(timeout = 8.0)
# Drop checkpoint-* dirs on explicit cancel only; stop-and-save
# keeps its artifacts.
if cancelled and output_dir:
try:
_cleanup_cancelled_checkpoints(output_dir)
except Exception:
logger.exception(
"Failed to clean up cancelled-run checkpoints under %s",
output_dir,
)
def is_training_active(self) -> bool:
"""Check if training is currently active."""
with self._lock:

View file

@ -42,6 +42,7 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp")
import hashlib
import mimetypes
import re as _re
import shutil
@ -103,7 +104,7 @@ if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
# warnings.filterwarnings("ignore", category=DeprecationWarning)
# warnings.filterwarnings("ignore", module="triton.*")
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
@ -134,6 +135,11 @@ import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
from utils.native_path_leases import native_path_leases_supported
from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
from utils.studio_version import get_studio_version
def get_unsloth_version() -> str:
@ -155,6 +161,25 @@ def get_unsloth_version() -> str:
UNSLOTH_VERSION = get_unsloth_version()
STUDIO_VERSION = get_studio_version()
def _load_desktop_owner() -> dict[str, str] | None:
token = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_TOKEN", "")
kind = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_KIND", "")
if kind != "tauri" or not token:
return None
return {
"kind": "tauri",
"token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
}
_DESKTOP_OWNER = _load_desktop_owner()
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
@asynccontextmanager
@ -235,6 +260,181 @@ logger = LogConfig.setup_logging(
app.add_middleware(LoggingMiddleware)
# Web-search favicons load from *.gstatic.com; everything else is same-origin.
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
def _build_csp(script_nonce: "str | None" = None) -> str:
script_src = "script-src 'self'"
if script_nonce:
script_src += f" 'nonce-{script_nonce}'"
return (
"default-src 'self'; "
"img-src 'self' data: blob: https://t0.gstatic.com "
"https://t1.gstatic.com https://t2.gstatic.com "
"https://t3.gstatic.com; "
"connect-src 'self' https://huggingface.co https://datasets-server.huggingface.co; "
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Set baseline security headers; splice per-response inline-script nonces into CSP."""
async def dispatch(self, request: _StarletteRequest, call_next):
response = await call_next(request)
# Strip the internal nonce hand-off header so it never reaches the client.
nonce = response.headers.get(_CSP_SCRIPT_NONCE_HEADER)
if nonce is not None:
del response.headers[_CSP_SCRIPT_NONCE_HEADER]
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
)
response.headers["server"] = "unsloth-studio"
return response
app.add_middleware(SecurityHeadersMiddleware)
# Cap upload body on protected POSTs; default 500 MB, env-tunable.
import json as _json_for_413 # noqa: E402
_MAX_BODY_BYTES = int(os.environ.get("UNSLOTH_STUDIO_MAX_BODY_MB", "500")) * 1024 * 1024
_BODY_PROTECTED_PREFIXES = (
"/v1/chat/completions",
"/v1/completions",
"/api/inference",
"/api/data-recipe",
"/api/datasets",
"/api/train",
"/api/export",
)
async def _send_413(send, total_bytes: int) -> None:
payload = _json_for_413.dumps(
{
"detail": (
f"Request body too large "
f"({total_bytes:,} bytes; max {_MAX_BODY_BYTES:,})."
)
},
).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": False})
class MaxBodyMiddleware:
"""Reject oversized bodies on protected POST/PUT/PATCH; raw ASGI so chunked uploads cannot bypass the cap."""
def __init__(self, app, max_bytes: int, protected_prefixes: tuple):
self.app = app
self.max_bytes = max_bytes
self.protected_prefixes = protected_prefixes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "").upper()
path = scope.get("path", "")
if method not in ("POST", "PUT", "PATCH") or not any(
path.startswith(p) for p in self.protected_prefixes
):
await self.app(scope, receive, send)
return
declared = None
for name, value in scope.get("headers", []):
if name == b"content-length":
try:
declared = int(value.decode("latin-1"))
except (ValueError, UnicodeDecodeError):
declared = None
break
if declared is not None and declared > self.max_bytes:
await _send_413(send, declared)
return
chunks: list = []
total = 0
while True:
msg = await receive()
mtype = msg.get("type")
if mtype == "http.disconnect":
return
if mtype != "http.request":
# Mid-stream unexpected frame: forwarding would corrupt downstream.
return
body = msg.get("body", b"") or b""
if body:
total += len(body)
if total > self.max_bytes:
await _send_413(send, total)
return
chunks.append(body)
if not msg.get("more_body", False):
break
replayed = {"sent": False}
async def replay_receive():
if not replayed["sent"]:
replayed["sent"] = True
return {
"type": "http.request",
"body": b"".join(chunks),
"more_body": False,
}
# After replay, fall through so http.disconnect still propagates.
return await receive()
await self.app(scope, replay_receive, send)
app.add_middleware(
MaxBodyMiddleware,
max_bytes = _MAX_BODY_BYTES,
protected_prefixes = _BODY_PROTECTED_PREFIXES,
)
from starlette.responses import RedirectResponse as _RedirectResponse # noqa: E402
@app.get("/recipes", include_in_schema = False)
@app.get("/recipes/{rest:path}", include_in_schema = False)
async def _recipes_redirect(rest: str = ""):
target = "/data-recipes" + (("/" + rest) if rest else "")
return _RedirectResponse(url = target, status_code = 308)
# CORS middleware
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
_cors_origins = ["*"]
@ -286,28 +486,63 @@ app.include_router(
@app.get("/api/health")
async def health_check():
"""Health check endpoint"""
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
async def health_check(request: Request):
"""Liveness only; full diagnostic dict gated on a valid bearer."""
minimal = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
}
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return minimal
try:
from auth.authentication import get_current_subject as _gcs
from fastapi.security import HTTPAuthorizationCredentials
creds = HTTPAuthorizationCredentials(
scheme = "Bearer", credentials = auth.split(" ", 1)[1]
)
# Must await: a bare coroutine is truthy and would skip the auth check.
subject = await _gcs(creds)
except HTTPException:
return minimal
except Exception:
return minimal
if not subject:
return minimal
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
**minimal,
"service": "Unsloth UI Backend",
"version": UNSLOTH_VERSION,
"studio_version": STUDIO_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_desktop_auth": True,
# why: launchers compare against an install-time hash so a sibling
# Studio on the same port is rejected; hex digest avoids leaking the
# raw install path on -H 0.0.0.0.
"supports_desktop_backend_ownership": True,
# Hex digest of the install path; launchers reject sibling Studios on the same port.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}
@app.get("/api/studio/install-source")
def studio_install_source(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware install metadata without remote update checks."""
return get_studio_install_source_status(UNSLOTH_VERSION)
@app.get("/api/studio/update-status")
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware manual update status for browser-served Studio."""
return get_studio_update_status(UNSLOTH_VERSION)
@app.post("/api/shutdown")
async def shutdown_server(
request: Request,
@ -422,21 +657,22 @@ def _strip_crossorigin(html_bytes: bytes) -> bytes:
return html.encode("utf-8")
def _inject_bootstrap(html_bytes: bytes, app: FastAPI) -> bytes:
"""Inject bootstrap credentials into HTML when password change is required.
def _inject_bootstrap(html_bytes: bytes, app: FastAPI):
"""Inject bootstrap credentials when password change is pending.
The script tag is only injected while the default admin account still
has ``must_change_password=True``. Once the user changes the password
the HTML is served clean no credentials leak.
Returns ``(html_bytes, script_nonce_or_None)``. Callers must forward
the nonce via ``_CSP_SCRIPT_NONCE_HEADER`` so the inline script is
not blocked by CSP.
"""
import json as _json
import secrets as _secrets
if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME):
return html_bytes
return html_bytes, None
bootstrap_pw = getattr(app.state, "bootstrap_password", None)
if not bootstrap_pw:
return html_bytes
return html_bytes, None
payload = _json.dumps(
{
@ -444,10 +680,11 @@ def _inject_bootstrap(html_bytes: bytes, app: FastAPI) -> bytes:
"password": bootstrap_pw,
}
)
tag = f"<script>window.__UNSLOTH_BOOTSTRAP__={payload}</script>"
nonce = _secrets.token_urlsafe(16)
tag = f'<script nonce="{nonce}">window.__UNSLOTH_BOOTSTRAP__={payload}</script>'
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 1)
return html.encode("utf-8")
return html.encode("utf-8"), nonce
def setup_frontend(app: FastAPI, build_path: Path):
@ -460,17 +697,23 @@ def setup_frontend(app: FastAPI, build_path: Path):
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
@app.get("/")
async def serve_root():
def _build_index_response() -> Response:
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
content, nonce = _inject_bootstrap(content, app)
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
if nonce:
headers[_CSP_SCRIPT_NONCE_HEADER] = nonce
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
headers = headers,
)
@app.get("/")
async def serve_root():
return _build_index_response()
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
@ -486,13 +729,6 @@ def setup_frontend(app: FastAPI, build_path: Path):
return FileResponse(file_path)
# Serve index.html as bytes — avoids Content-Length mismatch
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
)
return _build_index_response()
return True

View file

@ -37,7 +37,10 @@ class AuthStatusResponse(BaseModel):
initialized: bool = Field(
..., description = "True if the auth database contains a login user"
)
default_username: str = Field(..., description = "Default seeded admin username")
default_username: str = Field(
"unsloth",
description = "Default admin username for first-boot UI prefill.",
)
requires_password_change: bool = Field(
...,
description = "True if the seeded admin must still change the default password",

View file

@ -5,10 +5,36 @@
Pydantic schemas for Export API.
"""
from pydantic import BaseModel, Field
from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional, Literal, Dict, Any
def _validate_save_directory(value: str) -> str:
"""Reject save_directory values that escape the export root."""
if value is None:
raise ValueError("save_directory is required")
raw = str(value).strip()
if not raw:
raise ValueError("save_directory must not be empty")
if "\x00" in raw:
raise ValueError("save_directory may not contain null bytes")
if any(ch in raw for ch in ("\r", "\n")):
raise ValueError("save_directory may not contain control characters")
if len(raw) > 255:
raise ValueError("save_directory must be <= 255 characters")
path = Path(raw).expanduser()
if path.is_absolute():
raise ValueError(
"save_directory must be a name or relative path under the "
"export root; absolute paths are rejected"
)
if ".." in path.parts:
raise ValueError("save_directory may not contain '..' segments")
return raw
class LoadCheckpointRequest(BaseModel):
"""Request for loading a checkpoint into the export backend."""
@ -64,6 +90,12 @@ class ExportCommonOptions(BaseModel):
...,
description = "Local directory where the exported artifacts will be written",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
push_to_hub: bool = Field(
False,
description = "If True, also push the exported model to the Hugging Face Hub",
@ -108,6 +140,12 @@ class ExportGGUFRequest(BaseModel):
...,
description = "Directory where GGUF files will be saved",
)
@field_validator("save_directory", mode = "before")
@classmethod
def _check_save_directory(cls, v):
return _validate_save_directory(v)
quantization_method: str = Field(
"Q4_K_M",
description = 'GGUF quantization method (e.g. "Q4_K_M")',

View file

@ -425,14 +425,6 @@ class ChatMessage(BaseModel):
@model_validator(mode = "after")
def _validate_role_shape(self) -> "ChatMessage":
# Enforce the per-role OpenAI spec shape at the request boundary.
# Without this, malformed messages (e.g. user entries with no
# content, tool_calls on a user/system role, role="tool" without
# tool_call_id) would be silently forwarded to llama-server via
# the passthrough path, surfacing as opaque upstream errors or
# broken tool-call reconciliation downstream.
# Tool-call metadata must appear only on the appropriate role.
if self.tool_calls is not None and self.role != "assistant":
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
if self.tool_call_id is not None and self.role != "tool":
@ -440,23 +432,20 @@ class ChatMessage(BaseModel):
if self.name is not None and self.role != "tool":
raise ValueError('"name" is only valid on role="tool" messages.')
# Per-role content requirements. OpenAI-compatible clients may send
# ``content=""`` for image-only turns when the image travels in a
# companion field such as Studio's ``image_base64`` extension, so treat
# empty strings as present content for user/system messages.
if self.role == "tool":
if not self.tool_call_id:
raise ValueError(
'role="tool" messages require "tool_call_id" per the OpenAI spec.'
)
# Frontend's second-round POST drops the streamed id;
# synthesise one so the request round-trips.
import secrets as _secrets
self.tool_call_id = f"call_{_secrets.token_hex(8)}"
if not self.content:
raise ValueError('role="tool" messages require non-empty "content".')
elif self.role == "assistant":
# Assistant messages may omit content when tool_calls is set.
if not self.content and not self.tool_calls:
raise ValueError(
'role="assistant" messages require either "content" or "tool_calls".'
)
# Tolerate the post-Stop empty-assistant sentinel by
# collapsing content="" to None.
if (self.content == "" or self.content == []) and not self.tool_calls:
self.content = None
else: # "user" | "system"
if self.content is None or self.content == []:
raise ValueError(f'role="{self.role}" messages require "content".')

View file

@ -5,10 +5,43 @@
Pydantic schemas for Training API
"""
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Any, Optional, List, Dict, Literal
_MAX_BATCH_SIZE = 4096
_MAX_GRAD_ACCUM = 4096
_MAX_STEPS = 1_000_000
_MAX_EPOCHS = 1000
# 2M is a sanity cap; host RAM runs out long before this.
_MAX_SEQ_LENGTH = 2_000_000
_MAX_LR_VALUE = 1.0
_MAX_LORA_R = 16_384
_MAX_LORA_ALPHA = 32_768
def _parse_lr(v: Any) -> float:
"""Parse learning_rate as a positive float strictly below _MAX_LR_VALUE."""
if v is None:
raise ValueError("learning_rate is required")
if isinstance(v, bool):
raise ValueError("learning_rate must be a number, not a bool")
try:
lr = float(v)
except (TypeError, ValueError):
raise ValueError(f"learning_rate must be parseable as float (got {v!r})")
if not (lr > 0.0):
raise ValueError(
f"learning_rate must be > 0 (got {lr!r}); " "typical range is 1e-6 .. 1e-3"
)
if lr >= _MAX_LR_VALUE:
raise ValueError(
f"learning_rate must be < 1.0 (got {lr!r}); "
"values that large always diverge training"
)
return lr
class TrainingStartRequest(BaseModel):
"""Request schema for starting training"""
@ -64,6 +97,150 @@ class TrainingStartRequest(BaseModel):
values.setdefault("train_split", values.pop("split"))
return values
@field_validator("learning_rate", mode = "before")
@classmethod
def _check_learning_rate(cls, v):
# Stringify because downstream call sites float() it themselves.
lr = _parse_lr(v)
return str(lr)
@field_validator("batch_size")
@classmethod
def _check_batch_size(cls, v: int) -> int:
if v is None:
raise ValueError("batch_size is required")
if v < 1 or v > _MAX_BATCH_SIZE:
raise ValueError(
f"batch_size must be in [1, {_MAX_BATCH_SIZE}] (got {v!r})"
)
return v
@field_validator("gradient_accumulation_steps")
@classmethod
def _check_grad_accum(cls, v: int) -> int:
if v is None:
return 1
if v < 1 or v > _MAX_GRAD_ACCUM:
raise ValueError(
f"gradient_accumulation_steps must be in [1, {_MAX_GRAD_ACCUM}] "
f"(got {v!r})"
)
return v
@field_validator("num_epochs")
@classmethod
def _check_num_epochs(cls, v: int) -> int:
# 0 is a sentinel meaning "use max_steps instead"; the frontend's
# steps-vs-epochs toggle sends it.
if v is None:
return 1
if v < 0 or v > _MAX_EPOCHS:
raise ValueError(f"num_epochs must be in [0, {_MAX_EPOCHS}] (got {v!r})")
return v
@field_validator("max_steps")
@classmethod
def _check_max_steps(cls, v: Optional[int]) -> Optional[int]:
# 0 is the frontend's sentinel for "use num_epochs instead".
if v is None:
return v
if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
raise ValueError(
f"max_steps must be a non-negative int <= {_MAX_STEPS} (got {v!r})"
)
return v
@field_validator("max_seq_length")
@classmethod
def _check_max_seq_length(cls, v: int) -> int:
if v is None or v < 1 or v > _MAX_SEQ_LENGTH:
raise ValueError(
f"max_seq_length must be in [1, {_MAX_SEQ_LENGTH}] (got {v!r})"
)
return v
@field_validator("warmup_steps")
@classmethod
def _check_warmup_steps(cls, v: Optional[int]) -> Optional[int]:
if v is None:
return v
if not isinstance(v, int) or v < 0 or v > _MAX_STEPS:
raise ValueError(
f"warmup_steps must be a non-negative int <= {_MAX_STEPS} "
f"(got {v!r})"
)
return v
@field_validator("warmup_ratio")
@classmethod
def _check_warmup_ratio(cls, v):
if v is None:
return v
try:
r = float(v)
except (TypeError, ValueError):
raise ValueError(f"warmup_ratio must be a number (got {v!r})")
if not (0.0 <= r <= 1.0):
raise ValueError(f"warmup_ratio must be in [0.0, 1.0] (got {r!r})")
return r
@field_validator("save_steps")
@classmethod
def _check_save_steps(cls, v: int) -> int:
if v is None:
return 100
if v < 0 or v > _MAX_STEPS:
raise ValueError(f"save_steps must be in [0, {_MAX_STEPS}] (got {v!r})")
return v
@field_validator("weight_decay")
@classmethod
def _check_weight_decay(cls, v: float) -> float:
if v is None:
return 0.0
try:
wd = float(v)
except (TypeError, ValueError):
raise ValueError(f"weight_decay must be a number (got {v!r})")
if wd < 0 or wd > 10.0:
raise ValueError(
f"weight_decay must be in [0, 10] (got {wd!r}); typical 0..0.1"
)
return wd
@field_validator("lora_r")
@classmethod
def _check_lora_r(cls, v: int) -> int:
if v is None:
return 16
if v < 1 or v > _MAX_LORA_R:
raise ValueError(f"lora_r must be in [1, {_MAX_LORA_R}] (got {v!r})")
return v
@field_validator("lora_alpha")
@classmethod
def _check_lora_alpha(cls, v: int) -> int:
if v is None:
return 16
if v < 1 or v > _MAX_LORA_ALPHA:
raise ValueError(
f"lora_alpha must be in [1, {_MAX_LORA_ALPHA}] (got {v!r})"
)
return v
@field_validator("lora_dropout")
@classmethod
def _check_lora_dropout(cls, v: float) -> float:
if v is None:
return 0.0
try:
d = float(v)
except (TypeError, ValueError):
raise ValueError(f"lora_dropout must be a number (got {v!r})")
if not (0.0 <= d < 1.0):
raise ValueError(f"lora_dropout must be in [0.0, 1.0) (got {d!r})")
return d
custom_format_mapping: Optional[Dict[str, Any]] = Field(
None,
description = (
@ -147,6 +324,16 @@ class TrainingStartRequest(BaseModel):
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries.",
)
@model_validator(mode = "after")
def _check_steps_or_epochs(self) -> "TrainingStartRequest":
# num_epochs and max_steps each accept 0 as a "use the other one"
# sentinel. If both resolve to 0 there's nothing to train against.
if (self.max_steps is None or self.max_steps == 0) and self.num_epochs == 0:
raise ValueError(
"Either num_epochs or max_steps must be > 0; both cannot be 0."
)
return self
class TrainingJobResponse(BaseModel):
"""Immediate response when training is initiated"""

View file

@ -3,6 +3,7 @@ typer
fastapi
uvicorn
pydantic
packaging
matplotlib
pandas
nest_asyncio

View file

@ -5,8 +5,11 @@
Authentication API routes
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import threading
import time
from collections import deque
from datetime import datetime, timedelta, timezone
from models.auth import (
@ -33,14 +36,52 @@ from auth.authentication import (
router = APIRouter()
# In-memory per-IP login rate limiter; multi-process deployment needs a shared store.
_LOGIN_BUCKETS: dict[str, deque] = {}
_LOGIN_BUCKETS_LOCK = threading.Lock()
_LOGIN_WINDOW_SECONDS = 60.0
_LOGIN_MAX_FAILS = 5
_LOGIN_LOCKOUT_SECONDS = 60
def _client_key(request: Request | None) -> str:
if request is None or request.client is None:
return "_unknown"
return request.client.host or "_unknown"
def _record_login_failure(ip: str) -> int:
now = time.monotonic()
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.setdefault(ip, deque())
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
bucket.append(now)
return len(bucket)
def _login_blocked(ip: str) -> int:
"""Return seconds until the next attempt is allowed, or 0."""
now = time.monotonic()
with _LOGIN_BUCKETS_LOCK:
bucket = _LOGIN_BUCKETS.get(ip)
if not bucket:
return 0
while bucket and now - bucket[0] > _LOGIN_WINDOW_SECONDS:
bucket.popleft()
if len(bucket) >= _LOGIN_MAX_FAILS:
return max(1, int(_LOGIN_WINDOW_SECONDS - (now - bucket[0])))
return 0
def _clear_login_bucket(ip: str) -> None:
with _LOGIN_BUCKETS_LOCK:
_LOGIN_BUCKETS.pop(ip, None)
@router.get("/status", response_model = AuthStatusResponse)
async def auth_status() -> AuthStatusResponse:
"""
Check whether auth has already been initialized.
- initialized = False -> frontend should wait for the seeded admin bootstrap.
- initialized = True -> frontend should show login or force the first password change.
"""
"""Auth initialization state; ``default_username`` is exposed for first-boot UI prefill only."""
return AuthStatusResponse(
initialized = storage.is_initialized(),
default_username = storage.DEFAULT_ADMIN_USERNAME,
@ -53,12 +94,23 @@ async def auth_status() -> AuthStatusResponse:
@router.post("/login", response_model = Token)
async def login(payload: AuthLoginRequest) -> Token:
"""
Login with username/password and receive access + refresh tokens.
"""
async def login(payload: AuthLoginRequest, request: Request) -> Token:
"""Login with username/password. Rate-limited per source IP."""
ip = _client_key(request)
blocked_for = _login_blocked(ip)
if blocked_for > 0:
raise HTTPException(
status_code = status.HTTP_429_TOO_MANY_REQUESTS,
detail = (
f"Too many failed login attempts from {ip}. "
f"Try again in {blocked_for} seconds."
),
headers = {"Retry-After": str(blocked_for)},
)
record = storage.get_user_and_secret(payload.username)
if record is None:
_record_login_failure(ip)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
@ -66,11 +118,13 @@ async def login(payload: AuthLoginRequest) -> Token:
salt, pwd_hash, _jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
_record_login_failure(ip)
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Incorrect password. Run 'unsloth studio reset-password' in your terminal to reset it.",
)
_clear_login_bucket(ip)
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
return Token(
@ -81,6 +135,23 @@ async def login(payload: AuthLoginRequest) -> Token:
)
@router.post("/logout", status_code = status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Response:
"""Revoke refresh tokens for the subject; the access token is stateless and expires on its own."""
try:
storage.revoke_user_refresh_tokens(current_subject)
except Exception:
pass
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
return Response(status_code = status.HTTP_204_NO_CONTENT)
@router.post("/desktop-login", response_model = Token)
async def desktop_login(payload: DesktopLoginRequest) -> Token:
"""Exchange a local desktop secret for normal admin-subject tokens."""
@ -101,21 +172,20 @@ async def desktop_login(payload: DesktopLoginRequest) -> Token:
@router.post("/refresh", response_model = Token)
async def refresh(payload: RefreshTokenRequest) -> Token:
"""
Exchange a valid refresh token for a new access token.
The refresh token itself is reusable until it expires (7 days).
"""
new_access_token, username, is_desktop = refresh_access_token(payload.refresh_token)
if new_access_token is None or username is None:
"""Exchange a refresh token for a new access+refresh pair (single-use)."""
consumed = storage.consume_refresh_token(payload.refresh_token)
if consumed is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired refresh token",
)
username, is_desktop = consumed
new_access_token = create_access_token(subject = username, desktop = is_desktop)
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
return Token(
access_token = new_access_token,
refresh_token = payload.refresh_token,
refresh_token = new_refresh_token,
token_type = "bearer",
must_change_password = False
if is_desktop
@ -126,6 +196,7 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
@router.post("/change-password", response_model = Token)
async def change_password(
payload: ChangePasswordRequest,
request: Request,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Token:
"""Allow the authenticated user to replace the default password."""
@ -150,6 +221,10 @@ async def change_password(
storage.update_password(current_subject, payload.new_password)
storage.revoke_user_refresh_tokens(current_subject)
try:
request.app.state.bootstrap_password = None
except AttributeError:
pass
access_token = create_access_token(subject = current_subject)
refresh_token = create_refresh_token(subject = current_subject)
return Token(

View file

@ -7,6 +7,7 @@ Export API routes: checkpoint discovery and model export operations.
import asyncio
import json
import os
import sys
import time
from pathlib import Path
@ -184,14 +185,18 @@ async def get_export_status(
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
"""Wrap the resolved on-disk export path into the details dict the
frontend reads to populate the Export Complete screen. Returns None
when the export had no local component (Hub-only push) so the
Pydantic field stays absent rather than ``{"output_path": null}``.
"""
"""Return the export path relative to exports_root so the install path is not leaked."""
if not output_path:
return None
return {"output_path": output_path}
try:
from utils.paths.storage_roots import exports_root
rel = os.path.relpath(output_path, exports_root())
if rel.startswith(".."):
rel = os.path.basename(output_path)
return {"output_path": rel}
except Exception:
return {"output_path": os.path.basename(output_path)}
@router.post("/export/merged", response_model = ExportOperationResponse)

View file

@ -1743,7 +1743,7 @@ async def openai_chat_completions(
try:
import base64 as _b64
from io import BytesIO as _BytesIO
from PIL import Image as _Image
from PIL import Image as _Image, UnidentifiedImageError as _UIE
raw = _b64.b64decode(image_b64)
# Normalize to RGB so PNG encoding succeeds regardless of
@ -1754,9 +1754,15 @@ async def openai_chat_completions(
buf = _BytesIO()
img.save(buf, format = "PNG")
image_b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except _UIE:
raise HTTPException(
status_code = 400, detail = f"Failed to process image: {e}"
status_code = 400,
detail = "Unsupported or corrupt image format.",
)
except Exception:
raise HTTPException(
status_code = 400,
detail = "Failed to process image.",
)
# Build message list with system prompt prepended
@ -3426,10 +3432,10 @@ def _normalize_anthropic_openai_images(
buf = io.BytesIO()
img.save(buf, format = "PNG")
png_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except Exception:
raise HTTPException(
status_code = 400,
detail = f"Failed to process image: {e}",
detail = "Failed to process image.",
)
part["image_url"] = {"url": f"data:image/png;base64,{png_b64}"}
@ -3465,6 +3471,7 @@ async def anthropic_messages(
[m.model_dump() for m in payload.messages],
payload.system,
)
openai_messages = _drop_empty_assistant_sentinels(openai_messages)
# Enforce vision guard + re-encode embedded images to PNG so the
# Anthropic endpoint matches the behavior of /v1/chat/completions.
@ -4190,6 +4197,19 @@ async def _anthropic_passthrough_non_streaming(
# =====================================================================
def _drop_empty_assistant_sentinels(messages: list[dict]) -> list[dict]:
"""Drop bare ``{"role":"assistant"}`` Stop-button sentinels; passthrough backends reject them."""
out: list[dict] = []
for m in messages:
if m.get("role") == "assistant":
has_content = bool(m.get("content"))
has_tool_calls = bool(m.get("tool_calls"))
if not has_content and not has_tool_calls:
continue
out.append(m)
return out
def _openai_messages_for_passthrough(payload) -> list[dict]:
"""Build OpenAI-format message dicts for the /v1/chat/completions
passthrough path.
@ -4206,7 +4226,9 @@ def _openai_messages_for_passthrough(payload) -> list[dict]:
``image_url`` content part so vision + function-calling requests work
transparently.
"""
messages = [m.model_dump(exclude_none = True) for m in payload.messages]
messages = _drop_empty_assistant_sentinels(
[m.model_dump(exclude_none = True) for m in payload.messages]
)
if not payload.image_base64:
return messages
@ -4221,10 +4243,10 @@ def _openai_messages_for_passthrough(payload) -> list[dict]:
buf = _BytesIO()
img.save(buf, format = "PNG")
png_b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
except Exception:
raise HTTPException(
status_code = 400,
detail = f"Failed to process image: {e}",
detail = "Failed to process image.",
)
data_url = f"data:image/png;base64,{png_b64}"

View file

@ -307,7 +307,6 @@ def run_server(
import asyncio
from threading import Thread, Event
import time
import uvicorn
from main import app, setup_frontend
@ -336,10 +335,6 @@ def run_server(
print("=" * 50)
print("")
# Output port for Tauri to parse when in api-only mode
if api_only:
print(f"TAURI_PORT={port}", flush = True)
# Setup frontend if path provided (skip in api-only mode)
if frontend_path and not api_only:
if setup_frontend(app, frontend_path):
@ -349,11 +344,26 @@ def run_server(
if not silent:
print(f"[WARNING] Frontend not found at {frontend_path}")
# Create the uvicorn server and expose it for signal handlers
ready_event = Event()
startup_failed = Event()
startup_errors = []
class _ReadyServer(uvicorn.Server):
async def startup(self, *args, **kwargs):
await super().startup(*args, **kwargs)
if getattr(self, "started", False) and not self.should_exit:
ready_event.set()
# server_header=False suppresses uvicorn's "Server: uvicorn"; SecurityHeadersMiddleware sets its own.
config = uvicorn.Config(
app, host = host, port = port, log_level = "info", access_log = False
app,
host = host,
port = port,
log_level = "info",
access_log = False,
server_header = False,
)
_server = uvicorn.Server(config)
_server = _ReadyServer(config)
_shutdown_event = Event()
# Expose the actual bound port so request-handling code can build
@ -365,21 +375,8 @@ def run_server(
app.state.server_port = port if port and port > 0 else None
app.state.llama_parallel_slots = llama_parallel_slots
# Run server in a daemon thread
def _run():
asyncio.run(_server.serve())
thread = Thread(target = _run, daemon = True)
thread.start()
time.sleep(3)
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Expose a shutdown callable via app.state so the /api/shutdown endpoint
# can trigger graceful shutdown without circular imports.
# Expose a shutdown callable via app.state before the server can accept
# requests so /api/shutdown is available as soon as readiness is published.
def _trigger_shutdown():
_graceful_shutdown(_server)
if _shutdown_event is not None:
@ -387,6 +384,47 @@ def run_server(
app.state.trigger_shutdown = _trigger_shutdown
# Run server in a daemon thread
def _run():
try:
asyncio.run(_server.serve())
except BaseException as exc:
startup_errors.append(exc)
startup_failed.set()
finally:
if not ready_event.is_set():
startup_failed.set()
thread = Thread(target = _run, daemon = True)
thread.start()
# Wait until uvicorn has completed lifespan startup and bound sockets, or
# until the server exits/fails before startup. This intentionally has no
# correctness deadline: a slow but live startup should remain in progress.
try:
while not ready_event.is_set():
if startup_failed.is_set() or not thread.is_alive():
if startup_errors:
raise RuntimeError(
"Uvicorn server failed before startup completed"
) from startup_errors[0]
raise RuntimeError("Uvicorn server exited before startup completed")
ready_event.wait(timeout = 0.1)
except KeyboardInterrupt:
_graceful_shutdown(_server)
_shutdown_event.set()
raise
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Output port for Tauri to parse when in api-only mode. Emit only after
# uvicorn sockets are bound and FastAPI lifespan/startup has completed.
if api_only:
print(f"TAURI_PORT={port}", flush = True)
if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
print_studio_access_banner(

View file

@ -227,6 +227,60 @@ def test_desktop_refresh_preserves_desktop_marker():
assert payload["desktop"] is True
def test_consume_refresh_token_second_call_returns_none():
"""Single-use rotation rejects the same token on a second consume."""
seed_user()
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) + timedelta(days = 30)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
first = storage.consume_refresh_token(raw)
assert first == (storage.DEFAULT_ADMIN_USERNAME, False)
second = storage.consume_refresh_token(raw)
assert second is None
def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatch):
"""64-thread pile-up against one token; DELETE RETURNING permits one winner."""
seed_user()
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) + timedelta(days = 30)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
workers = 64
def attempt(_idx: int):
try:
return storage.consume_refresh_token(raw)
except sqlite3.OperationalError:
# "database is locked" under heavy contention; treat as losing the race.
return None
with ThreadPoolExecutor(max_workers = workers) as pool:
results = list(pool.map(attempt, range(workers)))
successes = [r for r in results if r is not None]
assert (
len(successes) == 1
), f"expected exactly one consumer to win, got {len(successes)}"
assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False)
def test_consume_refresh_token_expired_returns_none():
seed_user()
from datetime import datetime, timedelta, timezone
raw = secrets.token_urlsafe(48)
expires = (datetime.now(timezone.utc) - timedelta(hours = 1)).isoformat()
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
assert storage.consume_refresh_token(raw) is None
def test_desktop_session_uses_real_admin_identity_for_api_keys():
seed_user(must_change_password = True)
raw = storage.create_desktop_secret()
@ -392,7 +446,21 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch):
monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False)
body = asyncio.run(backend_main.health_check())
seed_user()
from auth.authentication import create_access_token
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
app = FastAPI()
app.add_api_route("/api/health", backend_main.health_check, methods = ["GET"])
client = TestClient(app)
response = client.get(
"/api/health",
headers = {"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
body = response.json()
assert body["desktop_protocol_version"] == 1
assert body["supports_desktop_auth"] is True

View file

@ -192,6 +192,7 @@ def _drive(
else:
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
matched = False
pin_fraction = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
@ -203,7 +204,7 @@ def _drive(
)
kv = inst._estimate_kv_cache_bytes(capped, cache_type_kv)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * 0.90:
if total_mib <= pool_mib * pin_fraction:
effective_ctx = capped
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
@ -211,6 +212,17 @@ def _drive(
break
if not matched:
effective_ctx = min(FALLBACK_CTX, effective_ctx)
# Mirror llama_cpp.py: re-check fit at FALLBACK_CTX.
if effective_ctx > 0:
for n_gpus in range(1, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_mib = sum(free for _, free in subset)
kv = inst._estimate_kv_cache_bytes(effective_ctx, cache_type_kv)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * pin_fraction:
gpu_indices = sorted(idx for idx, _ in subset)
use_fit = False
break
elif gpus:
gpu_indices, use_fit = inst._select_gpus(model_size, gpus)
if use_fit and not explicit_ctx:
@ -378,6 +390,52 @@ class TestFittableAutoPickRegressions:
assert plan["gpu_indices"] == [0]
# ---------------------------------------------------------------------------
# #5106 regression: 91-95% utilization must still pin GPU.
# ---------------------------------------------------------------------------
class TestTightFitPinsToGPU:
"""Models that fit at 91-95% of free VRAM must use the GPU."""
def test_rtx_4090_qwen_24gb_class(self):
# noahterbest's #5106 log: 20.8 GB model on 22805 MiB free
# GPU, ctx=4096 -> ~94% utilization, ~1.4 GiB headroom.
plan = _drive(
n_ctx = 0,
model_gib = 20.8,
gpus = [(0, 22_805)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is False
assert plan["gpu_indices"] == [0]
def test_explicit_ctx_at_94_pct_pins_to_gpu(self):
# Explicit-ctx branch must agree with auto-ctx on headroom.
plan = _drive(
n_ctx = 4096,
model_gib = 20.8,
gpus = [(0, 22_805)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is False
assert plan["gpu_indices"] == [0]
def test_genuine_overflow_still_uses_fit(self):
# Beyond 95% must still defer to --fit on.
plan = _drive(
n_ctx = 4096,
model_gib = 23,
gpus = [(0, 22_000)],
native_ctx = 131072,
kv_per_token_bytes = 25_000,
)
assert plan["use_fit"] is True
assert plan["gpu_indices"] is None
# ---------------------------------------------------------------------------
# Platform-agnostic input shape
# ---------------------------------------------------------------------------
@ -391,3 +449,81 @@ def test_identical_decision_across_platforms(platform_tag):
plan_a = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
plan_b = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
assert plan_a == plan_b, platform_tag
# ---------------------------------------------------------------------------
# _classify_gpu_offload: detect silent CPU fallback (#5106).
# ---------------------------------------------------------------------------
class TestClassifyGpuOffload:
def _backend(self, stdout_lines):
inst = LlamaCppBackend.__new__(LlamaCppBackend)
inst._stdout_lines = list(stdout_lines)
return inst
def test_cuda_buffer_present_returns_true(self):
inst = self._backend(
[
"load_tensors: offloaded 33/33 layers to GPU",
"load_tensors: CUDA0 model buffer size = 21000.0 MiB",
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
def test_cpu_only_buffer_returns_false(self):
# llama-server printed buffer lines but only CPU buffers --
# this is the silent CPU fallback symptom we want to catch.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
"load_tensors: CPU model buffer size = 0.6 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is False
def test_no_buffer_lines_returns_none(self):
# If we can't see buffer-allocation lines at all, don't guess.
inst = self._backend(
[
"INFO [main] starting server",
"load_tensors: file format = GGUF V3",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is None
def test_no_gpus_detected_returns_none(self):
# CPU-only systems are valid; suppress the warning entirely.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(False, []) is None
def test_user_did_not_intend_gpu_returns_none(self):
# Studio called start_llama_server without expecting GPU use;
# don't warn.
inst = self._backend(
[
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(False, [(0, 22805)]) is None
def test_rocm_buffer_marker_returns_true(self):
inst = self._backend(
[
"load_tensors: ROCm0 model buffer size = 21000.0 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
def test_metal_buffer_marker_returns_true(self):
inst = self._backend(
[
"load_tensors: Metal model buffer size = 8000.0 MiB",
]
)
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True

View file

@ -0,0 +1,269 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for MaxBodyMiddleware, SecurityHeadersMiddleware, and the /api/health auth gate."""
import asyncio
import importlib.util
import json
import os
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.testclient import TestClient
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture(scope = "module")
def main_module():
import main as _main # noqa: F401
return _main
# =====================================================================
# MaxBodyMiddleware
# =====================================================================
def _make_protected_app(max_bytes: int, main_module):
app = FastAPI()
app.add_middleware(
main_module.MaxBodyMiddleware,
max_bytes = max_bytes,
protected_prefixes = ("/v1/chat/completions", "/api/train"),
)
@app.post("/v1/chat/completions")
async def chat(payload: dict):
return {"ok": True, "n": len(payload.get("text", ""))}
@app.post("/api/other")
async def other(payload: dict):
return {"ok": True, "unprotected": True}
@app.get("/api/train/status")
async def status_get():
return {"ok": True, "get": True}
return app
class TestMaxBodyMiddleware:
def test_small_protected_body_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 100})
assert r.status_code == 200
assert r.json()["n"] == 100
def test_large_declared_content_length_rejected(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 5000})
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_unprotected_prefix_passes_large_body(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/api/other", json = {"text": "x" * 5000})
assert r.status_code == 200
assert r.json()["unprotected"] is True
def test_chunked_upload_over_cap_rejected(self, main_module):
# Regression: declared-Content-Length-only check could be bypassed
# by chunked transfer-encoding.
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 800
yield b'"}'
yield b"\n" + b"y" * 500
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_chunked_upload_under_cap_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 50
yield b'"}'
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 200
assert r.json()["n"] == 50
def test_get_not_subject_to_cap(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.get("/api/train/status")
assert r.status_code == 200
# =====================================================================
# SecurityHeadersMiddleware / CSP
# =====================================================================
def _make_csp_app(main_module, attach_nonce: str | None = None):
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/plain")
async def plain():
return {"ok": True}
@app.get("/with-nonce")
async def with_nonce():
headers = {}
if attach_nonce:
headers[main_module._CSP_SCRIPT_NONCE_HEADER] = attach_nonce
return Response(
content = b"<html></html>",
media_type = "text/html",
headers = headers,
)
return app
class TestSecurityHeadersMiddleware:
def test_csp_has_no_unsafe_inline_for_script_src(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.status_code == 200
csp = r.headers["content-security-policy"]
# Parse per-directive so style-src unsafe-inline does not false-match.
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
if chunk.strip()
}
assert "script-src" in directives
assert "'unsafe-inline'" not in directives["script-src"]
# style-src keeps unsafe-inline for Vite-injected styles.
assert "'unsafe-inline'" in directives["style-src"]
def test_default_security_headers_present(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.headers["x-frame-options"] == "DENY"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["referrer-policy"] == "no-referrer"
assert "camera=()" in r.headers["permissions-policy"]
assert r.headers["server"] == "unsloth-studio"
def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module):
nonce = "test-nonce-abc"
app = _make_csp_app(main_module, attach_nonce = nonce)
c = TestClient(app)
r = c.get("/with-nonce")
csp = r.headers["content-security-policy"]
assert f"'nonce-{nonce}'" in csp
# Internal handoff header must not leak to clients.
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {
k.lower() for k in r.headers.keys()
}
def test_build_csp_helper_shape(self, main_module):
plain = main_module._build_csp()
assert "script-src 'self';" in plain
assert "'unsafe-inline'" not in plain.split("script-src", 1)[1].split(";", 1)[0]
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
# =====================================================================
# /api/health auth gate
# =====================================================================
@pytest.fixture
def health_app(tmp_path, monkeypatch):
"""Mount /api/health on a fresh app against an isolated auth db."""
from auth import storage
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
import main as _main
app = FastAPI()
app.add_api_route("/api/health", _main.health_check, methods = ["GET"])
import secrets as _secrets
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = _secrets.token_urlsafe(64),
must_change_password = False,
)
return app
class TestHealthAuthGate:
def test_no_auth_returns_minimal_payload(self, health_app):
c = TestClient(health_app)
r = c.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "timestamp" in body
for forbidden in ("version", "device_type", "studio_root_id"):
assert forbidden not in body
def test_invalid_bearer_returns_minimal_payload(self, health_app):
# Regression: calling the async dep without await made any Bearer header pass.
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": "Bearer not-a-real-token"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
for forbidden in ("version", "device_type", "studio_root_id"):
assert forbidden not in body
def test_valid_bearer_returns_full_payload(self, health_app):
from auth import storage
from auth.authentication import create_access_token
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "version" in body
assert "device_type" in body
assert "studio_root_id" in body

View file

@ -125,22 +125,21 @@ class TestChatMessageToolRoles:
)
assert msg.content is None
def test_tool_role_missing_tool_call_id_rejected(self):
# Per OpenAI spec, role="tool" messages must carry tool_call_id so
# upstream backends can associate the result with its prior call.
# Pin the boundary-level rejection so a malformed tool-result
# message never reaches the passthrough path.
with pytest.raises(ValidationError) as exc_info:
ChatMessage(role = "tool", content = '{"temperature": 72}')
assert "tool_call_id" in str(exc_info.value)
def test_tool_role_missing_tool_call_id_synthesised(self):
# Frontend drops the id on second-round POST; validator synthesises one.
msg = ChatMessage(role = "tool", content = '{"temperature": 72}')
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
assert len(msg.tool_call_id) >= len("call_") + 8
def test_tool_role_empty_tool_call_id_rejected(self):
with pytest.raises(ValidationError):
ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
def test_tool_role_empty_tool_call_id_synthesised(self):
msg = ChatMessage(
role = "tool",
tool_call_id = "",
content = '{"temperature": 72}',
)
assert msg.tool_call_id is not None
assert msg.tool_call_id.startswith("call_")
# ── Role-aware content requirements ────────────────────────────
@ -162,10 +161,19 @@ class TestChatMessageToolRoles:
ChatMessage(role = "tool", tool_call_id = "call_1", content = "")
assert "content" in str(exc_info.value)
def test_assistant_without_content_or_tool_calls_rejected(self):
with pytest.raises(ValidationError) as exc_info:
ChatMessage(role = "assistant")
assert "content" in str(exc_info.value) or "tool_calls" in str(exc_info.value)
def test_assistant_without_content_or_tool_calls_tolerated(self):
# Stop-button leaves an empty assistant turn; tolerate so replay round-trips.
msg = ChatMessage(role = "assistant")
assert msg.content is None
assert msg.tool_calls is None
def test_assistant_empty_string_content_normalised_to_none(self):
msg = ChatMessage(role = "assistant", content = "")
assert msg.content is None
def test_assistant_empty_list_content_normalised_to_none(self):
msg = ChatMessage(role = "assistant", content = [])
assert msg.content is None
# ── Role-constrained tool-call metadata ────────────────────────
@ -472,3 +480,91 @@ class TestFriendlyErrorHttpx:
assert (
_friendly_error(RuntimeError("unrelated")) == "An internal error occurred"
)
from routes.inference import ( # noqa: E402
_drop_empty_assistant_sentinels,
_openai_messages_for_passthrough,
)
class TestDropEmptyAssistantSentinels:
def test_drops_empty_assistant_between_real_turns(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": ""},
{"role": "user", "content": "again"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == [
{"role": "user", "content": "hi"},
{"role": "user", "content": "again"},
]
def test_drops_assistant_with_no_content_key(self):
# exclude_none=True strips the content key entirely; filter must catch this.
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant"},
{"role": "user", "content": "ok"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == [
{"role": "user", "content": "hi"},
{"role": "user", "content": "ok"},
]
def test_preserves_assistant_with_text(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello back"},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_preserves_assistant_with_tool_calls_only(self):
msgs = [
{"role": "user", "content": "weather?"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
},
],
},
{
"role": "tool",
"tool_call_id": "call_1",
"content": '{"t": 72}',
},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_preserves_user_and_system_with_empty_content(self):
# Filter scoped to role="assistant" only.
msgs = [
{"role": "system", "content": ""},
{"role": "user", "content": ""},
]
out = _drop_empty_assistant_sentinels(msgs)
assert out == msgs
def test_openai_messages_for_passthrough_drops_sentinel(self):
"""End-to-end: Stop-sentinel must not reach the wire."""
req = ChatCompletionRequest(
model = "default",
messages = [
ChatMessage(role = "user", content = "hi"),
ChatMessage(role = "assistant", content = ""),
ChatMessage(role = "user", content = "again"),
],
)
out = _openai_messages_for_passthrough(req)
roles = [m["role"] for m in out]
assert roles == ["user", "user"]
for m in out:
assert m.get("content"), m

View file

@ -0,0 +1,241 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for the sandboxed-Python AST policy in core/inference/tools.py."""
import os
import sys
from pathlib import Path
import pytest
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from core.inference.tools import _check_code_safety
def _ok(code: str):
assert _check_code_safety(code) is None, code
def _blocked(code: str, *, expect_phrase: str):
msg = _check_code_safety(code)
assert msg is not None, code
assert expect_phrase in msg, (expect_phrase, msg)
class TestMetadataHostDenylist:
def test_aws_imds_literal_blocked(self):
_blocked(
'import requests; requests.get("http://169.254.169.254/latest/meta-data/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_gcp_metadata_dns_blocked(self):
_blocked(
'import requests; requests.get("http://metadata.google.internal/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_alibaba_ecs_literal_blocked(self):
_blocked(
'import socket; s=socket.socket(); s.connect(("100.100.100.200", 80))',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_ipv6_imds_literal_blocked(self):
_blocked(
'import urllib.request; urllib.request.urlopen("http://[fd00:ec2::254]/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_metadata_link_local_prefix_blocked(self):
_blocked(
'import requests; requests.get("http://169.254.170.2/v3/")',
expect_phrase = "Blocked: cloud-metadata host",
)
class TestTrustedHostAllowlist:
@pytest.mark.parametrize(
"url",
[
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"https://fr.wikipedia.org/wiki/Python_(langage)",
"https://www.google.com/search?q=foo",
"https://duckduckgo.com/?q=foo",
"https://huggingface.co/unsloth",
"https://cdn-lfs.huggingface.co/repos/abc/def/file.bin",
"https://raw.githubusercontent.com/foo/bar/main/README.md",
"https://api.github.com/repos/foo/bar",
"https://arxiv.org/abs/2401.12345",
"https://export.arxiv.org/abs/2401.12345",
"https://stackoverflow.com/questions/12345",
"https://math.stackexchange.com/questions/12345",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript",
"https://docs.python.org/3/library/asyncio.html",
"https://pypi.org/project/requests/",
"https://files.pythonhosted.org/packages/foo/bar.whl",
"https://www.bbc.com/news",
"https://api.weather.gov/points/40,-90",
"https://numpy.org/doc/stable/",
"https://pytorch.org/docs/stable/index.html",
],
)
def test_trusted_host_passes(self, url):
_ok(f"import requests; requests.get({url!r})")
def test_wikipedia_subdomain_passes(self):
_ok(
'import urllib.request; urllib.request.urlopen("https://m.en.wikipedia.org/wiki/Foo")'
)
def test_hf_co_short_form_passes(self):
_ok('import requests; requests.get("https://hf.co/unsloth/Qwen3.5-4B-GGUF")')
def test_github_io_pages_pass(self):
_ok('import requests; requests.get("https://unslothai.github.io/")')
class TestUntrustedHostBlock:
def test_example_com_blocked(self):
_blocked(
'import requests; requests.get("https://example.com/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_random_blog_blocked(self):
_blocked(
'import urllib.request; urllib.request.urlopen("https://random-blog-host.example/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_socket_connect_random_host_blocked(self):
_blocked(
'import socket; s=socket.socket(); s.connect(("evil.example", 80))',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_dynamic_url_not_statically_blocked(self):
# Static AST cannot resolve runtime URLs; bash blocklist is the fallback.
_ok('import requests; url = "https://example.com/"; requests.get(url)')
class TestHostNormalization:
def test_trailing_dot_treated_same(self):
_ok('import requests; requests.get("https://wikipedia.org./")')
def test_explicit_port_does_not_unblock_or_misblock(self):
_ok('import requests; requests.get("https://en.wikipedia.org:443/wiki/Foo")')
_blocked(
'import requests; requests.get("https://example.com:8080/")',
expect_phrase = "Blocked: host not in sandbox allowlist",
)
def test_userinfo_at_does_not_smuggle_metadata_host(self):
_blocked(
'import requests; requests.get("https://wikipedia.org@169.254.169.254/latest/")',
expect_phrase = "Blocked: cloud-metadata host",
)
def test_uppercase_host_normalised(self):
_ok('import requests; requests.get("https://EN.WIKIPEDIA.ORG/wiki/Foo")')
class TestUploadDenylist:
def test_requests_post_files_blocked(self):
_blocked(
(
"import requests\n"
'requests.post("https://huggingface.co/api/repos/upload", '
'files={"f": open("x.bin", "rb")})'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_requests_put_data_bytes_blocked(self):
_blocked(
(
"import requests\n"
'requests.put("https://huggingface.co/api/repos/upload", '
'data=b"\\x00\\x01\\x02")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_requests_post_data_open_handle_blocked(self):
_blocked(
(
"import requests\n"
'requests.post("https://huggingface.co/api/repos/upload", '
'data=open("x.bin", "rb"))'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_httpx_post_files_blocked(self):
_blocked(
(
"import httpx\n"
'httpx.post("https://huggingface.co/api/repos/upload", '
'files={"f": open("x.bin", "rb")})'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_api_upload_file_blocked(self):
_blocked(
(
"from huggingface_hub import HfApi\n"
'HfApi().upload_file(path_or_fileobj="x.bin", '
'path_in_repo="x.bin", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_module_upload_folder_blocked(self):
_blocked(
(
"import huggingface_hub\n"
'huggingface_hub.upload_folder(folder_path="./", repo_id="foo/bar")'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_hf_create_commit_method_blocked(self):
_blocked(
(
"import huggingface_hub\n"
"api = huggingface_hub.HfApi()\n"
'api.create_commit(repo_id="foo/bar", operations=[])'
),
expect_phrase = "Blocked: file upload disallowed in sandbox",
)
def test_plain_post_json_not_blocked(self):
_ok(
"import requests\n"
'requests.post("https://api.weather.gov/lookup", json={"k": "v"})'
)
class TestSandboxCpuRlimitDefault:
"""Pin the default so a regression below 600s without opt-in is caught."""
def test_default_cpu_s_is_600(self):
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
assert 'UNSLOTH_STUDIO_SANDBOX_CPU_S", "600"' in src
def test_clone_newnet_removed(self):
src = (_BACKEND_ROOT / "core" / "inference" / "tools.py").read_text()
assert "_libc.unshare(0x40000000)" not in src
# Explanatory comment retained.
assert "CLONE_NEWNET" in src
class TestMaxBodyDefault:
def test_default_is_500_mb(self):
src = (_BACKEND_ROOT / "main.py").read_text()
assert 'UNSLOTH_STUDIO_MAX_BODY_MB", "500"' in src

View file

@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Pin TrainingStartRequest hyperparameter caps at the at-cap / over-cap boundary."""
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
from models.training import (
_MAX_BATCH_SIZE,
_MAX_LORA_ALPHA,
_MAX_LORA_R,
_MAX_SEQ_LENGTH,
)
def _check_field(field_name: str, value):
"""Run the field validator without constructing a full TrainingStartRequest."""
from models.training import TrainingStartRequest
schema_field = TrainingStartRequest.model_fields[field_name]
return TrainingStartRequest.__pydantic_validator__.validate_assignment(
TrainingStartRequest.model_construct(),
field_name,
value,
)
class TestSeqLengthCap:
def test_at_cap_accepts(self):
_check_field("max_seq_length", _MAX_SEQ_LENGTH)
assert _MAX_SEQ_LENGTH == 2_000_000
def test_over_cap_rejects(self):
with pytest.raises(ValidationError) as exc:
_check_field("max_seq_length", _MAX_SEQ_LENGTH + 1)
assert "max_seq_length" in str(exc.value)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("max_seq_length", 0)
class TestBatchSizeCap:
def test_at_cap_accepts(self):
_check_field("batch_size", _MAX_BATCH_SIZE)
assert _MAX_BATCH_SIZE == 4096
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("batch_size", _MAX_BATCH_SIZE + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("batch_size", 0)
class TestLoraRCap:
def test_at_cap_accepts(self):
_check_field("lora_r", _MAX_LORA_R)
assert _MAX_LORA_R == 16_384
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_r", _MAX_LORA_R + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_r", 0)
class TestLoraAlphaCap:
def test_at_cap_accepts(self):
_check_field("lora_alpha", _MAX_LORA_ALPHA)
assert _MAX_LORA_ALPHA == 32_768
def test_over_cap_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_alpha", _MAX_LORA_ALPHA + 1)
def test_below_min_rejects(self):
with pytest.raises(ValidationError):
_check_field("lora_alpha", 0)

View file

@ -28,7 +28,16 @@ from utils.models.model_config import (
)
def test_scan_trained_models_includes_lora_and_full_finetune_outputs(tmp_path: Path):
def test_scan_trained_models_includes_lora_and_full_finetune_outputs(
tmp_path: Path, monkeypatch
):
# resolve_output_dir refuses absolutes outside outputs_root; point it at tmp_path.
from utils.models import model_config as _mc
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: tmp_path)
monkeypatch.setattr(_mc, "outputs_root", lambda: tmp_path)
lora_dir = tmp_path / "unsloth_SmolLM-135M_1775412608"
lora_dir.mkdir()
(lora_dir / "adapter_config.json").write_text(

View file

@ -0,0 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
accidentally report a stale release tag.
"""
STUDIO_RELEASE_VERSION = None

View file

@ -1696,20 +1696,21 @@ def get_base_model_from_checkpoint(checkpoint_path: str) -> Optional[str]:
)
return base_model
training_args_path = checkpoint_path_obj / "training_args.bin"
if training_args_path.exists():
try:
import torch
training_args = torch.load(training_args_path)
if hasattr(training_args, "model_name_or_path"):
base_model = training_args.model_name_or_path
logger.info(
"Detected base model from training_args.bin: %s", base_model
)
return base_model
except Exception as e:
logger.warning(f"Could not load training_args.bin: {e}")
# TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; re-enable via safe_globals or weights_only=False once threat model allows.
# training_args_path = checkpoint_path_obj / "training_args.bin"
# if training_args_path.exists():
# try:
# import torch
#
# training_args = torch.load(training_args_path)
# if hasattr(training_args, "model_name_or_path"):
# base_model = training_args.model_name_or_path
# logger.info(
# "Detected base model from training_args.bin: %s", base_model
# )
# return base_model
# except Exception as e:
# logger.warning(f"Could not load training_args.bin: {e}")
dir_name = checkpoint_path_obj.name
if dir_name.startswith("unsloth_"):
@ -1757,20 +1758,21 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
return base_model
# Fallback: try training_args.bin (requires torch)
training_args_path = lora_path_obj / "training_args.bin"
if training_args_path.exists():
try:
import torch
training_args = torch.load(training_args_path)
if hasattr(training_args, "model_name_or_path"):
base_model = training_args.model_name_or_path
logger.info(
f"Detected base model from training_args.bin: {base_model}"
)
return base_model
except Exception as e:
logger.warning(f"Could not load training_args.bin: {e}")
# TODO: torch.load default weights_only=True (torch >= 2.6) rejects pickled TrainingArguments; also an RCE sink for third-party LoRAs via this route, re-enable behind a trust check if needed.
# training_args_path = lora_path_obj / "training_args.bin"
# if training_args_path.exists():
# try:
# import torch
#
# training_args = torch.load(training_args_path)
# if hasattr(training_args, "model_name_or_path"):
# base_model = training_args.model_name_or_path
# logger.info(
# f"Detected base model from training_args.bin: {base_model}"
# )
# return base_model
# except Exception as e:
# logger.warning(f"Could not load training_args.bin: {e}")
# Last resort: parse from directory name
# Format: unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit_timestamp

View file

@ -276,21 +276,52 @@ def _clean_relative_path(
return Path(*parts) if parts else Path()
def _assert_contained(resolved: Path, root: Path) -> None:
"""Raise ValueError if ``resolved`` realpaths outside ``root``."""
try:
resolved_real = Path(os.path.realpath(resolved))
root_real = Path(os.path.realpath(root))
except OSError as exc:
raise ValueError(f"path resolution failed: {exc}") from exc
try:
resolved_real.relative_to(root_real)
except ValueError as exc:
raise ValueError(
f"path escapes root: {resolved!s} -> {resolved_real!s} "
f"is not under {root_real!s}"
) from exc
def resolve_under_root(
path_value: str | None,
*,
root: Path,
strip_prefixes: tuple[str, ...] = (),
) -> Path:
"""Resolve ``path_value`` and assert the result is under ``root``.
Absolutes are accepted only if already contained (so internal pre-resolved
paths re-enter idempotently); user-facing schemas reject absolutes upstream.
"""
if not path_value or not str(path_value).strip():
return root
path = Path(str(path_value).strip()).expanduser()
raw = str(path_value).strip()
if "\x00" in raw:
raise ValueError("path may not contain null bytes")
path = Path(raw).expanduser()
if ".." in path.parts:
raise ValueError(f"path may not contain '..' segments: {raw!r}")
if path.is_absolute():
_assert_contained(path, root)
return path
cleaned = _clean_relative_path(str(path), strip_prefixes = strip_prefixes)
return root / cleaned
cleaned = _clean_relative_path(raw, strip_prefixes = strip_prefixes)
candidate = root / cleaned
_assert_contained(candidate, root)
return candidate
def resolve_output_dir(path_value: str | None = None) -> Path:
@ -318,9 +349,22 @@ def resolve_tensorboard_dir(path_value: str | None = None) -> Path:
def resolve_dataset_path(path_value: str) -> Path:
path = Path(path_value).expanduser()
raw = str(path_value or "").strip()
if "\x00" in raw:
raise ValueError("dataset path may not contain null bytes")
path = Path(raw).expanduser()
if ".." in path.parts:
raise ValueError(f"dataset path may not contain '..' segments: {raw!r}")
if path.is_absolute():
return path
for root_fn in (datasets_root, dataset_uploads_root, recipe_datasets_root):
try:
_assert_contained(path, root_fn())
return path
except ValueError:
continue
raise ValueError(
f"dataset path must be relative or under a dataset root: {raw!r}"
)
parts = [part for part in Path(path_value).parts if part not in ("", ".")]
if parts[:2] == ["assets", "datasets"]:

View file

@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Network-free Studio release version resolution for display-only UI."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from utils import _studio_release_build
_DEV_VERSION = "dev"
_GIT_TIMEOUT_SECONDS = 1.0
_STUDIO_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$")
_GIT_DESCRIBE_SUFFIX_RE = re.compile(r"-\d+-g[0-9A-Fa-f]+(?:-dirty)?$")
_MAX_VERSION_LENGTH = 64
def is_valid_studio_release_version(value: object) -> bool:
"""Return True for Studio release tags such as ``v0.1.39-beta``."""
if not isinstance(value, str):
return False
version = value.strip()
if not version or len(version) > _MAX_VERSION_LENGTH:
return False
if version.endswith("-dirty") or _GIT_DESCRIBE_SUFFIX_RE.search(version):
return False
return _STUDIO_TAG_RE.fullmatch(version) is not None
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def _path_is_in_site_packages(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _is_source_checkout(repo_root: Path) -> bool:
return (repo_root / ".git").exists() and not _path_is_in_site_packages(
Path(__file__).resolve()
)
def _exact_git_studio_tag(repo_root: Path) -> str | None:
try:
result = subprocess.run(
[
"git",
"describe",
"--tags",
"--exact-match",
"--match",
"v[0-9]*",
"HEAD",
],
cwd = repo_root,
check = False,
stdout = subprocess.PIPE,
stderr = subprocess.DEVNULL,
text = True,
timeout = _GIT_TIMEOUT_SECONDS,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
tag = result.stdout.strip()
return tag if is_valid_studio_release_version(tag) else None
def get_studio_version(repo_root: Path | None = None) -> str:
"""Return the installed Studio release tag for display, or ``dev``.
This value is intentionally separate from the PyPI ``unsloth`` package
version used by update checks. It never performs network requests.
"""
resolved_repo_root = repo_root or _repo_root()
if _is_source_checkout(resolved_repo_root):
git_tag = _exact_git_studio_tag(resolved_repo_root)
return git_tag if git_tag is not None else _DEV_VERSION
stamped_version = _studio_release_build.STUDIO_RELEASE_VERSION
if is_valid_studio_release_version(stamped_version):
return stamped_version.strip()
return _DEV_VERSION

View file

@ -0,0 +1,374 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Web update status helpers for browser-served Unsloth Studio.
This module is intentionally side-effect light: no network work happens at
import time or from /api/health. The PyPI check is lazy, cached, and only used
for normal PyPI-managed installs.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from typing import Any
from packaging.version import InvalidVersion, Version
PACKAGE_NAME = "unsloth"
PYPI_JSON_URL = "https://pypi.org/pypi/unsloth/json"
PYPI_TIMEOUT_SECONDS = 3
PYPI_RESPONSE_MAX_BYTES = 5 * 1024 * 1024
PYPI_SUCCESS_TTL_SECONDS = 12 * 60 * 60
PYPI_FAILURE_TTL_SECONDS = 60 * 60
RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog"
DISABLE_ENV_VAR = "UNSLOTH_DISABLE_UPDATE_CHECK"
LOCAL_INSTALL_SOURCES = {"editable", "local_path", "vcs", "local_repo"}
@dataclass(frozen = True)
class LatestVersionResult:
latest_version: str | None
checked_at: str
reason: str | None = None
error: str | None = None
@dataclass
class _LatestVersionCacheEntry:
result: LatestVersionResult
expires_at: float
_cache_condition = threading.Condition()
_latest_version_cache: _LatestVersionCacheEntry | None = None
_latest_version_fetching = False
def reset_update_status_cache() -> None:
"""Clear the in-process PyPI cache. Intended for tests."""
global _latest_version_cache, _latest_version_fetching
with _cache_condition:
_latest_version_cache = None
_latest_version_fetching = False
_cache_condition.notify_all()
def detect_install_source() -> str:
"""Return a coarse install source without exposing local paths.
Sources are intentionally conservative. PEP 610 local/vcs metadata wins.
Legacy source installs are treated as local only when package files resolve
outside site-packages/dist-packages and under a Git checkout.
"""
try:
dist = distribution(PACKAGE_NAME)
except PackageNotFoundError:
return (
"local_repo"
if _path_has_git_parent(_repo_root_from_this_file())
else "unknown"
)
try:
direct_url = dist.read_text("direct_url.json")
except Exception:
return "unknown"
if direct_url:
return _source_from_direct_url(direct_url)
for package_path in _distribution_package_paths(dist):
if not _path_is_under_python_package_dir(package_path) and _path_has_git_parent(
package_path
):
return "local_repo"
return "pypi"
def get_studio_install_source_status(current_version: str) -> dict[str, Any]:
"""Return install-source metadata without remote update checks."""
install_source = detect_install_source()
reason = None
if install_source in LOCAL_INSTALL_SOURCES:
reason = "local_source"
elif install_source == "unknown":
reason = "unknown_source"
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = reason,
)
def get_studio_update_status(current_version: str) -> dict[str, Any]:
"""Return public, read-only update status for the web UI."""
install_source = detect_install_source()
if os.environ.get(DISABLE_ENV_VAR) == "1":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "disabled",
)
if install_source in LOCAL_INSTALL_SOURCES:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "local_source",
)
if install_source != "pypi":
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "unknown_source",
)
current = _parse_current_version(current_version)
if current is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = "invalid_current_version"
if current_version != "dev"
else "dev_build",
)
latest_result = get_latest_pypi_version()
if latest_result.latest_version is None:
return _status_response(
current_version = current_version,
latest_version = None,
install_source = install_source,
reason = latest_result.reason or "offline",
error = latest_result.error,
checked_at = latest_result.checked_at,
)
try:
latest = Version(latest_result.latest_version)
except InvalidVersion:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "invalid_latest_version",
error = "PyPI returned an invalid version.",
checked_at = latest_result.checked_at,
)
if latest > current:
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
update_available = True,
can_show_web_notification = True,
checked_at = latest_result.checked_at,
)
return _status_response(
current_version = current_version,
latest_version = latest_result.latest_version,
install_source = install_source,
reason = "current_not_older",
checked_at = latest_result.checked_at,
)
def get_latest_pypi_version() -> LatestVersionResult:
"""Return the latest PyPI version using a small in-process TTL cache."""
global _latest_version_cache, _latest_version_fetching
while True:
now = time.monotonic()
with _cache_condition:
if _latest_version_cache and _latest_version_cache.expires_at > now:
return _latest_version_cache.result
if not _latest_version_fetching:
_latest_version_fetching = True
break
_cache_condition.wait(timeout = PYPI_TIMEOUT_SECONDS + 1)
try:
result = _fetch_latest_pypi_version()
except Exception:
result = LatestVersionResult(
latest_version = None,
checked_at = _utc_now_iso(),
reason = "offline",
error = "Could not check PyPI update metadata.",
)
ttl = (
PYPI_SUCCESS_TTL_SECONDS if result.latest_version else PYPI_FAILURE_TTL_SECONDS
)
with _cache_condition:
_latest_version_cache = _LatestVersionCacheEntry(
result = result,
expires_at = time.monotonic() + ttl,
)
_latest_version_fetching = False
_cache_condition.notify_all()
return result
def _fetch_latest_pypi_version() -> LatestVersionResult:
checked_at = _utc_now_iso()
request = urllib.request.Request(
PYPI_JSON_URL,
headers = {"User-Agent": "unsloth-studio-update-check"},
)
try:
with urllib.request.urlopen(request, timeout = PYPI_TIMEOUT_SECONDS) as response:
body = response.read(PYPI_RESPONSE_MAX_BYTES + 1)
if len(body) > PYPI_RESPONSE_MAX_BYTES:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned oversized update metadata.",
)
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI returned malformed update metadata.",
)
except OSError:
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "offline",
error = "Could not reach PyPI for update metadata.",
)
latest = (
payload.get("info", {}).get("version") if isinstance(payload, dict) else None
)
if not isinstance(latest, str) or not latest.strip():
return LatestVersionResult(
latest_version = None,
checked_at = checked_at,
reason = "malformed_response",
error = "PyPI update metadata did not include a version.",
)
return LatestVersionResult(latest_version = latest.strip(), checked_at = checked_at)
def _status_response(
*,
current_version: str,
latest_version: str | None,
install_source: str,
reason: str | None = None,
error: str | None = None,
update_available: bool = False,
can_show_web_notification: bool = False,
checked_at: str | None = None,
) -> dict[str, Any]:
return {
"current_version": current_version,
"latest_version": latest_version,
"update_available": update_available,
"install_source": install_source,
"can_show_web_notification": can_show_web_notification,
"release_notes_url": RELEASE_NOTES_URL,
"checked_at": checked_at or _utc_now_iso(),
"reason": reason,
"error": error,
}
def _source_from_direct_url(direct_url: str) -> str:
try:
payload = json.loads(direct_url)
except json.JSONDecodeError:
return "unknown"
if not isinstance(payload, dict):
return "unknown"
dir_info = payload.get("dir_info")
if isinstance(dir_info, dict) and dir_info.get("editable") is True:
return "editable"
if isinstance(payload.get("vcs_info"), dict):
return "vcs"
url = payload.get("url")
if isinstance(url, str) and url.startswith("file:"):
return "local_path"
return "unknown"
def _distribution_package_paths(dist: Any) -> list[Path]:
paths: list[Path] = []
files = getattr(dist, "files", None) or []
for file in files:
text = str(file)
if not text.startswith(("unsloth/", "unsloth_cli/", "studio/")):
continue
try:
paths.append(Path(dist.locate_file(file)).resolve())
except OSError:
continue
return paths
def _path_is_under_python_package_dir(path: Path) -> bool:
return any(part in {"site-packages", "dist-packages"} for part in path.parts)
def _path_has_git_parent(path: Path) -> bool:
for candidate in (path, *path.parents):
if (candidate / ".git").exists():
return True
return False
def _repo_root_from_this_file() -> Path:
# update_status.py -> utils -> backend -> studio -> repo root
try:
return Path(__file__).resolve().parents[3]
except IndexError:
return Path(__file__).resolve().parent
def _parse_current_version(current_version: str) -> Version | None:
if current_version == "dev":
return None
try:
return Version(current_version)
except InvalidVersion:
return None
def _utc_now_iso() -> str:
return (
datetime.now(timezone.utc)
.replace(microsecond = 0)
.isoformat()
.replace("+00:00", "Z")
)

28
studio/frontend/.npmrc Normal file
View file

@ -0,0 +1,28 @@
# Studio frontend npm configuration.
#
# Mini Shai-Hulud / Axios-style supply chain defense.
# Requires npm >=11.10.0. Refuses tarballs published less than 7 days ago,
# closing the typical 4-72h attack window between malicious publish and
# upstream removal. npm interprets the bare integer as DAYS; do not
# append `d`, npm 11.x will parse `7d` as a Date string and abort.
min-release-age=7
# Defensive alias: `minimum-release-age` takes minutes (10080 = 7 days).
# Some npm versions / wrappers consult one key but not the other; setting
# both means a single setting-name parse change upstream cannot silently
# disable the cooldown. The two keys MUST agree; do not let them drift.
minimum-release-age=10080
# Belt-and-braces: refuse to write back loose `^x.y.z` ranges into
# package.json when a maintainer runs `npm install <pkg>` locally. This
# does NOT rewrite already-present ranges (those need an explicit
# `npm install <name>@<version> --save-exact` pass) but it stops new
# carets from creeping into the manifest as patch-version footguns.
save-exact=true
# Lock the registry. A user-set PIP_INDEX_URL-style override (here:
# NPM_CONFIG_REGISTRY env var or a stale ~/.npmrc) shouldn't redirect
# our installs to an attacker registry.
registry=https://registry.npmjs.org/
audit-level=high
fund=false
# Maintainer note: use `npm ci` (never `npm install`) in CI and locally
# when reproducing a build. The 7-day cooldown above is enforced by npm
# itself; downgrading or removing it bypasses the supply-chain gate.

View file

@ -41,7 +41,7 @@
"@streamdown/math": "1.0.2",
"@streamdown/mermaid": "1.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-router": "1.169.2",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
@ -83,6 +83,11 @@
"unpdf": "^1.4.0",
"zustand": "^5.0.11"
},
"overrides": {
"@tanstack/react-router": "1.169.2",
"@tanstack/router-core": "1.169.2",
"@tanstack/history": "1.161.6"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",

View file

@ -9,6 +9,7 @@ import {
shouldUseCustomWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { WebUpdateBanner } from "@/components/web/update-banner";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
@ -22,10 +23,6 @@ interface AppProviderProps {
children: ReactNode;
}
// ---------------------------------------------------------------------------
// Tauri window helpers (only imported in Tauri mode)
// ---------------------------------------------------------------------------
type TauriWindowMode = "setup" | "app";
type WindowLayoutGuard = () => boolean;
@ -52,19 +49,15 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
let finalH = 600;
if (monitor) {
// Convert physical pixels to logical using scale factor
const scale = monitor.scaleFactor;
const screenW = monitor.size.width / scale;
const screenH = monitor.size.height / scale;
// Target: 75% of screen width, golden ratio height, capped at min 900x600
finalW = Math.max(900, Math.round(screenW * 0.75));
const targetH = Math.max(600, Math.round(finalW / 1.618));
// Don't exceed screen height
finalH = Math.min(targetH, Math.round(screenH * 0.85));
}
// Apply constraints and finalize without animating through intermediate sizes
if (!isCurrent()) return;
await win.setSize(new LogicalSize(finalW, finalH));
if (!isCurrent()) return;
@ -107,10 +100,6 @@ function getTauriWindowMode(
}
}
// ---------------------------------------------------------------------------
// TauriWrapper
// ---------------------------------------------------------------------------
function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
const update = useTauriUpdate(isExternalServer);
const isUpdating =
@ -140,6 +129,8 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
dismissed={update.dismissed}
lastFailure={update.lastFailure}
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
onCopyDiagnostics={update.copyDiagnostics}
@ -154,6 +145,13 @@ const HIDDEN_TITLEBAR_SIDEBAR_ROUTES = new Set([
"/signup",
]);
const WEB_UPDATE_HIDDEN_ROUTES = new Set([
"/onboarding",
"/login",
"/change-password",
"/signup",
]);
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
@ -176,8 +174,7 @@ function TauriWrapper({ children }: { children: ReactNode }) {
};
}, []);
// Keep the Tauri window hidden during preflight, then show it centered in setup
// mode or apply the final app layout in one instant step.
// Keep the Tauri window hidden until setup or app layout is ready.
useEffect(() => {
if (!isTauri) return;
@ -234,7 +231,14 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return () => { disposed = true; };
}, [status, desktopAuthRetry]);
if (!isTauri) return <>{children}</>;
if (!isTauri) {
return (
<>
{children}
<WebUpdateBanner enabled={!WEB_UPDATE_HIDDEN_ROUTES.has(pathname)} />
</>
);
}
const showApp = status === "running" && desktopAuthReady;
const startupStatus = status === "running" ? "starting" : status;

View file

@ -19,10 +19,8 @@ import {
useAuiState,
} from "@assistant-ui/react";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Idea01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type VariantProps, cva } from "class-variance-authority";
import { ChevronDownIcon, CopyIcon, CheckIcon } from "lucide-react";
import { CheckIcon, ChevronDownIcon, CopyIcon, LightbulbIcon } from "lucide-react";
import {
type CSSProperties,
type ComponentProps,
@ -128,10 +126,7 @@ function ReasoningTrigger({
)}
{...props}
>
<HugeiconsIcon
icon={Idea01Icon}
className="aui-reasoning-trigger-icon size-4 shrink-0"
/>
<LightbulbIcon className="aui-reasoning-trigger-icon size-4 shrink-0" />
<span
data-slot="reasoning-trigger-label"
className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none"

View file

@ -210,13 +210,28 @@ const ThreadScrollToBottom: FC = () => {
};
const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
const [currentEmoji, setCurrentEmoji] = useState("large sloth drink.png");
useEffect(() => {
const hour = new Date().getHours();
if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png");
else if (hour >= 12 && hour < 17) setCurrentEmoji("sloth magnify final.png");
else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png");
else setCurrentEmoji("unsloth-gem.png");
}, []);
const currentEmojiSrc =
currentEmoji === "unsloth-gem.png"
? `/${currentEmoji}`
: `/Sloth emojis/${currentEmoji}`;
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
<div className="flex flex-col items-center gap-2 text-center">
<img
src="/Sloth emojis/sloth pc square.png"
src={currentEmojiSrc}
alt="Sloth mascot"
className="size-20"
/>

View file

@ -2,7 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import type { RetainedUpdateFailure, UpdateInfo, UpdateStatus } from "@/hooks/use-tauri-update";
import type {
DesktopUpdatePolicyMode,
RetainedUpdateFailure,
UpdateInfo,
UpdateStatus,
} from "@/hooks/use-tauri-update";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
@ -13,6 +18,8 @@ interface UpdateBannerProps {
dismissed: boolean;
lastFailure: RetainedUpdateFailure | null;
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
onInstall: () => void;
onDismiss: () => void;
onCopyDiagnostics: () => Promise<CopySupportDiagnosticsResult>;
@ -26,6 +33,8 @@ export function UpdateBanner({
dismissed,
lastFailure,
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
onInstall,
onDismiss,
onCopyDiagnostics,
@ -36,6 +45,10 @@ export function UpdateBanner({
const showFailure = Boolean(lastFailure) && !dismissed;
const showAvailable = status === "available" && !dismissed && !showFailure;
const show = showFailure || (showAvailable && Boolean(info));
const isManualLinuxPackage = updatePolicyMode === "manual_linux_package";
const installDisabled = isManualLinuxPackage
? manualReleaseUrl === null
: isExternalServer;
async function handleCopyDiagnostics() {
setCopying(true);
@ -67,18 +80,16 @@ export function UpdateBanner({
className="fixed top-4 right-4 z-[9999] w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
{/* Close button */}
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3L3 11M3 3l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Header */}
<div className="flex items-center gap-2">
<span className="text-lg">🦥</span>
<div>
@ -88,37 +99,39 @@ export function UpdateBanner({
<p className="text-xs text-muted-foreground">
{showFailure
? "Backend recovered. Diagnostics are still available."
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
: isManualLinuxPackage
? "Open the GitHub release page to install the Linux package"
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
</p>
</div>
</div>
{/* Retained failure */}
{showFailure && lastFailure && (
<p className="mt-3 line-clamp-2 text-xs text-destructive">
{lastFailure.error}
</p>
)}
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
{showFailure ? (
<>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => void handleCopyDiagnostics()}>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => {
handleCopyDiagnostics().catch(console.error);
}}>
{copying ? "Copying..." : "Copy Diagnostics"}
</Button>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Retry Update
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Retry Update"}
</Button>
</>
) : (
<>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Update Now
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Update Now"}
</Button>
<Button size="sm" variant="outline" className="corner-squircle" disabled>
<Button size="sm" variant="outline" className="corner-squircle" disabled={true}>
Release Notes
</Button>
</>
@ -132,7 +145,7 @@ export function UpdateBanner({
)}
{manualReport && (
<textarea
readOnly
readOnly={true}
value={manualReport}
onFocus={(event) => event.currentTarget.select()}
className="mt-2 h-28 w-full resize-none rounded-lg border border-border/50 bg-muted/30 p-2 font-mono text-[10px] text-muted-foreground"

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { useWebUpdateCheck } from "@/hooks/use-web-update-check";
import { isTauri } from "@/lib/api-base";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { AnimatePresence, motion } from "motion/react";
import { type ReactElement, useEffect, useRef, useState } from "react";
const STUDIO_UPDATE_CMD = "unsloth studio update";
const RELEASE_NOTES_URL = "https://unsloth.ai/docs/new/changelog";
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
interface WebUpdateBannerProps {
enabled?: boolean;
}
export function WebUpdateBanner({
enabled = true,
}: WebUpdateBannerProps): ReactElement | null {
const { status, dismiss } = useWebUpdateCheck({ enabled });
const [copiedVersion, setCopiedVersion] = useState<string | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
};
}, []);
if (isTauri) {
return null;
}
async function handleCopyCommand() {
if (!(await copyToClipboard(STUDIO_UPDATE_CMD))) {
return;
}
setCopiedVersion(status?.latestVersion ?? null);
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
dismissTimerRef.current = setTimeout(() => dismiss(), 900);
}
return (
<AnimatePresence>
{status ? (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[calc(100vw-2rem)] max-w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
<button
type="button"
onClick={dismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss update notification"
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3L3 11M3 3l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
<div className="flex items-start gap-2 pr-5">
<span className="text-lg" aria-hidden="true">
🦥
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
Package update available: {status.latestVersion}
</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Installed package: {status.currentVersion}. To update Studio,
run this in your terminal, then restart Studio.
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
className="corner-squircle"
onClick={handleCopyCommand}
>
{copiedVersion === status.latestVersion
? "Copied"
: "Copy command"}
</Button>
<Button
size="sm"
variant="outline"
className="corner-squircle"
asChild={true}
>
<a
href={RELEASE_NOTES_URL}
target="_blank"
rel="noopener noreferrer"
>
Release notes
</a>
</Button>
<Button
size="sm"
variant="ghost"
className="corner-squircle"
onClick={dismiss}
>
Later
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
@ -14,11 +14,42 @@ const STUDIO_UPDATE_FALLBACK_UNIX_CMD =
"curl -fsSL https://unsloth.ai/install.sh | sh";
const STUDIO_UPDATE_FALLBACK_WINDOWS_CMD =
"irm https://unsloth.ai/install.ps1 | iex";
const STUDIO_LOCAL_PULL_CMD = "git pull --ff-only";
const STUDIO_LOCAL_UPDATE_CMD = "unsloth studio update --local";
const STUDIO_LOCAL_FALLBACK_UNIX_CMD = "./install.sh --local";
const STUDIO_LOCAL_FALLBACK_WINDOWS_CMD = ".\\install.ps1 --local";
export type UpdateShell = "windows" | "unix";
export type UpdateInstallSource =
| "pypi"
| "editable"
| "local_path"
| "vcs"
| "local_repo"
| "unknown";
type UpdateInstallSourceState = UpdateInstallSource | "loading";
function getStudioUpdateInstructionLine(shell: UpdateShell): string {
return shell === "windows" ? "Open PowerShell and run:" : "Open Terminal and run:";
return shell === "windows"
? "Open PowerShell and run:"
: "Open Terminal and run:";
}
function isLocalInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return Boolean(
installSource &&
installSource !== "pypi" &&
installSource !== "unknown" &&
installSource !== "loading",
);
}
function isUnknownInstallSource(
installSource?: UpdateInstallSourceState | null,
): boolean {
return installSource === "unknown";
}
function CopyableCommand({
@ -54,7 +85,7 @@ function CopyableCommand({
<div className="flex min-w-0 items-stretch overflow-hidden rounded-md border border-border bg-muted/40">
<input
type="text"
readOnly
readOnly={true}
value={command}
className="min-w-0 flex-1 bg-transparent px-2 py-1.5 font-mono text-[11px] text-foreground outline-none"
title={command}
@ -68,7 +99,10 @@ function CopyableCommand({
aria-label={copied ? `${copyLabel} copied` : `Copy ${copyLabel}`}
>
{copied ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-emerald-600" />
<HugeiconsIcon
icon={Tick02Icon}
className="size-4 text-emerald-600"
/>
) : (
<HugeiconsIcon icon={Copy01Icon} className="size-4" />
)}
@ -77,24 +111,38 @@ function CopyableCommand({
);
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keep source-specific update guidance in one component so the command matrix stays visible.
export function UpdateStudioInstructions({
className,
defaultShell,
installSource,
showTitle = true,
}: {
className?: string;
defaultShell: UpdateShell;
installSource?: UpdateInstallSourceState | null;
showTitle?: boolean;
}): ReactElement {
const [shell, setShell] = useState<UpdateShell>(defaultShell);
const prefersReducedMotion = useReducedMotion();
const windows = shell === "windows";
const localInstallSource = isLocalInstallSource(installSource);
const checkoutInstallSource =
installSource === "editable" || installSource === "local_repo";
const packagedSourceInstall =
installSource === "vcs" || installSource === "local_path";
const loadingInstallSource = installSource === "loading";
const unknownInstallSource = isUnknownInstallSource(installSource);
const fadeTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.16, ease: [0.165, 0.84, 0.44, 1] as const };
const fadeInitial = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 2 };
const fadeInitial = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: 2 };
const fadeAnimate = { opacity: 1, y: 0 };
const fadeExit = prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: -2 };
const fadeExit = prefersReducedMotion
? { opacity: 1 }
: { opacity: 0, y: -2 };
useEffect(() => {
setShell(defaultShell);
@ -133,9 +181,9 @@ export function UpdateStudioInstructions({
onClick={() => setShell("unix")}
className={cn(
"px-0.5 py-0.5 font-medium transition-colors",
!windows
? "text-foreground"
: "text-muted-foreground hover:text-emerald-600",
windows
? "text-muted-foreground hover:text-emerald-600"
: "text-foreground",
)}
aria-pressed={!windows}
>
@ -143,43 +191,157 @@ export function UpdateStudioInstructions({
</button>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand command={STUDIO_UPDATE_CMD} copyLabel="update command" />
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
{loadingInstallSource ? (
<p className="text-xs text-muted-foreground leading-relaxed">
Checking how Studio was installed
</p>
) : localInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Source or local install detected. To avoid replacing it with PyPI,
update from the checkout or source you originally installed from.
</p>
{checkoutInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Pull latest changes from your Unsloth repo checkout, then update
Studio locally:
</p>
<CopyableCommand
command={STUDIO_LOCAL_PULL_CMD}
copyLabel="git pull command"
/>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If the Studio update command is unavailable, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`local-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
{packagedSourceInstall ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
This looks like a source or VCS package install. Reinstall from
the original local path or Git URL you used.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
If you still have the Unsloth repo checkout, run the local
installer from that checkout:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`source-fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_LOCAL_FALLBACK_WINDOWS_CMD
: STUDIO_LOCAL_FALLBACK_UNIX_CMD
}
copyLabel="local installer command"
/>
</motion.div>
</AnimatePresence>
</>
) : null}
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : unknownInstallSource ? (
<>
<p className="text-xs text-muted-foreground leading-relaxed">
Studio could not detect how it was installed. Check how you
installed Studio first, then choose the matching update path.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For curl or PyPI installs, run:
</p>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
For local checkout installs, update from that checkout instead and
use the local update command:
</p>
<CopyableCommand
command={STUDIO_LOCAL_UPDATE_CMD}
copyLabel="local update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
) : (
<>
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={`instruction-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
className="text-xs text-muted-foreground leading-relaxed"
>
{getStudioUpdateInstructionLine(shell)}
</motion.p>
</AnimatePresence>
<CopyableCommand
command={STUDIO_UPDATE_CMD}
copyLabel="update command"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
If that fails or unsloth studio update is unavailable, run:
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`fallback-${shell}`}
initial={fadeInitial}
animate={fadeAnimate}
exit={fadeExit}
transition={fadeTransition}
>
<CopyableCommand
command={
windows
? STUDIO_UPDATE_FALLBACK_WINDOWS_CMD
: STUDIO_UPDATE_FALLBACK_UNIX_CMD
}
copyLabel="fallback command"
/>
</motion.div>
</AnimatePresence>
<p className="text-xs text-muted-foreground leading-relaxed">
Restart Studio after updating for changes to take effect.
</p>
</>
)}
</div>
);
}

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { Button } from "@/components/ui/button";
import { usePlatformStore } from "@/config/env";
import { apiUrl } from "@/lib/api-base";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { getAuthToken } from "@/features/auth";
import { removeTrainingUnloadGuard } from "@/features/training";
import { apiUrl, isTauri } from "@/lib/api-base";
import {
ArrowUpRight01Icon,
Book03Icon,
@ -18,28 +18,108 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useState } from "react";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import {
type UpdateInstallSource,
UpdateStudioInstructions,
} from "../components/update-studio-instructions";
type ApiObject = Record<string, unknown>;
const INSTALL_SOURCE_KEY = "install_source";
const UPDATE_INSTALL_SOURCES = new Set<UpdateInstallSource>([
"pypi",
"editable",
"local_path",
"vcs",
"local_repo",
"unknown",
]);
function isUpdateInstallSource(value: unknown): value is UpdateInstallSource {
return (
typeof value === "string" &&
UPDATE_INSTALL_SOURCES.has(value as UpdateInstallSource)
);
}
async function fetchStudioVersions(): Promise<{
packageVersion: string | null;
studioVersion: string | null;
}> {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) {
return { packageVersion: null, studioVersion: null };
}
const data = (await res.json()) as ApiObject;
const packageVersion = data.version;
const studioVersion = data.studio_version;
return {
packageVersion:
typeof packageVersion === "string" ? packageVersion : null,
studioVersion: typeof studioVersion === "string" ? studioVersion : null,
};
} catch {
return { packageVersion: null, studioVersion: null };
}
}
async function fetchInstallSource(): Promise<UpdateInstallSource> {
if (isTauri) {
return "unknown";
}
const token = getAuthToken();
if (!token) {
return "unknown";
}
try {
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/studio/install-source"), { headers });
if (!res.ok) {
return "unknown";
}
const data = (await res.json()) as ApiObject;
const installSource = data[INSTALL_SOURCE_KEY];
return isUpdateInstallSource(installSource) ? installSource : "unknown";
} catch {
return "unknown";
}
}
export function AboutTab() {
const deviceType = usePlatformStore((s) => s.deviceType);
const defaultShell = deviceType === "windows" ? "windows" : "unix";
const [shutdownOpen, setShutdownOpen] = useState(false);
const [version, setVersion] = useState("dev");
const [packageVersion, setPackageVersion] = useState("dev");
const [studioVersion, setStudioVersion] = useState("dev");
const [installSource, setInstallSource] = useState<
UpdateInstallSource | "loading"
>("loading");
useEffect(() => {
let canceled = false;
(async () => {
try {
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {
setVersion(data.version);
}
} catch {
// fall back to dev label
fetchStudioVersions().then((nextVersions) => {
if (canceled) {
return;
}
})();
if (nextVersions.packageVersion) {
setPackageVersion(nextVersions.packageVersion);
}
if (nextVersions.studioVersion) {
setStudioVersion(nextVersions.studioVersion);
}
});
fetchInstallSource().then((nextInstallSource) => {
if (!canceled) {
setInstallSource(nextInstallSource);
}
});
return () => {
canceled = true;
@ -56,14 +136,25 @@ export function AboutTab() {
</header>
<SettingsSection title="Studio">
<SettingsRow label="Version">
<code className="font-mono text-xs text-muted-foreground">{version}</code>
<SettingsRow label="Studio Version">
<code className="font-mono text-xs text-muted-foreground">
{studioVersion}
</code>
</SettingsRow>
<SettingsRow label="Package Version">
<code className="font-mono text-xs text-muted-foreground">
{packageVersion}
</code>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Updates">
<div className="py-2">
<UpdateStudioInstructions defaultShell={defaultShell} showTitle={false} />
<UpdateStudioInstructions
defaultShell={defaultShell}
installSource={isTauri ? null : installSource}
showTitle={false}
/>
</div>
</SettingsSection>
@ -99,7 +190,10 @@ export function AboutTab() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={MessageNotification01Icon} className="size-3.5" />
<HugeiconsIcon
icon={MessageNotification01Icon}
className="size-3.5"
/>
Report an issue
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
@ -108,7 +202,7 @@ export function AboutTab() {
<SettingsSection title="Danger zone">
<SettingsRow
destructive
destructive={true}
label="Shut down Unsloth Studio"
description="Stops the Studio server process and ends your session."
>

View file

@ -17,10 +17,41 @@ function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type FastApiValidationError = {
loc?: unknown[];
msg?: string;
};
function formatDetail(detail: unknown): string | null {
if (typeof detail === "string" && detail) return detail;
if (!Array.isArray(detail)) return null;
const parts = detail
.map((entry) => {
if (!entry || typeof entry !== "object") return "";
const { loc, msg } = entry as FastApiValidationError;
const path = Array.isArray(loc)
? loc.filter((segment) => segment !== "body").join(".")
: "";
const message = typeof msg === "string" ? msg : "";
if (path && message) return `${path}: ${message}`;
return path || message;
})
.filter(Boolean);
return parts.length > 0 ? parts.join("; ") : null;
}
async function readError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string; message?: string };
return payload.detail || payload.message || `Request failed (${response.status})`;
const payload = (await response.json()) as {
detail?: unknown;
message?: string;
};
const formattedDetail = formatDetail(payload.detail);
if (formattedDetail) return formattedDetail;
if (typeof payload.message === "string" && payload.message) {
return payload.message;
}
return `Request failed (${response.status})`;
} catch {
return `Request failed (${response.status})`;
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect } from "react";
import { useTrainingRuntimeStore } from "@/features/training";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
@ -13,14 +13,18 @@ let currentHandler: ((e: BeforeUnloadEvent) => void) | null = null;
export function useTrainingUnloadGuard() {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!useTrainingRuntimeStore.getState().isTrainingRunning) return;
if (!useTrainingRuntimeStore.getState().isTrainingRunning) {
return;
}
e.preventDefault();
e.returnValue = "";
};
currentHandler = handler;
window.addEventListener("beforeunload", handler);
return () => {
if (currentHandler === handler) currentHandler = null;
if (currentHandler === handler) {
currentHandler = null;
}
window.removeEventListener("beforeunload", handler);
};
}, []);

View file

@ -16,7 +16,11 @@ export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-st
export { uploadTrainingDataset } from "./api/datasets-api";
export { listLocalModels } from "./api/models-api";
export type { LocalModelInfo } from "./api/models-api";
export type { TrainingPhase, TrainingViewData, TrainingSeriesPoint } from "./types/runtime";
export type {
TrainingPhase,
TrainingViewData,
TrainingSeriesPoint,
} from "./types/runtime";
export type {
TrainingRunSummary,
TrainingRunListResponse,

View file

@ -29,7 +29,10 @@ type DesktopPreflightDisposition =
| "not_installed"
| "managed_ready"
| "managed_stale"
| "attached_ready";
| "owned_ready"
| "owned_stale"
| "attached_ready"
| "external_conflict";
interface DesktopPreflightResult {
disposition: DesktopPreflightDisposition;
@ -39,28 +42,45 @@ interface DesktopPreflightResult {
managed_bin: string | null;
}
const MANAGED_STARTUP_TIMEOUT_MS = 5 * 60_000;
const MANAGED_STARTUP_POLL_MS = 500;
type TauriInvoke = typeof import("@tauri-apps/api/core").invoke;
type ManagedStartupResult =
| { status: "ready"; port: number }
| { status: "aborted" }
| { status: "missing-port" }
| { status: "unhealthy" };
| { status: "aborted" };
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
}
return preflight.port
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
invoke: TauriInvoke,
getPort: () => number | null,
shouldContinue: () => boolean,
): Promise<ManagedStartupResult> {
const deadline = Date.now() + MANAGED_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
while (true) {
if (!shouldContinue()) {
return { status: "aborted" };
}
@ -81,10 +101,6 @@ async function waitForManagedServerReady(
await wait(MANAGED_STARTUP_POLL_MS);
}
return getPort() === null
? { status: "missing-port" }
: { status: "unhealthy" };
}
export function useTauriBackend() {
@ -109,6 +125,7 @@ export function useTauriBackend() {
const externalPollAbortedRef = useRef(false);
const authFailureRef = useRef<string | null>(getTauriAuthFailure());
const elevationResumeRef = useRef<"install" | "repair" | null>(null);
const [tauriEventsReady, setTauriEventsReady] = useState(!isTauri);
function setBackendStatus(nextStatus: BackendStatus) {
if (authFailureRef.current) return;
@ -205,12 +222,24 @@ export function useTauriBackend() {
startExternalServerPoll(preflight.port);
return;
}
case "owned_ready":
if (!preflight.port) {
setBackendError("Desktop preflight found an owned backend without a port.");
return;
}
setApiBase(preflight.port);
portRef.current = preflight.port;
setIsExternalServer(false);
stopExternalServerPoll();
setRunningStatus();
return;
case "managed_ready":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendStatus("starting");
await startManagedServer();
return;
case "owned_stale":
case "managed_stale":
setIsExternalServer(false);
stopExternalServerPoll();
@ -218,10 +247,17 @@ export function useTauriBackend() {
await startRepair();
} else {
setBackendError(
"Managed Studio install is too old. Run `unsloth studio update`.",
preflight.disposition === "owned_stale"
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
: "Managed Studio install is too old. Run `unsloth studio update`.",
);
}
return;
case "external_conflict":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendError(externalConflictMessage(preflight));
return;
case "not_installed":
setBackendStatus("not-installed");
return;
@ -264,11 +300,6 @@ export function useTauriBackend() {
return;
}
const message =
startupResult.status === "missing-port"
? "Managed server started without reporting a port. Check the logs for details."
: "Server started but is not responding. Check the logs for details.";
setBackendError(message);
} catch (e) {
const msg = String(e);
if (msg.includes("already running")) {
@ -441,9 +472,9 @@ export function useTauriBackend() {
});
}, [currentStepIndex, elevationPackages, error, logs, progressDetail]);
// Initial check on mount (guarded against Strict Mode double-mount)
// Initial check on mount after Tauri event listeners are registered.
useEffect(() => {
if (mountedRef.current) return;
if (!tauriEventsReady || mountedRef.current) return;
mountedRef.current = true;
if (!isTauri) {
@ -451,7 +482,7 @@ export function useTauriBackend() {
return;
}
checkInstallAndStart();
}, []);
}, [tauriEventsReady]);
// Listen for Tauri events
useEffect(() => {
@ -460,17 +491,20 @@ export function useTauriBackend() {
let disposed = false;
import("@tauri-apps/api/event").then(({ listen }) => {
const registrations: Promise<void>[] = [];
function register<T>(
event: string,
handler: Parameters<typeof listen<T>>[1],
) {
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
});
registrations.push(
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
}),
);
}
register<string>("install-progress", (e) => {
@ -549,6 +583,16 @@ export function useTauriBackend() {
retry();
}
});
Promise.all(registrations)
.then(() => {
if (!disposed) setTauriEventsReady(true);
})
.catch((error) => {
if (!disposed) setBackendError(String(error));
});
}).catch((error) => {
if (!disposed) setBackendError(String(error));
});
const onAuthFailed = (event: Event) => {

View file

@ -31,6 +31,21 @@ export type UpdatePhase =
| "shell_install"
| "recovered_after_shell_failure";
export type DesktopUpdatePolicyMode = "in_app" | "manual_linux_package";
interface DesktopUpdatePolicy {
mode: DesktopUpdatePolicyMode;
releasePageBaseUrl: string;
releaseTagPrefix: string;
}
interface ManualUpdateInfo {
version: string;
currentVersion: string;
body?: string;
date?: string;
}
export interface RetainedUpdateFailure {
error: string;
phase: UpdatePhase;
@ -38,6 +53,29 @@ export interface RetainedUpdateFailure {
logs: string[];
}
const DEFAULT_UPDATE_POLICY: DesktopUpdatePolicy = {
mode: "in_app",
releasePageBaseUrl: "https://github.com/unslothai/unsloth/releases/tag/",
releaseTagPrefix: "desktop-v",
};
const UPDATE_VERSION_RE = /^v?\d+\.\d+\.\d+(?:(?:[-+][0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)|(?:\.(?:post|dev|rc)\d*)|(?:(?:post|dev|rc|a|b)\d*))?$/;
function normalizeUpdateVersion(version: string): string | null {
const trimmed = version.trim();
if (!UPDATE_VERSION_RE.test(trimmed)) return null;
return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
}
function manualReleasePageUrl(
policy: DesktopUpdatePolicy,
version: string,
): string | null {
const normalized = normalizeUpdateVersion(version);
if (!normalized) return null;
return `${policy.releasePageBaseUrl}${policy.releaseTagPrefix}${normalized}`;
}
export function useTauriUpdate(isExternalServer = false) {
const [status, setStatus] = useState<UpdateStatus>("idle");
const [info, setInfo] = useState<UpdateInfo | null>(null);
@ -50,6 +88,7 @@ export function useTauriUpdate(isExternalServer = false) {
const [dismissed, setDismissed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFailure, setLastFailure] = useState<RetainedUpdateFailure | null>(null);
const [updatePolicy, setUpdatePolicy] = useState<DesktopUpdatePolicy>(DEFAULT_UPDATE_POLICY);
const updateRef = useRef<Awaited<
ReturnType<typeof import("@tauri-apps/plugin-updater").check>
> | null>(null);
@ -93,12 +132,63 @@ export function useTauriUpdate(isExternalServer = false) {
return failure;
}
async function resolveUpdatePolicy(): Promise<DesktopUpdatePolicy> {
if (!isTauri) return DEFAULT_UPDATE_POLICY;
try {
const { invoke } = await import("@tauri-apps/api/core");
const policy = await invoke<DesktopUpdatePolicy>("desktop_update_policy");
setUpdatePolicy(policy);
return policy;
} catch (e) {
console.warn("Desktop update policy check failed:", e);
const failSafePolicy: DesktopUpdatePolicy = {
...DEFAULT_UPDATE_POLICY,
mode: "manual_linux_package",
};
setUpdatePolicy(failSafePolicy);
return failSafePolicy;
}
}
async function checkManualUpdateFallback(policy: DesktopUpdatePolicy) {
if (policy.mode !== "manual_linux_package") return false;
try {
const { invoke } = await import("@tauri-apps/api/core");
const manualUpdate = await invoke<ManualUpdateInfo | null>(
"check_desktop_manual_update",
);
if (!manualUpdate) return false;
updateRef.current = null;
setInfo({
version: manualUpdate.version,
currentVersion: manualUpdate.currentVersion,
body: manualUpdate.body,
date: manualUpdate.date,
});
setStatus("available");
return true;
} catch (e) {
console.error("Manual update metadata check failed:", e);
return false;
}
}
async function openManualUpdatePage(policy: DesktopUpdatePolicy, version: string) {
const url = manualReleasePageUrl(policy, version);
if (!url) {
throw new Error(`Invalid desktop update version: ${version}`);
}
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
}
useEffect(() => {
if (!isTauri || checkedRef.current) return;
checkedRef.current = true;
async function checkForUpdate() {
setStatus("checking");
const policy = await resolveUpdatePolicy();
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
@ -111,12 +201,14 @@ export function useTauriUpdate(isExternalServer = false) {
date: update.date,
});
setStatus("available");
} else {
} else if (!(await checkManualUpdateFallback(policy))) {
setStatus("idle");
}
} catch (e) {
console.error("Update check failed:", e);
setStatus("idle");
if (!(await checkManualUpdateFallback(policy))) {
setStatus("idle");
}
}
}
@ -125,14 +217,31 @@ export function useTauriUpdate(isExternalServer = false) {
}, []);
async function installUpdate() {
const update = updateRef.current;
if (!update || updatingRef.current) return;
if (updatingRef.current) return;
updatingRef.current = true;
const cleanups: (() => void)[] = [];
try {
// ── Step 1: Backend update ──
const policy = await resolveUpdatePolicy();
if (policy.mode === "manual_linux_package") {
const version = info?.version ?? updateRef.current?.version;
if (!version) return;
try {
await openManualUpdatePage(policy, version);
setDismissed(true);
setError(null);
} catch (manualError) {
const msg = String(manualError);
setError(msg);
toast.error("Could not open release page", { description: msg });
}
return;
}
const update = updateRef.current;
if (!update) return;
setUpdatePhase("backend");
setStatus("updating-backend");
replaceLogs([]);
@ -144,7 +253,6 @@ export function useTauriUpdate(isExternalServer = false) {
const { listen } = await import("@tauri-apps/api/event");
const { invoke } = await import("@tauri-apps/api/core");
// Listen for backend update progress
const unlistenProgress = await listen<string>(
"update-progress",
(e) => {
@ -153,7 +261,6 @@ export function useTauriUpdate(isExternalServer = false) {
);
cleanups.push(unlistenProgress);
// Wait for complete or failed
const backendResult = await new Promise<"complete" | string>(
(resolve) => {
listen<void>("update-complete", () => resolve("complete")).then(
@ -171,12 +278,9 @@ export function useTauriUpdate(isExternalServer = false) {
retainFailure(backendResult, "backend");
setError(backendResult);
setStatus("error");
updatingRef.current = false;
cleanup(cleanups);
return;
}
// ── Step 2: Shell update ──
setUpdatePhase("shell_download");
setStatus("downloading");
setUpdateProgress(0);
@ -201,7 +305,6 @@ export function useTauriUpdate(isExternalServer = false) {
}
});
// ── Step 3: Relaunch ──
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e) {
@ -281,6 +384,11 @@ export function useTauriUpdate(isExternalServer = false) {
});
}
const manualReleaseUrl =
updatePolicy.mode === "manual_linux_package" && info
? manualReleasePageUrl(updatePolicy, info.version)
: null;
return {
status,
info,
@ -291,6 +399,8 @@ export function useTauriUpdate(isExternalServer = false) {
phase,
lastFailure,
isExternalServer,
updatePolicyMode: updatePolicy.mode,
manualReleaseUrl,
installUpdate,
retryUpdate,
skipAndRestart,

View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { getAuthToken } from "@/features/auth";
import { apiUrl, isTauri } from "@/lib/api-base";
import { useCallback, useEffect, useState } from "react";
const WEB_UPDATE_CHECK_DELAY_MS = 5000;
const DISMISS_PREFIX = "unsloth_web_update_dismissed";
const CAN_SHOW_KEY = "can_show_web_notification";
const UPDATE_AVAILABLE_KEY = "update_available";
const INSTALL_SOURCE_KEY = "install_source";
const LATEST_VERSION_KEY = "latest_version";
const CURRENT_VERSION_KEY = "current_version";
const CHECKED_AT_KEY = "checked_at";
type ApiObject = Record<string, unknown>;
export type WebUpdateInstallSource =
| "pypi"
| "editable"
| "local_path"
| "vcs"
| "local_repo"
| "unknown";
export interface WebUpdateStatus {
currentVersion: string;
latestVersion: string;
installSource: "pypi";
checkedAt: string;
}
interface UseWebUpdateCheckOptions {
enabled?: boolean;
delayMs?: number;
}
function stringField(value: ApiObject, key: string): string | null {
const field = value[key];
return typeof field === "string" ? field : null;
}
function toDisplayableUpdateStatus(value: unknown): WebUpdateStatus | null {
if (!value || typeof value !== "object") {
return null;
}
const status = value as ApiObject;
const latestVersion = stringField(status, LATEST_VERSION_KEY);
const currentVersion = stringField(status, CURRENT_VERSION_KEY);
const checkedAt = stringField(status, CHECKED_AT_KEY);
if (
status[CAN_SHOW_KEY] !== true ||
status[UPDATE_AVAILABLE_KEY] !== true ||
status[INSTALL_SOURCE_KEY] !== "pypi" ||
!latestVersion ||
!currentVersion ||
!checkedAt
) {
return null;
}
return {
currentVersion,
latestVersion,
installSource: "pypi",
checkedAt,
};
}
function dismissalKey(status: WebUpdateStatus): string {
return `${DISMISS_PREFIX}:${status.installSource}:${status.latestVersion}`;
}
function isDismissed(status: WebUpdateStatus): boolean {
if (typeof window === "undefined") {
return true;
}
try {
return window.localStorage.getItem(dismissalKey(status)) !== null;
} catch {
return false;
}
}
function markDismissed(status: WebUpdateStatus): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(dismissalKey(status), String(Date.now()));
} catch {
// Ignore storage failures; the banner can still be dismissed in-memory.
}
}
async function fetchDisplayableUpdateStatus(): Promise<WebUpdateStatus | null> {
const token = getAuthToken();
if (!token) {
return null;
}
const headers = new Headers();
headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(apiUrl("/api/studio/update-status"), { headers });
if (!res.ok) {
return null;
}
return toDisplayableUpdateStatus(await res.json());
}
export function useWebUpdateCheck({
enabled = true,
delayMs = WEB_UPDATE_CHECK_DELAY_MS,
}: UseWebUpdateCheckOptions = {}) {
const [status, setStatus] = useState<WebUpdateStatus | null>(null);
useEffect(() => {
if (isTauri || !enabled || !getAuthToken()) {
const clearTimer = window.setTimeout(() => setStatus(null), 0);
return () => window.clearTimeout(clearTimer);
}
let canceled = false;
const timer = window.setTimeout(() => {
fetchDisplayableUpdateStatus()
.then((nextStatus) => {
if (canceled) {
return;
}
setStatus(nextStatus && !isDismissed(nextStatus) ? nextStatus : null);
})
.catch(() => {
if (!canceled) {
setStatus(null);
}
});
}, delayMs);
return () => {
canceled = true;
window.clearTimeout(timer);
};
}, [delayMs, enabled]);
const dismiss = useCallback(() => {
setStatus((current) => {
if (current) {
markDismissed(current);
}
return null;
});
}, []);
return { status: enabled && !isTauri ? status : null, dismiss };
}

View file

@ -1,7 +1,17 @@
// Central API base URL for Tauri vs browser mode
let apiBase = ''
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window
function detectTauri(): boolean {
if (typeof window === 'undefined') {
return false
}
return (
'__TAURI__' in window ||
'__TAURI_INTERNALS__' in window ||
window.location.protocol === 'tauri:'
)
}
const isTauri = detectTauri()
const isViteDev = import.meta.env.DEV
if (isTauri && !isViteDev) {

0
studio/install_llama_prebuilt.py Executable file → Normal file
View file

View file

@ -1,5 +1,5 @@
#!/bin/sh
# Post-removal script for Unsloth Studio (deb/rpm)
# Post-removal script for the Unsloth Studio Debian package
# Runs non-interactively; never deletes user data or touches other users' homes.
case "${1:-}" in

View file

@ -22,12 +22,78 @@ fn should_emit_repair_failed(msg: &str) -> bool {
!msg.contains("NEEDS_ELEVATION")
}
fn external_conflict_message(conflict: &crate::preflight::ExternalBackendConflict) -> String {
if conflict.reason == "desktop_owned_backend_active" {
return format!(
"A desktop-owned Studio server for this install is already running on port {}. Quit the other desktop app instance, then try again.",
conflict.port
);
}
format!(
"A Studio server for this install is already running from a terminal on port {}. Stop that server, or run `unsloth studio update` from that terminal before using desktop repair/update.",
conflict.port
)
}
fn owned_backend_port(state: &tauri::State<'_, BackendState>) -> Result<Option<u16>, String> {
state
.lock()
.map(|proc| proc.owned_backend_port())
.map_err(|e| e.to_string())
}
fn has_owned_backend(state: &tauri::State<'_, BackendState>) -> Result<bool, String> {
state
.lock()
.map(|proc| proc.has_owned_backend())
.map_err(|e| e.to_string())
}
async fn block_external_conflict(ignored_ports: &[u16]) -> Result<(), String> {
if let Some(conflict) =
crate::preflight::mutation_blocking_backend_ignoring(ignored_ports).await
{
return Err(external_conflict_message(&conflict));
}
Ok(())
}
#[tauri::command]
pub async fn desktop_preflight(
app: AppHandle,
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
diagnostics: tauri::State<'_, DiagnosticsState>,
) -> Result<crate::preflight::DesktopPreflightResult, String> {
let result = crate::preflight::desktop_preflight_result().await;
let (result, adopted_watchdog_generation) =
crate::preflight::desktop_preflight_result_with_state(state.inner()).await?;
diagnostics::record_preflight(&diagnostics, &result);
if let Some((generation, newly_adopted)) = adopted_watchdog_generation {
if newly_adopted {
if let Some(port) = result.port {
diagnostics::begin_adopted_backend_session(&diagnostics, port, generation);
}
}
if process::claim_adopted_watchdog_if_current(state.inner(), generation) {
shutdown.store(false, std::sync::atomic::Ordering::SeqCst);
let watchdog_state = state.inner().clone();
let watchdog_shutdown = shutdown.inner().clone();
let watchdog_diagnostics = diagnostics.inner().clone();
tokio::spawn(async move {
health_watchdog(
app,
watchdog_state,
watchdog_shutdown,
watchdog_diagnostics,
generation,
true,
)
.await;
});
}
}
Ok(result)
}
@ -121,6 +187,7 @@ pub async fn start_server(
watchdog_shutdown,
diagnostics_state,
generation,
false,
)
.await;
});
@ -151,6 +218,7 @@ pub async fn start_managed_server(
watchdog_shutdown,
diagnostics_state,
generation,
false,
)
.await;
});
@ -158,17 +226,22 @@ pub async fn start_managed_server(
Ok(())
}
/// Stop the backend server.
/// Sends SIGTERM to the process group, which triggers uvicorn's graceful
/// shutdown (same codepath as /api/shutdown). Falls back to SIGKILL after 5s.
/// Stop the current desktop-owned backend if this app can safely control it.
#[tauri::command]
pub fn stop_server(
pub async fn stop_server(
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
diagnostics: tauri::State<'_, DiagnosticsState>,
) -> Result<(), String> {
info!("stop_server command called");
process::stop_backend(&state, &shutdown, Some(diagnostics.inner()))
let state = state.inner().clone();
let shutdown = shutdown.inner().clone();
let diagnostics = diagnostics.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
process::stop_backend(&state, &shutdown, Some(&diagnostics))
})
.await
.map_err(|e| format!("stop backend task failed: {e}"))?
}
/// Check if a healthy Unsloth backend is running on the given port.
@ -207,6 +280,35 @@ async fn check_health_inner(port: u16) -> Result<bool, reqwest::Error> {
Ok(healthy && correct_service)
}
async fn check_watchdog_health(
state: &BackendState,
generation: u64,
port: u16,
has_adopted: bool,
) -> bool {
if !has_adopted {
return check_health_inner(port).await.unwrap_or(false);
}
let snapshot = match process::owned_backend_snapshot(state) {
Ok(Some(snapshot))
if snapshot.is_adopted
&& snapshot.generation == generation
&& snapshot.port == Some(port) =>
{
snapshot
}
_ => return false,
};
let Some(owner) = snapshot.owner else {
return false;
};
matches!(
crate::desktop_backend_owner::probe_owned_backend_state(owner, Some(port), false).await,
crate::desktop_backend_owner::OwnedBackendProbe::Verified(_)
)
}
/// Return buffered server logs.
#[tauri::command]
pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec<String> {
@ -308,12 +410,6 @@ pub async fn start_backend_update(
) -> Result<(), String> {
info!("start_backend_update command called");
// Signal the health watchdog to exit immediately, before any guards.
// This closes the race window where the watchdog could emit server-crashed
// between our command being called and stop_backend completing.
shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
// Guard: reject if install is running
if install_state
.lock()
.map(|s| s.child.is_some())
@ -322,7 +418,6 @@ pub async fn start_backend_update(
return Err("Cannot update while installation is in progress.".to_string());
}
// Guard: reject if update is already running
if update_state
.lock()
.map(|s| s.child.is_some())
@ -331,17 +426,20 @@ pub async fn start_backend_update(
return Err("Update is already running.".to_string());
}
// Stop backend if running
if backend_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
let owned_port = owned_backend_port(&backend_state)?;
let has_owned = has_owned_backend(&backend_state)?;
if has_owned {
if let Some(port) = owned_port {
block_external_conflict(&[port]).await?;
}
info!("Stopping backend before update...");
process::stop_backend(&backend_state, &shutdown, Some(diagnostics.inner()))?;
process::stop_backend_for_mutation(&backend_state, &shutdown, Some(diagnostics.inner()))?;
block_external_conflict(&[]).await?;
} else {
block_external_conflict(&[]).await?;
}
// Run update in a blocking thread
let state = update_state.inner().clone();
let diagnostics_state = diagnostics.inner().clone();
tokio::task::spawn_blocking(move || update::run_backend_update(app, state, diagnostics_state))
@ -378,20 +476,24 @@ pub async fn start_managed_repair(
}
let diagnostics_state = diagnostics.inner().clone();
let owned_port = owned_backend_port(&backend_state)?;
let has_owned = has_owned_backend(&backend_state)?;
if has_owned {
if let Some(port) = owned_port {
block_external_conflict(&[port]).await?;
}
info!("Stopping backend before repair...");
process::stop_backend_for_mutation(&backend_state, &shutdown, Some(&diagnostics_state))?;
block_external_conflict(&[]).await?;
} else {
block_external_conflict(&[]).await?;
}
let repair_group_id = install::take_pending_repair_group_for_resume(&install_state)
.unwrap_or_else(|| diagnostics::begin_repair_group(&diagnostics_state));
shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
if backend_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
info!("Stopping backend before repair...");
process::stop_backend(&backend_state, &shutdown, Some(&diagnostics_state))?;
}
let _ = app.emit("repair-progress", "Updating existing Studio install...");
let update_app = app.clone();
let update_state = update_state.inner().clone();
@ -446,6 +548,17 @@ pub async fn start_managed_repair(
}
}
if let Err(msg) = block_external_conflict(&[]).await {
diagnostics::finish_repair_group(
&diagnostics_state,
&repair_group_id,
"failed",
Some(msg.clone()),
);
let _ = app.emit("repair-failed", &msg);
return Err(msg);
}
let install_app = app.clone();
let install_state = install_state.inner().clone();
let install_diagnostics = diagnostics_state.clone();
@ -501,6 +614,62 @@ pub async fn start_managed_repair(
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const ROOT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const OWNER_TOKEN: &str = "desktop-owner-token";
fn ready_health(include_owner: bool) -> String {
let owner = if include_owner {
format!(
r#", "desktop_owner":{{"kind":"tauri","token_sha256":"{}"}}"#,
crate::desktop_backend_owner::token_sha256(OWNER_TOKEN)
)
} else {
String::new()
};
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{ROOT_ID}"{owner}}}"#
)
}
async fn command_test_backend(health_body: String) -> u16 {
let mut listener = None;
for port in 8888u16..=8908 {
if let Ok(bound) = TcpListener::bind(("127.0.0.1", port)).await {
listener = Some(bound);
break;
}
}
let listener = listener.expect("test needs a free desktop preflight port");
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
for _ in 0..2 {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut buffer = [0; 2048];
let Ok(n) = stream.read(&mut buffer).await else {
return;
};
let request = String::from_utf8_lossy(&buffer[..n]);
let (status, body) = if request.starts_with("GET /api/health ") {
("200 OK", health_body.as_str())
} else if request.starts_with("POST /api/auth/desktop-login ") {
("401 Unauthorized", "")
} else {
("404 Not Found", "")
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
}
});
port
}
#[test]
fn repair_elevation_is_not_a_terminal_repair_failure() {
@ -510,28 +679,36 @@ mod tests {
));
}
#[test]
fn watchdog_ignores_startup_failures_within_grace_period() {
assert!(!super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1)
));
#[tokio::test]
async fn mutation_guard_blocks_second_external_backend_when_owned_child_is_ignored() {
crate::desktop_backend_owner::install_test_owner(ROOT_ID, OWNER_TOKEN);
let owned_port = command_test_backend(ready_health(true)).await;
let external_port = command_test_backend(ready_health(false)).await;
let err = super::block_external_conflict(&[owned_port])
.await
.expect_err("external non-owned backend should block mutation");
assert!(err.contains(&format!("port {external_port}")));
assert!(err.contains("Stop that server"));
}
#[test]
fn watchdog_counts_failures_after_backend_was_healthy() {
assert!(super::should_count_watchdog_failure(
true,
Duration::from_secs(1)
));
}
#[test]
fn watchdog_counts_startup_failures_after_grace_period() {
assert!(super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD
));
fn watchdog_failure_policy_counts_only_after_health_or_grace_period() {
for (has_seen_healthy, elapsed, expected) in [
(
false,
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1),
false,
),
(true, Duration::from_secs(1), true),
(false, super::BACKEND_STARTUP_GRACE_PERIOD, true),
] {
assert_eq!(
super::should_count_watchdog_failure(has_seen_healthy, elapsed),
expected
);
}
}
}
@ -546,12 +723,13 @@ async fn health_watchdog(
shutdown: ShutdownFlag,
diagnostics: DiagnosticsState,
generation: u64,
count_failures_immediately: bool,
) {
use std::sync::atomic::Ordering;
let started_at = Instant::now();
let mut consecutive_failures: u32 = 0;
let mut has_seen_healthy = false;
let mut has_seen_healthy = count_failures_immediately;
loop {
tokio::time::sleep(HEALTH_WATCHDOG_INTERVAL).await;
@ -561,12 +739,17 @@ async fn health_watchdog(
break;
}
let (port, has_child, current_generation) = {
let (port, has_owned, has_adopted, current_generation) = {
let proc = match state.lock() {
Ok(p) => p,
Err(_) => break,
};
(proc.port, proc.child.is_some(), proc.generation)
(
proc.port,
proc.has_owned_backend(),
proc.has_adopted_backend(),
proc.generation,
)
};
if current_generation != generation {
@ -575,70 +758,94 @@ async fn health_watchdog(
}
// Stop watching if the backend is gone
if !has_child {
if !has_owned {
info!("Health watchdog: backend stopped, exiting");
break;
}
let should_count_failure =
should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
port.is_some() || should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
let Some(port) = port else {
if should_count_failure {
consecutive_failures += 1;
warn!(
"Health watchdog: backend has not reported a port ({}/{})",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
);
}
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!(
"Health watchdog: backend never reported a port, killing and declaring dead"
);
if has_adopted {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"no_port_after_grace",
"adopted_port_missing",
);
error!("Health watchdog: adopted backend lost its port, declaring dead");
process::clear_adopted_backend_if_current(
&state,
generation,
None,
"watchdog adopted port missing",
);
let _ = app.emit("server-crashed", ());
break;
}
if !should_count_failure {
info!("Health watchdog: backend has not reported a validated port yet");
continue;
}
consecutive_failures += 1;
warn!(
"Health watchdog: missing validated port failure {}/{}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"missing_validated_port",
);
error!("Health watchdog: backend never reported a validated port, killing and declaring dead");
let _ = process::stop_backend(&state, &shutdown, Some(&diagnostics));
let _ = app.emit("server-crashed", ());
break;
}
continue;
};
match check_health_inner(port).await {
Ok(true) => {
has_seen_healthy = true;
consecutive_failures = 0;
}
_ if !should_count_failure => {
info!(
"Health watchdog: startup health check failed on port {} before grace period elapsed",
port
if check_watchdog_health(&state, generation, port, has_adopted).await {
has_seen_healthy = true;
consecutive_failures = 0;
} else if !should_count_failure {
info!(
"Health watchdog: startup health check failed on port {} before grace period elapsed",
port
);
} else {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/{} on port {}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"unresponsive_health_check",
);
}
_ => {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/{} on port {}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!("Health watchdog: backend unresponsive, killing and declaring dead");
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"unresponsive_health_check",
if has_adopted {
error!(
"Health watchdog: adopted backend unresponsive, clearing state and declaring dead"
);
process::clear_adopted_backend_if_current(
&state,
generation,
Some(port),
"watchdog health check failures",
);
} else {
error!("Health watchdog: backend unresponsive, killing and declaring dead");
// Kill the zombie process so retry can start fresh
let _ = process::stop_backend(&state, &shutdown, Some(&diagnostics));
let _ = app.emit("server-crashed", ());
break;
}
let _ = app.emit("server-crashed", ());
break;
}
}
}
process::clear_adopted_watchdog_if_current(&state, generation);
}

View file

@ -91,13 +91,36 @@ fn read_secret_if_exists(path: &Path) -> Result<Option<String>, String> {
async fn current_backend_port(
state: &tauri::State<'_, BackendState>,
) -> Result<BackendPort, String> {
if let Some(port) = state.lock().map_err(|e| e.to_string())?.port {
let cached_port = {
let proc = state.lock().map_err(|e| e.to_string())?;
if let Some(port) = proc.owned_backend_port() {
return Ok(BackendPort {
port,
source: PortSource::Cached,
});
}
if proc.has_owned_backend() {
None
} else {
proc.port
}
};
if let Some(port) = cached_port {
return Ok(BackendPort {
port,
source: PortSource::Cached,
});
}
if state
.lock()
.map(|proc| proc.has_owned_backend())
.map_err(|e| e.to_string())?
{
return Err("Backend is not ready".to_string());
}
let port = discover_compatible_backend_port()
.await
.ok_or_else(|| "Backend is not ready".to_string())?;
@ -152,6 +175,16 @@ fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> b
)
}
fn can_retry_on_discovered_port(state: &BackendState, source: PortSource) -> Result<bool, String> {
if source != PortSource::Cached {
return Ok(false);
}
state
.lock()
.map(|proc| !proc.has_owned_backend())
.map_err(|e| e.to_string())
}
async fn exchange_desktop_secret(
client: &Client,
port: u16,
@ -234,7 +267,7 @@ async fn retry_on_discovered_port(
previous: BackendPort,
secret: &str,
) -> Result<Option<(Option<DesktopAuthResponse>, BackendPort)>, String> {
if previous.source != PortSource::Cached {
if !can_retry_on_discovered_port(state.inner(), previous.source)? {
return Ok(None);
}
let Some(port) = discover_compatible_backend_port().await else {
@ -359,23 +392,6 @@ mod tests {
port
}
#[test]
fn auth_secret_path_joins_expected_location() {
let home = PathBuf::from("/home/alex");
assert_eq!(
auth_secret_path(&home, ".desktop_secret"),
PathBuf::from("/home/alex/.unsloth/studio/auth/.desktop_secret")
);
}
#[test]
fn auth_url_builds_local_endpoint() {
assert_eq!(
auth_url(8890, "desktop-login"),
"http://127.0.0.1:8890/api/auth/desktop-login"
);
}
#[test]
fn retry_discovery_only_for_cached_recoverable_errors() {
assert!(should_retry_with_discovered_port(
@ -397,111 +413,56 @@ mod tests {
}
#[test]
fn read_secret_returns_none_for_missing_file() {
let path = std::env::temp_dir().join(format!(
"unsloth-missing-desktop-secret-{}",
std::process::id()
));
let _ = std::fs::remove_file(&path);
fn owned_handle_disables_discovered_auth_retry() {
const ROOT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const TOKEN: &str = "desktop-owner-token";
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
let state = crate::process::new_backend_state();
assert!(can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
assert!(!can_retry_on_discovered_port(&state, PortSource::Discovered).unwrap());
let owner = crate::desktop_backend_owner::test_owner_state(ROOT_ID, TOKEN, 8890);
state.lock().unwrap().owned = Some(crate::process::OwnedBackendHandle::adopted(
owner, 8890, 2, 3,
));
assert!(!can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
}
#[test]
fn read_secret_trims_existing_file() {
let path =
fn read_secret_handles_missing_trimmed_and_invalid_files() {
let base =
std::env::temp_dir().join(format!("unsloth-desktop-secret-{}", std::process::id()));
std::fs::write(&path, " desktop-secret\n").unwrap();
let missing = base.with_extension("missing");
let trimmed = base.with_extension("trimmed");
let invalid = base.with_extension("invalid");
let _ = std::fs::remove_file(&missing);
std::fs::write(&trimmed, " desktop-secret\n").unwrap();
std::fs::write(&invalid, [0xff, 0xfe]).unwrap();
assert_eq!(read_secret_if_exists(&missing).unwrap(), None);
assert_eq!(
read_secret_if_exists(&path).unwrap(),
read_secret_if_exists(&trimmed).unwrap(),
Some("desktop-secret".to_string())
);
std::fs::remove_file(path).unwrap();
}
#[test]
fn read_secret_treats_invalid_utf8_as_missing_for_repair() {
let path = std::env::temp_dir().join(format!(
"unsloth-invalid-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, [0xff, 0xfe]).unwrap();
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
std::fs::remove_file(path).unwrap();
}
#[cfg(unix)]
#[test]
fn read_secret_treats_permission_denied_as_missing_for_repair() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"unsloth-unreadable-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, "desktop-stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = read_secret_if_exists(&path);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::remove_file(path).unwrap();
if matches!(result, Ok(Some(_))) {
return;
}
assert_eq!(result.unwrap(), None);
}
#[test]
fn attached_ready_port_requires_attached_ready_with_port() {
let compatible = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::AttachedReady,
reason: None,
port: Some(8890),
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(compatible), Some(8890));
let missing_port = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::AttachedReady,
reason: None,
port: None,
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(missing_port), None);
let managed_ready = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::ManagedReady,
reason: None,
port: Some(8890),
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(managed_ready), None);
assert_eq!(read_secret_if_exists(&invalid).unwrap(), None);
let _ = std::fs::remove_file(trimmed);
let _ = std::fs::remove_file(invalid);
}
#[tokio::test]
async fn exchange_desktop_secret_returns_none_for_unauthorized() {
async fn exchange_desktop_secret_handles_unauthorized_and_not_found() {
let port = login_server("401 Unauthorized").await;
let tokens = exchange_desktop_secret(&Client::new(), port, "desktop-stale")
.await
.unwrap();
assert!(tokens.is_none());
}
#[tokio::test]
async fn exchange_desktop_secret_reports_unsupported_backend_on_not_found() {
let port = login_server("404 Not Found").await;
let error = exchange_desktop_secret(&Client::new(), port, "desktop-secret")
.await
.unwrap_err()
.message();
assert_eq!(
error,
"Running Studio backend is too old for this desktop app. Update that backend and restart."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
use serde::Serialize;
use std::collections::HashMap;
const DESKTOP_RELEASE_PAGE_BASE_URL: &str = "https://github.com/unslothai/unsloth/releases/tag/";
const DESKTOP_RELEASE_TAG_PREFIX: &str = "desktop-v";
const DESKTOP_UPDATER_CHANNEL_URL: &str =
"https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json";
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DesktopUpdateMode {
InApp,
ManualLinuxPackage,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesktopUpdatePolicy {
mode: DesktopUpdateMode,
release_page_base_url: &'static str,
release_tag_prefix: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManualUpdateInfo {
version: String,
current_version: String,
body: Option<String>,
date: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ChannelMetadata {
version: String,
body: Option<String>,
date: Option<String>,
platforms: HashMap<String, ChannelPlatform>,
}
#[derive(Debug, serde::Deserialize)]
struct ChannelPlatform {
url: String,
signature: String,
}
#[tauri::command]
pub(crate) fn desktop_update_policy() -> DesktopUpdatePolicy {
DesktopUpdatePolicy {
mode: desktop_update_mode(),
release_page_base_url: DESKTOP_RELEASE_PAGE_BASE_URL,
release_tag_prefix: DESKTOP_RELEASE_TAG_PREFIX,
}
}
#[tauri::command]
pub(crate) async fn check_desktop_manual_update() -> Result<Option<ManualUpdateInfo>, String> {
if !matches!(desktop_update_mode(), DesktopUpdateMode::ManualLinuxPackage) {
return Ok(None);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let response = match client.get(DESKTOP_UPDATER_CHANNEL_URL).send().await {
Ok(response) => response,
Err(error) => {
log::warn!("Manual update metadata check failed: {}", error);
return Ok(None);
}
};
if !response.status().is_success() {
log::warn!(
"Manual update metadata check returned HTTP {}",
response.status()
);
return Ok(None);
}
let metadata = response
.json::<ChannelMetadata>()
.await
.map_err(|e| format!("Invalid desktop updater metadata: {e}"))?;
let current_version = env!("CARGO_PKG_VERSION");
let Some(latest_version) = normalize_version(&metadata.version) else {
return Err(format!(
"Desktop updater metadata has invalid version: {}",
metadata.version
));
};
validate_channel_metadata(&metadata, &latest_version)?;
if compare_versions(&latest_version, current_version) <= 0 {
return Ok(None);
}
Ok(Some(ManualUpdateInfo {
version: latest_version,
current_version: current_version.to_string(),
body: metadata.body,
date: metadata.date,
}))
}
fn validate_channel_metadata(
metadata: &ChannelMetadata,
normalized_version: &str,
) -> Result<(), String> {
if metadata.platforms.is_empty() {
return Err("Desktop updater metadata has no platforms".to_string());
}
let expected_prefix = format!(
"https://github.com/unslothai/unsloth/releases/download/desktop-v{normalized_version}/"
);
for (platform, entry) in &metadata.platforms {
if entry.url.trim().is_empty() {
return Err(format!(
"Desktop updater metadata missing URL for {platform}"
));
}
if entry.signature.trim().is_empty() {
return Err(format!(
"Desktop updater metadata missing signature for {platform}"
));
}
if entry.url.contains("/releases/latest/")
|| entry.url.contains("/releases/download/desktop-latest/")
|| !entry.url.starts_with(&expected_prefix)
{
return Err(format!(
"Desktop updater metadata has untrusted URL for {platform}: {}",
entry.url
));
}
}
Ok(())
}
fn desktop_update_mode() -> DesktopUpdateMode {
#[cfg(target_os = "linux")]
{
if std::env::var_os("APPIMAGE").is_some() {
DesktopUpdateMode::InApp
} else {
DesktopUpdateMode::ManualLinuxPackage
}
}
#[cfg(not(target_os = "linux"))]
{
DesktopUpdateMode::InApp
}
}
fn normalize_version(version: &str) -> Option<String> {
let trimmed = version.trim();
let without_v = trimmed.strip_prefix('v').unwrap_or(trimmed);
if parse_version(without_v).is_some() {
Some(without_v.to_string())
} else {
None
}
}
fn compare_versions(left: &str, right: &str) -> i8 {
let Some(left) = parse_version(left) else {
return 0;
};
let Some(right) = parse_version(right) else {
return 0;
};
match compare_parsed_versions(&left, &right) {
std::cmp::Ordering::Greater => 1,
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Less => -1,
}
}
#[derive(Debug, Eq, PartialEq)]
struct ParsedVersion {
release: [u64; 3],
suffix: VersionSuffix,
}
#[derive(Debug, Eq, PartialEq)]
enum VersionSuffix {
Dev(u64),
Alpha(u64),
Beta(u64),
Rc(u64),
PreRelease(String),
Stable,
Build,
Post(u64),
}
fn parse_version(version: &str) -> Option<ParsedVersion> {
let mut parts = version.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_and_suffix = parts.next()?;
let patch_len = patch_and_suffix
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(patch_and_suffix.len());
if patch_len == 0 {
return None;
}
let suffix = parse_version_suffix(&patch_and_suffix[patch_len..])?;
Some(ParsedVersion {
release: [major, minor, patch_and_suffix[..patch_len].parse().ok()?],
suffix,
})
}
fn parse_version_suffix(suffix: &str) -> Option<VersionSuffix> {
if suffix.is_empty() {
return Some(VersionSuffix::Stable);
}
if let Some(value) = suffix.strip_prefix('+') {
if suffix_part_valid(value) {
return Some(VersionSuffix::Build);
}
return None;
}
if let Some(value) = suffix.strip_prefix('-') {
if suffix_part_valid(value) {
return Some(VersionSuffix::PreRelease(value.to_ascii_lowercase()));
}
return None;
}
if let Some(number) = parse_numbered_suffix(suffix, &[".post", "post"]) {
return number.map(VersionSuffix::Post);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".dev", "dev"]) {
return number.map(VersionSuffix::Dev);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".rc", "rc"]) {
return number.map(VersionSuffix::Rc);
}
if let Some(number) = parse_numbered_suffix(suffix, &["a"]) {
return number.map(VersionSuffix::Alpha);
}
if let Some(number) = parse_numbered_suffix(suffix, &["b"]) {
return number.map(VersionSuffix::Beta);
}
None
}
fn parse_numbered_suffix(suffix: &str, prefixes: &[&str]) -> Option<Option<u64>> {
for prefix in prefixes {
let Some(number) = suffix.strip_prefix(prefix) else {
continue;
};
if number.is_empty() {
return Some(Some(0));
}
return Some(number.parse().ok());
}
None
}
fn suffix_part_valid(value: &str) -> bool {
!value.is_empty()
&& value.split('.').all(|part| {
!part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn compare_parsed_versions(left: &ParsedVersion, right: &ParsedVersion) -> std::cmp::Ordering {
match left.release.cmp(&right.release) {
std::cmp::Ordering::Equal => compare_suffixes(&left.suffix, &right.suffix),
ordering => ordering,
}
}
fn compare_suffixes(left: &VersionSuffix, right: &VersionSuffix) -> std::cmp::Ordering {
let left_precedence = suffix_precedence(left);
let right_precedence = suffix_precedence(right);
if left_precedence != right_precedence {
return left_precedence.cmp(&right_precedence);
}
match (left, right) {
(VersionSuffix::Dev(left), VersionSuffix::Dev(right))
| (VersionSuffix::Alpha(left), VersionSuffix::Alpha(right))
| (VersionSuffix::Beta(left), VersionSuffix::Beta(right))
| (VersionSuffix::Rc(left), VersionSuffix::Rc(right))
| (VersionSuffix::Post(left), VersionSuffix::Post(right)) => left.cmp(right),
(VersionSuffix::Rc(left), VersionSuffix::PreRelease(right)) => {
compare_numbered_prefix_to_prerelease("rc", *left, right)
.unwrap_or(std::cmp::Ordering::Equal)
}
(VersionSuffix::PreRelease(left), VersionSuffix::Rc(right)) => {
compare_numbered_prefix_to_prerelease("rc", *right, left)
.map(std::cmp::Ordering::reverse)
.unwrap_or(std::cmp::Ordering::Equal)
}
(VersionSuffix::PreRelease(left), VersionSuffix::PreRelease(right)) => {
compare_prerelease(left, right)
}
_ => std::cmp::Ordering::Equal,
}
}
fn suffix_precedence(suffix: &VersionSuffix) -> u8 {
match suffix {
VersionSuffix::Dev(_) => 0,
VersionSuffix::Alpha(_) => 1,
VersionSuffix::Beta(_) => 2,
VersionSuffix::Rc(_) | VersionSuffix::PreRelease(_) => 3,
VersionSuffix::Stable | VersionSuffix::Build => 4,
VersionSuffix::Post(_) => 5,
}
}
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
for (left_part, right_part) in left.split('.').zip(right.split('.')) {
let ordering = compare_prerelease_part(left_part, right_part);
if !ordering.is_eq() {
return ordering;
}
}
left.split('.').count().cmp(&right.split('.').count())
}
fn compare_prerelease_part(left: &str, right: &str) -> std::cmp::Ordering {
let left_number = left.parse::<u64>();
let right_number = right.parse::<u64>();
match (left_number, right_number) {
(Ok(left), Ok(right)) => return left.cmp(&right),
(Ok(_), Err(_)) => return std::cmp::Ordering::Less,
(Err(_), Ok(_)) => return std::cmp::Ordering::Greater,
(Err(_), Err(_)) => {}
}
match (split_alpha_numeric(left), split_alpha_numeric(right)) {
(Some((left_prefix, left_number)), Some((right_prefix, right_number)))
if left_prefix == right_prefix =>
{
return left_number.cmp(&right_number);
}
_ => {}
}
left.cmp(right)
}
fn compare_numbered_prefix_to_prerelease(
prefix: &str,
number: u64,
prerelease: &str,
) -> Option<std::cmp::Ordering> {
let (other_prefix, other_number) = split_alpha_numeric(prerelease)?;
(other_prefix == prefix).then(|| number.cmp(&other_number))
}
fn split_alpha_numeric(value: &str) -> Option<(&str, u64)> {
let digit_start = value.find(|c: char| c.is_ascii_digit())?;
if digit_start == 0 || value[digit_start..].bytes().any(|b| !b.is_ascii_digit()) {
return None;
}
Some((&value[..digit_start], value[digit_start..].parse().ok()?))
}
#[cfg(test)]
mod tests {
#[test]
fn compare_versions_orders_supported_suffixes() {
assert!(super::compare_versions("2026.5.3", "2026.5.3-rc1") > 0);
assert!(super::compare_versions("2026.5.3", "2026.5.3rc1") > 0);
assert!(super::compare_versions("2026.5.3", "2026.5.3.dev1") > 0);
assert!(super::compare_versions("2026.5.3.post1", "2026.5.3") > 0);
assert!(super::compare_versions("2026.5.3.post1", "2026.5.3+build1") > 0);
assert!(super::compare_versions("2026.5.3+build1", "2026.5.3-beta.1") > 0);
assert!(super::compare_versions("2026.5.3-rc10", "2026.5.3-rc2") > 0);
assert!(super::compare_versions("2026.5.3-rc10", "2026.5.3rc2") > 0);
assert!(super::compare_versions("2026.5.3rc2", "2026.5.3-rc10") < 0);
assert!(super::compare_versions("2026.5.3-beta10", "2026.5.3-beta2") > 0);
assert!(super::compare_versions("2026.5.3rc2", "2026.5.3rc1") > 0);
assert!(super::compare_versions("2026.5.3b1", "2026.5.3a1") > 0);
}
#[test]
fn normalize_version_accepts_backend_suffix_formats() {
for version in [
"v2026.5.4.post1",
"2026.5.4post1",
"2026.5.4.dev1",
"2026.5.4dev1",
"2026.5.4.rc1",
"2026.5.4rc1",
"2026.5.4a1",
"2026.5.4b1",
"2026.5.4-rc1",
"2026.5.4+build1",
] {
assert!(super::normalize_version(version).is_some(), "{version}");
}
}
#[test]
fn normalize_version_rejects_invalid_suffixes() {
for version in [
"2026.5.4garbage",
"2026.5.4-",
"2026.5.4+",
"2026.5.4-rc..1",
"2026.5.4.devx",
] {
assert!(super::normalize_version(version).is_none(), "{version}");
}
}
}

View file

@ -13,13 +13,13 @@ pub use phase_log::append_phase_line;
#[cfg(target_os = "linux")]
pub use phase_log::PhaseLogHandle;
pub use state::{
begin_backend_session, begin_install_attempt, begin_repair_child, begin_repair_group,
begin_update_attempt, finish_attempt, finish_repair_group, new_diagnostics_state,
record_attached_external_backend, record_auth_failure, record_backend_exit,
record_backend_intentional_stop, record_backend_port, record_backend_start_failure,
record_backend_watchdog, record_diag_marker, record_elevation_packages, record_preflight,
record_progress, record_step, AttemptLog, BackendLog, DiagnosticsState,
FrontendSupportSnapshot,
begin_adopted_backend_session, begin_backend_session, begin_install_attempt,
begin_repair_child, begin_repair_group, begin_update_attempt, finish_attempt,
finish_repair_group, new_diagnostics_state, record_attached_external_backend,
record_auth_failure, record_backend_exit, record_backend_intentional_stop, record_backend_port,
record_backend_start_failure, record_backend_watchdog, record_diag_marker,
record_elevation_packages, record_preflight, record_progress, record_step, AttemptLog,
BackendLog, DiagnosticsState, FrontendSupportSnapshot,
};
pub const SCHEMA_VERSION: u32 = 1;
@ -151,8 +151,10 @@ async fn collect_backend_health(port: u16) -> report::BackendHealthSection {
"device_type",
"chat_only",
"desktop_protocol_version",
"desktop_manageability_version",
"supports_api_only",
"supports_desktop_auth",
"supports_desktop_backend_ownership",
] {
if let Some(value) = json.get(key) {
section

View file

@ -341,8 +341,10 @@ fn append_device_signals_section(
| "device_type"
| "chat_only"
| "desktop_protocol_version"
| "desktop_manageability_version"
| "supports_api_only"
| "supports_desktop_auth"
| "supports_desktop_backend_ownership"
) {
out.push_str(&format!(" {key}={value}\n"));
}

View file

@ -435,6 +435,27 @@ pub fn begin_backend_session(
}
}
pub fn begin_adopted_backend_session(diagnostics: &DiagnosticsState, port: u16, generation: u64) {
let session_id = new_id("adopted-backend");
let started_at_ms = now_ms();
with_snapshot(diagnostics, |snapshot| {
snapshot.backend = Some(BackendSummary {
session_id: Some(session_id.clone()),
backend_kind: "adopted".to_string(),
log_segments: Vec::new(),
requested_port: Some(port),
reported_port: Some(port),
generation: Some(generation),
started_at_ms: Some(started_at_ms),
ended_at_ms: None,
exit_status: None,
intentional_stop: false,
terminal_reason: None,
last_error: None,
});
});
}
pub fn record_backend_start_failure(
diagnostics: &DiagnosticsState,
requested_port: Option<u16>,
@ -769,6 +790,9 @@ fn disposition_label(disposition: &DesktopPreflightDisposition) -> &'static str
DesktopPreflightDisposition::NotInstalled => "not_installed",
DesktopPreflightDisposition::ManagedReady => "managed_ready",
DesktopPreflightDisposition::ManagedStale => "managed_stale",
DesktopPreflightDisposition::OwnedReady => "owned_ready",
DesktopPreflightDisposition::OwnedStale => "owned_stale",
DesktopPreflightDisposition::AttachedReady => "attached_ready",
DesktopPreflightDisposition::ExternalConflict => "external_conflict",
}
}

View file

@ -2,6 +2,8 @@
mod commands;
mod desktop_auth;
mod desktop_backend_owner;
mod desktop_update_policy;
mod diagnostics;
mod install;
mod native_backend_lease;
@ -192,6 +194,8 @@ fn main() {
commands::cancel_pending_elevation,
commands::install_system_packages,
desktop_auth::desktop_auth,
desktop_update_policy::check_desktop_manual_update,
desktop_update_policy::desktop_update_policy,
diagnostics::collect_support_diagnostics,
native_intents::drain_native_intents,
native_intents::register_native_model_path,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
use super::types::BackendProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Deserialize)]
struct DesktopOwnerHealth {
kind: Option<String>,
token_sha256: Option<String>,
}
#[derive(Debug)]
pub(super) struct BackendHealth {
desktop_protocol_version: Option<u16>,
desktop_manageability_version: Option<u16>,
supports_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
studio_root_id: Option<String>,
desktop_owner: Option<DesktopOwnerHealth>,
version: Option<String>,
stale_reason: Option<String>,
}
pub(super) async fn backend_health(client: &reqwest::Client, port: u16) -> Option<BackendHealth> {
let url = format!("http://127.0.0.1:{port}/api/health");
let response = client.get(url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let json = response.json::<serde_json::Value>().await.ok()?;
let healthy = json
.get("status")
.and_then(|v| v.as_str())
.map(|s| s == "healthy")
.unwrap_or(false);
let service = json
.get("service")
.and_then(|v| v.as_str())
.map(|s| s == "Unsloth UI Backend")
.unwrap_or(false);
if !healthy || !service {
return None;
}
let desktop_protocol_version = json
.get("desktop_protocol_version")
.and_then(|v| v.as_u64())
.and_then(|v| u16::try_from(v).ok());
let desktop_manageability_version = json
.get("desktop_manageability_version")
.and_then(|v| v.as_u64())
.and_then(|v| u16::try_from(v).ok());
let supports_desktop_auth = json.get("supports_desktop_auth").and_then(|v| v.as_bool());
let supports_desktop_backend_ownership = json
.get("supports_desktop_backend_ownership")
.and_then(|v| v.as_bool());
let studio_root_id = json
.get("studio_root_id")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let desktop_owner = json
.get("desktop_owner")
.and_then(|v| serde_json::from_value::<DesktopOwnerHealth>(v.clone()).ok());
let version = json
.get("version")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let stale_reason = match supports_desktop_auth {
Some(false) => json
.get("desktop_auth_stale_reason")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
_ => None,
};
Some(BackendHealth {
desktop_protocol_version,
desktop_manageability_version,
supports_desktop_auth,
supports_desktop_backend_ownership,
studio_root_id,
desktop_owner,
version,
stale_reason,
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BackendRootStatus {
SameRoot,
ForeignRoot,
AmbiguousRoot,
ExpectedUnavailable,
}
fn backend_root_status(
health: &BackendHealth,
expected_studio_root_id: Option<&str>,
) -> BackendRootStatus {
let Some(expected) = expected_studio_root_id else {
return BackendRootStatus::ExpectedUnavailable;
};
match health.studio_root_id.as_deref() {
Some(actual) if actual == expected => BackendRootStatus::SameRoot,
Some(actual) if crate::desktop_backend_owner::is_valid_studio_root_id(actual) => {
BackendRootStatus::ForeignRoot
}
_ => BackendRootStatus::AmbiguousRoot,
}
}
fn backend_desktop_owner_match(
health: &BackendHealth,
) -> crate::desktop_backend_owner::HealthOwnerMatch {
let Some(owner) = health.desktop_owner.as_ref() else {
return crate::desktop_backend_owner::HealthOwnerMatch::None;
};
crate::desktop_backend_owner::classify_health_desktop_owner(
health.studio_root_id.as_deref(),
owner.kind.as_deref(),
owner.token_sha256.as_deref(),
)
}
fn backend_capability_stale_reason(health: &BackendHealth) -> Option<String> {
if health.desktop_protocol_version != Some(DESKTOP_PROTOCOL_VERSION) {
return health
.stale_reason
.clone()
.or_else(|| Some("desktop_protocol_incompatible".to_string()));
}
if health.supports_desktop_auth != Some(true) {
return health
.stale_reason
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
if health.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if health.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
backend_version_stale_reason(health.version.as_deref())
}
#[derive(Serialize)]
struct DesktopLoginProbe<'a> {
secret: &'a str,
}
pub(super) async fn probe_ownerless_spawned_backend(port: u16) -> BackendProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return BackendProbe::Missing,
};
let Some(health) = backend_health(&client, port).await else {
return BackendProbe::Missing;
};
if let Some(reason) = backend_capability_stale_reason(&health) {
return BackendProbe::Old { port, reason };
}
let response = client
.post(format!("http://127.0.0.1:{port}/api/auth/desktop-login"))
.json(&DesktopLoginProbe {
secret: "desktop-preflight-invalid-secret",
})
.send()
.await;
match response.map(|response| response.status()) {
Ok(reqwest::StatusCode::UNAUTHORIZED) => BackendProbe::Ready { port },
Ok(reqwest::StatusCode::NOT_FOUND) => BackendProbe::Old {
port,
reason: "desktop_login_not_found".to_string(),
},
_ => BackendProbe::Old {
port,
reason: "desktop_login_probe_failed".to_string(),
},
}
}
pub(super) async fn backend_desktop_auth_status(
client: &reqwest::Client,
port: u16,
health: &BackendHealth,
expected_studio_root_id: Option<&str>,
) -> BackendProbe {
let root_status = backend_root_status(health, expected_studio_root_id);
let owner_match = backend_desktop_owner_match(health);
let verified_owner = owner_match == crate::desktop_backend_owner::HealthOwnerMatch::CurrentApp;
let same_root_external = root_status == BackendRootStatus::SameRoot && !verified_owner;
match root_status {
BackendRootStatus::AmbiguousRoot | BackendRootStatus::ExpectedUnavailable => {
return BackendProbe::ExternalConflict {
port,
reason: "ambiguous_root_external_backend_active".to_string(),
};
}
BackendRootStatus::ForeignRoot => {
return BackendProbe::Old {
port,
reason: "studio_root_id_mismatch".to_string(),
};
}
BackendRootStatus::SameRoot => {}
}
if same_root_external
&& matches!(
owner_match,
crate::desktop_backend_owner::HealthOwnerMatch::PreviousApp
| crate::desktop_backend_owner::HealthOwnerMatch::OtherDesktopOwner
)
{
return BackendProbe::ExternalConflict {
port,
reason: "desktop_owned_backend_active".to_string(),
};
}
if let Some(reason) = backend_capability_stale_reason(health) {
return if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
};
}
let url = format!("http://127.0.0.1:{port}/api/auth/desktop-login");
let response = client
.post(url)
.json(&DesktopLoginProbe {
secret: "desktop-preflight-invalid-secret",
})
.send()
.await;
let Ok(response) = response else {
let reason = backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string());
return if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
};
};
match response.status() {
reqwest::StatusCode::UNAUTHORIZED => BackendProbe::Ready { port },
reqwest::StatusCode::NOT_FOUND => {
let reason = "desktop_login_not_found".to_string();
if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
}
}
_ => {
let reason = backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string());
if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
}
}
}
}
pub(super) async fn probe_existing_backends(ignored_ports: &[u16]) -> BackendProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return BackendProbe::Missing,
};
// Fan out health probes concurrently. The desktop-auth probe is still
// sequential per candidate because it has auth-log side effects.
let ports: Vec<u16> = crate::desktop_backend_owner::desktop_candidate_ports().collect();
let mut health_futs = Vec::with_capacity(ports.len());
for port in ports {
let c = client.clone();
health_futs.push(tokio::spawn(async move {
backend_health(&c, port).await.map(|h| (port, h))
}));
}
let mut candidates: Vec<(u16, BackendHealth)> = Vec::new();
for fut in health_futs {
if let Ok(Some(pair)) = fut.await {
candidates.push(pair);
}
}
let expected_studio_root_id = crate::desktop_backend_owner::read_expected_studio_root_id();
let mut first_conflict = None;
let mut first_ready = None;
let mut first_old = None;
for (port, health) in candidates {
if ignored_ports.contains(&port) {
continue;
}
match backend_desktop_auth_status(
&client,
port,
&health,
expected_studio_root_id.as_deref(),
)
.await
{
conflict @ BackendProbe::ExternalConflict { .. } if first_conflict.is_none() => {
first_conflict = Some(conflict)
}
ready @ BackendProbe::Ready { .. } if first_ready.is_none() => {
first_ready = Some(ready)
}
old @ BackendProbe::Old { .. } if first_old.is_none() => first_old = Some(old),
_ => {}
}
}
first_conflict
.or(first_ready)
.or(first_old)
.unwrap_or(BackendProbe::Missing)
}

View file

@ -0,0 +1,169 @@
use super::types::ManagedProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
#[derive(Debug, Deserialize)]
struct DesktopCapability {
desktop_protocol_version: Option<u16>,
desktop_manageability_version: Option<u16>,
supports_api_only: Option<bool>,
supports_provision_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
desktop_auth_stale_reason: Option<String>,
version: Option<String>,
}
async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
let mut cmd = Command::new(bin);
cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return false;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => status.success(),
_ => {
let _ = child.kill().await;
let _ = child.wait().await;
false
}
}
}
async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
let mut cmd = Command::new(bin);
cmd.args(["studio", "desktop-capabilities", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return None;
};
let Some(mut stdout) = child.stdout.take() else {
return None;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) if status.success() => {}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
return None;
}
_ => return None,
}
let mut output = Vec::new();
if stdout.read_to_end(&mut output).await.is_err() {
return None;
}
serde_json::from_slice::<DesktopCapability>(&output).ok()
}
fn desktop_capability_stale_reason(capability: &DesktopCapability) -> Option<String> {
if capability.desktop_protocol_version != Some(DESKTOP_PROTOCOL_VERSION) {
return Some("desktop_protocol_incompatible".to_string());
}
if capability.supports_api_only != Some(true) {
return Some("desktop_api_only_unsupported".to_string());
}
if capability.supports_provision_desktop_auth != Some(true) {
return capability
.desktop_auth_stale_reason
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
if capability.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if capability.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
backend_version_stale_reason(capability.version.as_deref())
}
fn desktop_capability_ready(capability: &DesktopCapability) -> bool {
desktop_capability_stale_reason(capability).is_none()
}
pub(super) async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
if !run_cli_probe(&bin, &["-h"]).await {
return ManagedProbe::Stale {
bin,
reason: "cli_unusable".to_string(),
};
}
let capability = probe_cli_capability(&bin).await;
if let Some(capability) = capability {
if desktop_capability_ready(&capability) {
return ManagedProbe::Ready { bin };
}
return ManagedProbe::Stale {
bin,
reason: desktop_capability_stale_reason(&capability)
.unwrap_or_else(|| "desktop_capability_incompatible".to_string()),
};
}
ManagedProbe::Stale {
bin,
reason: "desktop_capability_probe_failed".to_string(),
}
}
pub(super) async fn probe_managed_install() -> ManagedProbe {
match crate::process::find_unsloth_binary() {
Some(bin) => probe_managed_bin(bin).await,
None => ManagedProbe::Missing,
}
}
pub async fn managed_install_ready() -> bool {
matches!(probe_managed_install().await, ManagedProbe::Ready { .. })
}

View file

@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopPreflightDisposition {
NotInstalled,
ManagedReady,
ManagedStale,
OwnedReady,
OwnedStale,
AttachedReady,
ExternalConflict,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DesktopPreflightResult {
pub disposition: DesktopPreflightDisposition,
pub reason: Option<String>,
pub port: Option<u16>,
pub can_auto_repair: bool,
pub managed_bin: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalBackendConflict {
pub port: u16,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ManagedProbe {
Missing,
Ready { bin: PathBuf },
Stale { bin: PathBuf, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum BackendProbe {
Missing,
Ready { port: u16 },
Old { port: u16, reason: String },
ExternalConflict { port: u16, reason: String },
}

View file

@ -0,0 +1,163 @@
use std::cmp::Ordering;
pub(crate) const DESKTOP_PROTOCOL_VERSION: u16 = 1;
pub(crate) const DESKTOP_MANAGEABILITY_VERSION: u16 = 1;
// Explicit backend package minimum, not the desktop app Cargo version: backend
// and app releases can diverge. When bumping, verify this package exists on PyPI.
pub(super) const MIN_DESKTOP_BACKEND_VERSION: &str = "2026.5.3";
#[derive(Debug, Eq, PartialEq)]
pub(super) struct ParsedVersion {
release: [u64; 3],
suffix: VersionSuffix,
}
#[derive(Debug, Eq, PartialEq)]
enum VersionSuffix {
Dev(u64),
Alpha(u64),
Beta(u64),
Rc(u64),
PreRelease(String),
Stable,
Build,
Post(u64),
}
pub(super) fn parse_version(value: &str) -> Option<ParsedVersion> {
let value = value.trim();
let mut parts = value.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_and_suffix = parts.next()?;
let patch_len = patch_and_suffix
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(patch_and_suffix.len());
if patch_len == 0 {
return None;
}
let suffix = parse_version_suffix(&patch_and_suffix[patch_len..])?;
Some(ParsedVersion {
release: [major, minor, patch_and_suffix[..patch_len].parse().ok()?],
suffix,
})
}
fn parse_version_suffix(suffix: &str) -> Option<VersionSuffix> {
if suffix.is_empty() {
return Some(VersionSuffix::Stable);
}
if let Some(value) = suffix.strip_prefix('+') {
return suffix_part_valid(value).then_some(VersionSuffix::Build);
}
if let Some(value) = suffix.strip_prefix('-') {
return suffix_part_valid(value)
.then(|| VersionSuffix::PreRelease(value.to_ascii_lowercase()));
}
if let Some(number) = parse_numbered_suffix(suffix, &[".post", "post"]) {
return number.map(VersionSuffix::Post);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".dev", "dev"]) {
return number.map(VersionSuffix::Dev);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".rc", "rc"]) {
return number.map(VersionSuffix::Rc);
}
if let Some(number) = parse_numbered_suffix(suffix, &["a"]) {
return number.map(VersionSuffix::Alpha);
}
if let Some(number) = parse_numbered_suffix(suffix, &["b"]) {
return number.map(VersionSuffix::Beta);
}
None
}
fn parse_numbered_suffix(suffix: &str, prefixes: &[&str]) -> Option<Option<u64>> {
for prefix in prefixes {
let Some(number) = suffix.strip_prefix(prefix) else {
continue;
};
if number.is_empty() {
return Some(Some(0));
}
return Some(number.parse().ok());
}
None
}
fn suffix_part_valid(value: &str) -> bool {
!value.is_empty()
&& value.split('.').all(|part| {
!part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn compare_versions(left: &ParsedVersion, right: &ParsedVersion) -> Ordering {
match left.release.cmp(&right.release) {
Ordering::Equal => compare_suffixes(&left.suffix, &right.suffix),
ordering => ordering,
}
}
fn compare_suffixes(left: &VersionSuffix, right: &VersionSuffix) -> Ordering {
let left_precedence = suffix_precedence(left);
let right_precedence = suffix_precedence(right);
if left_precedence != right_precedence {
return left_precedence.cmp(&right_precedence);
}
match (left, right) {
(VersionSuffix::Dev(left), VersionSuffix::Dev(right))
| (VersionSuffix::Alpha(left), VersionSuffix::Alpha(right))
| (VersionSuffix::Beta(left), VersionSuffix::Beta(right))
| (VersionSuffix::Rc(left), VersionSuffix::Rc(right))
| (VersionSuffix::Post(left), VersionSuffix::Post(right)) => left.cmp(right),
(VersionSuffix::PreRelease(left), VersionSuffix::PreRelease(right)) => left.cmp(right),
_ => Ordering::Equal,
}
}
fn suffix_precedence(suffix: &VersionSuffix) -> u8 {
match suffix {
VersionSuffix::Dev(_) => 0,
VersionSuffix::Alpha(_) => 1,
VersionSuffix::Beta(_) => 2,
VersionSuffix::Rc(_) | VersionSuffix::PreRelease(_) => 3,
VersionSuffix::Stable | VersionSuffix::Build => 4,
VersionSuffix::Post(_) => 5,
}
}
pub(super) fn backend_version_compatible(version: Option<&str>) -> bool {
let Some(version) = version else {
return false;
};
if cfg!(debug_assertions) && version == "dev" {
return true;
}
let Some(actual) = parse_version(version) else {
return false;
};
let Some(minimum) = parse_version(MIN_DESKTOP_BACKEND_VERSION) else {
return false;
};
compare_versions(&actual, &minimum) != Ordering::Less
}
pub(crate) fn backend_version_stale_reason(version: Option<&str>) -> Option<String> {
if backend_version_compatible(version) {
return None;
}
match version {
None | Some("") => Some("desktop_backend_version_missing".to_string()),
Some("dev") if !cfg!(debug_assertions) => {
Some("desktop_backend_version_invalid".to_string())
}
Some(value) if parse_version(value).is_none() => {
Some("desktop_backend_version_invalid".to_string())
}
Some(_) => Some("desktop_backend_version_too_old".to_string()),
}
}

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ2RDhGMDMzNDNEMTlGMkQKUldRdG45RkRNL0RZMXZsaVo2TElUQlVHb1hVNWEyajhUOGFXeTdNVDFZTFdBVUtlZUh5L2wwWVYK",
"endpoints": [
"https://github.com/unslothai/unsloth/releases/latest/download/latest.json"
"https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json"
],
"windows": {
"installMode": "passive"
@ -74,9 +74,6 @@
"linux": {
"deb": {
"postRemoveScript": "./linux/postremove.sh"
},
"rpm": {
"postRemoveScript": "./linux/postremove.sh"
}
}
}

View file

@ -139,3 +139,81 @@ if not _has_real_accelerator():
if not _preload_device_type("unsloth"):
_install_device_type_stub("unsloth.device_type")
_patch_torch_cuda_for_import()
# ---------------------------------------------------------------------------
# Apply the peft + transformers-4.x stub-injection fix before pytest collects
# tests that import peft.utils.transformers_weight_conversion. Production runs
# this via unsloth/_gpu_init.py, but the GPU-free harness above skips full
# package init, so we load just the standalone import-fixes module by path.
# ---------------------------------------------------------------------------
def _apply_unsloth_peft_import_fix_for_tests() -> None:
import importlib.util as _ilu
try:
pkg_spec = _ilu.find_spec("unsloth")
except Exception:
return
if pkg_spec is None or not pkg_spec.submodule_search_locations:
return
fix_path = os.path.join(
pkg_spec.submodule_search_locations[0],
"import_fixes.py",
)
if not os.path.exists(fix_path):
return
mod_name = "unsloth.import_fixes"
_installed_skeleton = False
if mod_name in sys.modules:
mod = sys.modules[mod_name]
else:
# Submodule import needs SOME parent ``unsloth`` entry; reuse or
# install a bare skeleton and pop on exit so later ``import unsloth``
# calls hit the real package init.
if "unsloth" not in sys.modules:
pkg = types.ModuleType("unsloth")
pkg.__path__ = list(pkg_spec.submodule_search_locations)
pkg.__spec__ = pkg_spec
pkg.__package__ = "unsloth"
pkg.__file__ = os.path.join(
pkg_spec.submodule_search_locations[0],
"__init__.py",
)
sys.modules["unsloth"] = pkg
_installed_skeleton = True
spec = _ilu.spec_from_file_location(mod_name, fix_path)
if spec is None or spec.loader is None:
if _installed_skeleton:
sys.modules.pop("unsloth", None)
return
mod = _ilu.module_from_spec(spec)
sys.modules[mod_name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(mod_name, None)
if _installed_skeleton:
sys.modules.pop("unsloth", None)
return
fix = getattr(mod, "fix_peft_transformers_weight_conversion_import", None)
if fix is None:
if _installed_skeleton:
sys.modules.pop("unsloth", None)
return
try:
fix()
except Exception:
# Individual fix is internally guarded; don't take pytest down.
pass
finally:
# Drop scratch skeleton; import_fixes itself stays cached as
# ``unsloth.import_fixes`` without an active parent.
if _installed_skeleton:
sys.modules.pop("unsloth", None)
_apply_unsloth_peft_import_fix_for_tests()

View file

Some files were not shown because too many files have changed in this diff Show more