Merge branch 'main' into woa-nvidia-wsl-fallback
This commit is contained in:
commit
f5aa0d75c4
35 changed files with 4206 additions and 1864 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 =
|
||||
|
|
|
|||
|
|
@ -2669,7 +2669,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
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -451,7 +446,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"]
|
||||
|
|
@ -460,17 +454,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")
|
||||
|
|
@ -638,7 +659,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
|
||||
|
|
@ -674,7 +695,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")
|
||||
|
||||
|
|
@ -689,8 +709,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})
|
||||
|
||||
|
|
|
|||
|
|
@ -878,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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
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