Merge branch 'main' into fix-gemma4-openai-api-streams
Resolve studio/backend/routes/inference.py: - keep the reasoning_content split (the #6471 fix) on the GGUF chat streams - keep each streaming path's direct _SameTaskStreamingResponse return (the cancel-scope fix) and route main's new _sse_streaming_response helper through _SameTaskStreamingResponse too, so every SSE path stays in the request task - keep both the new _SameTaskStreamingResponse class and main's _aclose_stream_resources teardown helper All 466 tests across the 9 changed test files pass.
This commit is contained in:
commit
21edbb94a3
43 changed files with 4540 additions and 1808 deletions
3
.github/workflows/studio-backend-ci.yml
vendored
3
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -222,6 +222,9 @@ jobs:
|
|||
for s in \
|
||||
tests/sh/test_get_torch_index_url.sh \
|
||||
tests/sh/test_mac_intel_compat.sh \
|
||||
tests/sh/test_node_decision.sh \
|
||||
tests/sh/test_studio_home_node_dir.sh \
|
||||
tests/sh/test_system_node_readonly.sh \
|
||||
tests/sh/test_nvcc_meets_llama_minimum.sh \
|
||||
tests/sh/test_tauri_install_exit_order.sh \
|
||||
tests/sh/test_torch_constraint.sh \
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ jobs:
|
|||
}
|
||||
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_node_decision.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -79,15 +79,15 @@ jobs:
|
|||
# Two surgical fixes against measured Windows-only install
|
||||
# waste (vs Mac/Linux on the same SHA):
|
||||
#
|
||||
# (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
|
||||
# 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
||||
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+
|
||||
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
||||
# actions/setup-node@v4 with `node-version: '22'` lands
|
||||
# Node 22.22.2 + the npm 10.9.7 it bundles, so the npm
|
||||
# check fails and setup.ps1 falls through to the
|
||||
# "winget install Node.js LTS" branch -- a ~35 s reinstall
|
||||
# of Node we don't need. `npm install -g npm@^11` updates
|
||||
# the bundled npm in-place in ~5 s, which makes setup.ps1
|
||||
# short-circuit on the existing Node.
|
||||
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision
|
||||
# is "bundled" and setup.ps1 downloads an isolated Node (~30
|
||||
# MB) we don't need on a runner that already has a fine Node.
|
||||
# `npm install -g npm@^11` updates the runner's npm in-place
|
||||
# in ~5 s, flipping the decision to "system" so setup.ps1
|
||||
# reuses the existing Node with no download.
|
||||
#
|
||||
# (2) Defender. windows-latest's real-time scan opens / hashes
|
||||
# every file Studio writes during install (Vite output =
|
||||
|
|
|
|||
58
README.md
58
README.md
|
|
@ -86,6 +86,8 @@ unsloth studio -p 8888
|
|||
```
|
||||
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
|
||||
|
||||
For a secure HTTPS link instead of a raw network port, use `unsloth studio --secure`. Studio stays bound to localhost and is served only through a free Cloudflare HTTPS tunnel (it fails closed if the tunnel can't start, so the raw port is never exposed).
|
||||
|
||||
#### Docker
|
||||
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
|
||||
```bash
|
||||
|
|
@ -162,13 +164,19 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
|
|||
|
||||
## 📥 Advanced Installation
|
||||
The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation).
|
||||
#### Developer installs: macOS, Linux, WSL:
|
||||
#### Developer / Nightly / Experimental installs: macOS, Linux, WSL:
|
||||
The developer install builds from the `main` branch, which is the latest (nightly) source.
|
||||
```bash
|
||||
git clone https://github.com/unslothai/unsloth
|
||||
cd unsloth
|
||||
./install.sh --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
|
||||
```bash
|
||||
UNSLOTH_STUDIO_HOME="$PWD/.studio" ./install.sh --local
|
||||
UNSLOTH_STUDIO_HOME="$PWD/.studio" unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```bash
|
||||
cd unsloth && git pull
|
||||
|
|
@ -176,7 +184,8 @@ cd unsloth && git pull
|
|||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Developer installs: Windows PowerShell:
|
||||
#### Developer / Nightly / Experimental installs: Windows PowerShell:
|
||||
The developer install builds from the `main` branch, which is the latest (nightly) source.
|
||||
```powershell
|
||||
git clone https://github.com/unslothai/unsloth.git
|
||||
cd unsloth
|
||||
|
|
@ -184,40 +193,31 @@ Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
|||
.\install.ps1 --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
|
||||
```powershell
|
||||
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; .\install.ps1 --local
|
||||
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```bash
|
||||
cd unsloth && git pull
|
||||
./install.sh --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Nightly: MacOS, Linux, WSL:
|
||||
```bash
|
||||
git clone https://github.com/unslothai/unsloth
|
||||
cd unsloth
|
||||
git checkout nightly
|
||||
./install.sh --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to launch every time:
|
||||
```bash
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Nightly: Windows:
|
||||
Run in Windows Powershell:
|
||||
```powershell
|
||||
git clone https://github.com/unslothai/unsloth.git
|
||||
cd unsloth
|
||||
git checkout nightly
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
cd unsloth; git pull
|
||||
.\install.ps1 --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to launch every time:
|
||||
|
||||
#### Remote access: `--secure` (HTTPS tunnel) vs raw port
|
||||
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
|
||||
|
||||
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
|
||||
```bash
|
||||
unsloth studio -p 8888
|
||||
unsloth studio --secure -p 8888
|
||||
```
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network.
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
```
|
||||
|
||||
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
|
||||
|
||||
#### Advanced launch options
|
||||
Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
|
||||
|
|
|
|||
|
|
@ -2298,7 +2298,9 @@ exit 0
|
|||
|
||||
# ── Run studio setup ──
|
||||
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
|
||||
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
|
||||
# CUDA Toolkit, and other dependencies automatically via winget. Node.js is
|
||||
# NOT installed via winget -- setup.ps1 uses an isolated Node it manages and
|
||||
# never touches the system Node/npm.
|
||||
Write-TauriLog "STEP" "Running studio setup"
|
||||
step "setup" "running unsloth studio setup..."
|
||||
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
|
|
@ -100,6 +101,14 @@ def get_connection() -> sqlite3.Connection:
|
|||
"""Get a connection to the auth database, creating tables if needed."""
|
||||
ensure_dir(DB_PATH.parent)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
# Keep the auth dir + DB private (they hold the JWT/identity secrets and
|
||||
# password hashes); sqlite3.connect would otherwise create the DB 0644 under
|
||||
# a 022 umask, letting another OS user read the identity secret and forge proofs.
|
||||
for _path, _mode in ((DB_PATH.parent, 0o700), (DB_PATH, 0o600)):
|
||||
try:
|
||||
os.chmod(_path, _mode)
|
||||
except OSError:
|
||||
pass
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
|
|
@ -210,6 +219,57 @@ def _get_or_create_api_key_pbkdf2_salt() -> bytes:
|
|||
return salt
|
||||
|
||||
|
||||
# Secret answering the /api/auth/identity challenge (HMAC(secret, nonce)). Lives
|
||||
# in this same-user DB so a port squatter or remote/fake server can't forge a
|
||||
# proof. Separate from the per-user JWT secret.
|
||||
_IDENTITY_SECRET_DB_KEY = "studio_identity_secret"
|
||||
_identity_secret_cache: Optional[bytes] = None
|
||||
|
||||
|
||||
def get_or_create_identity_secret() -> bytes:
|
||||
"""Return the identity secret (hex 32-byte row in app_secrets), creating it once."""
|
||||
global _identity_secret_cache
|
||||
if _identity_secret_cache is not None:
|
||||
return _identity_secret_cache
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
(_IDENTITY_SECRET_DB_KEY,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)",
|
||||
(_IDENTITY_SECRET_DB_KEY, secrets.token_hex(32)),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
(_IDENTITY_SECRET_DB_KEY,),
|
||||
).fetchone()
|
||||
secret = bytes.fromhex(row["value"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_identity_secret_cache = secret
|
||||
return secret
|
||||
|
||||
|
||||
def compute_identity_proof(nonce: bytes, host: str, port: int) -> str:
|
||||
"""HMAC-SHA256 proof that the caller holds this install's identity secret,
|
||||
bound to the loopback address and port the connection landed on. A proof
|
||||
relayed from a Studio on a different address/port (a squatter proxying to the
|
||||
real one, e.g. localhost resolving to ::1 while Studio is on 127.0.0.1) was
|
||||
computed for that other endpoint and won't match the one the client dialed."""
|
||||
try:
|
||||
host = ipaddress.ip_address(host).compressed # normalise 127.0.0.1 / ::1 forms
|
||||
except ValueError:
|
||||
host = (host or "").lower()
|
||||
msg = b"|".join([nonce, host.encode(), str(int(port)).encode()])
|
||||
return hmac.new(get_or_create_identity_secret(), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
_API_KEY_PBKDF2_ITERATIONS = 100_000
|
||||
DESKTOP_SECRET_PREFIX = "desktop-"
|
||||
_DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from loggers import get_logger
|
||||
from utils.node_runtime import resolve_node_executable
|
||||
from utils.paths import ensure_dir, oxc_validator_tmp_root
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -231,6 +232,14 @@ def _run_oxc_batch(
|
|||
"code_shape": code_shape,
|
||||
"codes": code_values,
|
||||
}
|
||||
# Resolve a usable Node (system or the isolated install, which is not on the
|
||||
# user's PATH); a bare "node" would fail for isolated-Node users.
|
||||
node_executable = resolve_node_executable()
|
||||
if not node_executable:
|
||||
return _fallback_results(
|
||||
len(code_values),
|
||||
"Node.js not found (install Node >= 20.19, or re-run Studio setup to provision it).",
|
||||
)
|
||||
try:
|
||||
tmp_dir = ensure_dir(oxc_validator_tmp_root())
|
||||
env = child_env_without_native_path_secret()
|
||||
|
|
@ -238,8 +247,13 @@ def _run_oxc_batch(
|
|||
env["TMPDIR"] = tmp_dir_str
|
||||
env["TMP"] = tmp_dir_str
|
||||
env["TEMP"] = tmp_dir_str
|
||||
# Resolved node's dir first on the child PATH so it finds its own npm/npx.
|
||||
node_bin_dir = os.path.dirname(node_executable)
|
||||
if node_bin_dir:
|
||||
env["PATH"] = node_bin_dir + os.pathsep + env.get("PATH", "")
|
||||
env.pop("NODE_PATH", None)
|
||||
proc = subprocess.run(
|
||||
["node", str(_OXC_RUNNER_PATH)],
|
||||
[node_executable, str(_OXC_RUNNER_PATH)],
|
||||
cwd = str(_OXC_TOOL_DIR),
|
||||
input = json.dumps(payload),
|
||||
text = True,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -412,12 +412,13 @@ class InferenceStatusResponse(BaseModel):
|
|||
"(auto on an MTP model, or forced mtp / mtp+ngram). "
|
||||
"'binary_no_mtp' / 'binary_outdated' -> a newer prebuilt would "
|
||||
"re-enable it (show the update affordance); 'runtime_error' -> the "
|
||||
"current build could not run it. 'mla_mtp_disabled' -> an Auto-mode "
|
||||
"policy downgrade: the model is MLA (GLM-5.2 et al.) whose llama.cpp "
|
||||
"MTP path runs slower than no speculation, so Auto used ngram-mod or "
|
||||
"spec-off instead -- updating won't help; choose MTP in Settings (or "
|
||||
"set UNSLOTH_MLA_MTP_ENABLED=1) to force it. None when MTP engaged or "
|
||||
"was not requested."
|
||||
"current build could not run it; 'drafter_not_found' -> the model's "
|
||||
"separate MTP drafter could not be resolved; 'mla_mtp_disabled' -> "
|
||||
"an Auto-mode policy downgrade: the model is MLA (GLM-5.2 et al.) "
|
||||
"whose llama.cpp MTP path runs slower than no speculation, so Auto "
|
||||
"used ngram-mod or spec-off instead -- updating won't help; choose "
|
||||
"MTP in Settings (or set UNSLOTH_MLA_MTP_ENABLED=1) to force it. "
|
||||
"None when MTP engaged or was not requested."
|
||||
),
|
||||
)
|
||||
llama_cpp_prebuilt_stale: bool = Field(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import os
|
||||
import shlex
|
||||
|
|
@ -214,6 +215,34 @@ def _clear_login_bucket(key: tuple[str, str]) -> None:
|
|||
_LOGIN_IP_BUCKETS.pop(ip, None)
|
||||
|
||||
|
||||
# Sync def (not async): compute_identity_proof touches SQLite on the first call,
|
||||
# so FastAPI runs it in the threadpool rather than blocking the event loop.
|
||||
@router.get("/identity")
|
||||
def identity(nonce: str, request: Request) -> dict:
|
||||
"""Challenge-response proof this is the real local Studio: caller sends a nonce,
|
||||
gets HMAC(install identity secret, nonce, connection address + port).
|
||||
Unauthenticated and side-effect free; a process that can't read the same-user
|
||||
secret can't forge a proof, and binding to the address/port the connection
|
||||
landed on stops a squatter relaying a proof from the real Studio elsewhere."""
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(nonce)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_400_BAD_REQUEST, detail = "nonce must be base64url"
|
||||
)
|
||||
if not 16 <= len(raw) <= 128:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_400_BAD_REQUEST, detail = "nonce must decode to 16-128 bytes"
|
||||
)
|
||||
# The address + port the connection actually landed on, from the socket
|
||||
# (request.scope is getsockname, so it is the real local address even when
|
||||
# bound to 0.0.0.0), never the client-controlled Host header.
|
||||
server = request.scope.get("server") or ("", 0)
|
||||
host = server[0] or ""
|
||||
port = server[1] if server[1] is not None else 0
|
||||
return {"proof": storage.compute_identity_proof(raw, host, port)}
|
||||
|
||||
|
||||
@router.get("/status", response_model = AuthStatusResponse)
|
||||
async def auth_status() -> AuthStatusResponse:
|
||||
"""Auth initialization state; ``default_username`` is exposed for first-boot UI prefill only."""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ class LlamaUpdateJob(BaseModel):
|
|||
message: str = ""
|
||||
from_tag: Optional[str] = None
|
||||
to_tag: Optional[str] = None
|
||||
reload_required: Optional[bool] = None
|
||||
error: Optional[str] = None
|
||||
progress: Optional[float] = Field(None, description = "0..1 while running, 1 on success.")
|
||||
started_at: Optional[str] = None
|
||||
|
|
|
|||
96
studio/backend/tests/test_identity.py
Normal file
96
studio/backend/tests/test_identity.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for the server identity handshake (`GET /api/auth/identity`).
|
||||
|
||||
The endpoint lets a client confirm an endpoint is really this Studio install
|
||||
before sending it a credential: the client sends a random nonce and checks the
|
||||
returned HMAC against one computed from the install identity secret. A process
|
||||
that cannot read this same-user secret cannot forge a matching proof.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth import storage
|
||||
|
||||
HOST = "127.0.0.1" # the proof is bound to the connection address...
|
||||
PORT = 8765 # ...and port
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def isolated_auth_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_identity_secret_cache", None)
|
||||
yield
|
||||
|
||||
|
||||
def test_identity_secret_is_persistent_and_cached():
|
||||
first = storage.get_or_create_identity_secret()
|
||||
assert isinstance(first, bytes) and len(first) == 32
|
||||
# Cached in-process.
|
||||
assert storage.get_or_create_identity_secret() == first
|
||||
# Persisted: a fresh process (cache cleared) reads the same stored value.
|
||||
storage._identity_secret_cache = None
|
||||
assert storage.get_or_create_identity_secret() == first
|
||||
|
||||
|
||||
def test_compute_identity_proof_matches_manual_hmac():
|
||||
nonce = b"a-fixed-nonce-for-the-proof-test!"
|
||||
secret = storage.get_or_create_identity_secret()
|
||||
expected = hmac.new(
|
||||
secret, b"|".join([nonce, HOST.encode(), str(PORT).encode()]), hashlib.sha256
|
||||
).hexdigest()
|
||||
assert storage.compute_identity_proof(nonce, HOST, PORT) == expected
|
||||
# Bound to nonce, host and port: changing any one yields a different proof.
|
||||
assert (
|
||||
storage.compute_identity_proof(b"a-different-nonce-entirely-here!!", HOST, PORT) != expected
|
||||
)
|
||||
assert storage.compute_identity_proof(nonce, "127.0.0.2", PORT) != expected
|
||||
assert storage.compute_identity_proof(nonce, HOST, PORT + 1) != expected
|
||||
|
||||
|
||||
def test_proof_differs_when_secret_differs(tmp_path, monkeypatch):
|
||||
nonce = b"shared-nonce-across-two-installs!"
|
||||
proof_a = storage.compute_identity_proof(nonce, HOST, PORT)
|
||||
# A different install (different secret) can't reproduce the proof.
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "other_auth.db")
|
||||
monkeypatch.setattr(storage, "_identity_secret_cache", None)
|
||||
assert storage.compute_identity_proof(nonce, HOST, PORT) != proof_a
|
||||
|
||||
|
||||
def _identity_client() -> TestClient:
|
||||
# routes.auth pulls the whole routes package (-> inference -> llama_cpp). Skip
|
||||
# if those heavy deps are missing; the proof crypto is covered above.
|
||||
try:
|
||||
from routes.auth import router
|
||||
except Exception as exc: # pragma: no cover - environment-dependent
|
||||
pytest.skip(f"routes.auth not importable in this environment: {exc}")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix = "/api/auth")
|
||||
# base_url host:port become scope["server"], which the route binds the proof to.
|
||||
return TestClient(app, base_url = f"http://{HOST}:{PORT}")
|
||||
|
||||
|
||||
def test_identity_route_returns_matching_proof():
|
||||
client = _identity_client()
|
||||
nonce = b"route-level-nonce-for-the-server!"
|
||||
encoded = base64.urlsafe_b64encode(nonce).decode()
|
||||
response = client.get(f"/api/auth/identity?nonce={encoded}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["proof"] == storage.compute_identity_proof(nonce, HOST, PORT)
|
||||
|
||||
|
||||
def test_identity_route_validates_nonce():
|
||||
client = _identity_client()
|
||||
# Decodes to < 16 bytes: too little entropy to be meaningful.
|
||||
short = base64.urlsafe_b64encode(b"tiny").decode()
|
||||
assert client.get(f"/api/auth/identity?nonce={short}").status_code == 400
|
||||
# Missing nonce: FastAPI request validation.
|
||||
assert client.get("/api/auth/identity").status_code == 422
|
||||
|
|
@ -1653,6 +1653,8 @@ _REAL_REPO_MATRIX = [
|
|||
|
||||
def _resolve_real(monkeypatch, repo, drafter, mode):
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
if "qwen" in repo.lower() and "-mtp" in repo.lower():
|
||||
backend._nextn_predict_layers = 1
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = mode,
|
||||
spec_draft_n_max = None,
|
||||
|
|
@ -1843,3 +1845,126 @@ def test_spec_fallback_reason_reset_on_off(monkeypatch):
|
|||
binary = "/fake/llama-server",
|
||||
)
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_is_gemma_mtp_family():
|
||||
from core.inference.llama_cpp import _is_gemma_mtp_family
|
||||
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-4-E4B-it-GGUF") is True
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-4-12b-it-GGUF") is True
|
||||
# gemma-3n ships no separate drafter, so it is not a drafter family.
|
||||
assert _is_gemma_mtp_family("unsloth/gemma-3n-E2B-it-GGUF") is False
|
||||
assert _is_gemma_mtp_family("unsloth/Qwen3.5-35B-A3B-MTP-GGUF") is False
|
||||
assert _is_gemma_mtp_family("unsloth/llama-3-8b") is False
|
||||
|
||||
|
||||
def test_gemma_3n_without_drafter_is_not_mtp(monkeypatch):
|
||||
# gemma-3n ships no drafter; it must take the normal non-MTP path, not
|
||||
# drafter_not_found (which would make every reload retry a missing drafter).
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-3n-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
assert backend.spec_fallback_reason is None
|
||||
|
||||
|
||||
def test_spec_fallback_reason_drafter_not_found(monkeypatch):
|
||||
# Drafterless Gemma should fall back to ngram-mod + drafter_not_found.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None, # Drafter download failed
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert backend.speculative_type == "ngram-mod"
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def test_is_gemma_mtp_name_none_safe():
|
||||
# model_identifier=None (local load) must not raise; recognise via filename.
|
||||
from core.inference.llama_cpp import _is_gemma_mtp_family, _is_gemma_mtp_name
|
||||
|
||||
assert _is_gemma_mtp_family(None) is False
|
||||
assert _is_gemma_mtp_name(None, "/models/gemma-4-E4B-it-Q4_K_M.gguf") is True
|
||||
assert _is_gemma_mtp_name("unsloth/Qwen3.5-4B-MTP-GGUF", None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["mtp", "mtp+ngram"])
|
||||
def test_forced_mtp_gemma_without_drafter_falls_back(monkeypatch, mode):
|
||||
# Forced MTP on a drafterless Gemma must fall back, not emit draft-mtp.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = mode,
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
model_path = None,
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert "--model-draft" not in parsed
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def test_local_gemma_gguf_without_identifier_falls_back(monkeypatch):
|
||||
# Local Gemma GGUF (family only in filename) must not crash; falls back.
|
||||
backend = _resolver_backend(monkeypatch)
|
||||
flags = backend._build_speculative_flags(
|
||||
speculative_type = "auto",
|
||||
spec_draft_n_max = None,
|
||||
extra_args = None,
|
||||
model_identifier = None,
|
||||
model_path = "/models/gemma-4-E4B-it-Q4_K_M.gguf",
|
||||
gpus = True,
|
||||
binary = "/fake/llama-server",
|
||||
mtp_draft_path = None,
|
||||
)
|
||||
parsed = _flags_dict(flags)
|
||||
assert parsed.get("--spec-type") == "ngram-mod"
|
||||
assert backend.spec_fallback_reason == "drafter_not_found"
|
||||
|
||||
|
||||
def _drafter_not_found_kwargs():
|
||||
return dict(
|
||||
model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
hf_variant = "Q4_K_M",
|
||||
n_ctx = 8192,
|
||||
cache_type_kv = None,
|
||||
speculative_type = "auto",
|
||||
chat_template_override = None,
|
||||
extra_args = None,
|
||||
is_vision = False,
|
||||
gguf_path = None, # HF load: drafter resolves inside load_model
|
||||
)
|
||||
|
||||
|
||||
def test_already_in_target_state_retries_after_hf_drafter_not_found():
|
||||
# Recoverable drafter_not_found must not dedupe; reload re-attempts download.
|
||||
backend = _mtp_backend(
|
||||
_model_identifier = "unsloth/gemma-4-E4B-it-GGUF",
|
||||
_speculative_type = "ngram-mod",
|
||||
_spec_fallback_reason = "drafter_not_found",
|
||||
_mtp_draft_path = None,
|
||||
_gguf_path = None,
|
||||
)
|
||||
assert backend._already_in_target_state(**_drafter_not_found_kwargs()) is False
|
||||
# Sanity: with no fallback reason the same request still dedupes (matches).
|
||||
ok = _mtp_backend(_model_identifier = "unsloth/gemma-4-E4B-it-GGUF", _gguf_path = None)
|
||||
assert ok._already_in_target_state(**_drafter_not_found_kwargs()) is True
|
||||
|
|
|
|||
|
|
@ -84,12 +84,7 @@ def _write_install(
|
|||
asset: str | None = None,
|
||||
release_tag: str | None = None,
|
||||
) -> str:
|
||||
"""Create a fake prebuilt install tree and return the llama-server path.
|
||||
|
||||
``asset`` is the bundle filename recorded in the marker; omit it to model an
|
||||
older marker that predates asset-based ROCm forwarding (backward compat).
|
||||
``release_tag`` is the full release tag (e.g. a ``b9596-mix-<sha>`` mix
|
||||
build); defaults to ``tag`` for a plain prebuilt."""
|
||||
"""Create a fake prebuilt install and return the llama-server path."""
|
||||
bin_dir = dir_ / "build" / "bin"
|
||||
bin_dir.mkdir(parents = True, exist_ok = True)
|
||||
binary = bin_dir / "llama-server"
|
||||
|
|
@ -436,7 +431,6 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
|
|||
assert res["job"]["from_tag"] == "b9493"
|
||||
assert res["job"]["progress"] == 0.0
|
||||
|
||||
# Wait for the background worker.
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
job = upd.get_update_status()["job"]
|
||||
|
|
@ -445,17 +439,44 @@ def test_start_update_happy_path(monkeypatch, tmp_path):
|
|||
time.sleep(0.05)
|
||||
assert job["state"] == "success", job
|
||||
assert job["to_tag"] == "b9518"
|
||||
# Installer was invoked with the resolved install dir + latest + repo.
|
||||
assert job["reload_required"] is False
|
||||
assert "--install-dir" in captured["cmd"]
|
||||
assert str(install_dir) in captured["cmd"]
|
||||
assert "--llama-tag" in captured["cmd"] and "latest" in captured["cmd"]
|
||||
assert "unslothai/llama.cpp" in captured["cmd"]
|
||||
# Progress lines were parsed and success pins progress at 1.0.
|
||||
assert job["progress"] == 1.0
|
||||
# The worker asks the installer for fine-grained progress milestones.
|
||||
assert popen_kwargs["env"]["UNSLOTH_PROGRESS_PERCENT_STEP"] == "5"
|
||||
|
||||
|
||||
def test_start_update_reports_full_release_tag(monkeypatch, tmp_path):
|
||||
install_dir = tmp_path / "llama.cpp"
|
||||
binary = _write_install(install_dir, "b9595")
|
||||
monkeypatch.setattr(upd, "_find_binary", lambda: binary)
|
||||
monkeypatch.setattr(upd, "_installer_script", lambda: tmp_path / "install_llama_prebuilt.py")
|
||||
monkeypatch.setattr(
|
||||
freshness,
|
||||
"_fetch_latest_release_tag",
|
||||
lambda repo, timeout = 5.0: "b9596-mix-e6f2453",
|
||||
)
|
||||
|
||||
def _on_start(cmd):
|
||||
_write_install(install_dir, "b9596", release_tag = "b9596-mix-e6f2453")
|
||||
|
||||
_patch_installer_popen(monkeypatch, on_start = _on_start)
|
||||
|
||||
res = upd.start_update()
|
||||
assert res["started"] is True
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
job = upd.get_update_status()["job"]
|
||||
if job["state"] in ("success", "error"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert job["state"] == "success", job
|
||||
assert job["to_tag"] == "b9596-mix-e6f2453"
|
||||
assert "Updated llama.cpp to b9596-mix-e6f2453." in job["message"]
|
||||
|
||||
|
||||
def test_start_update_installer_failure_reports_error(monkeypatch, tmp_path):
|
||||
install_dir = tmp_path / "llama.cpp"
|
||||
binary = _write_install(install_dir, "b9493")
|
||||
|
|
@ -623,7 +644,7 @@ def test_start_update_installer_missing_refuses(monkeypatch, tmp_path):
|
|||
|
||||
|
||||
class _FakeBackend:
|
||||
"""Minimal stand-in for LlamaCppBackend's update-coordination surface."""
|
||||
"""Fake backend for update coordination."""
|
||||
|
||||
def __init__(self):
|
||||
import threading
|
||||
|
|
@ -659,7 +680,6 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
|
|||
seen = {}
|
||||
|
||||
def _on_start(cmd):
|
||||
# The maintenance flag must be set while the installer runs.
|
||||
seen["flag_during_install"] = backend._llama_update_in_progress
|
||||
_write_install(install_dir, "b9518")
|
||||
|
||||
|
|
@ -674,8 +694,8 @@ def test_update_sets_maintenance_flag_and_unloads(monkeypatch, tmp_path):
|
|||
time.sleep(0.05)
|
||||
|
||||
assert backend.unloaded is True
|
||||
assert upd.get_update_status()["job"]["reload_required"] is True
|
||||
assert seen.get("flag_during_install") is True
|
||||
# Cleared in the finally so model loads work again after the swap.
|
||||
assert backend._llama_update_in_progress is False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -70,10 +70,11 @@ def test_status_response_exposes_source_build():
|
|||
"installed_at_utc": None,
|
||||
"age_days": None,
|
||||
"source_build": True,
|
||||
"job": {"state": "idle"},
|
||||
"job": {"state": "idle", "reload_required": False},
|
||||
}
|
||||
model = rl.LlamaUpdateStatusResponse(**payload)
|
||||
assert model.model_dump()["source_build"] is True
|
||||
assert model.model_dump()["job"]["reload_required"] is False
|
||||
# Extra/unknown keys must not crash the response model.
|
||||
rl.LlamaUpdateStatusResponse(**{**payload, "unexpected": 1})
|
||||
|
||||
|
|
|
|||
|
|
@ -829,6 +829,20 @@ class TestExtraArgsMtpDetection:
|
|||
"request.tensor_parallel,llama_backend.tensor_parallel)" in body
|
||||
)
|
||||
|
||||
def test_route_matcher_retries_after_drafter_not_found(self):
|
||||
# drafter_not_found must not report "already loaded" or the reload never
|
||||
# retries the download (#6459). Read source: importing routes pulls deps.
|
||||
routes_src = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "inference.py"
|
||||
).read_text()
|
||||
start = routes_src.index("def _request_matches_loaded_settings")
|
||||
end = routes_src.index("\ndef ", start + 1)
|
||||
body = "".join(routes_src[start:end].split())
|
||||
assert 'llama_backend.spec_fallback_reason=="drafter_not_found"' in body
|
||||
assert "not_extra_args_set_spec_type(effective_extra)" in body
|
||||
# HF-only (hf_repo): local/native loads have no download to retry.
|
||||
assert "llama_backend.hf_repo" in body
|
||||
|
||||
def test_extra_args_main_cache_type_heavier_axis(self):
|
||||
# Asymmetric --cache-type-k/-v must budget the heavier axis (extras win
|
||||
# per axis at launch), not the last-wins single type that under-reserves.
|
||||
|
|
@ -864,8 +878,10 @@ class TestExtraArgsMtpDetection:
|
|||
# f16/f16 on the layer fallback) (#6312).
|
||||
load = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
||||
assert "_tensor_dropped_extra_args=list(extra_args)" in load
|
||||
# All three tensor->layer downgrade points restore the saved originals.
|
||||
assert load.count("strip_split_mode_only(_tensor_dropped_extra_argsif") == 3
|
||||
# The original extras are restored via one shared closure, called at all
|
||||
# three tensor->layer downgrade points.
|
||||
assert "strip_split_mode_only(_tensor_dropped_extra_argsif" in load
|
||||
assert load.count("_restore_after_tensor_downgrade()") >= 3
|
||||
|
||||
def test_load_model_tensor_skips_reserve_for_cpu_drafter(self):
|
||||
# A separate CPU-offloaded drafter (no embedded head) uses no GPU, so the
|
||||
|
|
|
|||
22
studio/backend/tests/test_sse_streaming_headers.py
Normal file
22
studio/backend/tests/test_sse_streaming_headers.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Regression tests for the shared SSE streaming-response helper.
|
||||
|
||||
Streaming endpoints must disable proxy buffering (``X-Accel-Buffering: no``);
|
||||
without it a reverse proxy (nginx / cloudflare tunnel) buffers the response and
|
||||
tokens stop appearing in real time. The native ``/generate/stream`` and legacy
|
||||
``/v1/completions`` streams historically omitted it and now route through the
|
||||
shared helper, so locking the helper's headers guards every standard path.
|
||||
"""
|
||||
|
||||
import routes.inference as inference_route
|
||||
|
||||
|
||||
def test_sse_helper_sets_no_proxy_buffering_headers():
|
||||
resp = inference_route._sse_streaming_response(iter(()))
|
||||
assert resp.media_type == "text/event-stream"
|
||||
# Starlette lowercases header keys in init_headers.
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers["connection"] == "close"
|
||||
assert resp.headers["x-accel-buffering"] == "no"
|
||||
|
|
@ -1153,4 +1153,7 @@ def test_load_model_restores_quantized_kv_on_tensor_downgrade():
|
|||
# GPU-count and capacity-gate downgrades.
|
||||
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
||||
assert "_tensor_dropped_cache_type_kv=cache_type_kv" in compact # captured pre-null
|
||||
assert compact.count("cache_type_kv=_tensor_dropped_cache_type_kv") >= 2 # restored
|
||||
# Restore is shared in one closure, called at every tensor->layer downgrade.
|
||||
assert "cache_type_kv=_tensor_dropped_cache_type_kv" in compact # restored in the closure
|
||||
assert "def_restore_after_tensor_downgrade():" in compact # one shared restore helper
|
||||
assert compact.count("_restore_after_tensor_downgrade()") >= 3 # called at each downgrade
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ _job: dict = {
|
|||
"message": "",
|
||||
"from_tag": None,
|
||||
"to_tag": None,
|
||||
"reload_required": None,
|
||||
"error": None,
|
||||
"progress": None,
|
||||
"started_at": None,
|
||||
|
|
@ -416,8 +417,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
backend = None
|
||||
model_was_active = False
|
||||
try:
|
||||
# Maintenance state so no load starts a server from the half-swapped binary
|
||||
# (and the old binary is freed for the swap). Fails open without a backend.
|
||||
# Block loads and free the binary while the installer swaps it.
|
||||
try:
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
backend = get_llama_cpp_backend()
|
||||
|
|
@ -431,8 +431,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
try:
|
||||
with backend._serial_load_lock:
|
||||
backend._llama_update_in_progress = True
|
||||
# is_active covers the loading/unhealthy window is_loaded misses
|
||||
# (a live process also locks the exe on Windows during the swap).
|
||||
# Active processes can lock the exe on Windows.
|
||||
if getattr(backend, "is_active", False):
|
||||
model_was_active = True
|
||||
backend.unload_model()
|
||||
|
|
@ -451,8 +450,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
]
|
||||
cmd.extend(_rocm_install_args(asset))
|
||||
logger.info("llama update: installing", cmd = " ".join(cmd))
|
||||
# Stream the installer output so download percent lines feed
|
||||
# job["progress"]; finer milestones via UNSLOTH_PROGRESS_PERCENT_STEP.
|
||||
# Stream progress lines into job["progress"].
|
||||
env = dict(os.environ, UNSLOTH_PROGRESS_PERCENT_STEP = "5")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
|
|
@ -493,19 +491,15 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
tail = "".join(tail_lines).strip()[-1500:]
|
||||
raise RuntimeError(f"installer exited {returncode}: {tail or 'no output'}")
|
||||
|
||||
# New UNSLOTH_PREBUILT_INFO.json is on disk; drop the in-memory AND the
|
||||
# on-disk freshness caches, then re-prime the 24h disk cache with the
|
||||
# true newest, so the banner can't linger on a stale same-base value
|
||||
# after the swap. drop_disk matters when the refresh below can't reach
|
||||
# GitHub: without it, latest_published_release would replay the stale
|
||||
# disk value; with it, latest reads as None and the banner fails open.
|
||||
# Drop stale caches so the banner re-checks the swapped marker.
|
||||
# If GitHub is offline, latest stays unknown and the banner fails open.
|
||||
reset_caches(drop_disk = True)
|
||||
try:
|
||||
latest_published_release(repo, force_refresh = True)
|
||||
except Exception as exc: # pragma: no cover - network defensive
|
||||
logger.debug("llama update: post-install freshness refresh failed", error = str(exc))
|
||||
new_marker = read_install_marker(_find_binary())
|
||||
new_tag = (new_marker or {}).get("tag") or (new_marker or {}).get("release_tag")
|
||||
new_tag = (new_marker or {}).get("release_tag") or (new_marker or {}).get("tag")
|
||||
|
||||
with _job_lock:
|
||||
_job.update(
|
||||
|
|
@ -515,6 +509,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
+ (" Reload your model to use it." if model_was_active else "")
|
||||
),
|
||||
to_tag = new_tag,
|
||||
reload_required = model_was_active,
|
||||
error = None,
|
||||
progress = 1.0,
|
||||
finished_at = _utcnow(),
|
||||
|
|
@ -530,7 +525,7 @@ def _run_update(install_dir: Path, repo: str, asset: Optional[str], script: Path
|
|||
finished_at = _utcnow(),
|
||||
)
|
||||
finally:
|
||||
# Lift the maintenance state so model loads work again, success or not.
|
||||
# Always clear maintenance state.
|
||||
if backend is not None:
|
||||
try:
|
||||
backend._llama_update_in_progress = False
|
||||
|
|
@ -618,6 +613,7 @@ def start_update() -> dict:
|
|||
message = "Downloading and installing the latest llama.cpp prebuilt...",
|
||||
from_tag = from_tag,
|
||||
to_tag = None,
|
||||
reload_required = None,
|
||||
error = None,
|
||||
progress = 0.0,
|
||||
started_at = _utcnow(),
|
||||
|
|
@ -643,6 +639,7 @@ def _reset_job_for_tests() -> None:
|
|||
message = "",
|
||||
from_tag = None,
|
||||
to_tag = None,
|
||||
reload_required = None,
|
||||
error = None,
|
||||
progress = None,
|
||||
started_at = None,
|
||||
|
|
|
|||
130
studio/backend/utils/node_runtime.py
Normal file
130
studio/backend/utils/node_runtime.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Resolve a usable Node.js executable at runtime.
|
||||
|
||||
The installer provisions an isolated Node under ``<UNSLOTH_HOME>/node`` but only
|
||||
puts it on PATH for the *setup* process, never the user's shell. So backend code
|
||||
that shells out to ``node`` at runtime (the OXC validator) cannot rely on PATH.
|
||||
``resolve_node_executable`` prefers a version-adequate system Node, else the
|
||||
managed isolated Node (same floor the installer applies: ^20.19 || >=22.12 || >=23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
||||
|
||||
_NODE_VERSION_PROBE_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
# Keep in sync with the setup scripts' Node floor: Get-NodeDecision (setup.ps1) /
|
||||
# decide_node_source (setup.sh). Vite 8 needs Node ^20.19 || >=22.12 || >=23.
|
||||
def _version_meets_floor(version: str) -> bool:
|
||||
"""True iff a ``node -v`` string clears the installer's version bar."""
|
||||
match = re.match(r"v?(\d+)\.(\d+)", version.strip())
|
||||
if not match:
|
||||
return False
|
||||
major, minor = int(match.group(1)), int(match.group(2))
|
||||
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
|
||||
|
||||
|
||||
def managed_node_dir() -> Path:
|
||||
"""Isolated Node install dir. Mirrors ``_find_llama_server_binary``: shares a
|
||||
parent with llama.cpp -- ``<STUDIO_HOME>`` in custom mode, else legacy ``~/.unsloth``."""
|
||||
legacy_node = Path.home() / ".unsloth" / "node"
|
||||
try:
|
||||
# Lazy import (mirrors _find_llama_server_binary) so this module stays
|
||||
# importable even if utils.paths cannot be loaded.
|
||||
from utils.paths.storage_roots import studio_root
|
||||
|
||||
resolved = studio_root()
|
||||
legacy_studio = Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
is_legacy = resolved.resolve() == legacy_studio.resolve()
|
||||
except (OSError, ValueError):
|
||||
is_legacy = resolved == legacy_studio
|
||||
return legacy_node if is_legacy else (resolved / "node")
|
||||
except (ImportError, OSError, ValueError):
|
||||
# Degraded env (utils.paths unavailable): still honor an explicit
|
||||
# STUDIO_HOME override before the legacy default, mirroring studio_root().
|
||||
override = (
|
||||
os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME") or ""
|
||||
).strip()
|
||||
if override:
|
||||
try:
|
||||
return Path(override).expanduser().resolve() / "node"
|
||||
except (OSError, ValueError):
|
||||
return Path(override).expanduser() / "node"
|
||||
return legacy_node
|
||||
|
||||
|
||||
def managed_node_binary() -> Path:
|
||||
"""Node executable in the isolated install: ``<dir>/node.exe`` on Windows, ``<dir>/bin/node`` else."""
|
||||
node_dir = managed_node_dir()
|
||||
if os.name == "nt":
|
||||
return node_dir / "node.exe"
|
||||
return node_dir / "bin" / "node"
|
||||
|
||||
|
||||
def _node_version_ok(executable: str) -> bool:
|
||||
"""Run ``<executable> -v`` and check it clears the floor; False on any error."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[executable, "-v"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return _version_meets_floor(result.stdout)
|
||||
|
||||
|
||||
# Memoize ONLY a confirmed version-adequate executable: the installer runs in a
|
||||
# separate process and may finish after the first probe here, so a negative /
|
||||
# last-resort result must not be cached (it would stick until a backend restart).
|
||||
_resolved_node: str | None = None
|
||||
|
||||
|
||||
def _reset_resolved_node() -> None:
|
||||
"""Clear the memoized executable (used by tests)."""
|
||||
global _resolved_node
|
||||
_resolved_node = None
|
||||
|
||||
|
||||
def resolve_node_executable() -> str | None:
|
||||
"""Resolve a usable node executable, or None.
|
||||
|
||||
Order: version-adequate system ``node`` on PATH; else the managed isolated
|
||||
Node if adequate; else bare ``node`` (may be None). Only an adequate result
|
||||
is memoized, so a Node installed after the first probe is picked up live.
|
||||
"""
|
||||
global _resolved_node
|
||||
if _resolved_node is not None:
|
||||
return _resolved_node
|
||||
|
||||
system_node = shutil.which("node")
|
||||
if system_node and _node_version_ok(system_node):
|
||||
_resolved_node = system_node
|
||||
return _resolved_node
|
||||
|
||||
managed = managed_node_binary()
|
||||
try:
|
||||
managed_present = managed.is_file()
|
||||
except OSError:
|
||||
managed_present = False
|
||||
if managed_present and _node_version_ok(str(managed)):
|
||||
_resolved_node = str(managed)
|
||||
return _resolved_node
|
||||
|
||||
# Last-resort system node (may be None), NOT cached so a later install is picked up.
|
||||
return system_node
|
||||
|
|
@ -67,6 +67,7 @@ import {
|
|||
listPromptEntries,
|
||||
type PromptEntry,
|
||||
} from "@/features/chat/api/prompts-api";
|
||||
import { useChatPreferencesStore } from "@/features/chat/stores/chat-preferences-store";
|
||||
import { useChatProjects } from "@/features/chat/hooks/use-chat-projects";
|
||||
import { NewProjectDialog } from "@/features/chat/components/new-project-dialog";
|
||||
import { parseExternalModelId } from "@/features/chat/external-providers";
|
||||
|
|
@ -1177,6 +1178,9 @@ const ThreadComposerDock: FC<{
|
|||
promptQueueItemMatchesThreadIds(item, promptQueueThreadIds),
|
||||
),
|
||||
);
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(s) => s.showModelDisclaimer,
|
||||
);
|
||||
|
||||
// Report dock height so the viewport reserves matching scroll space when
|
||||
// attachments or multiline input grow the composer.
|
||||
|
|
@ -1220,9 +1224,11 @@ const ThreadComposerDock: FC<{
|
|||
menuSide="top"
|
||||
/>
|
||||
</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
{showModelDisclaimer && (
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import { toast } from "@/lib/toast";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Download } from "lucide-react";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
// Backend progress is coarse (5% steps, ~0.9 max) and the extract tail emits no
|
||||
// signal. Creep toward this cap so the bar keeps moving rather than freezing.
|
||||
// Creep toward this cap between coarse backend progress updates.
|
||||
const RUNNING_CAP = 0.95;
|
||||
|
||||
// Smoothed 0..1 bar progress: eases toward real `progress`, trickles toward a
|
||||
// ceiling when idle, animates to 100% when `done`. Resets to 0 on each start.
|
||||
// Smooth coarse backend progress without freezing between milestones.
|
||||
function useSmoothedProgress(
|
||||
active: boolean,
|
||||
progress: number | null,
|
||||
|
|
@ -35,8 +33,7 @@ function useSmoothedProgress(
|
|||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const tick = (now: number) => {
|
||||
// rAF timestamps can predate the performance.now() captured above, so
|
||||
// clamp dt at 0 to keep the first frame from stepping backwards.
|
||||
// Guard against a first rAF timestamp before the captured start time.
|
||||
const dt = Math.max(0, Math.min((now - last) / 1000, 0.1));
|
||||
last = now;
|
||||
const current = displayRef.current;
|
||||
|
|
@ -74,18 +71,11 @@ function useSmoothedProgress(
|
|||
|
||||
interface LlamaUpdateBannerProps {
|
||||
enabled?: boolean;
|
||||
// false: fill the parent instead of self-anchoring, so banners can stack in a
|
||||
// shared container. true (default) keeps standalone desktop mounts working.
|
||||
// false fills a shared stack; true self-anchors.
|
||||
positioned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-invasive "Update llama.cpp" affordance. Appears bottom-right ~1s after a
|
||||
* newer prebuilt is detected and stays up until the user explicitly acts on it
|
||||
* (X, Update, or Remind me later). Clicking Update swaps the prebuilt in place
|
||||
* via POST /api/llama/update. Can be turned off entirely in Settings ->
|
||||
* General -> Notifications (on by default).
|
||||
*/
|
||||
/** Bottom-right llama.cpp update toast. */
|
||||
export function LlamaUpdateBanner({
|
||||
enabled = true,
|
||||
positioned = true,
|
||||
|
|
@ -99,9 +89,11 @@ export function LlamaUpdateBanner({
|
|||
async function handleUpdate() {
|
||||
const result = await apply();
|
||||
if (result?.ok) {
|
||||
toast.success(
|
||||
`llama.cpp updated to ${result.tag ?? "the latest build"}. Reload your model to use it.`,
|
||||
);
|
||||
const updatedTag = result.tag ?? status?.latest_tag ?? "the latest build";
|
||||
const reloadHint = result.reloadRequired
|
||||
? " Reload your model to use it."
|
||||
: "";
|
||||
toast.success(`llama.cpp updated to ${updatedTag}.${reloadHint}`);
|
||||
} else if (result) {
|
||||
toast.error(
|
||||
`llama.cpp update failed: ${result.error ?? "unknown error"}`,
|
||||
|
|
@ -112,24 +104,20 @@ export function LlamaUpdateBanner({
|
|||
const show =
|
||||
visible && status != null && (status.update_available || applying);
|
||||
const sizeBytes = status?.update_size_bytes ?? null;
|
||||
// Round to whole MB; these prebuilts are hundreds of MB.
|
||||
const sizeLabel =
|
||||
sizeBytes && sizeBytes > 0
|
||||
? `${Math.round(sizeBytes / (1024 * 1024))} MB`
|
||||
: null;
|
||||
const updateProgress = status?.job.progress ?? null;
|
||||
const jobSucceeded = status?.job.state === "success";
|
||||
// Drives the bar so it animates continuously; aria reports the real value.
|
||||
// Display value animates; aria uses the real progress.
|
||||
const displayProgress = useSmoothedProgress(
|
||||
applying,
|
||||
updateProgress,
|
||||
jobSucceeded,
|
||||
);
|
||||
|
||||
// Render with no enter/exit animation. An opacity/transform transition (in or
|
||||
// out) promotes a GPU compositing layer whose creation or teardown can flash
|
||||
// for a frame on real displays, which reads as a flicker on appear and on
|
||||
// dismiss. A plain conditional mount appears and leaves cleanly.
|
||||
// Avoid opacity/transform transitions; GPU layer churn can flash.
|
||||
return show ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -220,7 +208,7 @@ export function LlamaUpdateBanner({
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
// -mr optically aligns the filled pill's edge with the card padding
|
||||
// Align pill edge with card padding.
|
||||
className="-mr-1 h-auto rounded-full px-3.5 py-2 text-[13px]"
|
||||
onClick={handleUpdate}
|
||||
data-testid="llama-update-button"
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import {
|
|||
loadOptionalBool,
|
||||
useChatRuntimeStore,
|
||||
} from "./stores/chat-runtime-store";
|
||||
import { useChatPreferencesStore } from "./stores/chat-preferences-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import { ArtifactSurface } from "./artifacts/artifact-surface";
|
||||
|
|
@ -492,6 +493,9 @@ function CompareShell({
|
|||
children: ReactElement;
|
||||
composer: ReactElement;
|
||||
}): ReactElement {
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(s) => s.showModelDisclaimer,
|
||||
);
|
||||
return (
|
||||
<CompareHandlesProvider handlesRef={handlesRef}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col">
|
||||
|
|
@ -503,9 +507,11 @@ function CompareShell({
|
|||
</div>
|
||||
<div className="shrink-0 bg-background pl-5 pr-5 md:pr-[30px] pb-2 pt-1">
|
||||
<div className="mx-auto w-full max-w-[48rem]">{composer}</div>
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
{showModelDisclaimer && (
|
||||
<p className="composer-footer-note">
|
||||
LLMs can make mistakes. Double-check responses.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CompareHandlesProvider>
|
||||
|
|
|
|||
|
|
@ -505,8 +505,7 @@ export function ChatSettingsPanel({
|
|||
(s) => s.loadedSpeculativeType,
|
||||
);
|
||||
const specFallbackReason = useChatRuntimeStore((s) => s.specFallbackReason);
|
||||
// "binary_no_mtp" / "binary_outdated" mean a newer prebuilt would re-enable
|
||||
// MTP; "runtime_error" means the current build cannot run it (no update push).
|
||||
// Only binary fallback states are solved by a newer prebuilt.
|
||||
const mtpUpdatable =
|
||||
specFallbackReason === "binary_no_mtp" ||
|
||||
specFallbackReason === "binary_outdated";
|
||||
|
|
@ -518,8 +517,11 @@ export function ChatSettingsPanel({
|
|||
const handleMtpUpdate = useCallback(async () => {
|
||||
const result = await applyLlamaUpdate();
|
||||
if (result.ok) {
|
||||
const reloadHint = result.reloadRequired
|
||||
? " Reload your model to enable MTP."
|
||||
: "";
|
||||
toast.success(
|
||||
`llama.cpp updated to ${result.tag ?? "the latest build"}. Reload your model to enable MTP.`,
|
||||
`llama.cpp updated to ${result.tag ?? "the latest build"}.${reloadHint}`,
|
||||
);
|
||||
} else {
|
||||
toast.error(`llama.cpp update failed: ${result.error ?? "unknown error"}`);
|
||||
|
|
@ -546,6 +548,12 @@ export function ChatSettingsPanel({
|
|||
const loadedTensorParallel = useChatRuntimeStore(
|
||||
(s) => s.loadedTensorParallel,
|
||||
);
|
||||
const chatTemplateOverride = useChatRuntimeStore(
|
||||
(s) => s.chatTemplateOverride,
|
||||
);
|
||||
const loadedChatTemplateOverride = useChatRuntimeStore(
|
||||
(s) => s.loadedChatTemplateOverride,
|
||||
);
|
||||
const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
|
||||
const setCustomContextLength = useChatRuntimeStore(
|
||||
(s) => s.setCustomContextLength,
|
||||
|
|
@ -584,8 +592,11 @@ export function ChatSettingsPanel({
|
|||
const specDirty = speculativeType !== loadedSpeculativeType;
|
||||
const specDraftDirty = specDraftNMax !== loadedSpecDraftNMax;
|
||||
const tpDirty = tensorParallel !== (loadedTensorParallel ?? false);
|
||||
// A saved chat-template override is a reload-time setting too, so surface
|
||||
// Apply for a template-only edit (otherwise it could never be applied).
|
||||
const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride;
|
||||
const modelSettingsDirty =
|
||||
kvDirty || ctxDirty || specDirty || specDraftDirty || tpDirty;
|
||||
kvDirty || ctxDirty || specDirty || specDraftDirty || tpDirty || templateDirty;
|
||||
const [presetNameInput, setPresetNameInput] = useState(activePreset);
|
||||
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState("");
|
||||
|
|
@ -1009,10 +1020,12 @@ export function ChatSettingsPanel({
|
|||
? "MTP is disabled by default for this model architecture because it currently runs slower than standard decoding. Select MTP above to force it."
|
||||
: specFallbackReason === "runtime_error"
|
||||
? "MTP could not start for this model on the installed llama.cpp build, so it is running without speculative decoding."
|
||||
: "MTP is not available in the installed llama.cpp build, so this model is running without it." +
|
||||
(llamaUpdateStatus?.update_available
|
||||
? " Update llama.cpp to enable it."
|
||||
: "")}
|
||||
: specFallbackReason === "drafter_not_found"
|
||||
? "This model supports MTP, but its drafter file could not be downloaded, so MTP is off and it falls back to n-gram speculative decoding where the llama.cpp build supports it. Check your network connection or Hugging Face access, then reload the model to retry the drafter."
|
||||
: "MTP is not available in the installed llama.cpp build, so this model is running without it." +
|
||||
(llamaUpdateStatus?.update_available
|
||||
? " Update llama.cpp to enable it."
|
||||
: "")}
|
||||
</p>
|
||||
{mtpUpdatable && llamaUpdateStatus?.update_available && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import { persist } from "zustand/middleware";
|
|||
|
||||
// Client-side chat UI prefs kept in localStorage, not the chat DB.
|
||||
// confirmDeleteChats: when off, deleting a chat skips the confirm dialog.
|
||||
// showModelDisclaimer: when off, hide the "LLMs can make mistakes" footer note.
|
||||
export interface ChatPreferencesState {
|
||||
confirmDeleteChats: boolean;
|
||||
setConfirmDeleteChats: (value: boolean) => void;
|
||||
showModelDisclaimer: boolean;
|
||||
setShowModelDisclaimer: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
||||
|
|
@ -17,6 +20,9 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
confirmDeleteChats: true,
|
||||
setConfirmDeleteChats: (confirmDeleteChats) =>
|
||||
set({ confirmDeleteChats }),
|
||||
showModelDisclaimer: true,
|
||||
setShowModelDisclaimer: (showModelDisclaimer) =>
|
||||
set({ showModelDisclaimer }),
|
||||
}),
|
||||
{
|
||||
name: "unsloth_chat_preferences",
|
||||
|
|
@ -25,6 +31,7 @@ export const useChatPreferencesStore = create<ChatPreferencesState>()(
|
|||
return {
|
||||
...current,
|
||||
confirmDeleteChats: saved?.confirmDeleteChats ?? true,
|
||||
showModelDisclaimer: saved?.showModelDisclaimer ?? true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -187,6 +187,12 @@ export function ChatTab() {
|
|||
const setConfirmDeleteChats = useChatPreferencesStore(
|
||||
(state) => state.setConfirmDeleteChats,
|
||||
);
|
||||
const showModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.showModelDisclaimer,
|
||||
);
|
||||
const setShowModelDisclaimer = useChatPreferencesStore(
|
||||
(state) => state.setShowModelDisclaimer,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void countAllChats().then(setCount);
|
||||
|
|
@ -310,6 +316,15 @@ export function ChatTab() {
|
|||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
<SettingsRow
|
||||
label={t("settings.chat.modelDisclaimer")}
|
||||
description={t("settings.chat.modelDisclaimerDescription")}
|
||||
>
|
||||
<Switch
|
||||
checked={showModelDisclaimer}
|
||||
onCheckedChange={setShowModelDisclaimer}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("settings.chat.artifacts.title")}>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,12 @@ import { authFetch, getAuthToken } from "@/features/auth";
|
|||
import { refreshHardwareInfo } from "@/hooks/use-hardware-info";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// First check shortly after load, then re-surface as an hourly reminder. The
|
||||
// banner stays up until the user explicitly acts on it (X, Update, or
|
||||
// Remind me later).
|
||||
// Initial check plus hourly reminders until dismissed or applied.
|
||||
const FIRST_CHECK_DELAY_MS = 1000;
|
||||
const REMINDER_INTERVAL_MS = 60 * 60 * 1000; // ~1 hour
|
||||
// "Remind me later" re-surfaces sooner than the hourly reminder.
|
||||
// Snooze checks sooner than the hourly reminder.
|
||||
const SNOOZE_DELAY_MS = 15 * 60 * 1000; // ~15 minutes
|
||||
// Poll cadence while applying. Short so the installer's ~5% milestones are
|
||||
// observed instead of a fast download finishing between two slow polls.
|
||||
// Poll fast enough to catch installer progress milestones.
|
||||
const JOB_POLL_INTERVAL_MS = 500;
|
||||
|
||||
export interface LlamaUpdateJob {
|
||||
|
|
@ -21,8 +18,9 @@ export interface LlamaUpdateJob {
|
|||
message: string;
|
||||
from_tag: string | null;
|
||||
to_tag: string | null;
|
||||
reload_required: boolean | null;
|
||||
error: string | null;
|
||||
// Download fraction (0..1) while running, 1 on success, null when unknown.
|
||||
// Download fraction while running, 1 on success.
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +29,7 @@ export interface LlamaUpdateStatus {
|
|||
update_available: boolean;
|
||||
installed_tag: string | null;
|
||||
latest_tag: string | null;
|
||||
// Download size of the prebuilt Update would fetch, in bytes (null if unknown).
|
||||
// Prebuilt download size in bytes, if known.
|
||||
update_size_bytes: number | null;
|
||||
job: LlamaUpdateJob;
|
||||
}
|
||||
|
|
@ -52,6 +50,8 @@ function parseStatus(value: unknown): LlamaUpdateStatus | null {
|
|||
message: typeof job.message === "string" ? job.message : "",
|
||||
from_tag: typeof job.from_tag === "string" ? job.from_tag : null,
|
||||
to_tag: typeof job.to_tag === "string" ? job.to_tag : null,
|
||||
reload_required:
|
||||
typeof job.reload_required === "boolean" ? job.reload_required : null,
|
||||
error: typeof job.error === "string" ? job.error : null,
|
||||
progress: typeof job.progress === "number" ? job.progress : null,
|
||||
},
|
||||
|
|
@ -73,9 +73,7 @@ async function fetchStatus(
|
|||
}
|
||||
}
|
||||
|
||||
// Update probes force a refresh so a newly published build is not masked by the
|
||||
// backend's 24h release cache (the banner would otherwise lag up to a day). The
|
||||
// job-progress poll below stays cached; it only reads local job state.
|
||||
// Manual checks bypass the 24h release cache; job polls read local state.
|
||||
const recheckStatus = () => fetchStatus(true);
|
||||
|
||||
interface UseLlamaUpdateCheckOptions {
|
||||
|
|
@ -85,16 +83,11 @@ interface UseLlamaUpdateCheckOptions {
|
|||
export interface LlamaApplyResult {
|
||||
ok: boolean;
|
||||
tag?: string | null;
|
||||
reloadRequired?: boolean | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the backend for a newer llama.cpp prebuilt. When one exists, `visible`
|
||||
* becomes true ~1s after load and stays up until the user dismisses it (X),
|
||||
* snoozes it ("Remind me later", ~15 min), or updates; it re-surfaces every
|
||||
* ~hour as a reminder. `apply()` triggers the in-place swap and tracks the
|
||||
* job.
|
||||
*/
|
||||
/** Tracks llama.cpp update visibility and apply progress. */
|
||||
export function useLlamaUpdateCheck({
|
||||
enabled = true,
|
||||
}: UseLlamaUpdateCheckOptions = {}) {
|
||||
|
|
@ -111,8 +104,7 @@ export function useLlamaUpdateCheck({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Poll the job to completion. Shared by apply() and surfaceIfAvailable() so a
|
||||
// job is tracked once whoever noticed it; onDone resolves with the result.
|
||||
// Used by apply() and another-tab job tracking.
|
||||
const startJobPoll = useCallback(
|
||||
(onDone?: (result: LlamaApplyResult) => void) => {
|
||||
clearPollTimer();
|
||||
|
|
@ -126,13 +118,15 @@ export function useLlamaUpdateCheck({
|
|||
if (s.job.state === "success") {
|
||||
setVisible(false);
|
||||
void refreshHardwareInfo();
|
||||
onDone?.({ ok: true, tag: s.job.to_tag });
|
||||
onDone?.({
|
||||
ok: true,
|
||||
tag: s.job.to_tag,
|
||||
reloadRequired: s.job.reload_required,
|
||||
});
|
||||
} else if (s.job.state === "error") {
|
||||
// Leave the banner up so the user can retry; clearing applying drops
|
||||
// the "Updating..." state.
|
||||
// Keep the banner visible so retry is available.
|
||||
onDone?.({ ok: false, error: s.job.error });
|
||||
} else {
|
||||
// idle without a terminal result (job reset): stop tracking.
|
||||
onDone?.({ ok: false, error: "update did not complete" });
|
||||
}
|
||||
}, JOB_POLL_INTERVAL_MS);
|
||||
|
|
@ -140,14 +134,12 @@ export function useLlamaUpdateCheck({
|
|||
[clearPollTimer],
|
||||
);
|
||||
|
||||
// Surface the banner when an update is available; it stays up until dismissed.
|
||||
const surfaceIfAvailable = useCallback(
|
||||
(next: LlamaUpdateStatus | null) => {
|
||||
if (!next) return;
|
||||
setStatus(next);
|
||||
if (next.job.state === "running") {
|
||||
// Swap in progress (e.g. another tab): keep the banner up and track the
|
||||
// job so "Updating..." clears when it finishes instead of sticking.
|
||||
// Another tab is applying; show progress here too.
|
||||
setApplying(true);
|
||||
setVisible(true);
|
||||
if (!pollTimer.current) startJobPoll();
|
||||
|
|
@ -162,9 +154,7 @@ export function useLlamaUpdateCheck({
|
|||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
// Disabled mid-update: stop showing and tracking, and clear `applying`
|
||||
// so the banner's animation loop stops too. Re-enabling re-detects a
|
||||
// still-running job below and resumes tracking via surfaceIfAvailable.
|
||||
// Re-enabling will rediscover any still-running job.
|
||||
setVisible(false);
|
||||
setApplying(false);
|
||||
return;
|
||||
|
|
@ -199,7 +189,6 @@ export function useLlamaUpdateCheck({
|
|||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
// Hide now, re-check and re-surface after SNOOZE_DELAY_MS.
|
||||
const snooze = useCallback(() => {
|
||||
setVisible(false);
|
||||
if (snoozeTimer.current) clearTimeout(snoozeTimer.current);
|
||||
|
|
@ -234,9 +223,7 @@ export function useLlamaUpdateCheck({
|
|||
return { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
// 200 without a started job (no marker / installer missing) leaves it idle,
|
||||
// so surface the reason instead of polling forever. already_running is the
|
||||
// exception: a job is in flight, so track it to completion below.
|
||||
// Non-started jobs stay idle; already_running is tracked below.
|
||||
if (
|
||||
action &&
|
||||
action.started === false &&
|
||||
|
|
|
|||
|
|
@ -208,6 +208,9 @@ export const en = {
|
|||
chat: {
|
||||
title: "Chat",
|
||||
description: "Manage chat history stored on this device.",
|
||||
modelDisclaimer: "Show model disclaimer",
|
||||
modelDisclaimerDescription:
|
||||
'Show "LLMs can make mistakes" under the chat box.',
|
||||
artifacts: {
|
||||
title: "Canvas",
|
||||
collapseHtmlBlocks: "Collapse HTML blocks",
|
||||
|
|
|
|||
765
studio/install_node_prebuilt.py
Normal file
765
studio/install_node_prebuilt.py
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
#!/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
|
||||
|
||||
"""Cross-platform Node.js prebuilt installer for Unsloth Studio.
|
||||
|
||||
Downloads an official Node.js archive from nodejs.org into an isolated
|
||||
``<UNSLOTH_HOME>/node`` and never touches the system Node/npm. Pinning Node 24+
|
||||
LTS clears the Studio frontend build floor (Vite 8: Node ^20.19 || >=22.12,
|
||||
npm >= 11) with the npm it bundles.
|
||||
|
||||
Mirrors ``install_llama_prebuilt.py`` so the setup scripts drive it the same way.
|
||||
Exit codes: 0 success, 1 error, 2 fallback, 3 busy. A re-run that already matches
|
||||
logs "already matches" and returns 0 without downloading (the scripts grep it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
try:
|
||||
from filelock import FileLock, Timeout as FileLockTimeout
|
||||
except ImportError:
|
||||
FileLock = None
|
||||
FileLockTimeout = None
|
||||
|
||||
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_ERROR = 1
|
||||
EXIT_FALLBACK = 2
|
||||
EXIT_BUSY = 3
|
||||
|
||||
# Node 24 LTS bundles npm 11, clearing Vite 8's floor (Node ^20.19 || >=22.12, npm >= 11).
|
||||
NODE_MIN_LTS_MAJOR = 24
|
||||
NPM_MIN_MAJOR = 11
|
||||
|
||||
NODE_DIST_BASE = "https://nodejs.org/dist"
|
||||
NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json"
|
||||
|
||||
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
|
||||
HTTP_FETCH_ATTEMPTS = 4
|
||||
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
|
||||
INSTALL_LOCK_TIMEOUT_SECONDS = 300
|
||||
INSTALL_STAGING_ROOT_NAME = ".staging"
|
||||
METADATA_FILENAME = "UNSLOTH_NODE_PREBUILT_INFO.json"
|
||||
METADATA_SCHEMA_VERSION = 1
|
||||
|
||||
# PowerShell renders stderr as NativeCommandError noise; main() flips logs to stdout.
|
||||
_LOG_TO_STDOUT = False
|
||||
|
||||
|
||||
class PrebuiltFallback(RuntimeError):
|
||||
"""Recoverable failure -- caller should fall back (exit code 2)."""
|
||||
|
||||
|
||||
class BusyInstallConflict(RuntimeError):
|
||||
"""Another process holds the install lock (exit code 3)."""
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[node-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
|
||||
|
||||
|
||||
# ── Host detection ──
|
||||
@dataclass(frozen = True)
|
||||
class HostInfo:
|
||||
system: str # platform.system()
|
||||
machine: str # lowered platform.machine()
|
||||
node_os: str # nodejs.org token: linux | darwin | win
|
||||
node_arch: str # nodejs.org token: x64 | arm64 | armv7l
|
||||
archive_ext: str # .tar.gz | .zip
|
||||
is_windows: bool
|
||||
|
||||
|
||||
def detect_host() -> HostInfo:
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
is_windows = system == "Windows"
|
||||
|
||||
if system == "Linux":
|
||||
node_os = "linux"
|
||||
elif system == "Darwin":
|
||||
node_os = "darwin"
|
||||
elif is_windows:
|
||||
node_os = "win"
|
||||
else:
|
||||
raise PrebuiltFallback(f"unsupported operating system for Node prebuilt: {system}")
|
||||
|
||||
if machine in {"x86_64", "amd64", "x64"}:
|
||||
node_arch = "x64"
|
||||
elif machine in {"arm64", "aarch64"}:
|
||||
node_arch = "arm64"
|
||||
else:
|
||||
# 32-bit ARM (armv7l) is intentionally unsupported: Node 24 LTS ships no
|
||||
# linux-armv7l build, so there is nothing at/above the floor to install.
|
||||
raise PrebuiltFallback(f"unsupported CPU architecture for Node prebuilt: {machine}")
|
||||
|
||||
# .tar.gz (not .tar.xz) on Unix so the extractor needs no xz; .zip on Windows.
|
||||
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||
return HostInfo(
|
||||
system = system,
|
||||
machine = machine,
|
||||
node_os = node_os,
|
||||
node_arch = node_arch,
|
||||
archive_ext = archive_ext,
|
||||
is_windows = is_windows,
|
||||
)
|
||||
|
||||
|
||||
# ── URL / asset construction (pure, unit tested) ──
|
||||
def node_asset_stem(version: str, host: HostInfo) -> str:
|
||||
"""e.g. node-v24.4.1-linux-x64 (no extension)."""
|
||||
return f"node-v{version}-{host.node_os}-{host.node_arch}"
|
||||
|
||||
|
||||
def node_asset_name(version: str, host: HostInfo) -> str:
|
||||
return f"{node_asset_stem(version, host)}{host.archive_ext}"
|
||||
|
||||
|
||||
def node_download_url(version: str, asset_name: str) -> str:
|
||||
return f"{NODE_DIST_BASE}/v{version}/{asset_name}"
|
||||
|
||||
|
||||
def node_shasums_url(version: str) -> str:
|
||||
return f"{NODE_DIST_BASE}/v{version}/SHASUMS256.txt"
|
||||
|
||||
|
||||
def expected_sha256_for(shasums_text: str, asset_name: str) -> str | None:
|
||||
"""Parse a nodejs.org SHASUMS256.txt ('<hex> <filename>' per line)."""
|
||||
for line in shasums_text.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[1] == asset_name:
|
||||
digest = parts[0].lower()
|
||||
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
|
||||
return digest
|
||||
return None
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(p) for p in value.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return ()
|
||||
|
||||
|
||||
def _meets_node_floor(version: str) -> bool:
|
||||
"""True iff version clears the setup floor (^20.19 || >=22.12 || >=23)."""
|
||||
parts = _version_tuple(version)
|
||||
if not parts:
|
||||
return False
|
||||
major = parts[0]
|
||||
minor = parts[1] if len(parts) > 1 else 0
|
||||
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
|
||||
|
||||
|
||||
def select_node_version(index: list[dict], *, channel: str, min_major: int) -> str:
|
||||
"""Pick a concrete Node version from nodejs.org index.json.
|
||||
|
||||
channel='lts' -> newest LTS release line whose major >= min_major.
|
||||
channel='latest' -> newest release overall whose major >= min_major.
|
||||
Otherwise the channel is treated as an explicit version string.
|
||||
"""
|
||||
if channel not in {"lts", "latest"}:
|
||||
return channel.lstrip("v")
|
||||
|
||||
best: tuple[int, ...] | None = None
|
||||
best_version: str | None = None
|
||||
for entry in index:
|
||||
version = str(entry.get("version", "")).lstrip("v")
|
||||
parsed = _version_tuple(version)
|
||||
if not parsed or parsed[0] < min_major:
|
||||
continue
|
||||
if channel == "lts" and not entry.get("lts"):
|
||||
continue
|
||||
if best is None or parsed > best:
|
||||
best = parsed
|
||||
best_version = version
|
||||
if best_version is None:
|
||||
raise PrebuiltFallback(
|
||||
f"no Node '{channel}' release found at or above major {min_major} in {NODE_DIST_INDEX}"
|
||||
)
|
||||
return best_version
|
||||
|
||||
|
||||
# ── HTTP (retry/backoff) ──
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
# A User-Agent keeps some proxies/CDNs happy; nodejs.org needs no auth.
|
||||
return {"User-Agent": "unsloth-studio-node-prebuilt"}
|
||||
|
||||
|
||||
def is_retryable_url_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return exc.code in RETRYABLE_HTTP_STATUS
|
||||
if isinstance(exc, (urllib.error.URLError, TimeoutError, socket.timeout)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sleep_backoff(attempt: int) -> None:
|
||||
delay = HTTP_FETCH_BASE_DELAY_SECONDS * (2 ** max(attempt - 1, 0))
|
||||
delay += random.uniform(0.0, 0.2)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def download_bytes(url: str, *, timeout: int = 60) -> bytes:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
|
||||
try:
|
||||
request = urllib.request.Request(url, headers = _auth_headers())
|
||||
with urllib.request.urlopen(request, timeout = timeout) as response:
|
||||
return response.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
|
||||
raise
|
||||
log(f"fetch failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
|
||||
sleep_backoff(attempt)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def fetch_json(url: str) -> object:
|
||||
return json.loads(download_bytes(url, timeout = 30).decode("utf-8"))
|
||||
|
||||
|
||||
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents = True, exist_ok = True)
|
||||
os.replace(tmp_path, destination)
|
||||
|
||||
|
||||
def download_file(url: str, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents = True, exist_ok = True)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
request = urllib.request.Request(url, headers = _auth_headers())
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix = destination.name + ".tmp-",
|
||||
dir = destination.parent,
|
||||
delete = False,
|
||||
) as handle:
|
||||
tmp_path = Path(handle.name)
|
||||
with urllib.request.urlopen(request, timeout = 120) as response:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"downloaded empty file from {url}")
|
||||
atomic_replace_from_tempfile(tmp_path, destination)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok = True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
|
||||
raise
|
||||
log(f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
|
||||
sleep_backoff(attempt)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_file_verified(
|
||||
url: str, destination: Path, *, expected_sha256: str, label: str
|
||||
) -> None:
|
||||
for attempt in range(1, 3):
|
||||
download_file(url, destination)
|
||||
actual = sha256_file(destination)
|
||||
if actual == expected_sha256:
|
||||
log(f"verified {label} sha256={actual}")
|
||||
return
|
||||
log(f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}")
|
||||
destination.unlink(missing_ok = True)
|
||||
if attempt == 2:
|
||||
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
|
||||
|
||||
|
||||
# ── Safe archive extraction (zip + tar.gz, traversal/symlink guarded) ──
|
||||
def _safe_extract_path(base: Path, member_name: str) -> Path:
|
||||
member_path = Path(member_name.replace("\\", "/"))
|
||||
if member_path.is_absolute():
|
||||
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
|
||||
target = (base / member_path).resolve()
|
||||
try:
|
||||
target.relative_to(base.resolve())
|
||||
except ValueError as exc:
|
||||
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
|
||||
return target
|
||||
|
||||
|
||||
def _extract_zip_safely(source: Path, base: Path) -> None:
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
for member in archive.infolist():
|
||||
target = _safe_extract_path(base, member.filename)
|
||||
mode = (member.external_attr >> 16) & 0o170000
|
||||
if mode == 0o120000:
|
||||
raise PrebuiltFallback(f"zip archive contained a symlink entry: {member.filename}")
|
||||
if member.is_dir():
|
||||
target.mkdir(parents = True, exist_ok = True)
|
||||
continue
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
with archive.open(member, "r") as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
def _extract_tar_safely(source: Path, base: Path) -> None:
|
||||
# Node Unix tarballs ship bin/npm, bin/npx, bin/corepack as relative
|
||||
# symlinks into lib/node_modules; defer links and resolve after files.
|
||||
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
|
||||
with tarfile.open(source, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
target = _safe_extract_path(base, member.name)
|
||||
if member.isdir():
|
||||
target.mkdir(parents = True, exist_ok = True)
|
||||
continue
|
||||
if member.islnk() or member.issym():
|
||||
pending_links.append((member, target))
|
||||
continue
|
||||
if not member.isfile():
|
||||
raise PrebuiltFallback(f"tar archive contained an unsupported entry: {member.name}")
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
extracted = archive.extractfile(member)
|
||||
if extracted is None:
|
||||
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
|
||||
with extracted, target.open("wb") as dst:
|
||||
shutil.copyfileobj(extracted, dst)
|
||||
if member.mode & 0o111:
|
||||
os.chmod(target, target.stat().st_mode | 0o111)
|
||||
|
||||
for member, target in pending_links:
|
||||
link_name = member.linkname.replace("\\", "/")
|
||||
link_path = Path(link_name)
|
||||
if link_path.is_absolute() or not link_name:
|
||||
raise PrebuiltFallback(
|
||||
f"archive link used an unsafe target: {member.name} -> {link_name}"
|
||||
)
|
||||
# tar symlink names are link-parent relative; hard-link names are archive-root relative.
|
||||
resolved = (target.parent / link_path if member.issym() else base / link_path).resolve()
|
||||
try:
|
||||
resolved.relative_to(base.resolve())
|
||||
except ValueError as exc:
|
||||
raise PrebuiltFallback(
|
||||
f"archive link escaped destination: {member.name} -> {link_name}"
|
||||
) from exc
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
if target.exists() or target.is_symlink():
|
||||
target.unlink()
|
||||
if member.issym():
|
||||
target.symlink_to(link_name)
|
||||
else: # hard link
|
||||
shutil.copy2(resolved, target)
|
||||
|
||||
|
||||
def extract_archive(archive_path: Path, destination: Path) -> None:
|
||||
destination.mkdir(parents = True, exist_ok = True)
|
||||
if archive_path.name.endswith(".zip"):
|
||||
_extract_zip_safely(archive_path, destination)
|
||||
elif archive_path.name.endswith(".tar.gz"):
|
||||
_extract_tar_safely(archive_path, destination)
|
||||
else:
|
||||
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
|
||||
|
||||
|
||||
# ── Install lock (concurrent setup runs share one UNSLOTH_HOME) ──
|
||||
def install_lock_path(install_dir: Path) -> Path:
|
||||
return install_dir.parent / f".{install_dir.name}.install.lock"
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int) -> bool:
|
||||
"""Best-effort process liveness check that never signals the process on Windows."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
**_windows_hidden_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
# Be conservative if tasklist itself is unavailable.
|
||||
return True
|
||||
return f'"{pid}"' in result.stdout
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def install_lock(lock_path: Path) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
if FileLock is None:
|
||||
fd: int | None = None
|
||||
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
os.write(fd, f"{os.getpid()}\n".encode())
|
||||
os.fsync(fd)
|
||||
break
|
||||
except FileExistsError:
|
||||
try:
|
||||
raw = lock_path.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
stale = False
|
||||
if raw:
|
||||
try:
|
||||
stale = not _pid_is_alive(int(raw))
|
||||
except ValueError:
|
||||
stale = True
|
||||
if stale:
|
||||
# Atomically rename before unlinking so only one racer removes
|
||||
# the stale lock; a process recreating it loses the rename and waits.
|
||||
try:
|
||||
stale_path = lock_path.with_name(f"{lock_path.name}.stale.{os.getpid()}")
|
||||
os.replace(str(lock_path), str(stale_path))
|
||||
stale_path.unlink(missing_ok = True)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() >= deadline:
|
||||
raise BusyInstallConflict(
|
||||
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
lock_path.unlink(missing_ok = True)
|
||||
return
|
||||
|
||||
try:
|
||||
with FileLock(str(lock_path), timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
|
||||
yield
|
||||
except FileLockTimeout as exc:
|
||||
raise BusyInstallConflict(
|
||||
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ── Install layout / metadata / health ──
|
||||
def node_binary_path(install_dir: Path, host: HostInfo) -> Path:
|
||||
return install_dir / "node.exe" if host.is_windows else install_dir / "bin" / "node"
|
||||
|
||||
|
||||
def npm_cli_path(install_dir: Path, host: HostInfo) -> Path:
|
||||
# Windows ships npm at <root>\node_modules\npm; Unix at <root>/lib/node_modules/npm.
|
||||
if host.is_windows:
|
||||
return install_dir / "node_modules" / "npm" / "bin" / "npm-cli.js"
|
||||
return install_dir / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js"
|
||||
|
||||
|
||||
def _windows_hidden_kwargs() -> dict[str, object]:
|
||||
if sys.platform != "win32":
|
||||
return {}
|
||||
kwargs: dict[str, object] = {}
|
||||
flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if flag:
|
||||
kwargs["creationflags"] = flag
|
||||
return kwargs
|
||||
|
||||
|
||||
def _run_node(
|
||||
install_dir: Path,
|
||||
host: HostInfo,
|
||||
args: list[str],
|
||||
*,
|
||||
timeout: int = 120,
|
||||
) -> str:
|
||||
node_bin = node_binary_path(install_dir, host)
|
||||
env = os.environ.copy()
|
||||
# Keep any `npm -g` writes inside the isolated prefix; Windows npm otherwise
|
||||
# defaults its global prefix to %APPDATA%\npm and touches the system install.
|
||||
env["NPM_CONFIG_PREFIX"] = str(install_dir)
|
||||
env["npm_config_prefix"] = str(install_dir)
|
||||
env.pop("NODE_PATH", None)
|
||||
result = subprocess.run(
|
||||
[str(node_bin), *args],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = timeout,
|
||||
env = env,
|
||||
**_windows_hidden_kwargs(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"node {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def installed_node_version(install_dir: Path, host: HostInfo) -> str | None:
|
||||
node_bin = node_binary_path(install_dir, host)
|
||||
if not node_bin.exists():
|
||||
return None
|
||||
try:
|
||||
return _run_node(install_dir, host, ["-v"], timeout = 30).lstrip("v")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def installed_npm_major(install_dir: Path, host: HostInfo) -> int | None:
|
||||
cli = npm_cli_path(install_dir, host)
|
||||
if not cli.exists():
|
||||
return None
|
||||
try:
|
||||
out = _run_node(install_dir, host, [str(cli), "--version"], timeout = 60)
|
||||
return _version_tuple(out)[0]
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def metadata_path(install_dir: Path) -> Path:
|
||||
return install_dir / METADATA_FILENAME
|
||||
|
||||
|
||||
def write_metadata(install_dir: Path, *, version: str, asset: str, sha256: str) -> None:
|
||||
payload = {
|
||||
"schema_version": METADATA_SCHEMA_VERSION,
|
||||
"kind": "node",
|
||||
"version": version,
|
||||
"asset": asset,
|
||||
"sha256": sha256,
|
||||
}
|
||||
metadata_path(install_dir).write_text(json.dumps(payload, indent = 2) + "\n")
|
||||
|
||||
|
||||
def load_metadata(install_dir: Path) -> dict | None:
|
||||
path = metadata_path(install_dir)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def existing_install_matches(install_dir: Path, host: HostInfo, *, version: str) -> bool:
|
||||
"""True iff the on-disk install is exactly this version and runs."""
|
||||
meta = load_metadata(install_dir)
|
||||
if not meta or meta.get("version") != version:
|
||||
return False
|
||||
if installed_node_version(install_dir, host) != version:
|
||||
return False
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
|
||||
|
||||
|
||||
def existing_install_usable(install_dir: Path, host: HostInfo) -> bool:
|
||||
"""True iff the on-disk install runs and clears the npm floor, ignoring version."""
|
||||
if not load_metadata(install_dir):
|
||||
return False
|
||||
if installed_node_version(install_dir, host) is None:
|
||||
return False
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
|
||||
|
||||
|
||||
def _swap_into_place(extracted_root: Path, install_dir: Path) -> None:
|
||||
"""Atomically replace install_dir with extracted_root (same filesystem)."""
|
||||
install_dir.parent.mkdir(parents = True, exist_ok = True)
|
||||
backup: Path | None = None
|
||||
if install_dir.exists():
|
||||
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
|
||||
os.replace(install_dir, backup)
|
||||
try:
|
||||
os.replace(extracted_root, install_dir)
|
||||
except OSError:
|
||||
if backup is not None and not install_dir.exists():
|
||||
os.replace(backup, install_dir)
|
||||
raise
|
||||
if backup is not None:
|
||||
shutil.rmtree(backup, ignore_errors = True)
|
||||
|
||||
|
||||
def _ensure_npm_floor(install_dir: Path, host: HostInfo) -> None:
|
||||
"""Self-upgrade npm inside the isolated prefix if a pinned build ships npm < 11 (no-op on Node 24+)."""
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
if npm_major is not None and npm_major >= NPM_MIN_MAJOR:
|
||||
return
|
||||
log(f"bundled npm {npm_major} below {NPM_MIN_MAJOR}; upgrading npm inside the isolated prefix")
|
||||
cli = npm_cli_path(install_dir, host)
|
||||
_run_node(install_dir, host, [str(cli), "install", "-g", f"npm@^{NPM_MIN_MAJOR}"], timeout = 300)
|
||||
|
||||
|
||||
# ── Orchestration ──
|
||||
def install_prebuilt(install_dir: Path, *, channel: str, min_major: int, force: bool) -> int:
|
||||
host = detect_host()
|
||||
|
||||
if channel in {"lts", "latest"}:
|
||||
try:
|
||||
index = fetch_json(NODE_DIST_INDEX)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# nodejs.org unreachable: keep a working isolated Node instead of aborting.
|
||||
if not force and existing_install_usable(install_dir, host):
|
||||
log(f"Node dist index unreachable ({exc}); keeping existing isolated Node")
|
||||
return EXIT_SUCCESS
|
||||
raise
|
||||
if not isinstance(index, list):
|
||||
raise PrebuiltFallback(f"unexpected index.json payload from {NODE_DIST_INDEX}")
|
||||
version = select_node_version(index, channel = channel, min_major = min_major)
|
||||
else:
|
||||
version = channel.lstrip("v")
|
||||
# Explicit version bypasses min_major; reject anything Vite/OXC cannot use.
|
||||
if not _meets_node_floor(version):
|
||||
raise PrebuiltFallback(
|
||||
f"requested Node v{version} is below the floor (^20.19 || >=22.12 || >=23)"
|
||||
)
|
||||
|
||||
asset = node_asset_name(version, host)
|
||||
log(f"target Node v{version} ({asset})")
|
||||
|
||||
if not force and existing_install_matches(install_dir, host, version = version):
|
||||
log(f"existing Node install already matches v{version}; nothing to do")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
with install_lock(install_lock_path(install_dir)):
|
||||
# Re-check under the lock: a concurrent run may have just finished.
|
||||
if not force and existing_install_matches(install_dir, host, version = version):
|
||||
log(f"existing Node install already matches v{version}; nothing to do")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
shasums = download_bytes(node_shasums_url(version), timeout = 30).decode("utf-8")
|
||||
expected_sha = expected_sha256_for(shasums, asset)
|
||||
if not expected_sha:
|
||||
raise PrebuiltFallback(f"no sha256 for {asset} in SHASUMS256.txt (v{version})")
|
||||
|
||||
staging_root = install_dir.parent / INSTALL_STAGING_ROOT_NAME
|
||||
staging_root.mkdir(parents = True, exist_ok = True)
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root)
|
||||
)
|
||||
try:
|
||||
archive_path = staging / asset
|
||||
log(f"downloading {node_download_url(version, asset)}")
|
||||
download_file_verified(
|
||||
node_download_url(version, asset),
|
||||
archive_path,
|
||||
expected_sha256 = expected_sha,
|
||||
label = asset,
|
||||
)
|
||||
extract_dir = staging / "extracted"
|
||||
extract_archive(archive_path, extract_dir)
|
||||
|
||||
roots = [p for p in extract_dir.iterdir() if p.is_dir()]
|
||||
if len(roots) != 1:
|
||||
raise PrebuiltFallback(f"unexpected archive layout: {[p.name for p in roots]}")
|
||||
extracted_root = roots[0]
|
||||
|
||||
_ensure_npm_floor(extracted_root, host)
|
||||
write_metadata(extracted_root, version = version, asset = asset, sha256 = expected_sha)
|
||||
_swap_into_place(extracted_root, install_dir)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors = True)
|
||||
try:
|
||||
staging_root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# A newer Node exists upstream but the shasums/archive fetch failed;
|
||||
# keep an existing usable Node rather than aborting the update.
|
||||
if not force and existing_install_usable(install_dir, host):
|
||||
log(f"Node download failed ({exc}); keeping existing isolated Node")
|
||||
return EXIT_SUCCESS
|
||||
raise
|
||||
|
||||
final_version = installed_node_version(install_dir, host)
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
if final_version != version or npm_major is None or npm_major < NPM_MIN_MAJOR:
|
||||
raise PrebuiltFallback(
|
||||
f"post-install verification failed: node={final_version} npm_major={npm_major}"
|
||||
)
|
||||
log(f"installed isolated Node v{final_version} (npm {npm_major}.x) at {install_dir}")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
global _LOG_TO_STDOUT
|
||||
_LOG_TO_STDOUT = True
|
||||
|
||||
parser = argparse.ArgumentParser(description = "Install an isolated Node.js for Unsloth Studio")
|
||||
parser.add_argument(
|
||||
"--install-dir", required = True, help = "isolated Node directory, e.g. <UNSLOTH_HOME>/node"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--node-version",
|
||||
default = os.environ.get("UNSLOTH_NODE_VERSION", "lts"),
|
||||
help = "'lts' (default), 'latest', or an explicit version like 24.4.1",
|
||||
)
|
||||
parser.add_argument("--min-major", type = int, default = NODE_MIN_LTS_MAJOR)
|
||||
parser.add_argument(
|
||||
"--force", action = "store_true", help = "reinstall even if the version matches"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
install_dir = Path(args.install_dir).expanduser().resolve()
|
||||
try:
|
||||
return install_prebuilt(
|
||||
install_dir,
|
||||
channel = args.node_version,
|
||||
min_major = args.min_major,
|
||||
force = args.force,
|
||||
)
|
||||
except BusyInstallConflict as exc:
|
||||
log(str(exc))
|
||||
return EXIT_BUSY
|
||||
except PrebuiltFallback as exc:
|
||||
log(f"prebuilt unavailable: {exc}")
|
||||
return EXIT_FALLBACK
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"unexpected error: {exc}")
|
||||
return EXIT_ERROR
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
232
studio/setup.ps1
232
studio/setup.ps1
|
|
@ -5,9 +5,10 @@
|
|||
.SYNOPSIS
|
||||
Full environment setup for Unsloth Studio on Windows (bundled version).
|
||||
.DESCRIPTION
|
||||
Always installs Node.js if needed. When running from pip install:
|
||||
skips frontend build (already bundled). When running from git repo:
|
||||
full setup including frontend build.
|
||||
Uses an isolated, Unsloth-managed Node.js for the frontend build when the
|
||||
system Node/npm do not meet requirements (never modifies the system Node).
|
||||
When running from pip install: skips frontend build (already bundled). When
|
||||
running from git repo: full setup including frontend build.
|
||||
Supports NVIDIA GPU (full training + inference) and CPU-only (GGUF chat mode).
|
||||
.NOTES
|
||||
Default output is minimal (step/substep), aligned with studio/setup.sh.
|
||||
|
|
@ -1491,71 +1492,92 @@ if ($HasROCm) {
|
|||
# ============================================
|
||||
# 1f. Node.js / npm (skip if pip-installed or Tauri -- only needed for frontend build)
|
||||
# ============================================
|
||||
# Frontend and OXC share this Node floor. The helper returns:
|
||||
# system | bundled | skip.
|
||||
function Get-NodeDecision {
|
||||
param(
|
||||
[string]$NodeVersion, # `node -v` output, e.g. v22.17.1 (or empty)
|
||||
[string]$NpmVersion, # `npm -v` output, e.g. 10.9.2 (or empty)
|
||||
[string]$SkipInstall # "1" => never auto-install
|
||||
)
|
||||
$node = ($NodeVersion -replace '^v', '').Trim()
|
||||
$npm = "$NpmVersion".Trim()
|
||||
if ($node -match '^\d+\.\d+' -and $npm -match '^\d+') {
|
||||
$nodeMajor = [int]($node.Split('.')[0])
|
||||
$nodeMinor = [int]($node.Split('.')[1])
|
||||
$npmMajor = [int]($npm.Split('.')[0])
|
||||
$nodeOk = ($nodeMajor -eq 20 -and $nodeMinor -ge 19) -or
|
||||
($nodeMajor -eq 22 -and $nodeMinor -ge 12) -or
|
||||
($nodeMajor -ge 23)
|
||||
if ($nodeOk -and $npmMajor -ge 11) { return "system" }
|
||||
}
|
||||
if ($SkipInstall -eq "1") { return "skip" }
|
||||
return "bundled"
|
||||
}
|
||||
|
||||
$SkipFrontend = ($env:SKIP_STUDIO_FRONTEND -eq "1")
|
||||
$NodeOverride = $null
|
||||
$NodeParent = $null
|
||||
$NodeDir = $null
|
||||
$SysNodeVersion = ""
|
||||
$SysNpmVersion = ""
|
||||
$NodeSource = $null
|
||||
|
||||
if (-not $IsPipInstall) {
|
||||
# Put Node beside the Studio root. OXC can still need npm when the
|
||||
# frontend build is skipped.
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $NodeOverride = $env:UNSLOTH_STUDIO_HOME.Trim() }
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $NodeOverride = $env:STUDIO_HOME.Trim() }
|
||||
if ($NodeOverride) {
|
||||
if ($NodeOverride -eq "~") {
|
||||
$NodeOverride = $env:USERPROFILE
|
||||
} elseif ($NodeOverride -like "~/*" -or $NodeOverride -like "~\*") {
|
||||
$NodeOverride = (Join-Path $env:USERPROFILE $NodeOverride.Substring(1).TrimStart('/', '\'))
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $NodeOverride -PathType Container)) {
|
||||
Write-Host "ERROR: UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist." -ForegroundColor Red
|
||||
Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$NodeParent = (Resolve-Path -LiteralPath $NodeOverride).Path
|
||||
# An override pointing at the legacy default maps to the legacy sibling
|
||||
# ~/.unsloth/node (what the runtime resolver and setup.sh use), not <root>/node.
|
||||
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
|
||||
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
|
||||
}
|
||||
if ($NodeParent -eq $_legacyStudio) {
|
||||
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
|
||||
$NodeOverride = $null
|
||||
}
|
||||
} else {
|
||||
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
|
||||
}
|
||||
$NodeDir = Join-Path $NodeParent "node"
|
||||
|
||||
# Probe system node/npm without letting a missing/broken command abort setup.
|
||||
# Under $ErrorActionPreference = "Stop" a bare `node -v` for an absent node
|
||||
# throws a terminating error `2>$null` cannot swallow, and a present-but-broken
|
||||
# shim throws too. Guard with Get-Command (node/npm independently) + try/catch;
|
||||
# empty version => Get-NodeDecision returns "bundled".
|
||||
$SysNodeVersion = try { if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>$null) } else { "" } } catch { "" }
|
||||
$SysNpmVersion = try { if (Get-Command npm -ErrorAction SilentlyContinue) { (npm -v 2>$null) } else { "" } } catch { "" }
|
||||
$NodeSource = Get-NodeDecision -NodeVersion "$SysNodeVersion" -NpmVersion "$SysNpmVersion" -SkipInstall "$($env:UNSLOTH_SKIP_NODE_INSTALL)"
|
||||
}
|
||||
|
||||
if ($IsPipInstall) {
|
||||
step "frontend" "bundled (pip install)"
|
||||
} elseif ($SkipFrontend) {
|
||||
step "frontend" "bundled (Tauri)"
|
||||
} else {
|
||||
# setup.sh installs Node LTS (v22) via nvm. We enforce the same range here:
|
||||
# Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11.
|
||||
$NeedNode = $true
|
||||
try {
|
||||
$NodeVersion = (node -v 2>$null)
|
||||
$NpmVersion = (npm -v 2>$null)
|
||||
if ($NodeVersion -and $NpmVersion) {
|
||||
$NodeParts = ($NodeVersion -replace 'v','').Split('.')
|
||||
$NodeMajor = [int]$NodeParts[0]
|
||||
$NodeMinor = [int]$NodeParts[1]
|
||||
$NpmMajor = [int]$NpmVersion.Split('.')[0]
|
||||
|
||||
# Vite 8: ^20.19.0 || >=22.12.0
|
||||
$NodeOk = ($NodeMajor -eq 20 -and $NodeMinor -ge 19) -or
|
||||
($NodeMajor -eq 22 -and $NodeMinor -ge 12) -or
|
||||
($NodeMajor -ge 23)
|
||||
if ($NodeOk -and $NpmMajor -ge 11) {
|
||||
substep "Node $NodeVersion and npm $NpmVersion already meet requirements."
|
||||
$NeedNode = $false
|
||||
} else {
|
||||
substep "Node $NodeVersion / npm $NpmVersion too old." "Yellow"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
substep "Node/npm not found." "Yellow"
|
||||
}
|
||||
|
||||
if ($NeedNode) {
|
||||
substep "installing Node.js LTS via winget..."
|
||||
try {
|
||||
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements
|
||||
Refresh-Environment
|
||||
} catch {
|
||||
Write-Host "[ERROR] Could not install Node.js automatically." -ForegroundColor Red
|
||||
Write-Host "Please install Node.js >= 20 from https://nodejs.org/" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
step "node" "$(node -v) | npm $(npm -v)"
|
||||
|
||||
# ── bun (optional, faster package installs) ──
|
||||
# Installed via npm — Node is already guaranteed above. Works on all platforms.
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
substep "installing bun (faster frontend package installs)..."
|
||||
$prevEAP_bun = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
|
||||
# postinstall fetches its binary; without it the install is a broken stub.
|
||||
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
|
||||
$ErrorActionPreference = $prevEAP_bun
|
||||
Refresh-Environment
|
||||
if (Get-Command bun -ErrorAction SilentlyContinue) {
|
||||
substep "bun installed ($(bun --version))"
|
||||
} else {
|
||||
substep "bun install skipped (npm will be used instead)"
|
||||
}
|
||||
# Stale npm used to trigger system Node changes. Keep this process-local
|
||||
# and provision only when the build or OXC needs Node.
|
||||
if ($NodeSource -eq "system") {
|
||||
substep "Node $SysNodeVersion and npm $SysNpmVersion already meet requirements (system)."
|
||||
} elseif ($NodeSource -eq "bundled") {
|
||||
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable; will use an isolated Node (system left untouched)."
|
||||
} else {
|
||||
substep "bun already installed ($(bun --version))"
|
||||
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable and UNSLOTH_SKIP_NODE_INSTALL set; frontend build will be skipped." "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,12 +1652,14 @@ function Add-PythonDirToProcessPath {
|
|||
}
|
||||
|
||||
# Reuse the install.ps1 / venv interpreter before any system probe.
|
||||
$ValidatedSetupPython = $null
|
||||
if ($ReusedSetupPython) {
|
||||
$_reusedVer = Get-CompatiblePythonVersion $ReusedSetupPython
|
||||
if ($_reusedVer -and -not (Test-IsConda $ReusedSetupPython)) {
|
||||
$DetectedPyVer = $_reusedVer
|
||||
Add-PythonDirToProcessPath $ReusedSetupPython
|
||||
$PythonOk = $true
|
||||
$ValidatedSetupPython = $ReusedSetupPython
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1766,6 +1790,86 @@ if ($IsPipInstall) {
|
|||
substep "Frontend source changed since last build -- rebuilding..." "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
# Provision Node when the frontend build OR the OXC runtime install needs it (the
|
||||
# OXC `npm install` runs whenever its dir exists, regardless of dist staleness);
|
||||
# never eagerly. System Node is used read-only; the isolated one is ours.
|
||||
$NeedNodeForSetup = (-not $IsPipInstall) -and ($NeedFrontendBuild -or (Test-Path $OxcValidatorDir))
|
||||
if ($NeedNodeForSetup) {
|
||||
if ($NodeSource -eq "skip") {
|
||||
if ($NeedFrontendBuild) {
|
||||
step "frontend" "skipped (no suitable Node; system left untouched)" "Yellow"
|
||||
}
|
||||
$NeedFrontendBuild = $false
|
||||
substep "found Node='$SysNodeVersion' npm='$SysNpmVersion'; Studio needs Node >=20.19/22.12/23 and npm >= 11" "Yellow"
|
||||
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node" "Yellow"
|
||||
} elseif ($NodeSource -eq "bundled") {
|
||||
New-Item -ItemType Directory -Force -Path $NodeParent -ErrorAction SilentlyContinue | Out-Null
|
||||
# Minimal ownership guard for a custom-home dir (the full Studio-owned
|
||||
# helpers are defined later); never os.replace over a user-owned dir.
|
||||
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
|
||||
$nodeOwnedMarker = Join-Path $NodeDir ".unsloth-studio-owned"
|
||||
$nodeMeta = Join-Path $NodeDir "UNSLOTH_NODE_PREBUILT_INFO.json"
|
||||
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
|
||||
Write-Host "[ERROR] $NodeDir already exists and is not a Studio-owned Node install." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
substep "installing isolated Node (system Node/npm left untouched)..."
|
||||
# The main Python resolver runs later; bare `python` may be a Store stub or
|
||||
# absent this early, so prefer the validated handed-off/venv Python.
|
||||
$NodeInstallPython = if ($ValidatedSetupPython) { $ValidatedSetupPython } else { "python" }
|
||||
$nodeOut = & $NodeInstallPython "$PSScriptRoot\install_node_prebuilt.py" --install-dir $NodeDir 2>&1 | Out-String
|
||||
$nodeExit = $LASTEXITCODE
|
||||
if ($nodeExit -eq 3) {
|
||||
Write-Host $nodeOut -ForegroundColor DarkGray
|
||||
step "node" "install blocked by another active Studio install" "Red"
|
||||
exit 3
|
||||
} elseif ($nodeExit -ne 0) {
|
||||
Write-Host $nodeOut -ForegroundColor DarkGray
|
||||
Write-Host "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red
|
||||
Write-Host " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
|
||||
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
# Windows Node zip ships node.exe + npm.cmd at the root; prepend it (this
|
||||
# process only) so node/npm/bun resolve here for the build.
|
||||
$env:PATH = "$NodeDir;" + $env:PATH
|
||||
# Keep npm and module resolution inside the isolated Node.
|
||||
$env:NPM_CONFIG_PREFIX = $NodeDir
|
||||
$env:npm_config_prefix = $NodeDir
|
||||
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
|
||||
step "node" "$(node -v) | npm $(npm -v) (isolated)"
|
||||
|
||||
# bun (optional, faster installs); npm -g stays in the isolated prefix.
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
substep "installing bun (faster frontend package installs)..."
|
||||
$prevEAP_bun = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
|
||||
$ErrorActionPreference = $prevEAP_bun
|
||||
Refresh-Environment
|
||||
# Refresh-Environment rebuilds PATH (Machine;User;current), demoting the
|
||||
# isolated-Node prepend; re-prepend so it wins for the build and OXC step.
|
||||
$env:PATH = "$NodeDir;" + $env:PATH
|
||||
$env:NPM_CONFIG_PREFIX = $NodeDir
|
||||
$env:npm_config_prefix = $NodeDir
|
||||
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
|
||||
if (Get-Command bun -ErrorAction SilentlyContinue) {
|
||||
substep "bun installed ($(bun --version))"
|
||||
} else {
|
||||
substep "bun install skipped (npm will be used instead)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# system Node already satisfies requirements; use it as-is. We do NOT
|
||||
# install global packages (bun) here -- the build falls back to npm.
|
||||
step "node" "$SysNodeVersion | npm $SysNpmVersion (system)"
|
||||
}
|
||||
}
|
||||
if ($NeedFrontendBuild -and -not $IsPipInstall) {
|
||||
Write-Host ""
|
||||
substep "building frontend..."
|
||||
|
|
@ -1876,7 +1980,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
|
|||
}
|
||||
}
|
||||
|
||||
if (Test-Path $OxcValidatorDir) {
|
||||
if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||
substep "installing OXC validator runtime..."
|
||||
$prevEAP_oxc = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
|
@ -1891,6 +1995,10 @@ if (Test-Path $OxcValidatorDir) {
|
|||
Pop-Location
|
||||
$ErrorActionPreference = $prevEAP_oxc
|
||||
step "oxc runtime" "installed"
|
||||
} elseif ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip") {
|
||||
# No npm on PATH (e.g. a pip install with no system Node and no isolated Node
|
||||
# provisioned). Skip rather than abort; the runtime resolver degrades. Mirrors setup.sh.
|
||||
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "Yellow"
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
|
|
|
|||
198
studio/setup.sh
198
studio/setup.sh
|
|
@ -439,14 +439,13 @@ _STUDIO_HOME_IS_CUSTOM=false
|
|||
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_STUDIO_HOME_IS_CUSTOM=true
|
||||
fi
|
||||
# Directory-local evidence that Studio created "$1", used to adopt a custom-home
|
||||
# llama.cpp predating the .unsloth-studio-owned marker without weakening the guard.
|
||||
# Only UNSLOTH_PREBUILT_INFO.json counts (written exclusively by the prebuilt
|
||||
# installer). A top-level llama-quantize symlink is NOT trusted: a user may have
|
||||
# their own build with one, and this runs right before a destructive rm -rf, so we
|
||||
# match Windows and keep markerless source builds strict.
|
||||
# Directory-local evidence Studio created "$1": only prebuilt-installer metadata
|
||||
# counts (UNSLOTH_PREBUILT_INFO.json for llama.cpp, UNSLOTH_NODE_PREBUILT_INFO.json
|
||||
# for Node), both written only by our installers. Mirrors the setup.ps1 Node guard.
|
||||
# A markerless source build stays strict since this runs right before an rm -rf.
|
||||
_studio_owned_adoptable() {
|
||||
[ -f "$1/UNSLOTH_PREBUILT_INFO.json" ] && return 0
|
||||
[ -f "$1/UNSLOTH_NODE_PREBUILT_INFO.json" ] && return 0
|
||||
return 1
|
||||
}
|
||||
_assert_studio_owned_or_absent() {
|
||||
|
|
@ -485,84 +484,141 @@ if [ -d "$SCRIPT_DIR/frontend/dist" ]; then
|
|||
fi
|
||||
fi # end SKIP_STUDIO_FRONTEND guard
|
||||
|
||||
if [ "$_NEED_FRONTEND_BUILD" = false ]; then
|
||||
# OXC validator runtime (below) needs node/npm whenever its dir exists, regardless
|
||||
# of dist staleness; provision Node when the frontend builds OR the OXC dir exists.
|
||||
_OXC_DIR="$SCRIPT_DIR/backend/core/data_recipe/oxc-validator"
|
||||
if [ "$_NEED_FRONTEND_BUILD" = false ] && [ ! -d "$_OXC_DIR" ]; then
|
||||
step "frontend" "up to date"
|
||||
verbose_substep "frontend dist is newer than source inputs"
|
||||
else
|
||||
|
||||
# ── Node ──
|
||||
NEED_NODE=true
|
||||
if command -v node &>/dev/null && command -v npm &>/dev/null; then
|
||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
NODE_MINOR=$(node -v | sed 's/v//' | cut -d. -f2)
|
||||
NPM_MAJOR=$(npm -v | cut -d. -f1)
|
||||
# Vite 8 requires Node ^20.19.0 || >=22.12.0
|
||||
NODE_OK=false
|
||||
if [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -ge 19 ]; then NODE_OK=true; fi
|
||||
if [ "$NODE_MAJOR" -eq 22 ] && [ "$NODE_MINOR" -ge 12 ]; then NODE_OK=true; fi
|
||||
if [ "$NODE_MAJOR" -ge 23 ]; then NODE_OK=true; fi
|
||||
if [ "$NODE_OK" = true ] && [ "$NPM_MAJOR" -ge 11 ]; then
|
||||
NEED_NODE=false
|
||||
else
|
||||
if [ "$IS_COLAB" = true ] && [ "$NODE_OK" = true ]; then
|
||||
# In Colab, just upgrade npm directly - nvm doesn't work well
|
||||
if [ "$NPM_MAJOR" -lt 11 ]; then
|
||||
substep "upgrading npm..."
|
||||
run_maybe_quiet npm install -g npm@latest
|
||||
fi
|
||||
NEED_NODE=false
|
||||
# ── Node (isolated; never touches the system Node/npm) ──
|
||||
# Studio's frontend (Vite 8) needs Node ^20.19 || >=22.12 || >=23 and npm >= 11.
|
||||
# Three sources:
|
||||
# system -- system Node + npm already satisfy both; used read-only.
|
||||
# bundled -- install a pinned isolated Node under $UNSLOTH_HOME/node, build-only.
|
||||
# skip -- UNSLOTH_SKIP_NODE_INSTALL=1 and system unsuitable; print manual fix.
|
||||
# decide_node_source(node_v, npm_v, skip_flag) -> system | bundled | skip
|
||||
# (pure; unit-tested in tests/sh/test_node_decision.sh).
|
||||
decide_node_source() {
|
||||
_dns_node="${1#v}"
|
||||
_dns_npm="$2"
|
||||
_dns_skip="$3"
|
||||
# Treat empty or non-numeric versions as "missing".
|
||||
case "$_dns_node" in ''|*[!0-9.]*) _dns_node='' ;; esac
|
||||
case "$_dns_npm" in ''|*[!0-9.]*) _dns_npm='' ;; esac
|
||||
if [ -n "$_dns_node" ] && [ -n "$_dns_npm" ]; then
|
||||
_dns_nmaj="${_dns_node%%.*}"
|
||||
case "$_dns_node" in
|
||||
*.*) _dns_rest="${_dns_node#*.}"; _dns_nmin="${_dns_rest%%.*}" ;;
|
||||
*) _dns_nmin=0 ;;
|
||||
esac
|
||||
case "$_dns_nmin" in ''|*[!0-9]*) _dns_nmin=0 ;; esac
|
||||
_dns_pmaj="${_dns_npm%%.*}"
|
||||
_dns_ok=false
|
||||
if [ "$_dns_nmaj" -eq 20 ] && [ "$_dns_nmin" -ge 19 ]; then _dns_ok=true; fi
|
||||
if [ "$_dns_nmaj" -eq 22 ] && [ "$_dns_nmin" -ge 12 ]; then _dns_ok=true; fi
|
||||
if [ "$_dns_nmaj" -ge 23 ]; then _dns_ok=true; fi
|
||||
if [ "$_dns_ok" = true ] && [ "$_dns_pmaj" -ge 11 ]; then
|
||||
echo system
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ "$_dns_skip" = "1" ]; then
|
||||
echo skip
|
||||
return 0
|
||||
fi
|
||||
echo bundled
|
||||
}
|
||||
|
||||
# Mirror the llama.cpp UNSLOTH_HOME derivation; the frontend build runs first.
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
||||
_NODE_PARENT="$STUDIO_HOME"
|
||||
else
|
||||
_NODE_PARENT="$HOME/.unsloth"
|
||||
fi
|
||||
NODE_DIR="$_NODE_PARENT/node"
|
||||
|
||||
if [ "$NEED_NODE" = true ]; then
|
||||
substep "installing nvm..."
|
||||
export NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
if _is_verbose; then
|
||||
curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
_SYS_NODE_VER="$(node -v 2>/dev/null || true)"
|
||||
_SYS_NPM_VER="$(npm -v 2>/dev/null || true)"
|
||||
NODE_SOURCE="$(decide_node_source "$_SYS_NODE_VER" "$_SYS_NPM_VER" "${UNSLOTH_SKIP_NODE_INSTALL:-0}")"
|
||||
_FRONTEND_SKIP=false
|
||||
|
||||
if [ "$NODE_SOURCE" = system ]; then
|
||||
step "node" "$(node -v) | npm $(npm -v) (system)"
|
||||
elif [ "$NODE_SOURCE" = bundled ]; then
|
||||
mkdir -p "$_NODE_PARENT"
|
||||
# install_node_prebuilt.py uses os.replace(); guard a custom-home dir so we
|
||||
# never displace a user-owned $UNSLOTH_STUDIO_HOME/node.
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
||||
_assert_studio_owned_or_absent "$NODE_DIR" "Node install"
|
||||
fi
|
||||
substep "installing isolated Node (system Node/npm left untouched)..."
|
||||
# Runs before the venv is activated, so bare `python` may be absent; resolve
|
||||
# venv python, then python3, then python.
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
_NODE_PY="$VENV_DIR/bin/python"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
_NODE_PY="python3"
|
||||
else
|
||||
curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash > /dev/null 2>&1
|
||||
_NODE_PY="python"
|
||||
fi
|
||||
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
set +u
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
if [ -f "$HOME/.npmrc" ]; then
|
||||
if grep -qE '^\s*(prefix|globalconfig)\s*=' "$HOME/.npmrc"; then
|
||||
sed -i.bak '/^\s*\(prefix\|globalconfig\)\s*=/d' "$HOME/.npmrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
substep "installing Node LTS..."
|
||||
run_quiet "nvm install" nvm install --lts
|
||||
_NODE_LOG="$(mktemp)"
|
||||
set +e
|
||||
if _is_verbose; then
|
||||
nvm use --lts
|
||||
"$_NODE_PY" "$SCRIPT_DIR/install_node_prebuilt.py" --install-dir "$NODE_DIR" 2>&1 | tee "$_NODE_LOG"
|
||||
_NODE_STATUS=${PIPESTATUS[0]}
|
||||
else
|
||||
nvm use --lts > /dev/null 2>&1
|
||||
"$_NODE_PY" "$SCRIPT_DIR/install_node_prebuilt.py" --install-dir "$NODE_DIR" >"$_NODE_LOG" 2>&1
|
||||
_NODE_STATUS=$?
|
||||
fi
|
||||
set -u
|
||||
|
||||
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
NPM_MAJOR=$(npm -v | cut -d. -f1)
|
||||
|
||||
if [ "$NODE_MAJOR" -lt 20 ]; then
|
||||
step "node" "FAILED -- version must be >= 20 (got $(node -v))" "$C_ERR"
|
||||
set -e
|
||||
if [ "$_NODE_STATUS" -eq 3 ]; then
|
||||
step "node" "install blocked by another active Studio install" "$C_ERR"
|
||||
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
|
||||
substep "close other Studio installs and retry"
|
||||
exit 3
|
||||
elif [ "$_NODE_STATUS" -ne 0 ]; then
|
||||
step "node" "isolated Node install failed" "$C_ERR"
|
||||
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
|
||||
substep "install Node >= 20.19 (with npm >= 11) yourself and re-run, or check your network"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$NPM_MAJOR" -lt 11 ]; then
|
||||
substep "upgrading npm..."
|
||||
run_quiet "npm update" npm install -g npm@latest
|
||||
grep -Fq "already matches" "$_NODE_LOG" && verbose_substep "isolated Node already up to date"
|
||||
rm -f "$_NODE_LOG"
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$NODE_DIR" ]; then
|
||||
: > "$NODE_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||
fi
|
||||
# Prepend the isolated bin (this process only) so node/npm/bun resolve here.
|
||||
export PATH="$NODE_DIR/bin:$PATH"
|
||||
# Keep npm and module resolution inside the isolated Node.
|
||||
export NPM_CONFIG_PREFIX="$NODE_DIR"
|
||||
export npm_config_prefix="$NODE_DIR"
|
||||
unset NODE_PATH
|
||||
hash -r 2>/dev/null || true
|
||||
step "node" "$(node -v) | npm $(npm -v) (isolated)"
|
||||
else
|
||||
_FRONTEND_SKIP=true
|
||||
step "frontend" "skipped (no suitable Node; system left untouched)" "$C_WARN"
|
||||
substep "found Node='${_SYS_NODE_VER:-none}' npm='${_SYS_NPM_VER:-none}'; Studio needs Node >=20.19/22.12/23 and npm >= 11"
|
||||
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node"
|
||||
fi
|
||||
verbose_substep "node source: $NODE_SOURCE (sys node=${_SYS_NODE_VER:-none} npm=${_SYS_NPM_VER:-none}) dir=$NODE_DIR"
|
||||
|
||||
step "node" "$(node -v) | npm $(npm -v)"
|
||||
verbose_substep "node check: NEED_NODE=$NEED_NODE NODE_OK=${NODE_OK:-unknown} NPM_MAJOR=${NPM_MAJOR:-unknown}"
|
||||
if [ "$_FRONTEND_SKIP" = true ]; then
|
||||
: # no suitable Node (skip source): message already shown above; nothing to build
|
||||
elif [ "$_NEED_FRONTEND_BUILD" = false ]; then
|
||||
# Node was provisioned only for the OXC runtime; the dist is already current.
|
||||
step "frontend" "up to date"
|
||||
verbose_substep "frontend dist is newer than source inputs"
|
||||
else
|
||||
|
||||
# ── Install bun (optional, faster package installs) ──
|
||||
# Uses npm to install bun globally -- Node is already guaranteed above,
|
||||
# avoids platform-specific installers, PATH issues, and admin requirements.
|
||||
if ! command -v bun &>/dev/null; then
|
||||
# Install bun via npm only when we manage the isolated Node (npm -g lands in the
|
||||
# isolated prefix); on a system Node we install nothing global. Build falls back to npm.
|
||||
if command -v bun &>/dev/null; then
|
||||
substep "bun already installed ($(bun --version))"
|
||||
elif [ "$NODE_SOURCE" = bundled ]; then
|
||||
substep "installing bun..."
|
||||
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
|
||||
# postinstall fetches its binary; without it the install is a broken stub.
|
||||
|
|
@ -572,7 +628,7 @@ if ! command -v bun &>/dev/null; then
|
|||
substep "bun install skipped (npm will be used instead)"
|
||||
fi
|
||||
else
|
||||
substep "bun already installed ($(bun --version))"
|
||||
verbose_substep "skipping global bun install on system Node (npm will be used)"
|
||||
fi
|
||||
|
||||
# ── Build frontend ──
|
||||
|
|
@ -669,17 +725,25 @@ fi
|
|||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
fi # end _FRONTEND_SKIP guard (Node available: system or isolated)
|
||||
|
||||
fi # end frontend build check
|
||||
|
||||
# ── oxc-validator runtime ──
|
||||
if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm &>/dev/null; then
|
||||
cd "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator"
|
||||
# Skip when the user opted out of Node (NODE_SOURCE=skip): there is no suitable
|
||||
# Node, so do not run npm install against an unsuitable/absent system Node.
|
||||
if [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ] && command -v npm &>/dev/null; then
|
||||
cd "$_OXC_DIR"
|
||||
run_quiet_no_exit "npm install (oxc validator runtime)" npm install --no-fund --no-audit --loglevel=error
|
||||
_oxc_install_rc=$?
|
||||
if [ "$_oxc_install_rc" -ne 0 ]; then
|
||||
exit "$_oxc_install_rc"
|
||||
fi
|
||||
cd "$SCRIPT_DIR"
|
||||
elif [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ]; then
|
||||
# No npm on PATH: skip rather than abort; the backend Node resolver degrades
|
||||
# the validator gracefully. Mirrors setup.ps1's elseif on this block.
|
||||
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "$C_WARN"
|
||||
fi
|
||||
|
||||
# ── Python venv + deps ──
|
||||
|
|
|
|||
63
tests/sh/test_node_decision.sh
Normal file
63
tests/sh/test_node_decision.sh
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/bin/bash
|
||||
# Unit tests for decide_node_source() from studio/setup.sh.
|
||||
# Slices the pure function out of setup.sh and exercises the three outcomes:
|
||||
# system -- system Node + npm already satisfy Vite 8 (^20.19/22.12/>=23) + npm>=11
|
||||
# bundled -- otherwise install an isolated Node (the Discord-reported npm-only case)
|
||||
# skip -- UNSLOTH_SKIP_NODE_INSTALL=1 and the system is unsuitable
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
_FUNC_FILE=$(mktemp)
|
||||
sed -n '/^decide_node_source()/,/^}/p' "$SETUP_SH" > "$_FUNC_FILE"
|
||||
if [ ! -s "$_FUNC_FILE" ]; then
|
||||
echo "FAIL: could not extract decide_node_source from $SETUP_SH"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
. "$_FUNC_FILE"
|
||||
|
||||
assert_decision() {
|
||||
_label="$1"; _node="$2"; _npm="$3"; _skip="$4"; _expected="$5"
|
||||
_actual="$(decide_node_source "$_node" "$_npm" "$_skip")"
|
||||
if [ "$_actual" = "$_expected" ]; then
|
||||
echo " PASS: $_label (node='$_node' npm='$_npm' skip='$_skip' -> $_actual)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (node='$_node' npm='$_npm' skip='$_skip' expected '$_expected', got '$_actual')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "decide_node_source"
|
||||
# system: both satisfy
|
||||
assert_decision "node22 + npm11" "v22.17.1" "11.13.0" "0" system
|
||||
assert_decision "node20.19 + npm11" "v20.19.0" "11.0.0" "0" system
|
||||
assert_decision "node24 + npm11" "v24.17.0" "11.13.0" "0" system
|
||||
assert_decision "node23 + npm11" "v23.5.0" "11.0.0" "0" system
|
||||
|
||||
# bundled: the reported bug -- fine Node, stale npm
|
||||
assert_decision "node22 + npm10 (bug)" "v22.17.1" "10.9.2" "0" bundled
|
||||
# bundled: node too old / wrong line
|
||||
assert_decision "node18" "v18.20.0" "11.0.0" "0" bundled
|
||||
assert_decision "node22.11 (<22.12)" "v22.11.0" "11.0.0" "0" bundled
|
||||
assert_decision "node20.18 (<20.19)" "v20.18.0" "11.0.0" "0" bundled
|
||||
assert_decision "node21 (odd)" "v21.7.0" "11.0.0" "0" bundled
|
||||
# bundled: missing entirely
|
||||
assert_decision "no node/npm" "" "" "0" bundled
|
||||
# bundled: garbage versions
|
||||
assert_decision "garbage versions" "vfoo" "bar" "0" bundled
|
||||
|
||||
# skip: unsuitable + skip flag
|
||||
assert_decision "npm10 + skip" "v22.17.1" "10.9.2" "1" skip
|
||||
assert_decision "missing + skip" "" "" "1" skip
|
||||
# skip flag does NOT override an already-good system
|
||||
assert_decision "good system + skip" "v22.17.1" "11.13.0" "1" system
|
||||
|
||||
rm -f "$_FUNC_FILE"
|
||||
echo ""
|
||||
echo "Passed: $PASS Failed: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
58
tests/sh/test_studio_home_node_dir.sh
Executable file
58
tests/sh/test_studio_home_node_dir.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test: setup.sh installs the isolated Node under <UNSLOTH_STUDIO_HOME>
|
||||
# (or the STUDIO_HOME alias), matching node_runtime.managed_node_dir(). Extracts
|
||||
# the real STUDIO_HOME + NODE_DIR logic from setup.sh by content anchors (not line
|
||||
# numbers) and runs it against a hermetic fake HOME for each override case.
|
||||
set -u
|
||||
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
|
||||
SETUP="$HERE/../../studio/setup.sh"
|
||||
fails=0
|
||||
check() { # name expected actual
|
||||
if [ "$2" = "$3" ]; then printf ' PASS %s\n' "$1"
|
||||
else printf ' FAIL %s : expected [%s] got [%s]\n' "$1" "$2" "$3"; fails=$((fails+1)); fi
|
||||
}
|
||||
|
||||
# Block A: studio override -> STUDIO_HOME -> _STUDIO_HOME_IS_CUSTOM.
|
||||
blockA="$(awk '
|
||||
/^_studio_override_var=""/ {grab=1}
|
||||
grab {print}
|
||||
/_STUDIO_HOME_IS_CUSTOM=true/ {seen=1}
|
||||
seen && /^fi$/ {exit}
|
||||
' "$SETUP")"
|
||||
# Block B: _STUDIO_HOME_IS_CUSTOM -> _NODE_PARENT -> NODE_DIR.
|
||||
blockB="$(awk '
|
||||
/^if \[ "\$_STUDIO_HOME_IS_CUSTOM" = true \]; then/ {grab=1}
|
||||
grab {print}
|
||||
/^NODE_DIR="\$_NODE_PARENT\/node"/ {exit}
|
||||
' "$SETUP")"
|
||||
SNIP="$blockA"$'\n'"$blockB"$'\n''echo "$NODE_DIR"'
|
||||
|
||||
# Self-validate the extraction so a future setup.sh refactor fails loudly here.
|
||||
case "$blockA" in *"_STUDIO_HOME_IS_CUSTOM=true"*) : ;; *) echo "FAIL: blockA extraction broke"; exit 1 ;; esac
|
||||
case "$blockB" in *'NODE_DIR="$_NODE_PARENT/node"'*) : ;; *) echo "FAIL: blockB extraction broke"; exit 1 ;; esac
|
||||
|
||||
node_dir_for() { # HOME UNSLOTH_STUDIO_HOME STUDIO_HOME
|
||||
env -i HOME="$1" UNSLOTH_STUDIO_HOME="$2" STUDIO_HOME="$3" PATH="$PATH" \
|
||||
bash -c "$SNIP" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
T="$(mktemp -d)"
|
||||
trap 'rm -rf "$T"' EXIT
|
||||
mkdir -p "$T/custom" "$T/fakehome/.unsloth/studio"
|
||||
CUSTOM="$(CDPATH= cd -P -- "$T/custom" && pwd -P)"
|
||||
FAKEHOME="$(CDPATH= cd -P -- "$T/fakehome" && pwd -P)"
|
||||
LEGACY="$FAKEHOME/.unsloth/studio"
|
||||
|
||||
# 1. UNSLOTH_STUDIO_HOME = custom dir -> <custom>/node
|
||||
check "UNSLOTH_STUDIO_HOME=<custom> -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "")"
|
||||
# 2. STUDIO_HOME alias = custom dir -> <custom>/node
|
||||
check "STUDIO_HOME alias -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "" "$CUSTOM")"
|
||||
# 3. UNSLOTH_STUDIO_HOME wins over STUDIO_HOME
|
||||
check "UNSLOTH_STUDIO_HOME wins over STUDIO_HOME" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "$T/fakehome")"
|
||||
# 4. Override = legacy default -> sibling ~/.unsloth/node
|
||||
check "legacy-valued override -> ~/.unsloth/node sibling" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "$LEGACY" "")"
|
||||
# 5. No override -> ~/.unsloth/node
|
||||
check "no override -> ~/.unsloth/node" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "" "")"
|
||||
|
||||
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
|
||||
echo "All checks passed"
|
||||
58
tests/sh/test_system_node_readonly.sh
Executable file
58
tests/sh/test_system_node_readonly.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test: setup.sh's reuse (NODE_SOURCE=system) path is strictly
|
||||
# read-only. It runs no global npm install and sets no NPM_CONFIG_PREFIX, so
|
||||
# reusing a good system Node never mutates the user's Node/npm/NVM. Only the
|
||||
# isolated (bundled) path redirects npm into its own prefix and installs
|
||||
# anything global (and even then -g lands in the isolated prefix). Extraction is
|
||||
# anchored on setup.sh content, not line numbers, and self-validates so a
|
||||
# refactor fails loudly here.
|
||||
set -u
|
||||
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
|
||||
SETUP="$HERE/../../studio/setup.sh"
|
||||
fails=0
|
||||
fail() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); }
|
||||
pass() { printf ' PASS %s\n' "$1"; }
|
||||
|
||||
# Arm 1: the NODE_SOURCE=system branch body (reuse a good system Node).
|
||||
system_arm="$(awk '
|
||||
/^if \[ "\$NODE_SOURCE" = system \]; then/ {grab=1; next}
|
||||
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=0}
|
||||
grab {print}
|
||||
' "$SETUP")"
|
||||
# Arm 2: the NODE_SOURCE=bundled branch body (provision the isolated Node).
|
||||
bundled_arm="$(awk '
|
||||
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=1; next}
|
||||
grab && /^else$/ {grab=0}
|
||||
grab {print}
|
||||
' "$SETUP")"
|
||||
# The optional-bun block (the only global install, gated on the bundled path).
|
||||
bun_block="$(awk '
|
||||
/^if command -v bun &>\/dev\/null; then/ {grab=1}
|
||||
grab {print}
|
||||
grab && /^fi$/ {exit}
|
||||
' "$SETUP")"
|
||||
|
||||
# Self-validate extraction so a setup.sh refactor cannot silently void the test.
|
||||
[ -n "$system_arm" ] || { echo "FAIL: system arm extraction broke"; exit 1; }
|
||||
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) : ;; *) echo "FAIL: bundled arm extraction broke"; exit 1 ;; esac
|
||||
case "$bun_block" in *'npm install -g bun'*) : ;; *) echo "FAIL: bun block extraction broke"; exit 1 ;; esac
|
||||
|
||||
# 1. system (reuse) arm performs no global npm install.
|
||||
case "$system_arm" in *"npm install -g"*) fail "system arm runs no 'npm install -g'" ;; *) pass "system arm runs no 'npm install -g'" ;; esac
|
||||
# 2. system (reuse) arm sets no npm prefix redirect (either casing of the var).
|
||||
case "$system_arm" in *NPM_CONFIG_PREFIX*|*npm_config_prefix*) fail "system arm sets no NPM_CONFIG_PREFIX" ;; *) pass "system arm sets no NPM_CONFIG_PREFIX" ;; esac
|
||||
# 3. system (reuse) arm does not rewrite PATH toward a managed Node dir.
|
||||
case "$system_arm" in *"export PATH="*) fail "system arm does not rewrite PATH" ;; *) pass "system arm does not rewrite PATH" ;; esac
|
||||
# 4. positive control: the bundled arm DOES pin the prefix (so 1-3 aren't vacuous).
|
||||
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) pass "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; *) fail "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; esac
|
||||
# 5. the only global install (bun) is gated behind NODE_SOURCE=bundled.
|
||||
guard_at=$(printf '%s\n' "$bun_block" | grep -n 'elif \[ "\$NODE_SOURCE" = bundled \]; then' | head -1 | cut -d: -f1)
|
||||
bun_at=$(printf '%s\n' "$bun_block" | grep -n 'npm install -g bun' | head -1 | cut -d: -f1)
|
||||
if [ -n "$guard_at" ] && [ -n "$bun_at" ] && [ "$guard_at" -lt "$bun_at" ]; then
|
||||
pass "global bun install gated behind NODE_SOURCE=bundled"
|
||||
else
|
||||
fail "global bun install gated behind NODE_SOURCE=bundled"
|
||||
fi
|
||||
|
||||
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
|
||||
echo "All checks passed"
|
||||
477
tests/studio/install/test_install_node_prebuilt_logic.py
Normal file
477
tests/studio/install/test_install_node_prebuilt_logic.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Logic tests for studio/install_node_prebuilt.py -- the isolated Node installer.
|
||||
# No network/GPU: downloads are monkeypatched and archives are built in-memory.
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import types
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_node_prebuilt.py"
|
||||
SPEC = importlib.util.spec_from_file_location("studio_install_node_prebuilt", MODULE_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
M = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = M
|
||||
SPEC.loader.exec_module(M)
|
||||
|
||||
HostInfo = M.HostInfo
|
||||
PrebuiltFallback = M.PrebuiltFallback
|
||||
|
||||
|
||||
def _host(node_os: str, node_arch: str) -> HostInfo:
|
||||
ext = ".zip" if node_os == "win" else ".tar.gz"
|
||||
return HostInfo(
|
||||
system = {"linux": "Linux", "darwin": "Darwin", "win": "Windows"}[node_os],
|
||||
machine = node_arch,
|
||||
node_os = node_os,
|
||||
node_arch = node_arch,
|
||||
archive_ext = ext,
|
||||
is_windows = node_os == "win",
|
||||
)
|
||||
|
||||
|
||||
# ── Host detection (per OS/arch) ──
|
||||
@pytest.mark.parametrize(
|
||||
"system,machine,exp_os,exp_arch,exp_ext",
|
||||
[
|
||||
("Linux", "x86_64", "linux", "x64", ".tar.gz"),
|
||||
("Linux", "aarch64", "linux", "arm64", ".tar.gz"),
|
||||
("Darwin", "x86_64", "darwin", "x64", ".tar.gz"),
|
||||
("Darwin", "arm64", "darwin", "arm64", ".tar.gz"),
|
||||
("Windows", "AMD64", "win", "x64", ".zip"),
|
||||
("Windows", "ARM64", "win", "arm64", ".zip"),
|
||||
],
|
||||
)
|
||||
def test_detect_host(monkeypatch, system, machine, exp_os, exp_arch, exp_ext):
|
||||
monkeypatch.setattr(M.platform, "system", lambda: system)
|
||||
monkeypatch.setattr(M.platform, "machine", lambda: machine)
|
||||
host = M.detect_host()
|
||||
assert (host.node_os, host.node_arch, host.archive_ext) == (exp_os, exp_arch, exp_ext)
|
||||
assert host.is_windows == (exp_os == "win")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"system,machine",
|
||||
[("Plan9", "x86_64"), ("Linux", "sparc64"), ("Linux", "armv7l"), ("Linux", "armhf")],
|
||||
)
|
||||
def test_detect_host_unsupported(monkeypatch, system, machine):
|
||||
monkeypatch.setattr(M.platform, "system", lambda: system)
|
||||
monkeypatch.setattr(M.platform, "machine", lambda: machine)
|
||||
with pytest.raises(PrebuiltFallback):
|
||||
M.detect_host()
|
||||
|
||||
|
||||
# ── URL / asset construction (pure) ──
|
||||
def test_asset_and_url_linux():
|
||||
host = _host("linux", "x64")
|
||||
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-linux-x64.tar.gz"
|
||||
assert (
|
||||
M.node_download_url("24.17.0", M.node_asset_name("24.17.0", host))
|
||||
== "https://nodejs.org/dist/v24.17.0/node-v24.17.0-linux-x64.tar.gz"
|
||||
)
|
||||
|
||||
|
||||
def test_asset_windows_is_zip():
|
||||
host = _host("win", "x64")
|
||||
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-win-x64.zip"
|
||||
|
||||
|
||||
def test_shasums_url():
|
||||
assert M.node_shasums_url("24.17.0") == "https://nodejs.org/dist/v24.17.0/SHASUMS256.txt"
|
||||
|
||||
|
||||
def test_binary_layout_is_host_aware():
|
||||
# Windows ships node.exe + node_modules\npm at the root; Unix uses bin/ + lib/.
|
||||
win = _host("win", "x64")
|
||||
nix = _host("linux", "x64")
|
||||
assert M.node_binary_path(Path("/n"), win) == Path("/n/node.exe")
|
||||
assert M.node_binary_path(Path("/n"), nix) == Path("/n/bin/node")
|
||||
assert M.npm_cli_path(Path("/n"), win) == Path("/n/node_modules/npm/bin/npm-cli.js")
|
||||
assert M.npm_cli_path(Path("/n"), nix) == Path("/n/lib/node_modules/npm/bin/npm-cli.js")
|
||||
|
||||
|
||||
# ── SHASUMS256.txt parsing ──
|
||||
def test_expected_sha256_for():
|
||||
asset = "node-v24.17.0-linux-x64.tar.gz"
|
||||
good = "a" * 64
|
||||
text = (
|
||||
f"{'b' * 64} node-v24.17.0-linux-arm64.tar.gz\n"
|
||||
f"{good} {asset}\n"
|
||||
f"{'c' * 64} node-v24.17.0-win-x64.zip\n"
|
||||
)
|
||||
assert M.expected_sha256_for(text, asset) == good
|
||||
assert M.expected_sha256_for(text, "node-v24.17.0-darwin-x64.tar.gz") is None
|
||||
|
||||
|
||||
def test_expected_sha256_rejects_malformed():
|
||||
asset = "node-v24.17.0-linux-x64.tar.gz"
|
||||
assert M.expected_sha256_for(f"notahex {asset}\n", asset) is None
|
||||
|
||||
|
||||
# ── Version selection from index.json ──
|
||||
INDEX = [
|
||||
{"version": "v26.3.1", "lts": False},
|
||||
{"version": "v24.17.0", "lts": "Krypton"},
|
||||
{"version": "v24.9.0", "lts": "Krypton"},
|
||||
{"version": "v22.20.0", "lts": "Jod"},
|
||||
{"version": "v20.19.0", "lts": "Iron"},
|
||||
]
|
||||
|
||||
|
||||
def test_select_lts_respects_min_major():
|
||||
# Newest LTS at/above 24 -> 24.17.0 (22.x LTS is below the floor).
|
||||
assert M.select_node_version(INDEX, channel = "lts", min_major = 24) == "24.17.0"
|
||||
|
||||
|
||||
def test_select_latest_overall():
|
||||
assert M.select_node_version(INDEX, channel = "latest", min_major = 24) == "26.3.1"
|
||||
|
||||
|
||||
def test_select_explicit_passthrough():
|
||||
assert M.select_node_version(INDEX, channel = "v24.5.0", min_major = 24) == "24.5.0"
|
||||
|
||||
|
||||
def test_select_no_candidate_raises():
|
||||
with pytest.raises(PrebuiltFallback):
|
||||
M.select_node_version(INDEX, channel = "lts", min_major = 99)
|
||||
|
||||
|
||||
# ── Archive extraction (zip + tar.gz with the npm-style symlink), traversal guard ──
|
||||
def _add_file(
|
||||
tar: tarfile.TarFile,
|
||||
name: str,
|
||||
data: bytes,
|
||||
mode: int = 0o644,
|
||||
):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
info.mode = mode
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _add_symlink(tar: tarfile.TarFile, name: str, target: str):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = target
|
||||
tar.addfile(info)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason = "Node ships a .zip (no symlinks) on Windows; the tar+symlink path is Unix-only",
|
||||
)
|
||||
def test_extract_tar_gz_with_npm_symlink(tmp_path: Path):
|
||||
# Mirrors the real Node tarball: bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
|
||||
archive = tmp_path / "node.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
_add_file(tar, "node-v24/bin/node", b"#!/bin/sh\necho v24.17.0\n", mode = 0o755)
|
||||
_add_file(tar, "node-v24/lib/node_modules/npm/bin/npm-cli.js", b"// npm")
|
||||
_add_symlink(tar, "node-v24/bin/npm", "../lib/node_modules/npm/bin/npm-cli.js")
|
||||
|
||||
dest = tmp_path / "out"
|
||||
M.extract_archive(archive, dest)
|
||||
npm_link = dest / "node-v24" / "bin" / "npm"
|
||||
assert npm_link.is_symlink()
|
||||
assert (dest / "node-v24" / "bin" / "node").exists()
|
||||
# executable bit preserved
|
||||
assert (dest / "node-v24" / "bin" / "node").stat().st_mode & 0o111
|
||||
|
||||
|
||||
def test_extract_zip(tmp_path: Path):
|
||||
archive = tmp_path / "node.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("node-v24-win-x64/node.exe", b"MZ")
|
||||
zf.writestr("node-v24-win-x64/npm.cmd", b"@echo off")
|
||||
dest = tmp_path / "out"
|
||||
M.extract_archive(archive, dest)
|
||||
assert (dest / "node-v24-win-x64" / "node.exe").exists()
|
||||
|
||||
|
||||
def test_extract_rejects_path_traversal(tmp_path: Path):
|
||||
archive = tmp_path / "evil.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
_add_file(tar, "../escape.txt", b"pwn")
|
||||
with pytest.raises(PrebuiltFallback):
|
||||
M.extract_archive(archive, tmp_path / "out")
|
||||
|
||||
|
||||
# ── Checksum-verified download (accept + reject) ──
|
||||
def test_download_file_verified_accepts_match(tmp_path: Path, monkeypatch):
|
||||
payload = b"real-node-archive"
|
||||
sha = M.hashlib.sha256(payload).hexdigest()
|
||||
|
||||
def fake_download(url: str, destination: Path):
|
||||
destination.write_bytes(payload)
|
||||
|
||||
monkeypatch.setattr(M, "download_file", fake_download)
|
||||
dest = tmp_path / "a.tar.gz"
|
||||
M.download_file_verified("http://x/a.tar.gz", dest, expected_sha256 = sha, label = "a")
|
||||
assert dest.read_bytes() == payload
|
||||
|
||||
|
||||
def test_download_file_verified_rejects_mismatch(tmp_path: Path, monkeypatch):
|
||||
def fake_download(url: str, destination: Path):
|
||||
destination.write_bytes(b"tampered")
|
||||
|
||||
monkeypatch.setattr(M, "download_file", fake_download)
|
||||
with pytest.raises(PrebuiltFallback):
|
||||
M.download_file_verified("http://x/a", tmp_path / "a", expected_sha256 = "0" * 64, label = "a")
|
||||
|
||||
|
||||
# ── Lock liveness probe (Windows must not use os.kill(pid, 0)) ──
|
||||
def test_pid_is_alive_windows_uses_tasklist_not_os_kill(monkeypatch):
|
||||
monkeypatch.setattr(M.sys, "platform", "win32")
|
||||
|
||||
def fail_kill(pid, sig):
|
||||
raise AssertionError("Windows liveness must not call os.kill(pid, 0)")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd[:2] == ["tasklist", "/FI"]
|
||||
assert "PID eq 1234" in cmd
|
||||
return types.SimpleNamespace(stdout = '"node.exe","1234","Console","1","12,345 K"\n')
|
||||
|
||||
monkeypatch.setattr(M.os, "kill", fail_kill)
|
||||
monkeypatch.setattr(M.subprocess, "run", fake_run)
|
||||
assert M._pid_is_alive(1234) is True
|
||||
|
||||
|
||||
def test_pid_is_alive_windows_false_when_tasklist_omits_pid(monkeypatch):
|
||||
monkeypatch.setattr(M.sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
M.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: types.SimpleNamespace(
|
||||
stdout = "INFO: No tasks are running which match the specified criteria.\n"
|
||||
),
|
||||
)
|
||||
assert M._pid_is_alive(1234) is False
|
||||
|
||||
|
||||
def test_pid_is_alive_windows_assumes_alive_when_tasklist_fails(monkeypatch):
|
||||
monkeypatch.setattr(M.sys, "platform", "win32")
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise OSError("tasklist unavailable")
|
||||
|
||||
monkeypatch.setattr(M.subprocess, "run", boom)
|
||||
assert M._pid_is_alive(1234) is True
|
||||
|
||||
|
||||
def test_pid_is_alive_posix_signal_zero(monkeypatch):
|
||||
monkeypatch.setattr(M.sys, "platform", "linux")
|
||||
calls = []
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
calls.append((pid, sig))
|
||||
if pid == 9999:
|
||||
raise ProcessLookupError
|
||||
|
||||
monkeypatch.setattr(M.os, "kill", fake_kill)
|
||||
assert M._pid_is_alive(1234) is True
|
||||
assert M._pid_is_alive(9999) is False
|
||||
assert calls == [(1234, 0), (9999, 0)]
|
||||
|
||||
|
||||
# ── existing_install_matches + install_prebuilt short-circuit ──
|
||||
def test_existing_install_matches_false_without_metadata(tmp_path: Path):
|
||||
host = _host("linux", "x64")
|
||||
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
|
||||
|
||||
|
||||
def test_existing_install_matches_true_when_version_and_runtime_ok(tmp_path: Path, monkeypatch):
|
||||
host = _host("linux", "x64")
|
||||
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True
|
||||
# npm too old -> not a match
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
|
||||
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
|
||||
|
||||
|
||||
def test_install_prebuilt_short_circuits_when_version_matches(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "node"
|
||||
install_dir.mkdir()
|
||||
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("must not download when the install already matches")
|
||||
|
||||
monkeypatch.setattr(M, "download_file", boom)
|
||||
monkeypatch.setattr(M, "download_bytes", boom)
|
||||
|
||||
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
|
||||
assert rc == M.EXIT_SUCCESS
|
||||
|
||||
|
||||
def test_existing_install_usable_is_version_agnostic(tmp_path: Path, monkeypatch):
|
||||
host = _host("linux", "x64")
|
||||
assert M.existing_install_usable(tmp_path, host) is False # no metadata
|
||||
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
assert M.existing_install_usable(tmp_path, host) is True
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
|
||||
assert M.existing_install_usable(tmp_path, host) is False # npm below floor
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: None)
|
||||
assert M.existing_install_usable(tmp_path, host) is False # node does not run
|
||||
|
||||
|
||||
def _offline(*a, **k):
|
||||
raise OSError("nodejs.org unreachable")
|
||||
|
||||
|
||||
def test_install_prebuilt_keeps_existing_when_index_unreachable(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "node"
|
||||
install_dir.mkdir()
|
||||
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
monkeypatch.setattr(M, "fetch_json", _offline)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("must not download when keeping the existing install")
|
||||
|
||||
monkeypatch.setattr(M, "download_file", boom)
|
||||
monkeypatch.setattr(M, "download_bytes", boom)
|
||||
|
||||
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
|
||||
assert rc == M.EXIT_SUCCESS
|
||||
|
||||
|
||||
def test_install_prebuilt_reraises_when_index_unreachable_and_no_install(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
install_dir = tmp_path / "node" # nothing on disk to fall back to
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "fetch_json", _offline)
|
||||
with pytest.raises(OSError):
|
||||
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
|
||||
|
||||
|
||||
def test_install_prebuilt_force_does_not_keep_existing_offline(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "node"
|
||||
install_dir.mkdir()
|
||||
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
monkeypatch.setattr(M, "fetch_json", _offline)
|
||||
with pytest.raises(OSError):
|
||||
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ver,ok",
|
||||
[
|
||||
("20.19.0", True),
|
||||
("20.18.9", False),
|
||||
("22.12.0", True),
|
||||
("22.11.5", False),
|
||||
("23.0.0", True),
|
||||
("24.4.1", True),
|
||||
("21.7.3", False),
|
||||
("24", True),
|
||||
("20", False),
|
||||
],
|
||||
)
|
||||
def test_meets_node_floor(ver, ok):
|
||||
assert M._meets_node_floor(ver) is ok
|
||||
|
||||
|
||||
def test_install_prebuilt_rejects_explicit_below_floor(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "node"
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("must not download a below-floor Node")
|
||||
|
||||
monkeypatch.setattr(M, "download_file", boom)
|
||||
monkeypatch.setattr(M, "download_bytes", boom)
|
||||
with pytest.raises(PrebuiltFallback):
|
||||
M.install_prebuilt(install_dir, channel = "20.18.0", min_major = 24, force = False)
|
||||
|
||||
|
||||
def test_install_prebuilt_keeps_existing_when_shasums_fetch_fails(tmp_path: Path, monkeypatch):
|
||||
# index.json resolves a newer version, but the later SHASUMS fetch fails and a
|
||||
# usable older isolated Node is on disk -> keep it instead of aborting.
|
||||
install_dir = tmp_path / "node"
|
||||
install_dir.mkdir()
|
||||
M.write_metadata(install_dir, version = "24.9.0", asset = "x", sha256 = "y")
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # newest LTS = 24.17.0
|
||||
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
|
||||
monkeypatch.setattr(M, "download_bytes", _offline) # SHASUMS fetch fails
|
||||
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
|
||||
assert rc == M.EXIT_SUCCESS
|
||||
|
||||
|
||||
def test_install_prebuilt_reraises_shasums_failure_without_existing(tmp_path: Path, monkeypatch):
|
||||
install_dir = tmp_path / "node" # nothing usable on disk
|
||||
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
|
||||
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
|
||||
monkeypatch.setattr(M, "download_bytes", _offline)
|
||||
with pytest.raises(OSError):
|
||||
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
|
||||
|
||||
|
||||
# ── Isolation invariant: the installer only writes inside its own install_dir ──
|
||||
def test_run_node_pins_npm_prefix_to_install_dir(tmp_path: Path, monkeypatch):
|
||||
# Every node/npm call the installer makes redirects npm's global prefix into
|
||||
# the isolated install_dir and drops an inherited NODE_PATH, so a stray `npm
|
||||
# -g` can never write to the user's system Node/npm.
|
||||
install_dir = tmp_path / "node"
|
||||
monkeypatch.setenv("NPM_CONFIG_PREFIX", "/usr/local") # user's own global prefix
|
||||
monkeypatch.setenv("NODE_PATH", "/usr/lib/node_modules")
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
captured["env"] = kw["env"]
|
||||
return types.SimpleNamespace(returncode = 0, stdout = "v24.17.0\n", stderr = "")
|
||||
|
||||
monkeypatch.setattr(M.subprocess, "run", fake_run)
|
||||
assert M._run_node(install_dir, _host("linux", "x64"), ["-v"]) == "v24.17.0"
|
||||
env = captured["env"]
|
||||
assert env["NPM_CONFIG_PREFIX"] == str(install_dir)
|
||||
assert env["npm_config_prefix"] == str(install_dir)
|
||||
assert "NODE_PATH" not in env # inherited NODE_PATH is dropped, not leaked in
|
||||
|
||||
|
||||
def test_ensure_npm_floor_scopes_upgrade_to_install_dir(tmp_path: Path, monkeypatch):
|
||||
# A pinned build shipping npm < 11 self-upgrades, but only inside the isolated
|
||||
# prefix: it goes through _run_node against install_dir, never the system.
|
||||
install_dir = tmp_path / "node"
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
|
||||
calls = []
|
||||
monkeypatch.setattr(M, "_run_node", lambda d, h, args, **kw: calls.append((d, args)) or "")
|
||||
M._ensure_npm_floor(install_dir, _host("linux", "x64"))
|
||||
assert len(calls) == 1
|
||||
target_dir, args = calls[0]
|
||||
assert target_dir == install_dir # upgrade scoped to the isolated dir
|
||||
assert args[-3:] == ["install", "-g", f"npm@^{M.NPM_MIN_MAJOR}"]
|
||||
|
||||
|
||||
def test_ensure_npm_floor_noop_when_npm_meets_bar(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: M.NPM_MIN_MAJOR)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("must not run an npm upgrade when npm already meets the floor")
|
||||
|
||||
monkeypatch.setattr(M, "_run_node", boom)
|
||||
M._ensure_npm_floor(tmp_path / "node", _host("linux", "x64"))
|
||||
185
tests/studio/install/test_managed_node_runtime.py
Normal file
185
tests/studio/install/test_managed_node_runtime.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for the runtime managed-Node resolver (studio/backend/utils/node_runtime.py).
|
||||
|
||||
The Studio frontend installer may provision an isolated Node under
|
||||
``<UNSLOTH_HOME>/node`` that is never added to the user's PATH. The backend OXC
|
||||
validator must still find a usable Node at runtime: a version-adequate system
|
||||
Node, else the managed isolated one. These tests pin that resolution and the
|
||||
version floor (kept in sync with the setup scripts' Node decision).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# node_runtime imports sibling backend packages by top-level name, so put
|
||||
# studio/backend on sys.path before importing it.
|
||||
_BACKEND = Path(__file__).resolve().parents[3] / "studio" / "backend"
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND))
|
||||
|
||||
nr = importlib.import_module("utils.node_runtime")
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _clear_resolver_cache():
|
||||
nr._reset_resolved_node()
|
||||
yield
|
||||
nr._reset_resolved_node()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
("v20.19.0", True),
|
||||
("v20.18.9", False),
|
||||
("v21.7.0", False), # Node 21 (odd, non-LTS) is below the bar
|
||||
("v22.12.0", True),
|
||||
("v22.11.0", False),
|
||||
("v23.0.0", True),
|
||||
("v24.17.0", True),
|
||||
("v18.20.0", False),
|
||||
("not-a-version", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_version_floor_matches_setup_bar(version, expected):
|
||||
assert nr._version_meets_floor(version) is expected
|
||||
|
||||
|
||||
def test_managed_binary_layout_is_host_aware(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
binary = nr.managed_node_binary()
|
||||
if os.name == "nt":
|
||||
assert binary == tmp_path / "node" / "node.exe"
|
||||
else:
|
||||
assert binary == tmp_path / "node" / "bin" / "node"
|
||||
|
||||
|
||||
def test_managed_dir_uses_legacy_sibling_by_default(monkeypatch):
|
||||
# No env override -> ~/.unsloth/node (sibling of ~/.unsloth/studio).
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def _raise_oserror():
|
||||
raise OSError("simulated degraded import environment")
|
||||
|
||||
|
||||
def test_managed_dir_fallback_honors_override(monkeypatch, tmp_path):
|
||||
# If utils.paths cannot be loaded / studio_root() fails, the resolver must
|
||||
# still honor an explicit STUDIO_HOME override (not silently use legacy).
|
||||
import utils.paths.storage_roots as sr
|
||||
|
||||
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_fallback_legacy_without_override(monkeypatch):
|
||||
import utils.paths.storage_roots as sr
|
||||
|
||||
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def test_managed_dir_honors_studio_home_alias(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.setenv("STUDIO_HOME", str(tmp_path))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_unsloth_studio_home_wins_over_alias(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("STUDIO_HOME", str(tmp_path / "other"))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_legacy_valued_override_uses_sibling(monkeypatch):
|
||||
# An override set explicitly to the legacy default maps to the sibling
|
||||
# ~/.unsloth/node (matching setup.sh / setup.ps1), not ~/.unsloth/studio/node.
|
||||
legacy = Path.home() / ".unsloth" / "studio"
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(legacy))
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def test_resolve_prefers_adequate_system_node(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
nr.shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None
|
||||
)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: exe == "/usr/bin/node")
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_managed_when_no_system(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("#!/bin/sh\necho v24.17.0\n")
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_resolve_prefers_managed_over_unsuitable_system(monkeypatch, tmp_path):
|
||||
# System node present but too old; managed isolated Node is adequate.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("fake")
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_resolve_returns_old_system_as_last_resort(monkeypatch, tmp_path):
|
||||
# Old system node, no managed install -> preserve pre-isolation behaviour.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() == "/old/node"
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_nothing_available(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) # managed dir is empty
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() is None
|
||||
|
||||
|
||||
def test_negative_result_is_not_cached(monkeypatch, tmp_path):
|
||||
# A Node that appears after the first (empty) probe must be picked up without
|
||||
# a restart, so None must not be memoized.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() is None
|
||||
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("now-installed")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_positive_result_is_cached(monkeypatch):
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/usr/bin/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: True)
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
|
||||
# A cached positive result must not re-probe (shutil.which would now raise).
|
||||
def _boom(name):
|
||||
raise AssertionError("resolver re-probed despite a cached positive result")
|
||||
|
||||
monkeypatch.setattr(nr.shutil, "which", _boom)
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
83
tests/studio/test_node_decision.ps1
Normal file
83
tests/studio/test_node_decision.ps1
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env pwsh
|
||||
# Unit test for setup.ps1's Get-NodeDecision (the isolated-Node source picker:
|
||||
# system | bundled | skip). Pure helper, AST-extracted and run in-process -- no
|
||||
# Node/npm/network needed. Also serves as a setup.ps1 parse/syntax gate.
|
||||
# Run: pwsh -NoProfile -File tests/studio/test_node_decision.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$setupPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1")
|
||||
$setupPath = (Resolve-Path $setupPath).Path
|
||||
$source = Get-Content -Raw -Path $setupPath
|
||||
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens, [ref]$errors)
|
||||
if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "setup.ps1 has parse errors" }
|
||||
|
||||
$fn = $ast.FindAll({ param($n)
|
||||
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Get-NodeDecision"
|
||||
}, $true)
|
||||
if ($fn.Count -ne 1) { throw "expected exactly one Get-NodeDecision in setup.ps1, found $($fn.Count)" }
|
||||
Invoke-Expression $fn[0].Extent.Text
|
||||
|
||||
$failures = 0
|
||||
function Check($name, $cond) {
|
||||
if ($cond) { Write-Host " PASS $name" }
|
||||
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
|
||||
}
|
||||
|
||||
function D($node, $npm, $skip) { Get-NodeDecision -NodeVersion $node -NpmVersion $npm -SkipInstall $skip }
|
||||
|
||||
Write-Host "Get-NodeDecision"
|
||||
# system
|
||||
Check "node22 + npm11 -> system" ((D "v22.17.1" "11.13.0" "0") -eq "system")
|
||||
Check "node20.19 + npm11 -> system" ((D "v20.19.0" "11.0.0" "0") -eq "system")
|
||||
Check "node24 + npm11 -> system" ((D "v24.17.0" "11.13.0" "0") -eq "system")
|
||||
Check "node23 + npm11 -> system" ((D "v23.5.0" "11.0.0" "0") -eq "system")
|
||||
# bundled (the reported bug: fine Node, stale npm)
|
||||
Check "node22 + npm10 -> bundled" ((D "v22.17.1" "10.9.2" "0") -eq "bundled")
|
||||
Check "node18 -> bundled" ((D "v18.20.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node22.11 -> bundled" ((D "v22.11.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node20.18 -> bundled" ((D "v20.18.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node21 (odd) -> bundled" ((D "v21.7.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "missing -> bundled" ((D "" "" "0") -eq "bundled")
|
||||
# skip flag
|
||||
Check "npm10 + skip -> skip" ((D "v22.17.1" "10.9.2" "1") -eq "skip")
|
||||
Check "missing + skip -> skip" ((D "" "" "1") -eq "skip")
|
||||
Check "good + skip -> system" ((D "v22.17.1" "11.13.0" "1") -eq "system")
|
||||
|
||||
# Structural guards: OXC can need Node when frontend is skipped, custom roots
|
||||
# must exist before NodeParent creation, bundled Node must isolate npm, and the
|
||||
# reuse (system) arm must touch nothing -- no prefix pin, no global install.
|
||||
$nodeSourceOffset = $source.IndexOf('$NodeSource = Get-NodeDecision')
|
||||
$skipFrontendBranchOffset = $source.IndexOf('} elseif ($SkipFrontend) {')
|
||||
$customHomeErrorOffset = $source.IndexOf('UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist')
|
||||
$nodeParentMkdirOffset = $source.IndexOf('New-Item -ItemType Directory -Force -Path $NodeParent')
|
||||
$npmPrefixOffset = $source.IndexOf('$env:NPM_CONFIG_PREFIX = $NodeDir')
|
||||
$nodePathClearOffset = $source.IndexOf('Remove-Item Env:NODE_PATH')
|
||||
$bundledBranchOffset = $source.IndexOf('} elseif ($NodeSource -eq "bundled") {')
|
||||
$systemArmOffset = $source.IndexOf('$SysNodeVersion | npm $SysNpmVersion (system)')
|
||||
$globalBunOffset = $source.IndexOf('npm install -g bun')
|
||||
Check "NodeSource initialized before SKIP_STUDIO_FRONTEND branch" (
|
||||
$nodeSourceOffset -ge 0 -and $skipFrontendBranchOffset -ge 0 -and $nodeSourceOffset -lt $skipFrontendBranchOffset
|
||||
)
|
||||
Check "custom Studio home validated before Node parent creation" (
|
||||
$customHomeErrorOffset -ge 0 -and $nodeParentMkdirOffset -ge 0 -and $customHomeErrorOffset -lt $nodeParentMkdirOffset
|
||||
)
|
||||
Check "bundled Node pins npm prefix and clears NODE_PATH" (
|
||||
$npmPrefixOffset -ge 0 -and $nodePathClearOffset -ge 0 -and $npmPrefixOffset -lt $nodePathClearOffset
|
||||
)
|
||||
# Symmetric to tests/sh/test_system_node_readonly.sh: the prefix pin and the only
|
||||
# global install (bun) sit between the bundled-branch marker and the system arm,
|
||||
# i.e. inside bundled, so reusing a good system Node mutates nothing.
|
||||
Check "npm prefix pin lives in the bundled branch, not the system arm" (
|
||||
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
|
||||
$bundledBranchOffset -lt $npmPrefixOffset -and $npmPrefixOffset -lt $systemArmOffset
|
||||
)
|
||||
Check "global bun install lives in the bundled branch, not the system arm" (
|
||||
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
|
||||
$bundledBranchOffset -lt $globalBunOffset -and $globalBunOffset -lt $systemArmOffset
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) { Write-Host "$failures check(s) FAILED" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "All checks passed" -ForegroundColor Green
|
||||
73
tests/studio/test_node_probe_guard.ps1
Normal file
73
tests/studio/test_node_probe_guard.ps1
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Regression test for the setup.ps1 system-node/npm probes. Under "Stop", a bare
|
||||
# `node -v` for an absent/broken node throws a terminating error `2>$null` cannot
|
||||
# swallow, which used to abort setup before the bundled-Node decision. The probes
|
||||
# are now guarded (Get-Command + try/catch); this runs the real probe lines with
|
||||
# node/npm absent or throwing and asserts setup would NOT terminate.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:failures = 0
|
||||
function Check($name, $cond) {
|
||||
if ($cond) { Write-Host " PASS $name" }
|
||||
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
|
||||
}
|
||||
|
||||
$setupPath = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1"))).Path
|
||||
# Match specifically the two system-version probe assignments (not every
|
||||
# Get-Command node/npm in the file, e.g. the OXC-runtime npm guard).
|
||||
$probeLines = (Get-Content $setupPath) | Where-Object {
|
||||
$_ -match '\$Sys(Node|Npm)Version = try \{ if \(Get-Command (node|npm) -ErrorAction SilentlyContinue'
|
||||
}
|
||||
Check "setup.ps1 guards both node and npm probes with Get-Command" ($probeLines.Count -eq 2)
|
||||
|
||||
# Resolve pwsh by absolute path BEFORE scrubbing PATH, so we can launch a child
|
||||
# whose PATH has no node/npm while still invoking the interpreter.
|
||||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
|
||||
if (-not $pwshExe) { $pwshExe = (Get-Command powershell).Source }
|
||||
$emptyDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uns_probe_" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force -Path $emptyDir | Out-Null
|
||||
|
||||
function Invoke-WithoutNode([string]$body) {
|
||||
$script = "`$ErrorActionPreference = 'Stop'`n$body"
|
||||
$file = Join-Path $emptyDir ("probe_" + [guid]::NewGuid().ToString("N") + ".ps1")
|
||||
Set-Content -Path $file -Value $script -Encoding utf8
|
||||
$saved = $env:PATH
|
||||
try {
|
||||
$env:PATH = $emptyDir # node/npm guaranteed absent for the child
|
||||
$out = & $pwshExe -NoProfile -File $file 2>&1 | Out-String
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$env:PATH = $saved
|
||||
}
|
||||
return [pscustomobject]@{ ExitCode = $code; Output = $out }
|
||||
}
|
||||
|
||||
# 1. The real guarded probes must NOT terminate, and must yield empty versions
|
||||
# (which Get-NodeDecision then maps to "bundled").
|
||||
$guarded = ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
|
||||
$r = Invoke-WithoutNode $guarded
|
||||
Check "guarded probes do not terminate when node is absent (exit 0)" ($r.ExitCode -eq 0)
|
||||
Check "guarded probes yield empty node/npm versions" ($r.Output -match 'RESULT node=\[\] npm=\[\]')
|
||||
|
||||
# 2. Negative control: the OLD unguarded form DOES terminate -- proves this test
|
||||
# can actually distinguish the bug from the fix.
|
||||
$unguarded = "`$SysNodeVersion = (node -v 2>`$null)`nWrite-Output ""REACHED"""
|
||||
$n = Invoke-WithoutNode $unguarded
|
||||
Check "unguarded bare probe terminates under Stop (negative control)" ($n.ExitCode -ne 0 -and $n.Output -notmatch 'REACHED')
|
||||
|
||||
# 3. Present-but-broken shim: Get-Command finds it but invoking it throws (corrupt
|
||||
# Node / blocked npm.ps1). The try/catch must still degrade to empty, not abort.
|
||||
$throwShims = "function node { throw 'boom' }`nfunction npm { throw 'boom' }`n"
|
||||
$broken = $throwShims + ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
|
||||
$b = Invoke-WithoutNode $broken
|
||||
Check "guarded probes do not terminate when a present shim throws (exit 0)" ($b.ExitCode -eq 0)
|
||||
Check "guarded probes yield empty versions when a present shim throws" ($b.Output -match 'RESULT node=\[\] npm=\[\]')
|
||||
|
||||
# 4. Negative control: the if-guard WITHOUT try/catch terminates when a present
|
||||
# command throws -- proves the try/catch (not just Get-Command) is load-bearing.
|
||||
$brokenUnguarded = "function node { throw 'boom' }`n`$SysNodeVersion = if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>`$null) } else { '' }`nWrite-Output ""REACHED"""
|
||||
$bn = Invoke-WithoutNode $brokenUnguarded
|
||||
Check "if-guard without try/catch terminates on a throwing present command (negative control)" ($bn.ExitCode -ne 0 -and $bn.Output -notmatch 'REACHED')
|
||||
|
||||
Remove-Item -Recurse -Force $emptyDir -ErrorAction SilentlyContinue
|
||||
|
||||
if ($script:failures -gt 0) { Write-Host "$($script:failures) check(s) failed" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "All checks passed"
|
||||
|
|
@ -18,6 +18,28 @@ _THINK_BLOCK = re.compile(rf"{re.escape(_THINK_OPEN)}.*?</think>", re.DOTALL)
|
|||
# "Python-urllib/X.Y" User-Agent as a bot; send a real one on every request.
|
||||
_USER_AGENT = "unsloth-cli"
|
||||
|
||||
# Built lazily; urllib stays function-local to match this module.
|
||||
_no_redirect_opener = None
|
||||
|
||||
|
||||
def urlopen_no_redirect(request, timeout):
|
||||
"""urlopen that errors on any redirect: following a 3xx would send a bearer
|
||||
token (or accept an identity proof) to a base we never vetted, letting a port
|
||||
squatter relay a real Studio's response."""
|
||||
global _no_redirect_opener
|
||||
if _no_redirect_opener is None:
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise urllib.error.HTTPError(
|
||||
req.full_url, code, f"refusing redirect to {newurl}", headers, fp
|
||||
)
|
||||
|
||||
_no_redirect_opener = urllib.request.build_opener(_NoRedirect)
|
||||
return _no_redirect_opener.open(request, timeout = timeout)
|
||||
|
||||
|
||||
def ensure_studio_backend_path() -> None:
|
||||
backend_dir = str(Path(__file__).resolve().parents[1] / "studio" / "backend")
|
||||
|
|
@ -258,16 +280,122 @@ def load_chat_backend(
|
|||
return ChatBackend("unsloth", backend)
|
||||
|
||||
|
||||
def _loopback_candidate_bases(base: str) -> list:
|
||||
"""For a bare ``localhost`` base, the concrete IP bases to try, IPv4
|
||||
127.0.0.1 first (where ``unsloth studio`` binds by default). Pinning to one
|
||||
address up front means discovery, the identity check, and the credential we
|
||||
then send all target the same endpoint instead of racing IPv4/IPv6
|
||||
resolution -- which would otherwise let the health probe land on one address
|
||||
and the identity check on another. A literal IP or remote name is unchanged.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(base)
|
||||
if (parsed.hostname or "").lower() != "localhost":
|
||||
return [base]
|
||||
import socket
|
||||
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
ips = {
|
||||
ai[4][0] for ai in socket.getaddrinfo(parsed.hostname, port, type = socket.SOCK_STREAM)
|
||||
}
|
||||
except Exception:
|
||||
return [base]
|
||||
ordered = sorted(ips, key = lambda ip: (ip != "127.0.0.1", ip))
|
||||
bases = [
|
||||
f"{parsed.scheme}://" + (f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}")
|
||||
for ip in ordered
|
||||
]
|
||||
return bases or [base]
|
||||
|
||||
|
||||
def find_studio_server(timeout: float = 3.0) -> Optional[str]:
|
||||
import urllib.request
|
||||
|
||||
base = os.environ.get("UNSLOTH_STUDIO_URL", "http://127.0.0.1:8888").rstrip("/")
|
||||
request = urllib.request.Request(f"{base}/api/health", headers = {"User-Agent": _USER_AGENT})
|
||||
# Try the concrete loopback addresses in order and return the first that
|
||||
# answers, so the rest of the flow talks to that exact address.
|
||||
for candidate in _loopback_candidate_bases(base):
|
||||
request = urllib.request.Request(
|
||||
f"{candidate}/api/health", headers = {"User-Agent": _USER_AGENT}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout = timeout):
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def is_loopback_url(base: str) -> bool:
|
||||
"""True only when *base* resolves to loopback. find_studio_server() trusts a
|
||||
base after only a health probe, so credentials are auto-sent only to loopback
|
||||
(a local Studio or an SSH tunnel on 127.0.0.1), the targets the auto flows mean."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = (urlparse(base).hostname or "").lower()
|
||||
if host in ("localhost", "127.0.0.1", "::1"):
|
||||
return True
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout = timeout):
|
||||
return base
|
||||
import ipaddress
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def verify_studio_identity(base: str, timeout: float = 3.0) -> bool:
|
||||
"""Confirm `base` is really this machine's Studio before sending a secret.
|
||||
|
||||
Send a random nonce to /api/auth/identity and check the returned HMAC against
|
||||
the one computed from the local same-user secret; an endpoint without that
|
||||
secret (port squatter, remote/fake) can't match. Fails closed on any error."""
|
||||
import base64
|
||||
import hmac as _hmac
|
||||
import json
|
||||
import secrets as _secrets
|
||||
import socket
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import studio.backend.core # noqa: F401 puts studio/backend on sys.path
|
||||
from studio.backend.auth import storage
|
||||
except Exception:
|
||||
return None
|
||||
return False
|
||||
|
||||
parsed = urlparse(base)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
# Resolve to one concrete address and talk to *that* address, then bind the
|
||||
# proof to (address, port). A name like localhost can resolve to a squatter on
|
||||
# ::1 while the real Studio is on 127.0.0.1; connecting to the resolved IP and
|
||||
# binding to it means a proof relayed from a different address/port won't match.
|
||||
try:
|
||||
ip = socket.getaddrinfo(host, port, type = socket.SOCK_STREAM)[0][4][0]
|
||||
except Exception:
|
||||
return False
|
||||
netloc = f"[{ip}]:{port}" if ":" in ip else f"{ip}:{port}"
|
||||
nonce = _secrets.token_bytes(32)
|
||||
query = base64.urlsafe_b64encode(nonce).decode()
|
||||
request = urllib.request.Request(
|
||||
f"{parsed.scheme}://{netloc}/api/auth/identity?nonce={query}",
|
||||
headers = {"User-Agent": _USER_AGENT, "Host": parsed.netloc},
|
||||
)
|
||||
try:
|
||||
# No redirects: a 302 could relay a real Studio's proof (see urlopen_no_redirect).
|
||||
# Cap the read: the server is still unverified, so don't trust its length.
|
||||
with urlopen_no_redirect(request, timeout = timeout) as response:
|
||||
proof = json.loads(response.read(65536).decode() or "{}").get("proof")
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(proof, str):
|
||||
return False
|
||||
try:
|
||||
expected = storage.compute_identity_proof(nonce, ip, port)
|
||||
except Exception:
|
||||
return False
|
||||
return _hmac.compare_digest(proof, expected)
|
||||
|
||||
|
||||
def _studio_token() -> Optional[str]:
|
||||
|
|
@ -316,7 +444,8 @@ class HttpChatBackend:
|
|||
},
|
||||
method = method,
|
||||
)
|
||||
return urllib.request.urlopen(request, timeout = timeout)
|
||||
# No redirects: this carries a bearer token (see urlopen_no_redirect).
|
||||
return urlopen_no_redirect(request, timeout = timeout)
|
||||
|
||||
def ensure_loaded(self, model: str, *, hf_token, max_seq_length, load_in_4bit) -> None:
|
||||
typer.echo(f"Loading {model} on the Studio server", err = True)
|
||||
|
|
@ -414,9 +543,37 @@ def connect_studio_server(model: str, *, hf_token, max_seq_length, load_in_4bit)
|
|||
base_url = find_studio_server()
|
||||
if not base_url:
|
||||
return None
|
||||
|
||||
# Explicit server (UNSLOTH_STUDIO_URL) we can't safely attach to -> fail loudly;
|
||||
# opportunistic local discovery just falls back to a local load.
|
||||
explicit = bool(os.environ.get("UNSLOTH_STUDIO_URL"))
|
||||
|
||||
def _refuse(reason: str):
|
||||
if not explicit:
|
||||
return None
|
||||
typer.echo(
|
||||
f"Can't attach to the Studio server at {base_url}: {reason} Run Studio "
|
||||
"on this machine, or unset UNSLOTH_STUDIO_URL to load the model locally.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
# Only hand the self-issued JWT (signed with the local secret) to loopback: a
|
||||
# remote URL is unverified and a real remote Studio would reject it anyway.
|
||||
if not is_loopback_url(base_url):
|
||||
return _refuse(
|
||||
"it isn't a local Studio, so a self-issued token can't "
|
||||
"authenticate to it and must not be sent to it."
|
||||
)
|
||||
# Confirm the loopback responder is really our Studio (not a port squatter).
|
||||
if not verify_studio_identity(base_url):
|
||||
return _refuse(
|
||||
"its identity couldn't be verified (it may be running as a "
|
||||
"different OS user, or another process took the port)."
|
||||
)
|
||||
token = _studio_token()
|
||||
if not token:
|
||||
return None
|
||||
return _refuse("couldn't self-issue a Studio token (is Studio set up here?).")
|
||||
backend = HttpChatBackend(base_url, token)
|
||||
backend.ensure_loaded(
|
||||
model, hf_token = hf_token, max_seq_length = max_seq_length, load_in_4bit = load_in_4bit
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ from unsloth_cli._inference import (
|
|||
_studio_token,
|
||||
ensure_studio_backend_path,
|
||||
find_studio_server,
|
||||
is_loopback_url,
|
||||
urlopen_no_redirect,
|
||||
verify_studio_identity,
|
||||
)
|
||||
|
||||
connect_app = typer.Typer(
|
||||
|
|
@ -46,7 +49,11 @@ _KEY_OPTION = typer.Option(
|
|||
None,
|
||||
"--api-key",
|
||||
envvar = "UNSLOTH_API_KEY",
|
||||
help = "Studio API key; minted automatically when omitted. Keys are remembered, so passing one once is enough.",
|
||||
help = (
|
||||
"Studio API key. For a local Studio it is minted automatically and "
|
||||
"remembered per server. For a remote server, pass one with --api-key "
|
||||
"(or UNSLOTH_API_KEY); it is remembered for next time."
|
||||
),
|
||||
)
|
||||
_LAUNCH_OPTION = typer.Option(
|
||||
True,
|
||||
|
|
@ -88,7 +95,8 @@ def _http_json(
|
|||
method = method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout = timeout) as response:
|
||||
# No redirects: a 3xx would leak this bearer token to an unvetted base.
|
||||
with urlopen_no_redirect(request, timeout = timeout) as response:
|
||||
return json.loads(response.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
if error is None:
|
||||
|
|
@ -117,18 +125,35 @@ def _key_cache_path() -> Path:
|
|||
return auth_root() / "agent_api_key.json"
|
||||
|
||||
|
||||
def _cached_keys(cache: Path) -> list:
|
||||
def _read_cache(cache: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(cache.read_text())
|
||||
data = json.loads(cache.read_text(encoding = "utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
keys = [k for k in data.get("keys", []) if isinstance(k, str)]
|
||||
legacy = data.get("key") # pre-multi-key cache format
|
||||
if isinstance(legacy, str) and legacy not in keys:
|
||||
keys.append(legacy)
|
||||
return keys
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _server_buckets(servers: dict, base: str) -> dict:
|
||||
# Normalise a server's entry to {"saved": [...], "minted": [...]}, tolerating a
|
||||
# corrupt/legacy value (bare string/list -> treated as minted, behind the handshake).
|
||||
entry = servers.get(base) if isinstance(servers, dict) else None
|
||||
if isinstance(entry, list):
|
||||
return {"saved": [], "minted": [k for k in entry if isinstance(k, str)]}
|
||||
if not isinstance(entry, dict):
|
||||
return {"saved": [], "minted": []}
|
||||
|
||||
def _strs(name: str) -> list:
|
||||
value = entry.get(name)
|
||||
return [k for k in value if isinstance(k, str)] if isinstance(value, list) else []
|
||||
|
||||
return {"saved": _strs("saved"), "minted": _strs("minted")}
|
||||
|
||||
|
||||
def _cached_keys(cache: Path, base: str, source: str) -> list:
|
||||
# Keys are scoped per server. `source` splits user-supplied --api-key keys
|
||||
# ("saved", trusted for that base) from auto-minted ones ("minted", replayed
|
||||
# only after the identity check). Legacy unscoped caches are ignored.
|
||||
return _server_buckets(_read_cache(cache).get("servers", {}), base)[source]
|
||||
|
||||
|
||||
def _write_private_json(path: Path, data: dict) -> None:
|
||||
|
|
@ -159,59 +184,90 @@ def _subdict(parent: dict, key: str) -> dict:
|
|||
return child
|
||||
|
||||
|
||||
def _remember_key(cache: Path, key: str) -> None:
|
||||
existing = _cached_keys(cache)
|
||||
keys = ([key] + [k for k in existing if k != key])[:8]
|
||||
if keys == existing:
|
||||
def _remember_key(cache: Path, base: str, key: str, source: str) -> None:
|
||||
data = _read_cache(cache)
|
||||
servers = data.get("servers")
|
||||
if not isinstance(servers, dict):
|
||||
servers = data["servers"] = {}
|
||||
buckets = _server_buckets(servers, base)
|
||||
other = "minted" if source == "saved" else "saved"
|
||||
buckets[source] = ([key] + [k for k in buckets[source] if k != key])[:8]
|
||||
buckets[other] = [k for k in buckets[other] if k != key] # a key has one provenance
|
||||
new_entry = {"saved": buckets["saved"], "minted": buckets["minted"]}
|
||||
if servers.get(base) == new_entry:
|
||||
return
|
||||
servers[base] = new_entry
|
||||
# Collapse legacy unscoped fields.
|
||||
data.pop("keys", None)
|
||||
data.pop("key", None)
|
||||
try:
|
||||
_write_private_json(cache, {"keys": keys})
|
||||
_write_private_json(cache, data)
|
||||
except OSError:
|
||||
pass # worst case the next launch mints another key
|
||||
|
||||
|
||||
def _key_accepted(base: str, key: str) -> bool:
|
||||
try:
|
||||
_http_json("GET", f"{base}/v1/models", key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_api_key(base: str, explicit: Optional[str]) -> str:
|
||||
cache = _key_cache_path()
|
||||
if explicit:
|
||||
_remember_key(cache, explicit)
|
||||
_remember_key(cache, base, explicit, "saved")
|
||||
return explicit
|
||||
|
||||
# Keys are per-server, so when switching between Studios (local one day,
|
||||
# an SSH-tunnelled remote the next) the right key is whichever validates.
|
||||
for key in _cached_keys(cache):
|
||||
try:
|
||||
_http_json("GET", f"{base}/v1/models", key)
|
||||
except Exception:
|
||||
continue
|
||||
_remember_key(cache, key)
|
||||
return key
|
||||
# Replay a key the user saved for *this exact* server first (scoped per base,
|
||||
# so it only goes back there -- including a remote/SSH-tunnelled Studio whose
|
||||
# secret the local handshake can't match). Skip ones the server rejects.
|
||||
for key in _cached_keys(cache, base, "saved"):
|
||||
if _key_accepted(base, key):
|
||||
_remember_key(cache, base, key, "saved")
|
||||
return key
|
||||
|
||||
# Beyond here we auto-mint or replay an auto-minted key. find_studio_server()
|
||||
# trusts a base after only a health check, so both are limited to a loopback
|
||||
# server we can cryptographically confirm is ours.
|
||||
if not is_loopback_url(base):
|
||||
_fail(
|
||||
f"No saved API key for {base} and automatic minting only runs against "
|
||||
"a local Studio. Create an API key in Studio → Settings → API and "
|
||||
"pass it with --api-key (it is remembered per server), or set "
|
||||
"UNSLOTH_API_KEY."
|
||||
)
|
||||
if not verify_studio_identity(base):
|
||||
_fail(
|
||||
f"Couldn't verify that {base} is your Studio (it may be running as a "
|
||||
"different OS user, or another process took the port). Create an API "
|
||||
"key in Studio → Settings → API and pass it with --api-key, or set "
|
||||
"UNSLOTH_API_KEY."
|
||||
)
|
||||
|
||||
# Identity verified: replay a previously auto-minted key, else mint a new one.
|
||||
for key in _cached_keys(cache, base, "minted"):
|
||||
if _key_accepted(base, key):
|
||||
_remember_key(cache, base, key, "minted")
|
||||
return key
|
||||
|
||||
# Self-issue a JWT (signed with the local secret) and mint a key.
|
||||
token = _studio_token()
|
||||
auth_help = (
|
||||
"Couldn't authenticate with the Studio server automatically (it may be "
|
||||
"remote, or running as a different OS user). Create an API key in "
|
||||
"Studio → Settings → API and pass it once with --api-key; it is "
|
||||
"remembered for next time."
|
||||
)
|
||||
if token is None:
|
||||
_fail(auth_help)
|
||||
try:
|
||||
key = _http_json(
|
||||
"POST",
|
||||
f"{base}/api/auth/api-keys",
|
||||
token,
|
||||
{"name": "Coding agents (unsloth connect)"},
|
||||
)["key"]
|
||||
except urllib.error.HTTPError as exc:
|
||||
# A self-issued token only validates against a local, same-OS-user
|
||||
# server; a remote Studio signs with a different secret and rejects it
|
||||
# (401/403). Point at --api-key instead of the raw "expired token".
|
||||
if exc.code in (401, 403):
|
||||
_fail(auth_help)
|
||||
_fail(f"Couldn't create an API key: {_http_error_detail(exc)}")
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
_fail(f"Couldn't create an API key: {getattr(exc, 'reason', None) or exc}")
|
||||
_remember_key(cache, key)
|
||||
_fail(
|
||||
"Couldn't authenticate with the Studio server automatically. Create "
|
||||
"an API key in Studio → Settings → API and pass it with --api-key, "
|
||||
"or set UNSLOTH_API_KEY."
|
||||
)
|
||||
key = _http_json(
|
||||
"POST",
|
||||
f"{base}/api/auth/api-keys",
|
||||
token,
|
||||
{"name": "Coding agents (unsloth connect)"},
|
||||
error = "Couldn't create an API key",
|
||||
)["key"]
|
||||
_remember_key(cache, base, key, "minted")
|
||||
return key
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ BASE = "http://127.0.0.1:8888"
|
|||
MODEL = {"id": "unsloth/gemma-4-26B-A4B-it-GGUF", "context_length": 131072}
|
||||
|
||||
|
||||
# --no-launch prints shell setup as POSIX (export/unset) on Unix/WSL and
|
||||
# PowerShell ($env:/Remove-Item) on native Windows; assert the host's form.
|
||||
def _assert_env_set(output: str, name: str, value: str) -> None:
|
||||
needle = f'$env:{name} = "{value}"' if os.name == "nt" else f"export {name}={value}"
|
||||
assert needle in output, f"{needle!r} not found in:\n{output}"
|
||||
|
||||
|
||||
def _assert_env_unset(output: str, name: str) -> None:
|
||||
needle = f"Remove-Item Env:{name}" if os.name == "nt" else f"unset {name}"
|
||||
assert needle in output, f"{needle!r} not found in:\n{output}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claude_settings(tmp_path, monkeypatch):
|
||||
path = tmp_path / "claude" / "settings.json"
|
||||
|
|
@ -173,6 +185,9 @@ def fake_studio(tmp_path, monkeypatch, claude_settings):
|
|||
raise AssertionError(f"unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(connect, "find_studio_server", lambda: BASE)
|
||||
# Identity handshake has its own tests; trust the loopback server here.
|
||||
monkeypatch.setattr(connect, "verify_studio_identity", lambda base: True)
|
||||
# _studio_token / api-keys are faked so the mint flow stays offline.
|
||||
monkeypatch.setattr(connect, "_studio_token", lambda: "jwt-token")
|
||||
monkeypatch.setattr(connect, "_http_json", http_json)
|
||||
monkeypatch.setattr(connect, "_key_cache_path", lambda: tmp_path / "agent_api_key.json")
|
||||
|
|
@ -186,13 +201,13 @@ def fake_studio(tmp_path, monkeypatch, claude_settings):
|
|||
def test_connect_claude_no_launch(fake_studio, claude_settings):
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "unset ANTHROPIC_API_KEY" in result.output
|
||||
assert "unset CLAUDE_CODE_OAUTH_TOKEN" in result.output
|
||||
assert f"export ANTHROPIC_BASE_URL={BASE}" in result.output
|
||||
assert "export ANTHROPIC_AUTH_TOKEN=sk-unsloth-feedfacefeedface" in result.output
|
||||
assert f"export ANTHROPIC_MODEL={MODEL['id']}" in result.output
|
||||
assert "export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" in result.output
|
||||
assert "export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1" in result.output
|
||||
_assert_env_unset(result.output, "ANTHROPIC_API_KEY")
|
||||
_assert_env_unset(result.output, "CLAUDE_CODE_OAUTH_TOKEN")
|
||||
_assert_env_set(result.output, "ANTHROPIC_BASE_URL", BASE)
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
|
||||
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
|
||||
_assert_env_set(result.output, "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
||||
_assert_env_set(result.output, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "1")
|
||||
assert f"claude --model {MODEL['id']} --exclude-dynamic-system-prompt-sections" in result.output
|
||||
settings = json.loads(claude_settings.read_text())
|
||||
assert settings["env"]["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0"
|
||||
|
|
@ -222,6 +237,11 @@ def test_connect_claude_launch_scrubs_conflicting_auth_env(fake_studio, monkeypa
|
|||
assert captured["env"]["ANTHROPIC_MODEL"] == MODEL["id"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
|
||||
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
|
||||
)
|
||||
def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
|
||||
|
|
@ -261,6 +281,11 @@ def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypat
|
|||
assert name in captured["env"]["WSLENV"].split(":")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
|
||||
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
|
||||
)
|
||||
def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(fake_studio, monkeypatch):
|
||||
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -280,7 +305,7 @@ def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(fake_studi
|
|||
def test_connect_codex_no_launch(fake_studio, tmp_path):
|
||||
result = CliRunner().invoke(connect.connect_app, ["codex", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export UNSLOTH_STUDIO_AUTH_TOKEN=sk-unsloth-feedfacefeedface" in result.output
|
||||
_assert_env_set(result.output, "UNSLOTH_STUDIO_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
|
||||
assert "codex --oss --profile unsloth_api" in result.output
|
||||
assert (tmp_path / "codex" / "config.toml").exists()
|
||||
assert (tmp_path / "codex" / "unsloth_api.config.toml").exists()
|
||||
|
|
@ -289,10 +314,11 @@ def test_connect_codex_no_launch(fake_studio, tmp_path):
|
|||
def test_connect_key_minted_once_then_cached(fake_studio, tmp_path):
|
||||
CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
# First run mints; second reuses the minted key cached for this server.
|
||||
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
|
||||
assert len(mints) == 1
|
||||
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
|
||||
assert cached["keys"] == ["sk-unsloth-feedfacefeedface"]
|
||||
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface"]
|
||||
|
||||
|
||||
def test_connect_explicit_key_remembered_for_keyless_runs(fake_studio, tmp_path):
|
||||
|
|
@ -302,14 +328,20 @@ def test_connect_explicit_key_remembered_for_keyless_runs(fake_studio, tmp_path)
|
|||
)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export ANTHROPIC_AUTH_TOKEN=sk-unsloth-deadbeefdeadbeef" in result.output
|
||||
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
|
||||
assert mints == []
|
||||
# Reused, not re-minted (a mint would return the feedface stand-in).
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
|
||||
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
|
||||
# An explicit key is remembered as "saved" so it replays without the handshake.
|
||||
assert cached["servers"][BASE]["saved"] == ["sk-unsloth-deadbeefdeadbeef"]
|
||||
|
||||
|
||||
def test_connect_skips_cached_keys_the_server_rejects(fake_studio, tmp_path, monkeypatch):
|
||||
cache = tmp_path / "agent_api_key.json"
|
||||
cache.write_text(json.dumps({"keys": ["sk-unsloth-stale", "sk-unsloth-feedfacefeedface"]}))
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{"servers": {BASE: {"minted": ["sk-unsloth-stale", "sk-unsloth-feedfacefeedface"]}}}
|
||||
)
|
||||
)
|
||||
inner = connect._http_json
|
||||
|
||||
def http_json(
|
||||
|
|
@ -327,21 +359,22 @@ def test_connect_skips_cached_keys_the_server_rejects(fake_studio, tmp_path, mon
|
|||
monkeypatch.setattr(connect, "_http_json", http_json)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export ANTHROPIC_AUTH_TOKEN=sk-unsloth-feedfacefeedface" in result.output
|
||||
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
|
||||
assert mints == []
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
|
||||
# The working key moves to the front so the next run tries it first.
|
||||
cached = json.loads(cache.read_text())
|
||||
assert cached["keys"] == ["sk-unsloth-feedfacefeedface", "sk-unsloth-stale"]
|
||||
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface", "sk-unsloth-stale"]
|
||||
|
||||
|
||||
def test_connect_reads_legacy_single_key_cache(fake_studio, tmp_path):
|
||||
def test_connect_legacy_unscoped_cache_not_replayed(fake_studio, tmp_path):
|
||||
# Legacy unscoped caches have no server binding (could leak across servers),
|
||||
# so they're ignored: a fresh key is minted and stored scoped to this server.
|
||||
(tmp_path / "agent_api_key.json").write_text(json.dumps({"key": "sk-unsloth-oldformat"}))
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export ANTHROPIC_AUTH_TOKEN=sk-unsloth-oldformat" in result.output
|
||||
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
|
||||
assert mints == []
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
|
||||
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
|
||||
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface"]
|
||||
assert "key" not in cached # legacy field collapsed away
|
||||
|
||||
|
||||
def test_connect_model_flag_loads_on_server(fake_studio):
|
||||
|
|
@ -353,7 +386,7 @@ def test_connect_model_flag_loads_on_server(fake_studio):
|
|||
assert loads == [
|
||||
("POST", f"{BASE}/api/inference/load", {"model_path": "unsloth/Qwen3.5-35B-A3B"})
|
||||
]
|
||||
assert "export ANTHROPIC_MODEL=unsloth/Qwen3.5-35B-A3B" in result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_MODEL", "unsloth/Qwen3.5-35B-A3B")
|
||||
|
||||
|
||||
def test_connect_model_flag_matches_canonical_id(fake_studio, monkeypatch):
|
||||
|
|
@ -384,7 +417,7 @@ def test_connect_model_flag_matches_canonical_id(fake_studio, monkeypatch):
|
|||
connect.connect_app, ["claude", "--no-launch", "--model", requested]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert f"export ANTHROPIC_MODEL={canonical}" in result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_MODEL", canonical)
|
||||
|
||||
|
||||
def test_connect_no_model_loaded_errors(fake_studio, monkeypatch):
|
||||
|
|
@ -452,28 +485,270 @@ def test_connect_codex_rejects_non_gguf_model(fake_studio, monkeypatch):
|
|||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_connect_remote_token_rejected_points_at_api_key(fake_studio, monkeypatch):
|
||||
# A self-issued token is invalid against a remote Studio (different secret);
|
||||
# the auto-mint 401 should become actionable --api-key guidance.
|
||||
inner = connect._http_json
|
||||
|
||||
def http_json(
|
||||
method,
|
||||
url,
|
||||
token,
|
||||
payload = None,
|
||||
timeout = 30,
|
||||
error = None,
|
||||
):
|
||||
if url.endswith("/api/auth/api-keys"):
|
||||
raise urllib.error.HTTPError(url, 401, "Invalid or expired token", None, None)
|
||||
return inner(method, url, token, payload, timeout, error)
|
||||
|
||||
monkeypatch.setattr(connect, "_http_json", http_json)
|
||||
def test_connect_nonloopback_keyless_refuses_to_send_credential(fake_studio, monkeypatch):
|
||||
# A server known only by URL + health check is unverified: keyless connect
|
||||
# must refuse and make no request at all.
|
||||
monkeypatch.setattr(connect, "find_studio_server", lambda: "http://studio.evil.example:8888")
|
||||
result = CliRunner().invoke(connect.connect_app, ["opencode", "--no-launch"])
|
||||
assert result.exit_code == 1
|
||||
assert "Settings → API" in result.output
|
||||
assert "--api-key" in result.output
|
||||
assert fake_studio == [] # no HTTP request of any kind (no mint, no /v1/models)
|
||||
|
||||
|
||||
def test_connect_nonloopback_explicit_key_is_allowed(fake_studio, monkeypatch):
|
||||
# User named both server and key, so it's their choice; only auto-send is blocked.
|
||||
monkeypatch.setattr(connect, "find_studio_server", lambda: "http://studio.example:8888")
|
||||
result = CliRunner().invoke(
|
||||
connect.connect_app,
|
||||
["opencode", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
def test_connect_nonloopback_replays_saved_key(fake_studio, tmp_path, monkeypatch):
|
||||
# A key saved for a remote (non-loopback) Studio is replayed on keyless runs;
|
||||
# auto-minting stays blocked for non-loopback.
|
||||
remote = "http://studio.example:8888"
|
||||
monkeypatch.setattr(connect, "find_studio_server", lambda: remote)
|
||||
(tmp_path / "agent_api_key.json").write_text(
|
||||
json.dumps({"servers": {remote: {"saved": ["sk-unsloth-deadbeefdeadbeef"]}}})
|
||||
)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
|
||||
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # never minted
|
||||
|
||||
|
||||
def test_connect_studio_server_errors_on_explicit_remote(monkeypatch):
|
||||
# A user who pointed UNSLOTH_STUDIO_URL at a remote Studio should get an
|
||||
# error, not a silent local model load (which they did not ask for).
|
||||
import typer
|
||||
|
||||
import unsloth_cli._inference as inference
|
||||
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://studio.example:8888")
|
||||
monkeypatch.setattr(
|
||||
inference, "find_studio_server", lambda *a, **k: "http://studio.example:8888"
|
||||
)
|
||||
with pytest.raises(typer.Exit):
|
||||
inference.connect_studio_server("m", hf_token = None, max_seq_length = 4096, load_in_4bit = False)
|
||||
|
||||
|
||||
def test_connect_studio_server_falls_back_locally_on_default_discovery(monkeypatch):
|
||||
# Opportunistic local discovery (no UNSLOTH_STUDIO_URL): if the loopback
|
||||
# server can't be verified, fall back to a local load rather than erroring.
|
||||
import unsloth_cli._inference as inference
|
||||
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_URL", raising = False)
|
||||
monkeypatch.setattr(inference, "find_studio_server", lambda *a, **k: "http://127.0.0.1:8888")
|
||||
monkeypatch.setattr(inference, "verify_studio_identity", lambda *a, **k: False)
|
||||
assert (
|
||||
inference.connect_studio_server("m", hf_token = None, max_seq_length = 4096, load_in_4bit = False)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_connect_unverified_loopback_without_cached_key_refuses_to_mint(
|
||||
fake_studio, tmp_path, monkeypatch
|
||||
):
|
||||
# With no saved key, the next step would auto-mint; an unverified loopback
|
||||
# server (port squatter) must be refused, with nothing sent.
|
||||
monkeypatch.setattr(connect, "verify_studio_identity", lambda base: False)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 1
|
||||
assert "--api-key" in result.output
|
||||
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # never minted
|
||||
|
||||
|
||||
def test_connect_replays_saved_key_without_identity_check(fake_studio, tmp_path, monkeypatch):
|
||||
# A "saved" key (e.g. for an SSH-tunnelled Studio the handshake can't match)
|
||||
# replays on keyless runs without the handshake, scoped to its own base.
|
||||
cache = tmp_path / "agent_api_key.json"
|
||||
cache.write_text(json.dumps({"servers": {BASE: {"saved": ["sk-unsloth-deadbeefdeadbeef"]}}}))
|
||||
monkeypatch.setattr(connect, "verify_studio_identity", lambda base: False)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
|
||||
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # reused, not minted
|
||||
|
||||
|
||||
def test_connect_minted_cache_requires_identity_check(fake_studio, tmp_path, monkeypatch):
|
||||
# A "minted" key is NOT replayed to an unverified loopback server: minting and
|
||||
# minted-key replay both sit behind the handshake, so a squatter can't grab it.
|
||||
cache = tmp_path / "agent_api_key.json"
|
||||
cache.write_text(json.dumps({"servers": {BASE: {"minted": ["sk-unsloth-feedfacefeedface"]}}}))
|
||||
monkeypatch.setattr(connect, "verify_studio_identity", lambda base: False)
|
||||
result = CliRunner().invoke(connect.connect_app, ["claude", "--no-launch"])
|
||||
assert result.exit_code == 1
|
||||
assert "--api-key" in result.output
|
||||
assert not any(c[1].endswith("/v1/models") for c in fake_studio) # minted key never sent
|
||||
|
||||
|
||||
def test_connect_explicit_key_skips_identity_check(fake_studio, monkeypatch):
|
||||
# An explicit key is the user's deliberate choice, so it does not require
|
||||
# the automatic identity handshake.
|
||||
monkeypatch.setattr(connect, "verify_studio_identity", lambda base: False)
|
||||
result = CliRunner().invoke(
|
||||
connect.connect_app,
|
||||
["claude", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
|
||||
|
||||
|
||||
def _serve_identity(proof_for):
|
||||
"""Start a localhost HTTP server answering /api/auth/identity with
|
||||
proof_for(nonce_bytes). Returns (base_url, shutdown)."""
|
||||
import base64
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/api/auth/identity":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
nonce = base64.urlsafe_b64decode(parse_qs(parsed.query)["nonce"][0])
|
||||
host, port = self.server.server_address[0], self.server.server_address[1]
|
||||
body = json.dumps({"proof": proof_for(nonce, host, port)}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target = server.serve_forever, daemon = True).start()
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
return base, server.shutdown
|
||||
|
||||
|
||||
def test_verify_studio_identity_end_to_end(tmp_path, monkeypatch):
|
||||
# Real crypto end to end: verify_studio_identity reads the install secret from
|
||||
# an isolated DB; a "good" server proves the same secret, a spoofing one can't.
|
||||
import unsloth_cli._inference as inference
|
||||
|
||||
inference.ensure_studio_backend_path()
|
||||
try:
|
||||
from studio.backend.auth import storage
|
||||
except Exception as exc: # backend not importable here (e.g. missing deps)
|
||||
pytest.skip(f"studio backend not importable: {exc}")
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_identity_secret_cache", None)
|
||||
|
||||
good = lambda nonce, host, port: storage.compute_identity_proof(
|
||||
nonce, host, port
|
||||
) # real secret
|
||||
bad = lambda nonce, host, port: "00" * 32 # spoofer without the secret
|
||||
base_ok, stop_ok = _serve_identity(good)
|
||||
base_bad, stop_bad = _serve_identity(bad)
|
||||
try:
|
||||
assert inference.verify_studio_identity(base_ok) is True
|
||||
assert inference.verify_studio_identity(base_bad) is False
|
||||
finally:
|
||||
stop_ok()
|
||||
stop_bad()
|
||||
|
||||
|
||||
def _serve_redirect(target):
|
||||
"""Start a localhost server that 302-redirects every GET to target+path."""
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", target + self.path)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target = server.serve_forever, daemon = True).start()
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
return base, server.shutdown
|
||||
|
||||
|
||||
def test_verify_studio_identity_rejects_redirect(tmp_path, monkeypatch):
|
||||
# A squatter could 302 /api/auth/identity to the real Studio and relay its
|
||||
# proof; redirects must be refused so the squatter's base isn't accepted.
|
||||
import unsloth_cli._inference as inference
|
||||
|
||||
inference.ensure_studio_backend_path()
|
||||
try:
|
||||
from studio.backend.auth import storage
|
||||
except Exception as exc:
|
||||
pytest.skip(f"studio backend not importable: {exc}")
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_identity_secret_cache", None)
|
||||
|
||||
real_base, stop_real = _serve_identity(
|
||||
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, port)
|
||||
)
|
||||
squatter_base, stop_squatter = _serve_redirect(real_base)
|
||||
try:
|
||||
assert inference.verify_studio_identity(real_base) is True # direct: ok
|
||||
assert inference.verify_studio_identity(squatter_base) is False # relayed: refused
|
||||
finally:
|
||||
stop_real()
|
||||
stop_squatter()
|
||||
|
||||
|
||||
def test_verify_studio_identity_rejects_relayed_proof(tmp_path, monkeypatch):
|
||||
# A squatter that proxies the nonce to the real Studio on another port gets a
|
||||
# proof bound to *that* port; the client expects one bound to the port it
|
||||
# connected to, so the relayed proof is rejected.
|
||||
import unsloth_cli._inference as inference
|
||||
|
||||
inference.ensure_studio_backend_path()
|
||||
try:
|
||||
from studio.backend.auth import storage
|
||||
except Exception as exc:
|
||||
pytest.skip(f"studio backend not importable: {exc}")
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_identity_secret_cache", None)
|
||||
|
||||
real_base, stop_real = _serve_identity(
|
||||
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, port)
|
||||
)
|
||||
real_port = int(real_base.rsplit(":", 1)[1])
|
||||
# The squatter answers on its own port but returns the proof for the real port.
|
||||
squatter_base, stop_squatter = _serve_identity(
|
||||
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, real_port)
|
||||
)
|
||||
try:
|
||||
assert inference.verify_studio_identity(real_base) is True
|
||||
assert inference.verify_studio_identity(squatter_base) is False
|
||||
finally:
|
||||
stop_real()
|
||||
stop_squatter()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, loopback",
|
||||
[
|
||||
("http://127.0.0.1:8888", True),
|
||||
("http://localhost:8888", True),
|
||||
("http://[::1]:8888", True),
|
||||
("http://127.0.0.5:9001", True), # SSH tunnels can land anywhere in 127/8
|
||||
("http://0.0.0.0:8888", False),
|
||||
("http://10.0.0.5:8888", False),
|
||||
("http://studio.evil.example:8888", False),
|
||||
("https://studio.example.com", False),
|
||||
],
|
||||
)
|
||||
def test_is_loopback_url(url, loopback):
|
||||
assert connect.is_loopback_url(url) is loopback
|
||||
|
||||
|
||||
def test_connect_no_studio_errors(fake_studio, monkeypatch):
|
||||
|
|
@ -489,7 +764,7 @@ def test_connect_explicit_api_key_skips_mint(fake_studio):
|
|||
["claude", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export ANTHROPIC_AUTH_TOKEN=sk-unsloth-deadbeefdeadbeef" in result.output
|
||||
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
|
||||
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio)
|
||||
|
||||
|
||||
|
|
@ -670,7 +945,7 @@ def test_connect_hermes_no_launch(fake_studio, hermes_config):
|
|||
yaml = pytest.importorskip("yaml")
|
||||
result = CliRunner().invoke(connect.connect_app, ["hermes", "--no-launch"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "export UNSLOTH_API_KEY=sk-unsloth-feedfacefeedface" in result.output
|
||||
_assert_env_set(result.output, "UNSLOTH_API_KEY", "sk-unsloth-feedfacefeedface")
|
||||
assert "hermes" in result.output
|
||||
config = yaml.safe_load(hermes_config.read_text())
|
||||
assert config["model"]["provider"] == "custom:unsloth"
|
||||
|
|
|
|||
|
|
@ -260,6 +260,40 @@ def test_find_studio_server_none_when_not_running(monkeypatch):
|
|||
assert _inference.find_studio_server() is None
|
||||
|
||||
|
||||
def test_find_studio_server_prefers_ipv4_loopback_for_localhost(monkeypatch):
|
||||
# localhost resolving ::1-first must not hide a Studio bound to 127.0.0.1:
|
||||
# discovery tries each loopback address and returns the one that answers.
|
||||
import socket
|
||||
import urllib.request
|
||||
|
||||
from unsloth_cli import _inference
|
||||
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://localhost:8888")
|
||||
monkeypatch.setattr(
|
||||
socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **k: [
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("::1", 8888, 0, 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 8888)),
|
||||
],
|
||||
)
|
||||
|
||||
class _OK:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def only_ipv4(request, *a, **k):
|
||||
if "127.0.0.1" not in request.full_url:
|
||||
raise OSError("connection refused")
|
||||
return _OK()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", only_ipv4)
|
||||
assert _inference.find_studio_server() == "http://127.0.0.1:8888"
|
||||
|
||||
|
||||
class _FakeSSEResponse:
|
||||
def __init__(self, lines):
|
||||
self._lines = lines
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue