Reduce antivirus false positives in the desktop installers (#8586)

* Windows setup: install uv from a pinned release instead of running remote script text

studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.

Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.

* Windows: stop pairing a hidden window with a bypassed execution policy

The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.

The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.

Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.

* Installers: keep download-and-run command lines out of the shipped script text

AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.

Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.

Same treatment for scripts/uninstall.ps1's header.

* Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.

* Desktop: say who blocked the install when AMSI stops the script

PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".

Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.

Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.

* Desktop: ship each bundle only the installer it can run

resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.

Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.

The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.

* POSIX installers: install uv from a pinned release before falling back

install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.

Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.

Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.

* tests: pin the installer shapes antivirus heuristics score

One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.

The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.

Runs on the existing discovery-based pytest step, no workflow list to update.

* release: emit a false-positive submission packet for whatever gets flagged

The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.

The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.

The gate stays advisory; this only makes acting on it take seconds.

* Revert "Windows: resolve process image paths with one Win32_Process query"

This reverts commit 7897865c9.

tests/python/test_windows_installer_concurrency_guard.py bans Get-CimInstance
and $process.Path from Get-RunningStudioVenvProcesses outright, and requires the
native image-path lookup. That contract came out of #7764, which closed a set of
races where the installer inferred "in use" from something other than a confirmed
executable identity and blocked installs that should have proceeded.

Win32_Process.ExecutablePath does answer the same question, but a wrongly blocked
install costs far more than the heuristic weight of three native imports. Record
the imports in the AV-shapes allowlist with that reasoning instead, and keep the
ban on the process-memory APIs, which the installer has no use for.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added by this branch

Opening comment-reduction pass over the PR diff: same intent, fewer lines. Cut
hardest on the prose that restated the PR description rather than explaining the
code next to it. Comments and docstrings only, verified with comment_tools.py
check --strip-docstrings across every Python file in the diff.

* Drop an unused helper from the uv pinned-release test

* Fix three review findings on the installer hardening

Stray-resource check aborted the step it was meant to assert. grep exits 1 when
it selects nothing, and under this step's set -o pipefail plus the runner's
bash -e that kills the assignment outright, so every correctly split .deb failed
clean-machine CI before reaching the check. Both lookups take || true now: no
match is the passing case for the stray one, and for install.sh it was swallowing
the explicit annotation in favour of a bare exit 1.

studio/setup.sh skipped astral's XDG_DATA_HOME/../bin destination tier, which
install.sh, install.ps1 and studio/setup.ps1 all honour. A host that configured
an XDG location got uv under ~/.local/bin instead, where no later shell looks for
it. The session PATH prepend hid it at install time.

The AMSI guidance claimed nothing was changed even when the block landed on the
nested studio/setup.ps1, which install.ps1 launches through the same inherited
pipes after the venv, PyTorch and the packages are already on disk. Split the
wording on whether a [TAURI:STEP] marker has been seen: a pre-start block
produces none, so the reassurance is only given where it is true.

* Key the submission packet on the flagged count, not the engine list

stats and results are separate fields of the same VirusTotal response, so an
asset can carry a flagged count with no readable results map. The summary table
reports that asset and the packet skipped it, which is exactly the one that needs
a packet. Select on stats.flagged and keep the engine list for the Flagging
engines section, which is correctly keyed on having engines to name.

* Drop the bundle stray-resource assertion from clean-machine CI

That job downloads a published release, never a bundle built from the branch, so
asserting the new resource split there turns every run red until a release ships
with it. The split is a property of the Tauri config, and
tests/studio/test_tauri_installer_resource_contract.py already enforces it at the
right layer.

The || true on the install.sh lookup stays: it is what lets the explicit
annotation print instead of the step dying on grep's exit 1 under pipefail.

* Cover the uv host matrix and repeat application in the pinned-release test

The pinned path picks an archive per host triple, and a wrong pick installs a
binary that cannot execute, which is worse than not installing at all. Drive
_uv_pinned_asset over 20 host combinations and require each one to return its
own triple or decline to the fallback.

Also run the installer three times over one HOME and require an identical tree,
and require a stale uv at the destination to be replaced rather than joined by a
second copy: the installer is re-run on every upgrade and every repair.

* Pick the pinned uv archive off a positive libc check, not the absence of musl

An independent audit pass found the Linux selector accepts any host whose ldd
output does not say musl. That is not the same question astral's installer asks:
it checks a minimum glibc and drops to its musl-static archive below it, so
three hosts that worked before this branch now get a GNU binary that cannot exec,
and the helper reports success so the fallback never runs.

  aarch64 with glibc below 2.28 (Ubuntu 18.04)
  x86_64 with glibc below 2.17 (RHEL 6)
  a musl image with no ldd at all, where the probe simply finds nothing

Read the version instead, from ldd or getconf, and require it to clear astral's
floor for the triple. Anything unreadable declines to the fallback. Also ask the
userland for its bitness rather than trusting uname on a 64-bit kernel running a
32-bit userland, and follow astral in reading hw.optional.arm64 so a translated
shell under Rosetta 2 still gets the native macOS build.

Three more from the same pass:

Report success only when the destination uv is executable. A copy onto a busy or
read-only destination could leave a file that is not, and reporting success there
skipped the fallback. Nothing is unwound on the failure path on purpose: the
fallback installs over whatever is at the destination, and deleting there would
take out a working uv the host already had.

Clear the mark of the web on the launcher we author. WriteAllText replaces the
unnamed data stream and leaves other NTFS streams alone, so a launch-studio.ps1
that somehow carried one would keep it across the rewrite, and RemoteSigned
refuses a marked unsigned script.

Store the security-block kind and resolve its wording in message(). stdout and
stderr are read by independent threads, so a [TAURI:STEP] written before a block
can be observed after it, and freezing the wording at observation time could tell
a user nothing was changed on a run that had already installed PyTorch. Also
require the error id to appear as the value of a FullyQualifiedErrorId field, so
a scanner log that merely names it cannot attach antivirus guidance to whatever
fails next.

The host matrix in the shell test grows to 28 rows covering every case above, and
removing the new gate fails 8 of them. install.rs gains two tests: 37 pass.

* Replace a symlinked uv destination instead of writing through it

Three from the review on the previous head.

cp onto a destination that is a symlink follows the link, so installing over
`~/.local/bin/uv -> /opt/homebrew/bin/uv` rewrote the Homebrew binary in place
and left the link pointing at a file another package manager owns. Stage next to
the destination and rename over it: rename replaces the link itself, and it is
atomic, so a concurrent reader never sees a half-written uv either. The staging
file is removed when the rename fails, so a failed run leaves no debris.

Verify the Windows copy the same way the shell scripts now do. Copy-Item is
non-terminating under the caller's ErrorActionPreference, so a locked or
ACL-denied destination let execution reach `$haveUv = $true` and the function
reported success over whatever was already there. Compare the destination against
the archive we just verified, so a stale uv.exe cannot pass for the one we meant
to install. install.ps1 carried the same shape and gets the same treatment.

Point the header links at the heading that exists. The README has no "Install
Unsloth Studio"; it is "Unsloth Studio (web UI)", whose anchor is
#unsloth-studio-web-ui.

Three test cases cover the symlink: the file behind the link is untouched, the
link itself is replaced, and no staging file survives. Reverting the fix fails
two of them.

* Fail the build when the uv pin drifts from a version floor

Before the pin, astral's endpoint always delivered the newest uv, so raising
UV_MIN_VERSION was safe on its own. It is not any more: a floor above the pin
means a host with no uv gets 0.12.1 installed and then judged too old by the same
script that installed it, on the one path where the pin is what runs.

Two checks. All four installers must name the same uv, or which version a machine
ends up with depends on which script reached it first. And the pin must clear
every floor in the tree (UV_MIN_VERSION, UV_OFFLINE_MIN_VERSION, $UvMinVersion).
Raising a floor past the pin fails the first, bumping one installer's pin alone
fails the second.

* Write the shell profile entry the pinned uv path no longer gets for free

The P1 here is a real regression and it took a second look to see why.

install.sh decides whether to add ~/.local/bin to the user's shell profile with
`case ":$PATH:"`, near the end of the run. By then this process has prepended
that directory twice, once for the uv bootstrap and once for the venv, so the
guard answers yes for a login shell that would answer no and the profile line is
never written. That was survivable while astral's installer ran, because it wrote
its own profile line and its env file. The pinned path writes neither, so on a
fresh account whose login PATH lacks ~/.local/bin the install succeeds, the
current shell works, and the next terminal cannot find `unsloth` or `uv`.

Snapshot the inherited PATH before anything prepends to it and test the guard
against that.

Two more from the same review.

Honour a configured uv mirror exclusively. UV_INSTALLER_GHE_BASE_URL and
UV_INSTALLER_GITHUB_BASE_URL already win outright in both PowerShell installers
and in astral's own; the shell path ignored them and tried the public hosts
first. A restricted network sets one precisely because those hosts are
unreachable, and download() has no timeout, so it would hang rather than reach
the fallback.

Do not let the twin of an earlier clear erase a later AMSI verdict.
Clear-TauriInstallError writes one logical clear to BOTH streams
(install.ps1:198) and independent threads read them, so a block observed between
a clear and its own twin was discarded by the twin. Ignore a clear identical to
the one just processed; a genuine later recovery carries different text and still
clears. Two tests cover both directions, 39 install tests pass.

* Stage the uv copy under a per-process name

An audit pass reproduced a race I introduced with the symlink fix. Both POSIX
helpers staged through a fixed destination-side name, so two installers targeting
one directory shared it:

  A finishes copying the staging file
  B opens the same path with truncation
  A renames that inode into place as uv
  B keeps writing through its open descriptor, which is now the published uv

The published uv was observable at zero bytes until B resumed, which makes the
claim in the comment about a concurrent reader flatly wrong. install.ps1 is
covered by its named mutex, but nothing serialises the POSIX helpers, and
studio/setup.sh runs standalone on every studio update.

mktemp in the destination directory instead. Each rename then publishes a file no
other process can still be writing, which is what the atomicity argument needed
all along. The loser cleans up its own staging file and declines, so the caller
falls back rather than reporting a success it did not achieve.

* Keep a default install as quiet as it was when a uv mirror misbehaves

Two console regressions from the audit pass, both on paths the install still
recovers from.

download() runs curl -LsSf, and -S deliberately prints its own errors. The
fallback ran under run_maybe_quiet, so a failed download printed nothing before;
the pinned attempts run outside that wrapper, so an unreachable mirror now put
two curl: (N) lines on the console of a default install that then succeeded.
Redirect stderr on the speculative attempts only, leaving download() untouched
for every other caller.

[TAURI:WARN] is a marker level install.sh has never emitted, and the app forwards
unknown markers to its progress UI verbatim (install.rs:639), so a digest
mismatch would have surfaced as raw text in the desktop window. Make it a verbose
only stderr line: the next mirror or the fallback still runs, so a default
install has nothing to say here.

Printed-string diff against the merge base is back to additions inside $(...)
capture plus that one verbose-gated line, with nothing removed or changed.

* Ask the installed uv whether it runs before skipping the fallback

The libc gate reads a glibc version from ldd or getconf and treats that as proof
a GNU binary will execute. It is not. A stripped NixOS-derived image without
nix-ld reports a glibc version through getconf while its loader lives in the Nix
store, so the pinned x86_64 uv asks for /lib64/ld-linux-x86-64.so.2 and gets
nothing. Every static check passed, so the helper reported success, the astral
fallback was skipped, and the first real uv call failed with No such file or
directory. astral's installer fails its own glibc probe on that host and ships
the fully static musl archive, which runs. The user went from a working uv to
none.

The archive is digest-verified astral uv by the time it is placed, so ask it:
run --version and require it to succeed. One exec closes the whole class rather
than this one host, covering a wrong triple, a loader that is not where the
binary looks, and a destination we could not really write.

A test drives an archive whose uv cannot execute and requires the helper to
decline; removing the exec check fails it.

* Pair every clear with its twin, not just the previous one

install.ps1 clears after each recovered step, so a lagging reader can be several
clears behind when a block lands. With clears A then B on one stream and A's twin
arriving on the other after the verdict, asking only whether this is the message
just seen answers no, and the delayed twin discarded the verdict the guidance
exists to explain.

Each logical clear emits exactly two markers, so count unpaired ones by message:
the first sighting is the clear, the next pairs with it. A test drives the A, B,
verdict, A', B' ordering; 40 install tests pass.

* Validate the staged uv before it replaces a working one

My own exec check was on the wrong side of the rename. The sequence that bites:
a host has a uv good enough for UV_OFFLINE_MIN_VERSION but below UV_MIN_VERSION,
so the block runs with _uv_present_before true; the pinned path renames over that
working binary; the --version check then fails because the loader is missing or
the destination is mounted noexec; the fallback download also fails. The
installer neither restores the old uv nor reports that none is available, and
every later command runs the broken one.

Test the staging file instead, before the rename. It sits on the destination
filesystem, so it answers the noexec question too, and a binary that cannot run
here never gets to replace one that could.

Two tests: a working incumbent uv survives an archive whose uv cannot execute,
and the rejected staging file is cleaned up. Moving the check back after the
rename fails the first.

* Stop the AMSI guidance claiming more than it knows

Two of these are honesty defects in text a blocked user reads.

"nothing was changed on this machine" is false. Rust starts a diagnostics
attempt and its phase log before PowerShell is ever spawned, and spawn_script can
create ~/.unsloth first, so a pre-start block has already written to disk. The
honest claim is that no installation step ran.

"This is a false positive" is not something the classifier can know. It proves
the output carries a PowerShell error id and nothing about the script's
integrity, and install.ps1 can sit in a user-writable directory, so a locally
modified copy can earn a genuine verdict. Telling someone to report a correct
detection to their vendor is worse than telling them to reinstall from an
official package first and only escalate if an unmodified copy is still blocked.

Two smaller ones from the same pass. The matcher tested for the field name and
the id independently, so a line naming both in prose qualified; it now requires
the id to follow the colon and end at a comma or whitespace, which is what the
comment always claimed. And the clear-pairing map is bounded: legitimate
producers use a small fixed label set, and child output must not be able to grow
it without limit.

42 install tests pass.

* Honour astral's download override, and stop Unblock-File asking

Three from the second audit round.

Unblock-File declares SupportsShouldProcess at the default Medium impact, so a
profile that sets $ConfirmPreference to Medium or Low gets a prompt from the line
I added, even for a launcher that never carried the stream. -ErrorAction does not
suppress a ShouldProcess prompt, and a noninteractive host turns it into an error
that skips shortcut setup entirely. -Confirm:$false.

UV_DOWNLOAD_URL and its older alias INSTALLER_DOWNLOAD_URL outrank the mirror
variables in astral's installer, and the merge-base path inherited that because it
ran astral's script. All four implementations now honour them first and
exclusively. My earlier comment argued they point at a version the pin would
reject, but that reasoning had it backwards: a host sets one because it cannot
reach the public endpoints, so ignoring it meant public egress first and, with no
timeout on the download, a hang instead of a fallback. The pin still applies, so a
source serving a different build fails the digest and the caller falls back to
astral's installer, which honours the same variable.

chmod 0755 on the staging file rather than +x. cp gives it the umask default and
+x then adds execute only where the umask allowed read, so a umask of 077 left uv
unusable for every other account on a shared machine. astral ships them 0755.

Four checks pin the override precedence across all four installers and the mode
across both shell ones, with the behaviour verified against a stubbed downloader.

* Validate uv before it replaces an incumbent on Windows, and bound the probe

install.ps1 and studio/setup.ps1 copied the extracted uv.exe straight over the
destination and only asked whether it ran afterwards. A host with a working older
uv and a policy (AppLocker, WDAC, endpoint protection) that refuses the new one
was left with neither. Run the extracted binary where it landed first, then keep
a copy of the incumbent across the publish and restore it if the published copy
will not run, since Windows has no atomic replace for a file that may be open.

The probe itself is bounded: Start-Process with a 20s WaitForExit and redirected
streams, and on POSIX no stdin plus a 20s ceiling where timeout exists. A binary
this installer just downloaded must not be able to hang an unattended install by
prompting or by never exiting.

install.sh and studio/setup.sh also published the pinned uvx after rejecting the
pinned uv, leaving a pairing that is never built or tested. A uv that fails to
stage, copy or run now abandons the whole placement.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Verify each uv mirror, and persist PATH when the account has no rc file

Both PowerShell installers checked the archive digest once, after the download
loop had already broken out. A captive portal or a proxy answering 200 with its
own body is a successful download by every measure Invoke-WebRequest has, so the
first mirror consumed the only attempt and the second, healthy one was never
tried. The digest now decides whether a mirror counts as served.

install.sh picked a shell profile from .zshrc, .bashrc or .profile and did
nothing when none existed. A fresh account has none: astral's installer used to
create its own PATH setup there, the pinned path does not, so the next terminal
resolved neither unsloth nor uv. Fall back to creating ~/.profile, which every
POSIX login shell reads. The existing content guard keeps it written once.

* Remove the install.sh a Windows upgrade would otherwise keep forever

Windows bundles now carry only install.ps1, but NSIS writes the current resource
manifest and deletes nothing, and the uninstaller deletes only what is in that
manifest. An in-place upgrade from a release that bundled both installers left
install.sh in $INSTDIR permanently, which also made the non-recursive
RMDir "$INSTDIR" fail at uninstall. The pre-install and pre-uninstall hooks now
delete it, so the population most likely to upgrade actually gets the split.

Also silence the speculative mktemp -d in the pinned uv path: its failure falls
back to astral's installer, so an unusable TMPDIR printed a line the user could
not act on and that the merge base did not print.

* Remove the pinned uv temporaries when an install is interrupted

The pinned path unpacks a 40 MB archive into a work directory and stages the
binary next to the destination, but only cleaned both up when the helper returned
normally. A Ctrl-C in between left the archive behind and left a staging file
inside a directory that is on PATH. Both paths are now published to the exit and
signal traps as they are created and cleared when the helper releases them.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Persist the PATH the way each shell actually reads it, and fail a half-published pair

Four follow-ups from review:

A uvx that the archive carried but that could not be staged or renamed left uv
published next to a stale or missing uvx and still reported success, skipping the
fallback that would have installed both. Either half failing now fails the
placement, in install.sh and studio/setup.sh.

studio/setup.sh had none of the interrupt cleanup install.sh gained: a Ctrl-C
left the unpacked archive behind and a staging file inside a directory on PATH.
It now owns HUP, INT, TERM and EXIT for the duration of the pinned install and
hands them back on the way out.

fish sources none of the POSIX rc files, so the ~/.profile fallback was a no-op
for a fish user. The persistence helper writes a conf.d drop-in with
fish_add_path there, and honours ZDOTDIR for zsh.

UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME can put uv
somewhere other than ~/.local/bin, and astral's installer wrote a PATH line for
whichever it picked. The pinned path now persists its own destination too, with
UV_NO_MODIFY_PATH honoured as astral honours it.

* Make the Windows uv publish a real transaction, and quote persisted paths

The companion copies ran bare: under install.ps1's Stop preference a locked or
ACL-denied destination threw past the rollback and left a mismatched set with the
backups still on disk, and under setup.ps1's Continue preference it kept a stale
companion and reported success. Both now copy under -ErrorAction Stop inside the
transaction, so any failure unwinds like the others.

A failed restore also used to delete the backup anyway, which is the one path in
this block that could leave the host with less than it started with: the two
things that make a restore fail, an open incumbent and a denied ACL, are the same
two that made the replace risky. The backup is now kept and named.

fish takes an unquoted path with a space as two directories, neither of which
exists, so the drop-in single-quotes it; and the rc line is written inside double
quotes, so a uv directory holding a dollar or a backtick is escaped. The second
test caught a doubled backslash in the escaper itself.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added by this branch

Comments only, no code touched: 161 comment lines become 106 across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1 and the NSIS hooks. Each one keeps
the reason it was written for, said once.

Verified with the PowerShell AST parser, sh -n and bash -n, the 50-check uv
pinned release suite and 114 installer tests, and by confirming the diff contains
no non-comment line.

* Tighten the install.rs comments too

Comments only: 35 lines become 27, each keeping the reason it was written for.
42 install tests pass and the diff contains no non-comment line.

* Abort on a companion that cannot be backed up, and pair clears by stream

A uvx.exe that could not be copied aside, because it is locked or its ACL denies
reads, was skipped and the new uv.exe published anyway, so the function reported
success with a mismatched pair and the fallback never ran. Any backup failure now
fails the placement and runs the rollback, in install.ps1 and studio/setup.ps1.

The ERROR_CLEAR pairing keyed only on the message, so two real clears of one label
on one stream were taken for a clear and its twin. That happens:
_install_torch_default_index emits its recovery during the install and again
during the ROCm repair. A verdict landing between them was then erased by the
genuinely later clear arriving on the other stream. The map is keyed by stream as
well, so only the opposite stream's copy can consume a pending marker.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Do not fail an install because the uv probe could not get an answer

Three clean-machine CI legs that pass on main failed on this branch: arm64 and
two Windows containers, all three with winget unavailable, which is the only
condition under which the pinned fallback runs. Each downloaded the right asset,
passed the digest, and then failed the probe. Start-Process -NoNewWindow with
redirected streams does not behave in a container or on the arm64 image the way
it does in a desktop session, and a boolean probe reported that as a broken
binary and aborted the install.

The probe is now tri-state. Only the binary answering non-zero is a failure. A
launch that throws or a wait that times out is inconclusive, and since the digest
already proved the bytes are astral's pinned release, an inconclusive probe
publishes as the pre-pin code did. Every path prints why, with the captured
stderr and the exit code, so the next occurrence is not opaque.

Also from review: the POSIX path now stages both binaries and publishes them
together with the incumbents saved aside, so a failed uvx rename restores the
uv it replaced instead of leaving a new uv beside a stale uvx; the Windows
rollback records the destination before the copy that can truncate it; and
UV_UNMANAGED_INSTALL suppresses the profile write, as it does for astral.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Give setup.sh the same pair publish and PATH persistence as install.sh

studio/setup.sh published uv and then uvx one after the other, so a failed uvx
rename left a new uv beside the host's stale one, and the remote fallback can be
unavailable. It now stages both, validates uv, and publishes the two renames back
to back with the incumbents saved aside, restoring them if the second fails.

setup.sh is also run directly for local and Colab setup, where astral's installer
used to write the profile line for whichever destination it chose. Without one the
PATH export died with that shell and every later run reinstalled uv. It now
persists its own destination, with fish handled on its own terms and both of
astral's opt-outs honoured.

* Treat an empty uv exit code as no verdict, not as a failure

The arm64 clean-machine leg still failed on the tri-state probe, and the
diagnostic that came with it said why: "uv --version exited ." with no number.
WaitForExit(ms) can return before the exit code is cached, so ExitCode was empty
and an empty value is not 0, which read a working uv as broken.

The parameterless WaitForExit settles it and returns at once because the process
has already exited, and a code that is still missing is inconclusive rather than
a failure, which is the same rule the launch and timeout paths already follow.
Verified against pwsh that a real non-zero exit and a real launch failure still
classify as failed and unknown respectively.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Eight review fixes across the uv publish and PATH persistence

The fish escaper in studio/setup.sh reached sed as an invalid expression, so a
fish user running setup directly would have had setup killed under set -e right
after uv was published. It now matches the one in install.sh, and the test runs
both escapers rather than reading them.

An incumbent that cannot be hard-linked or copied cannot be restored either, so
publishing over it would be a one-way move. Both shells now decline. Writing that
test turned up that my own rollback deleted both incumbents when nothing had been
published, since the no-predecessor branch cannot tell the two cases apart; the
rollback is now reached only after a publish was attempted.

A rollback with no predecessor removes the binary it published, rather than
leaving half a pair the host never had.

A signal between the two renames left the undo copy as the only reference to the
incumbent, and the handler deleted it. It restores it now, in both shells.

setup.sh prepended ~/.local/bin unconditionally after a successful pinned
install, so a stale uv there could shadow a custom UV_INSTALL_DIR destination and
the rest of setup would run the wrong one. That prepend is now only for astral's
installer, which is what writes there.

PATH entries are compared literally rather than as case patterns, so a
destination holding *, ? or [ is not mistaken for an unrelated entry.

On Windows, a .unsloth-old left behind by a failed restore is the only copy of a
working uv, and the next run reused that exact name. It takes a distinct one.

* Keep the pinned uv first on PATH, and only count an active profile entry

install.sh prepends ~/.local/bin after the uv bootstrap, and astral's env file
does too, so a custom UV_INSTALL_DIR destination was pushed behind a stale uv
sitting in the home directory and every bare uv below picked the wrong one. The
pinned destination goes back in front. setup.sh had the same shape and was fixed
in defd2292a.

The profile check treated any occurrence of the destination text as proof the
PATH entry was already there, so a commented-out old export, or /opt/uv-old when
the destination is /opt/uv, suppressed the write and left the next shell without
uv. Comments are stripped and the directory has to appear as a whole entry.

* Gate the NSIS tidy-up, and remove an orphan uv on signal

The pre-install hook runs before the user can still cancel, and $INSTDIR can be a
directory they picked in the GUI, so deleting install.sh there could take a file
that was never ours. Both hooks now only act where our own executable already is.

A signal between the two renames restored a predecessor but did nothing when
there was none, leaving a 0.12.1 uv beside whatever uvx the machine had. It now
removes what it published, which is what the ordinary rollback already does.

* Write the uv PATH entry to every startup file astral's installer wired

astral's uv installer wires ~/.profile, each of .bashrc, .bash_profile and
.bash_login that exists, .zshrc or .zshenv under ZDOTDIR, and a fish drop-in
under ~/.config. Replacing that installer with a pinned archive meant the PATH
entry only reached the one file for whichever shell happened to be running, so a
bash user whose .bash_profile does not source .bashrc, a /bin/sh login, or anyone
who later switched shells would have no uv on PATH where they used to.

Both POSIX installers now write the same set, once each, with the existing
whole-entry check keeping a re-run idempotent. Files that do not exist are not
created, apart from ~/.profile, which astral creates too.

* Cut the uv publish back to what the common case needs

The rollback machinery that grew over the review rounds covered cases a user is
very unlikely to meet: an incumbent that cannot be hard-linked, a signal landing
between two renames, a restore that itself fails, a second installer racing the
first. It was 281 net lines, and every finding in the last two rounds was in it
rather than in the hardening.

What stays is what the common case needs. POSIX stages both binaries, runs the
staged uv, and publishes the pair with two renames; a failure anywhere before
them leaves the destination untouched, and the caller falls back to astral's
installer exactly as before. Windows probes the extracted uv.exe before touching
the destination, then copies the three under -ErrorAction Stop and re-checks the
digest at the destination.

The staging files are still removed on a signal, since they live in a directory
that is on PATH. 64 shell checks and 114 installer tests cover the rest.

* Match the exact fish entry, and let a UNC launcher load

The fish drop-in is the only thing that puts uv on a fish user's PATH, since fish
reads none of the POSIX files, and its check treated any occurrence of the
directory as proof: /opt/uv-old suppressed /opt/uv. It now matches the exact
fish_add_path line it would write.

A launcher on a UNC share is a remote script to PowerShell, and RemoteSigned
refuses an unsigned one, so a roaming profile got a shortcut that exits without
starting Studio. That case, and only that case, uses Bypass, and drops
-WindowStyle Hidden with it so the pair the detections key on never appears.

* Wire every startup file on a DEFAULT install too, and give setup.ps1 a fallback

The all-profile PATH write was gated on the uv destination differing from
~/.local/bin, which is exactly where a normal install puts it, so every ordinary
machine still got the single-file write the shim path has always done. Three
independent audits found this. The gate is gone, and the idempotency check now
also matches the $HOME-relative spelling the shim block writes, so the default
case does not end up with two lines for one directory.

studio/setup.ps1 replaced astral's installer with the pinned archive and had
nothing to fall back to. A failed pinned install therefore left UseUv false and
silently ran torch, bitsandbytes, Triton and the rest through pip: a different
resolver, not just a different download. winget is the fallback, as install.ps1
already does, rather than the remote script this branch exists to remove.

* Read the pinned install's real result, and three narrower publish guards

The winget fallback I added last round read Invoke-SetupCommand's return value,
which is [int]$LASTEXITCODE rather than the function's $true, so it fired on
every run: a redundant managed install, and a second copy on a machine that asked
for UV_UNMANAGED_INSTALL. The function records its own success on the script
scope and the fallback reads that.

A directory named uv at the destination looked like a published binary: mv moves
into it and reports success, and a searchable directory passes -x, so the install
reported success and the first later uv call failed instead. Both shells refuse a
directory target, as the installer already does for its own shim.

The PATH idempotency pattern escaped only part of the ERE metacharacter set, so a
destination holding + ( or | did not match itself and every reinstall appended
another block to every profile.

* Restrict the profile duplicate check to PATH lines, and cover mapped drives for PR #8586

* Match only PATH-setting lines in the profile duplicate check for PR #8586

* Tighten the installer comments added by PR #8586

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-08-13 07:02:18 -07:00 committed by GitHub
parent 79fbf949fc
commit 5a5bf64130
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2993 additions and 105 deletions

View file

@ -438,10 +438,13 @@ jobs:
# The launch step below only proves the process stayed alive: on a fresh home
# preflight reports not_installed and the app waits for a click on the install
# screen (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a bundle
# with a missing or broken install.sh passed both Linux rows. tauri.conf.json:56-59
# with a missing or broken install.sh passed both Linux rows. tauri.linux.conf.json
# ships it as a bundle resource, so find it there and run it as install.rs does.
if [ "${{ matrix.kind }}" = "deb" ]; then
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)"
# `|| true`: grep exits 1 when it selects nothing, and under the `set -o pipefail`
# above plus this step's `bash -e` that aborts the assignment outright, swallowing
# the explicit annotation below in favour of a bare exit 1.
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1 || true)"
else
# ls returns a bare filename here, and a command word with no slash resolves
# through PATH, not the cwd, so this needs the ./ prefix.

View file

@ -1,16 +1,12 @@
# Unsloth Studio Installer for Windows PowerShell
#
# Usage: irm https://unsloth.ai/install.ps1 | iex
# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local
# Usage, options and the web one-liner: see "Unsloth Studio (web UI)" in the README
# (https://github.com/unslothai/unsloth#unsloth-studio-web-ui). Not repeated here, because
# AMSI scans this file in full before a line of it runs and nothing reads the header from inside.
#
# irm | iex cannot forward arguments, so web installs take options as env vars set
# before the pipe (flags still work via .\install.ps1):
# $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only)
# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch
# $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version
# $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
# .\install.ps1 --no-torch # equivalent flag
# Or pass flags to a scriptblock: & ([scriptblock]::Create((irm https://unsloth.ai/install.ps1))) --no-torch
# The web entry point cannot forward arguments, so it takes options as environment variables set
# beforehand (UNSLOTH_NO_TORCH, UNSLOTH_SKIP_AUTOSTART, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME); a
# local run takes the equivalent flags (--no-torch, --skip-autostart, --python, --local).
#
# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $USERPROFILE\.unsloth\studio
#
@ -20,7 +16,7 @@ function Install-UnslothStudio {
$ErrorActionPreference = "Stop"
# The user's PowerShell profile has already run by the time this does, and the documented
# "irm https://unsloth.ai/install.ps1 | iex" entry point has no script file to re-launch
# piped web entry point documented in the README has no script file to re-launch
# with -NoProfile, so each way a profile can reach in here is cut individually below.
#
# Off, not Latest: this script predates strict mode, testing environment variables that are
@ -60,7 +56,7 @@ function Install-UnslothStudio {
# (27.8 MB) took 41.34s with the bar on against 0.08s with it off, and the uv archive the
# same. That is the multi-minute "slow download" users report. -UseBasicParsing does NOT
# avoid it and PowerShell 7 never had the cost; only this preference does. Same scoping rule
# as the table above: no qualifier, so the caller's own preference survives "irm ... | iex".
# as the table above: no qualifier, so the caller's own preference survives a piped web run.
$ProgressPreference = 'SilentlyContinue'
# The kept proxies travel to studio/setup.ps1 (launched -NoProfile by unsloth_cli, and it
@ -90,7 +86,7 @@ function Install-UnslothStudio {
} catch { }
}
}
# A FUNCTION-local, not $script: or an environment variable: under "irm ... | iex" this runs
# A FUNCTION-local, not $script: or an environment variable: under a piped web run this runs
# in the caller's own session, and the value can carry credentials (http://user:secret@proxy
# is the ordinary corporate form) that must not outlive the install on any of the dozens of
# return paths. Module-qualified serializer, as in the probe: a profile alias or function
@ -108,7 +104,7 @@ function Install-UnslothStudio {
$PSNativeCommandUseErrorActionPreference = $false
# Reset per invocation, for the reason at $script:IsIntelXpu further down: under
# "irm ... | iex" $script: is the caller's session scope, so a second run in the same
# a piped web run, $script: is the caller's session scope, so a second run in the same
# console would start on the first run's state. These two are the only ones no later
# statement re-assigns unconditionally.
$script:UvExe = 'uv'
@ -1361,11 +1357,14 @@ try {
# Single-quote the path in the child -Command so `$` / backtick in custom
# roots don't get reparsed; double any apostrophes so 'O''Brien' survives.
`$studioCommand = "& '" + (`$studioExe -replace "'", "''") + "' studio -p " + `$launchPort
# RemoteSigned, not Bypass: the child runs an inline -Command against an executable, so no
# script file is loaded and the two behave identically here. No reason to spend a scored
# token on a launch that needs no policy relief.
`$launchArgs = @(
'-NoExit',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'RemoteSigned',
'-Command',
`$studioCommand
)
@ -1419,6 +1418,15 @@ exit 0
# even when install.ps1 is executed from PowerShell 7.
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
# WriteAllText replaces the unnamed data stream and leaves any other NTFS stream on
# an existing file alone, so a launcher that somehow acquired a mark of the web keeps
# it across the rewrite. The shortcut loads this under RemoteSigned, which refuses a
# marked unsigned script, so clear the mark on the file we just authored. A no-op on
# every ordinary install, where the stream was never there.
# -Confirm:$false: Unblock-File is SupportsShouldProcess at Medium impact, so a
# profile lowering $ConfirmPreference would prompt here, and ErrorAction does not
# suppress a ShouldProcess prompt. On a noninteractive host that skips shortcut setup.
Unblock-File -LiteralPath $launcherPs1 -Confirm:$false -ErrorAction SilentlyContinue
# No .vbs launcher is written. A WScript.Shell .vbs that spawns a hidden
# ExecutionPolicy-Bypass PowerShell is exactly the shape VBS-dropper
# heuristics score (e.g. Kaspersky HEUR:Trojan.VBS.Agent.gen). The .lnk
@ -1516,9 +1524,28 @@ exit 0
# launch-studio.ps1 with a hidden window. We deliberately avoid a
# .vbs/WScript.Shell wrapper -- that script-engine shape is what AV
# VBS-dropper heuristics score (Kaspersky HEUR:Trojan.VBS.Agent.gen).
#
# RemoteSigned, not Bypass: a hidden window beside a bypassed policy is the pair
# Microsoft's detections key on, and install.rs makes the same call for the app's own
# launch. This launcher is written locally, so RemoteSigned loads it either way.
$powershellForLnk = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$shortcutTarget = $powershellForLnk
$shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcherPs1`""
$shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$launcherPs1`""
# A launcher on a share is a REMOTE script to PowerShell and RemoteSigned refuses an
# unsigned one, so a roaming profile would get a shortcut that exits without starting
# Studio. Bypass for that case only, and without the hidden window: a console beats
# nothing launching. A mapped drive (H:, Z:) is the same share and the same zone, and
# DriveInfo on the root reports Network for both.
$launcherIsRemote = $launcherPs1 -like "\\*"
if (-not $launcherIsRemote) {
try {
$launcherIsRemote = ([System.IO.DriveInfo]::new(
[System.IO.Path]::GetPathRoot($launcherPs1))).DriveType -eq 'Network'
} catch {}
}
if ($launcherIsRemote) {
$shortcutArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$launcherPs1`""
}
try {
$wshell = New-Object -ComObject WScript.Shell
@ -1612,7 +1639,7 @@ exit 0
}
# ── Leave Windows system directories before installing ──
# "Run as administrator" starts in C:\Windows\System32, so `irm ... | iex` installs
# "Run as administrator" starts in C:\Windows\System32, so a piped web run installs
# from there and `unsloth studio setup` refuses only after PyTorch has downloaded,
# then rolls back. Relocating is safe: nothing here reads the caller's directory
# ($RepoRoot from $PSCommandPath, $StudioHome from the environment), so only
@ -2501,6 +2528,55 @@ exit 0
}
}
function Get-UvExecutableVerdict {
# "ok", "failed" or "unknown". Only the binary itself answering non-zero is "failed".
# A launch that throws or a wait that times out is "unknown", because the probe got no
# verdict: Start-Process -NoNewWindow with redirected streams does not behave in a
# Windows container or on the arm64 image the way it does in a desktop session, and
# treating that as a broken binary turned three clean-machine CI legs into hard install
# failures. The digest already proved these bytes are astral's pinned release, so no
# verdict publishes, as the pre-pin code did. Every path says why.
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return "failed" }
# Redirected: uv's version line is not part of this installer's output.
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
try {
$proc = Start-Process -FilePath $Path -ArgumentList "--version" -NoNewWindow -PassThru `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile -ErrorAction Stop
if (-not $proc.WaitForExit(20000)) {
try { $proc.Kill() } catch {}
substep "uv did not answer --version within 20s; installing it unprobed." "Yellow"
return "unknown"
}
# The timed overload can return before the exit code is cached, which is how
# arm64 and the Windows containers reported an EMPTY code and had a working uv
# read as broken. The parameterless wait settles it and returns at once, since
# the process has already exited. No code at all is still no verdict.
try { $proc.WaitForExit() } catch {}
$code = $null
try { $code = $proc.ExitCode } catch {}
if ($null -eq $code -or "$code" -eq "") {
substep "uv --version gave no exit code; installing it unprobed." "Yellow"
return "unknown"
}
if ($code -eq 0) { return "ok" }
$detail = ""
try {
$detail = Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue
} catch {}
if ($detail) { $detail = " " + (($detail.Trim()) -replace '\s+', ' ') }
substep "uv --version exited $code.$detail" "Yellow"
return "failed"
} catch {
substep "could not probe uv: $($_.Exception.Message); installing it unprobed." "Yellow"
return "unknown"
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
}
# Fallback for hosts without winget. Same archive, destination and user-PATH
# prepend as astral's install.ps1, but it fetches a data file with a pinned
# SHA-256 instead of script text run in-process, which is what AMSI and cloud
@ -2538,10 +2614,14 @@ exit 0
$destDir = Join-Path $userHome ".local\bin"
}
# Same mirrors and precedence as astral's installer; each serves the
# identical asset, so the pin holds across all of them. UV_DOWNLOAD_URL is
# not honoured: it points at an arbitrary version the pin would reject.
$uvBase = if ($env:UV_INSTALLER_GHE_BASE_URL) {
# astral's sources in astral's order, each exclusive when set: a host that sets one
# usually cannot reach the public endpoints at all, so trying those first would stall.
# The pin still applies, so a source serving a different build fails the digest.
$uvBase = if ($env:UV_DOWNLOAD_URL) {
@("$($env:UV_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:INSTALLER_DOWNLOAD_URL) {
@("$($env:INSTALLER_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:UV_INSTALLER_GHE_BASE_URL) {
@("$($env:UV_INSTALLER_GHE_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
} elseif ($env:UV_INSTALLER_GITHUB_BASE_URL) {
@("$($env:UV_INSTALLER_GITHUB_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
@ -2554,39 +2634,77 @@ exit 0
$zip = Join-Path $work $asset
try {
[System.IO.Directory]::CreateDirectory($work) | Out-Null
# Digest per mirror, not once after the loop: a proxy answering 200 with its own
# body is a successful download by every measure Invoke-WebRequest has, and checking
# afterwards spends the only attempt on it.
$downloaded = $false
foreach ($base in $uvBase) {
substep "downloading uv $UvPinnedVersion ($arch) from $base..." "Yellow"
try {
Invoke-WebRequest -UseBasicParsing -OutFile $zip -Uri "$base/$asset"
$downloaded = $true
break
} catch {
substep "uv download failed: $($_.Exception.Message)" "Yellow"
continue
}
$actual = ""
try { $actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash } catch {}
if ($actual -eq $wanted) {
$downloaded = $true
break
}
}
if (-not $downloaded) { return $false }
$actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash
if ($actual -ne $wanted) {
substep "uv download failed checksum verification -- discarding it." "Red"
substep "expected $wanted, got $actual" "Red"
return $false
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
}
if (-not $downloaded) { return $false }
# The Windows archives are flat: uv.exe, uvx.exe, uvw.exe at the root.
Expand-Archive -LiteralPath $zip -DestinationPath $work -Force
[System.IO.Directory]::CreateDirectory($destDir) | Out-Null
$haveUv = $false
$stagedUv = Join-Path $work "uv.exe"
if (-not (Test-Path -LiteralPath $stagedUv)) {
substep "uv.exe was not present in $asset." "Yellow"
return $false
}
# Run it where it landed, before the destination is touched. A host can have a
# working older uv while AppLocker, WDAC or endpoint protection refuses this one, and
# copying first would leave the user with neither. A policy scoped to the destination
# path is not covered here: the caller's fallback handles it.
if ((Get-UvExecutableVerdict -Path $stagedUv) -eq "failed") {
substep "the downloaded uv $UvPinnedVersion could not run on this machine." "Yellow"
return $false
}
# uvw.exe is the windowless launcher and has no console to answer a probe on, so
# the staged uv.exe above stands for the set: it came from the same verified
# archive. Copy-Item under Stop so a locked or ACL-denied destination fails the
# install rather than leaving half a set behind quietly.
$ok = $true
foreach ($exe in @("uv.exe", "uvx.exe", "uvw.exe")) {
$src = Join-Path $work $exe
if (Test-Path -LiteralPath $src) {
Copy-Item -LiteralPath $src -Destination (Join-Path $destDir $exe) -Force
if ($exe -eq "uv.exe") { $haveUv = $true }
if (-not (Test-Path -LiteralPath $src)) { continue }
$dst = Join-Path $destDir $exe
try {
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
} catch {
$ok = $false
break
}
if ($exe -eq "uv.exe") {
# Copy-Item is non-terminating under some callers preference, so compare
# against the archive we verified: a stale uv.exe must not pass for ours.
$copied = $false
try {
$copied = (Test-Path -LiteralPath $dst) -and
(Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash -eq
(Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
} catch { $copied = $false }
if (-not $copied) { $ok = $false; break }
}
}
if (-not $haveUv) {
substep "uv.exe was not present in $asset." "Yellow"
if (-not $ok) {
substep "the downloaded uv $UvPinnedVersion could not run on this machine." "Yellow"
return $false
}
} finally {
@ -3642,7 +3760,7 @@ exit 0
# qualify, UHD / HD / Iris Xe do not.
$HasIntelGpu = $false
$IntelGpuLabel = $null
# Reset every invocation: under "irm ... | iex" $script: is the caller's session scope, so a
# Reset every invocation: under a piped web run $script: is the caller's session scope, so a
# second run would inherit a stale $true and reroute a now-NVIDIA host to the xpu index.
$script:IsIntelXpu = $false
# $AmdHasGpuWheels keeps a wheel-served AMD host out of the XPU reroute below; an AMD host

View file

@ -2,17 +2,16 @@
#
# Unsloth Studio Installer
#
# Usage: curl -fsSL https://unsloth.ai/install.sh | sh
# wget -qO- https://unsloth.ai/install.sh | sh
# ./install.sh --local (install from a cloned repo instead of PyPI)
# Usage, supported options and the web one-liner are documented in the repository README under
# "Unsloth Studio (web UI)": https://github.com/unslothai/unsloth#unsloth-studio-web-ui.
# They are not repeated here: this file ships inside the Linux desktop bundle, where a header
# rehearsing download-and-run command lines is the first thing a generic script classifier reads,
# and nothing in the script consults it.
#
# Piped installs take options as env vars after the pipe (a bare `| sh --no-torch`
# makes sh reject --no-torch as its own option). Flags still work via ./install.sh:
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only)
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh # do not prompt to launch
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version
# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
# Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch)
# A piped install takes options as environment variables after the pipe (UNSLOTH_NO_TORCH,
# UNSLOTH_SKIP_AUTOSTART, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME) because a bare `--no-torch` after
# the pipe would be read as an option to sh itself; a local run takes the equivalent flags
# (--no-torch, --python, --local).
#
# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $HOME/.unsloth/studio
#
@ -20,7 +19,7 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
set -e
# ── Why the installer lives in a function ──
# Under `curl ... | sh`, sh is the pipe READER. This file is ~150KB, so a top-level
# Under a piped web install, sh is the pipe READER. This file is ~150KB, so a top-level
# `exit` left most of it unread, the write end failed, and curl tacked
# "(56) Failure writing output to destination" onto our own error message. Wrapping
# the body forces sh to parse to the closing brace first, so the pipe always drains
@ -595,6 +594,12 @@ _resolve_studio_destinations() {
_STUDIO_HOME_REDIRECT=default
}
_resolve_studio_destinations
# The PATH we inherited, before anything below prepends to it. The shim setup at the end asks
# whether a NEW login shell will find _LOCAL_BIN, and by then this process has prepended it
# several times (uv bootstrap, venv), so testing $PATH there answers yes for a shell that would
# answer no and the profile entry never gets written. astral's installer used to write that line
# for us; the pinned path does not.
_UNSLOTH_LOGIN_PATH="$PATH"
VENV_DIR="$STUDIO_HOME/unsloth_studio"
_VENV_ROLLBACK_DIR=""
_VENV_ROLLBACK_TARGET="$VENV_DIR"
@ -719,6 +724,11 @@ _cleanup_install_temporaries() {
[ -n "${_UV_OVERRIDE_TMPDIR:-}" ] && rm -rf "$_UV_OVERRIDE_TMPDIR" 2>/dev/null || true
[ -n "${_UV_INSTALL_NAME_TOOL_SHIM_DIR:-}" ] && rm -rf "$_UV_INSTALL_NAME_TOOL_SHIM_DIR" 2>/dev/null || true
[ -n "${_UNSLOTH_TORCH_OVERRIDES:-}" ] && rm -f "$_UNSLOTH_TORCH_OVERRIDES" 2>/dev/null || true
# The pinned uv path's own cleanup only runs when that function returns, so a Ctrl-C left
# the unpacked archive behind plus a staging file inside a directory that is on PATH.
[ -n "${_UIP_WORK:-}" ] && rm -rf "$_UIP_WORK" 2>/dev/null || true
[ -n "${_UIP_STAGE:-}" ] && rm -f "$_UIP_STAGE" 2>/dev/null || true
[ -n "${_UIP_STAGE2:-}" ] && rm -f "$_UIP_STAGE2" 2>/dev/null || true
}
_on_install_exit() {
@ -746,6 +756,9 @@ _on_install_signal() {
_UV_OVERRIDE_TMPDIR=""
_UV_INSTALL_NAME_TOOL_SHIM_DIR=""
_UNSLOTH_TORCH_OVERRIDES=""
_UIP_WORK=""
_UIP_STAGE=""
_UIP_STAGE2=""
trap _on_install_exit EXIT
trap '_on_install_signal 129' HUP
trap '_on_install_signal 130' INT
@ -2048,7 +2061,7 @@ _maybe_reroute_strixhalo_to_2404() {
echo ""
substep "ROCm-on-WSL (GPU) needs Ubuntu 24.04; this distro is Ubuntu ${_rr_ver:-unknown}." "$C_WARN"
substep "Found an existing $_rr_target distro -- continuing the GPU install there." "$C_OK"
# A --local checkout can't be replayed via curl|sh (the repo isn't in the target
# A --local checkout can't be replayed by a piped web install (the repo isn't in the target
# distro), so tell the user to re-run there rather than silently run a different install.
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep "This is a --local install; re-run it from $_rr_target instead:" "$C_WARN"
@ -2083,7 +2096,7 @@ _maybe_reroute_strixhalo_to_2404() {
else
_rr_cmd="curl -fsSL https://unsloth.ai/install.sh | sh"
fi
# pipefail so a failed curl in `curl | sh` isn't masked by sh exiting 0 on empty
# pipefail so a failed download in a piped web install isn't masked by sh exiting 0 on empty
# input (which would wrongly report success and exit 0 the parent installer).
_rr_rc=0
wsl.exe -d "$_rr_target" -- bash -lc "$_rr_exports; $_rr_cmd" || _rr_rc=$?
@ -2407,6 +2420,213 @@ _uv_version_ok() { # uv command, floor (defaults to UV_MIN_VERSION)
return 0
}
# ── uv from a pinned release ──
# Same archive, destination and PATH treatment as astral's installer, but it fetches a
# data file with a pinned SHA-256 instead of a script it runs and deletes. Mirrors
# Install-UvFromRelease in install.ps1. Bumping the version means bumping every hash:
# curl -sL https://github.com/astral-sh/uv/releases/download/<ver>/<asset>.sha256
#
# Only the four mainstream targets are pinned. musl, armv7 and the rest fall through to
# the caller's existing path rather than risk a wrong triple.
UV_PINNED_VERSION="0.12.1"
# Echoes the glibc minor version (the N in 2.N), or nothing when this is not a glibc host or
# the version cannot be read. "not musl" is not the same as "a glibc new enough to run the GNU
# build": astral's installer checks a minimum and drops to its musl-static archive below it, so
# a host we cannot positively confirm has to reach the fallback rather than take a binary that
# will not exec.
_uv_glibc_minor() {
_ugm_line=$( (ldd --version 2>/dev/null || true) | head -1 )
case "$_ugm_line" in *[Mm]usl*) return 1 ;; esac
_ugm_ver=$(printf '%s\n' "$_ugm_line" | awk '{print $NF}')
# getconf is the fallback for an ldd that prints no version, and for hosts with no ldd.
case "$_ugm_ver" in
2.[0-9]*) : ;;
*) _ugm_ver=$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}') ;;
esac
case "$_ugm_ver" in 2.[0-9]*) : ;; *) return 1 ;; esac
_ugm_minor=${_ugm_ver#2.}
_ugm_minor=${_ugm_minor%%.*}
case "$_ugm_minor" in "" | *[!0-9]*) return 1 ;; esac
echo "$_ugm_minor"
return 0
}
# Prints "<asset> <sha256>" for this host, or nothing when the host is not pinned.
_uv_pinned_asset() {
_upa_os=$(uname -s 2>/dev/null || echo unknown)
_upa_arch=$(uname -m 2>/dev/null || echo unknown)
case "$_upa_os" in
Linux)
# A 64-bit kernel under a 32-bit userland reports x86_64 from uname but cannot load
# a 64-bit binary, so ask the userland, not the kernel.
[ "$(getconf LONG_BIT 2>/dev/null || echo 0)" = "64" ] || return 1
# Rejects musl, an unreadable libc, and a glibc below astral's floor for the triple.
_upa_glibc=$(_uv_glibc_minor) || return 1
case "$_upa_arch" in
x86_64|amd64)
[ "$_upa_glibc" -ge 17 ] 2>/dev/null || return 1
echo "uv-x86_64-unknown-linux-gnu.tar.gz 90b2f223fb69d19db49e117da601f64978593417988530aa733d456141b4bcbb" ;;
aarch64|arm64)
[ "$_upa_glibc" -ge 28 ] 2>/dev/null || return 1
echo "uv-aarch64-unknown-linux-gnu.tar.gz 769d373e146692c639b5fbaae33b331c297a32e03d30448772051902df52bbf4" ;;
*) return 1 ;;
esac
;;
Darwin)
# Under Rosetta 2 a translated shell reports x86_64 on an Apple Silicon Mac. astral
# reads the same sysctl and ships the native build; matching it keeps the uv the user
# ends up with identical to the one they had before.
if [ "$_upa_arch" = "x86_64" ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
_upa_arch=arm64
fi
case "$_upa_arch" in
x86_64)
echo "uv-x86_64-apple-darwin.tar.gz 69d9f9a00337f25a50dcb13882052da08b8469bac11091c98c5694c3c6721467" ;;
arm64|aarch64)
echo "uv-aarch64-apple-darwin.tar.gz 77d2906988e8074fd43f2f329ec452ebbf9b0c257ba1c66451c71de70a6baf42" ;;
*) return 1 ;;
esac
;;
*) return 1 ;;
esac
return 0
}
# Echoes the SHA-256 of "$1", or nothing when the host has no digest tool.
_uv_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
fi
}
# Can a freshly downloaded binary run at all? Both ways it could hang are closed off: no stdin,
# so a build that prompts reads EOF, and a ceiling where `timeout` exists (stock macOS has none).
# A healthy uv answers in milliseconds, so only a binary we would refuse reaches the ceiling.
_uv_probe_exec() {
if command -v timeout >/dev/null 2>&1; then
timeout 20 "$1" --version >/dev/null 2>&1 </dev/null
else
"$1" --version >/dev/null 2>&1 </dev/null
fi
}
_uv_install_pinned() {
_uip_spec=$(_uv_pinned_asset) || return 1
[ -n "$_uip_spec" ] || return 1
_uip_asset=${_uip_spec%% *}
_uip_want=${_uip_spec##* }
# Unverified is worth less than astral's own release flow, so decline instead.
command -v tar >/dev/null 2>&1 || return 1
if [ -z "$(_uv_sha256 /dev/null)" ]; then return 1; fi
# astral's destination priority, so an existing uv is replaced in place and the
# PATH lines below still find it.
_uip_dest=""
for _uip_candidate in "${UV_INSTALL_DIR:-}" "${UV_UNMANAGED_INSTALL:-}" "${XDG_BIN_HOME:-}"; do
if [ -n "$_uip_candidate" ]; then _uip_dest="$_uip_candidate"; break; fi
done
if [ -z "$_uip_dest" ] && [ -n "${XDG_DATA_HOME:-}" ]; then _uip_dest="$XDG_DATA_HOME/../bin"; fi
if [ -z "$_uip_dest" ]; then
[ -n "${HOME:-}" ] || return 1
_uip_dest="$HOME/.local/bin"
fi
# 2>/dev/null: this is a speculative attempt whose failure falls back to astral's
# installer, so an unusable $TMPDIR must not print a line the user cannot act on.
_uip_work=$(mktemp -d 2>/dev/null) || return 1
_UIP_WORK="$_uip_work"
_uip_rc=1
# astral's mirrors and precedence; each serves the identical asset, so one pin holds. A
# configured mirror is EXCLUSIVE, as it is for astral: a restricted network sets one because
# the public hosts are unreachable, and download() has no timeout, so trying them first
# would hang rather than fall through.
if [ -n "${UV_DOWNLOAD_URL:-}" ]; then
_uip_bases="${UV_DOWNLOAD_URL%/}"
elif [ -n "${INSTALLER_DOWNLOAD_URL:-}" ]; then
_uip_bases="${INSTALLER_DOWNLOAD_URL%/}"
elif [ -n "${UV_INSTALLER_GHE_BASE_URL:-}" ]; then
_uip_bases="${UV_INSTALLER_GHE_BASE_URL%/}/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
elif [ -n "${UV_INSTALLER_GITHUB_BASE_URL:-}" ]; then
_uip_bases="${UV_INSTALLER_GITHUB_BASE_URL%/}/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
else
_uip_bases="https://releases.astral.sh/github/uv/releases/download/$UV_PINNED_VERSION
https://github.com/astral-sh/uv/releases/download/$UV_PINNED_VERSION"
fi
for _uip_base in $_uip_bases; do
# 2>/dev/null: curl -sS prints its own errors and these attempts are speculative, so an
# unreachable mirror stays off the console when the install still succeeds.
if ! download "$_uip_base/$_uip_asset" "$_uip_work/$_uip_asset" 2>/dev/null; then continue; fi
_uip_got=$(_uv_sha256 "$_uip_work/$_uip_asset")
if [ "$_uip_got" != "$_uip_want" ]; then
# Not tauri_log: [TAURI:WARN] is a marker install.sh has never emitted, and the app
# forwards unknown markers to its progress UI verbatim. Verbose only, since the next
# mirror or the fallback still runs.
if _is_verbose; then
echo "uv archive digest mismatch from $_uip_base, trying the next source" >&2
fi
continue
fi
# The POSIX archives hold uv and uvx under a uv-<triple>/ directory.
if ! tar -xzf "$_uip_work/$_uip_asset" -C "$_uip_work" 2>/dev/null; then continue; fi
mkdir -p "$_uip_dest" 2>/dev/null || break
_uip_placed=0
# uv first, and either half failing aborts the placement: the two ship as a set, and a
# pinned uvx beside the host's older uv is a pairing we never built or tested.
# Stage both, then publish both: the renames sit next to each other so the pair is
# replaced as one, and a failure anywhere before them leaves the destination untouched.
_uip_ready=1
for _uip_exe in uv uvx; do
# `mv f d` moves f INTO d and reports success, and a searchable directory passes -x
# too, so a directory called uv at the destination would look like a published
# binary and skip the fallback. The installer already refuses one for its own shim.
if [ -d "$_uip_dest/$_uip_exe" ]; then _uip_ready=0; break; fi
_uip_src=$(find "$_uip_work" -type f -name "$_uip_exe" 2>/dev/null | head -1)
if [ -z "$_uip_src" ] || [ ! -f "$_uip_src" ]; then _uip_ready=0; break; fi
# cp onto a symlinked destination writes through it and would rewrite, say, the
# Homebrew binary `~/.local/bin/uv` points at; rename replaces the link. mktemp, not
# a fixed name, so two installers racing here cannot publish each other's file.
_uip_stage=$(mktemp "$_uip_dest/.$_uip_exe.XXXXXX" 2>/dev/null) || { _uip_ready=0; break; }
if [ "$_uip_exe" = "uv" ]; then _UIP_STAGE="$_uip_stage"; else _UIP_STAGE2="$_uip_stage"; fi
if ! cp -f "$_uip_src" "$_uip_stage" 2>/dev/null; then _uip_ready=0; break; fi
# 0755, not +x: the staging file carries the umask default and +x only adds execute
# where read was allowed, so umask 077 would leave uv unusable for every other
# account. astral ships these 0755.
chmod 0755 "$_uip_stage" 2>/dev/null || true
# Validate BEFORE publishing: the rename destroys the incumbent, and a missing loader
# or a noexec mount would leave the host with neither. The staging file is on the
# destination filesystem, so this answers noexec too.
if [ "$_uip_exe" = "uv" ] && ! _uv_probe_exec "$_uip_stage"; then _uip_ready=0; break; fi
done
if [ "$_uip_ready" = "1" ] &&
mv -f "$_UIP_STAGE" "$_uip_dest/uv" 2>/dev/null &&
mv -f "$_UIP_STAGE2" "$_uip_dest/uvx" 2>/dev/null; then
_uip_placed=1
fi
rm -f "$_UIP_STAGE" "$_UIP_STAGE2" 2>/dev/null || true
_UIP_STAGE=""
_UIP_STAGE2=""
# The staged binary already answered --version above, before it replaced anything.
if [ "$_uip_placed" = "1" ] && [ -x "$_uip_dest/uv" ]; then
export PATH="$_uip_dest:$PATH"
# Where uv landed, for the profile write below: UV_INSTALL_DIR and friends can put
# it outside ~/.local/bin, and that directory has to reach a new shell too.
_UNSLOTH_UV_BIN_DIR="$_uip_dest"
_uip_rc=0
fi
break
done
rm -rf "$_uip_work"
_UIP_WORK=""
_UIP_STAGE=""
_UIP_STAGE2=""
# Nothing is unwound on the failure path on purpose: the fallback installs over whatever is
# at the destination, and deleting there would take out a working uv the host already had.
return "$_uip_rc"
}
if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
# Raising the floor pulled every 0.8.16-0.9.2 host into this block, and those
# installs used to succeed without touching the network, so a download
@ -2429,13 +2649,21 @@ if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
# which an `if` cannot catch, so probe first: a minimal image with uv copied
# in but no downloader must keep the install it had before the floor moved.
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
_uv_tmp=$(mktemp)
if download "https://astral.sh/uv/install.sh" "$_uv_tmp"; then
run_maybe_quiet sh "$_uv_tmp" </dev/null || _uv_refreshed=false
# Pinned release first: a digest-checked data file scores far lower than
# download-run-delete, which is the literal shape of a dropper.
if _uv_install_pinned; then
:
else
_uv_refreshed=false
# Unpinned hosts keep the path they have always had: a wrong triple
# breaks the install outright, which costs more than the fallback's score.
_uv_tmp=$(mktemp)
if download "https://astral.sh/uv/install.sh" "$_uv_tmp"; then
run_maybe_quiet sh "$_uv_tmp" </dev/null || _uv_refreshed=false
else
_uv_refreshed=false
fi
rm -f "$_uv_tmp"
fi
rm -f "$_uv_tmp"
else
_uv_refreshed=false
fi
@ -2449,6 +2677,12 @@ if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
. "$HOME/.local/bin/env"
fi
export PATH="$HOME/.local/bin:$PATH"
# ...and put the pinned destination back in front. UV_INSTALL_DIR and friends can put uv
# somewhere other than ~/.local/bin, and both the line above and astral's env file prepend
# ~/.local/bin, so a stale uv there would shadow the 0.12.1 we just verified.
if [ -n "${_UNSLOTH_UV_BIN_DIR:-}" ] && [ "$_UNSLOTH_UV_BIN_DIR" != "$HOME/.local/bin" ]; then
export PATH="$_UNSLOTH_UV_BIN_DIR:$PATH"
fi
fi
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
@ -2761,7 +2995,7 @@ TORCHAUDIO_CONSTRAINT="torchaudio>=2.4,<2.11.0"
# ── Resolve repo root (for --local installs) ──
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
# Whether the scripts next to install.sh may be trusted. A piped install (curl ... | sh)
# Whether the scripts next to install.sh may be trusted. A piped web install
# has $0 = "sh", so _REPO_ROOT is just the caller's cwd and a file planted there would run.
# Marker files cannot decide this (whoever can plant a helper can plant those), so require
# the explicit --local intent AND a run from the file itself; else fetch the official copy.
@ -3965,7 +4199,7 @@ _maybe_bootstrap_rocm_wsl() {
# Consent: the narrow guarded case is exactly the GPU setup the user ran the
# installer for, so it proceeds AUTOMATICALLY by default (works with no TTY,
# e.g. `curl ... | sh`). Opt out via UNSLOTH_SKIP_ROCM_WSL_SETUP=1 (top of
# e.g. a piped web install). Opt out via UNSLOTH_SKIP_ROCM_WSL_SETUP=1 (top of
# function). The Tauri app drives its own consent UI, so under TAURI_MODE it
# only runs when the app passes UNSLOTH_ROCM_WSL_AUTO=1; else surface and wait.
_rw_go=1
@ -5166,33 +5400,144 @@ fi
# the shim path (the directory guard above already rejects a real directory).
ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"
case ":$PATH:" in
*":$_LOCAL_BIN:"*) ;; # already on PATH
*)
# Is $2 one of the colon-separated entries of $1? Field splitting also globs, so pathname
# expansion is off for the walk and restored afterwards: a directory holding *, ? or [ would
# otherwise match an unrelated entry and the persistence would be skipped.
_path_has_dir() {
_phd_glob=on
case $- in *f*) _phd_glob=off ;; esac
set -f
_phd_found=1
_phd_old_ifs="$IFS"
IFS=:
for _phd_entry in $1; do
if [ "$_phd_entry" = "$2" ]; then _phd_found=0; break; fi
done
IFS="$_phd_old_ifs"
[ "$_phd_glob" = on ] && set +f
return "$_phd_found"
}
# fish reads none of the POSIX rc files, so an `export` line is a no-op for a fish user: the
# next session resolves neither uv nor the shim. conf.d is fish's own drop-in directory and
# fish_add_path is idempotent by design. ~/.config, not XDG_CONFIG_HOME, because that is where
# astral's installer put its own fish file.
_persist_fish_path_dir() {
_pfp_dir="$1"; _pfp_label="${2:-$1}"
[ -n "${HOME:-}" ] || return 0
_pfp_dir_conf="$HOME/.config/fish/conf.d"
mkdir -p "$_pfp_dir_conf" 2>/dev/null || return 0
_pfp_file="$_pfp_dir_conf/unsloth.fish"
# Single-quoted: an unquoted path with a space is two arguments to fish_add_path and
# neither exists. Inside fish single quotes only \\ and \' carry meaning.
_pfp_quoted=$(printf '%s' "$_pfp_dir" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
# The exact line we would write, not any occurrence of the directory: /opt/uv-old must not
# pass for /opt/uv, and fish reads none of the POSIX files that would otherwise cover it.
if ! grep -v '^[[:space:]]*#' "$_pfp_file" 2>/dev/null | grep -qxF "fish_add_path '$_pfp_quoted'"; then
echo "# Added by Unsloth installer" >> "$_pfp_file"
echo "fish_add_path '$_pfp_quoted'" >> "$_pfp_file"
step "path" "added $_pfp_label to PATH in $_pfp_file"
fi
}
# A line that SETS PATH, as opposed to one that merely names the directory. The name boundary
# keeps PYTHONPATH and friends out; the three helpers are the common non-assignment spellings.
_PATH_LINE_RE='(^|[^[:alnum:]_])(PATH[[:space:]]*=|fish_add_path|pathmunge|path_helper)'
# Put a directory on the PATH of the NEXT shell, not just this process.
# $1 the directory $2 the rc-file literal (~/.local/bin keeps $HOME unexpanded, as it always
# has) $3 how to name it in the line we print $4 the grep that says it is already there
# $5 an explicit profile file, or empty to pick one the way this installer always has
_persist_login_path_dir() {
_plp_dir="$1"; _plp_literal="$2"; _plp_label="$3"; _plp_pattern="$4"; _plp_file="${5:-}"
[ -n "${HOME:-}" ] || return 0
# fish reads none of the POSIX rc files, so an `export` line there is a no-op for a fish
# user: the next session resolves neither uv nor the shim. conf.d is fish's own drop-in
# directory and fish_add_path is idempotent by design.
if [ -z "$_plp_file" ] && [ "$(basename "${SHELL:-}")" = "fish" ]; then
_persist_fish_path_dir "$_plp_dir" "$_plp_label"
return 0
fi
_SHELL_PROFILE="$_plp_file"
if [ -n "$_SHELL_PROFILE" ]; then
:
elif [ -n "${ZSH_VERSION:-}" ] || [ "$(basename "${SHELL:-}")" = "zsh" ]; then
_SHELL_PROFILE="${ZDOTDIR:-$HOME}/.zshrc"
elif [ -f "$HOME/.bashrc" ]; then
_SHELL_PROFILE="$HOME/.bashrc"
elif [ -f "$HOME/.profile" ]; then
_SHELL_PROFILE="$HOME/.profile"
elif [ -w "$HOME" ]; then
# A fresh account can have no rc file at all: astral's installer used to create one,
# the pinned path does not. The append creates it, and every POSIX login shell reads
# ~/.profile.
_SHELL_PROFILE="$HOME/.profile"
fi
[ -n "$_SHELL_PROFILE" ] || return 0
# Comments stripped first, then only lines that actually set PATH: a commented-out old export
# is not an active entry, and neither is `UV_CACHE=/opt/uv` or `PYTHONPATH=/opt/uv`. The name
# boundary is what keeps PYTHONPATH out. Taking any of them for a PATH entry leaves the next
# shell with no uv at all.
if ! grep -v '^[[:space:]]*#' "$_SHELL_PROFILE" 2>/dev/null \
| grep -E "$_PATH_LINE_RE" | grep -qE "$_plp_pattern"; then
echo '' >> "$_SHELL_PROFILE"
echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE"
echo "export PATH=\"$_plp_literal:\$PATH\"" >> "$_SHELL_PROFILE"
step "path" "added $_plp_label to PATH in $_SHELL_PROFILE"
fi
}
if ! _path_has_dir "$_UNSLOTH_LOGIN_PATH" "$_LOCAL_BIN"; then # not on a new shell's PATH
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
export PATH="$_LOCAL_BIN:$PATH"
step "path" "exported $_LOCAL_BIN for this session (no rc-file append in env-override mode)"
else
_SHELL_PROFILE=""
if [ -n "${ZSH_VERSION:-}" ] || [ "$(basename "${SHELL:-}")" = "zsh" ]; then
_SHELL_PROFILE="$HOME/.zshrc"
elif [ -f "$HOME/.bashrc" ]; then
_SHELL_PROFILE="$HOME/.bashrc"
elif [ -f "$HOME/.profile" ]; then
_SHELL_PROFILE="$HOME/.profile"
fi
if [ -n "$_SHELL_PROFILE" ]; then
if ! grep -q '\.local/bin' "$_SHELL_PROFILE" 2>/dev/null; then
echo '' >> "$_SHELL_PROFILE"
echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE"
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_SHELL_PROFILE"
step "path" "added ~/.local/bin to PATH in $_SHELL_PROFILE"
fi
fi
_persist_login_path_dir "$_LOCAL_BIN" '$HOME/.local/bin' "~/.local/bin" '\.local/bin'
export PATH="$_LOCAL_BIN:$PATH"
fi
;;
esac
fi
# UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME all outrank ~/.local/bin,
# and astral's installer wrote a PATH line for whichever it picked, so replacing that installer
# means persisting its destination too. Both of astral's opt-outs are honoured. Not gated on the
# destination differing from ~/.local/bin: that IS the default, so gating there left every
# ordinary machine with the single-file write.
if [ -n "${_UNSLOTH_UV_BIN_DIR:-}" ] \
&& [ -z "${UV_NO_MODIFY_PATH:-}" ] && [ -z "${UV_UNMANAGED_INSTALL:-}" ] \
&& [ "$_STUDIO_HOME_REDIRECT" != "env" ]; then
if ! _path_has_dir "$_UNSLOTH_LOGIN_PATH" "$_UNSLOTH_UV_BIN_DIR"; then
# The rc line is double-quoted, so a path holding $, ` or " would be expanded or
# terminated by the shell that reads it. The ~/.local/bin literal is exempt: its
# $HOME is meant to stay unexpanded.
_uv_rc_literal=$(printf '%s' "$_UNSLOTH_UV_BIN_DIR" | sed 's/[\\"$`]/\\&/g')
# Anchored on both sides, so /opt/uv is not satisfied by /opt/uv-old and the match has
# to be a whole PATH entry rather than any occurrence of the text.
_uv_grep_esc=$(printf '%s' "$_UNSLOTH_UV_BIN_DIR" | sed 's/[].[\\()*+?{}|^$\/]/\\&/g')
# ...and the $HOME-relative spelling as well, because the shim block above writes
# `export PATH="$HOME/.local/bin:$PATH"` unexpanded. Without this the default install
# would add a second line for the same directory in the same file.
case "$_UNSLOTH_UV_BIN_DIR" in
"$HOME"/*)
_uv_grep_esc="$_uv_grep_esc|\\\$HOME$(printf '%s' "${_UNSLOTH_UV_BIN_DIR#$HOME}" | sed 's/[].[\\()*+?{}|^$\/]/\\&/g')"
;;
esac
_uv_pattern="(^|[^[:alnum:]_.~/-])($_uv_grep_esc)([^[:alnum:]_.~/-]|\$)"
# Every startup file astral's installer wired, because it is the installer we replaced.
# Writing only the file for the shell that happens to be running leaves a bash user whose
# .bash_profile does not source .bashrc, or anyone who later switches shells, without uv.
for _uv_prof in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.bash_profile" \
"$HOME/.bash_login" "${ZDOTDIR:-$HOME}/.zshrc" "${ZDOTDIR:-$HOME}/.zshenv"; do
# ~/.profile is created when absent, as astral does; the rest are only touched when
# the user already has them.
if [ "$_uv_prof" = "$HOME/.profile" ] || [ -f "$_uv_prof" ]; then
_persist_login_path_dir "$_UNSLOTH_UV_BIN_DIR" "$_uv_rc_literal" \
"$_UNSLOTH_UV_BIN_DIR" "$_uv_pattern" "$_uv_prof"
fi
done
_persist_fish_path_dir "$_UNSLOTH_UV_BIN_DIR"
fi
fi
# end of the PATH persistence block
# Non-Tauri installs keep shortcuts even if setup reports failure.
# create_studio_shortcuts gates persistent menu shortcuts on env-mode;
@ -5276,7 +5621,7 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then
case "${_reply:-y}" in
[Yy]*|"")
step "launch" "starting Unsloth Studio..."
# Detach stdin from the `curl | sh` pipe: as a foreground server the
# Detach stdin from the piped web install's pipe: as a foreground server the
# studio would otherwise drain the rest of this piped script, leaving
# the shell to die parsing the now-truncated tail (`unexpected fi`).
# trap '' INT: wait for studio's shutdown instead of racing the prompt.

View file

@ -4,13 +4,13 @@
# Unsloth Studio uninstaller for Windows PowerShell. Run -Help for details.
# Custom roots (UNSLOTH_STUDIO_HOME / STUDIO_HOME) come from share\studio.conf.
#
# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex
# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\scripts\uninstall.ps1
# Usage: run -Help. The web one-liner is in that help text and is not repeated here, since
# AMSI scans this file in full before any of it runs and nothing reads the header.
function Uninstall-UnslothStudio {
$ErrorActionPreference = "Continue"
# Reset at entry: `irm ... | iex` defines this function in the caller's session, so a
# Reset at entry: a piped web run defines this function in the caller's session, so a
# second run in the same window would otherwise inherit the first run's flags.
$script:RemoveFailed = $false
$script:StudioDbRemoved = $false

View file

@ -198,6 +198,43 @@ def _md_text(text: str) -> str:
SUMMARY_HEADING = "### VirusTotal release asset scan"
def submission_packet_lines(detected: Sequence[FileReport]) -> list[str]:
"""Pre-fill a false-positive submission for every flagged asset.
The build job assembles one too, but only for the Windows `-setup.exe`, so the detection
that actually arrived -- `Trojan:Script/Wacatac.B!ml` on the 0.1.701-beta Linux AppImage --
produced nothing to submit. Anything flagged here gets a packet, whatever built it.
Engine names are not repeated: third-party text, already escaped under "Flagging engines".
"""
lines = [
"",
"#### False-positive submission packet",
"",
"Submit each flagged asset before announcing this release.",
"",
"- Microsoft: <https://www.microsoft.com/en-us/wdsi/filesubmission>, "
"**Software developer** -> **Incorrectly detected as malware/malicious** "
"(50 MB cap; use <https://security.microsoft.com/reportsubmission> for larger bundles).",
"- Any other flagging vendor: use that vendor's own false-positive form. "
"Microsoft clearance does not carry across engines.",
"",
"| Asset | SHA-256 | Size |",
"| --- | --- | ---: |",
]
for report in detected:
lines.append(
f"| `{_md_code(report.name)}` | `{_md_code(report.sha256 or 'n/a')}` | "
f"{report.size} bytes |"
)
lines += [
"",
"> Clearance is per hash. It fixes the release you submit and nothing after it, so this",
"> is a complement to signing, not a substitute.",
]
return lines
def render_markdown(reports: Sequence[FileReport], threshold: int) -> str:
"""Render the job-summary table. Kept pure so it is unit testable."""
lines = [
@ -231,6 +268,13 @@ def render_markdown(reports: Sequence[FileReport], threshold: int) -> str:
for report in notes:
lines.append(f"- `{_md_code(report.name)}`: {_md_text(report.note)}")
# Keyed on the counts, not the engine list: stats and results are separate fields of the
# same response, so an asset can carry a flagged count with no readable results map. The
# table would report it and the packet would skip it, which is the one asset that needs one.
flagged = [report for report in reports if report.stats is not None and report.stats.flagged]
if flagged:
lines += submission_packet_lines(flagged)
lines += ["", "<details><summary>SHA-256</summary>", ""]
for report in reports:
lines.append(f"- `{_md_code(report.name)}`: `{_md_code(report.sha256 or 'n/a')}`")

View file

@ -39,9 +39,10 @@ $PackageDir = Split-Path -Parent $ScriptDir
# The 'Get-ExecutionPolicy' command was found in the module
# 'Microsoft.PowerShell.Security', but the module could not be loaded.
#
# astral's uv installer calls Get-ExecutionPolicy, and the run ends there with
# exit 1 and no further output. The try/catch around that call does not help,
# because Invoke-Expression runs the installer in this process.
# Any Security cmdlet reached during the run ends it there with exit 1 and no
# further output -- Get-AuthenticodeSignature, which verifies the VC++ runtime
# download, sits on this path. A try/catch does not help, because the failure is
# module loading in this process rather than an error the caller can catch.
#
# Prepended, not appended: the problem is precedence, not absence. Clearing the
# variable so 5.1 rebuilds its default does not help either, because the
@ -4336,6 +4337,217 @@ function Assert-VenvActivated {
Exit-SetupFailure "Activating $VenvDir did not put its interpreter on PATH (python resolves to $_where)"
}
# Mirrors install.ps1's Install-UvFromRelease: same archive, destination priority and user-PATH
# prepend as astral's installer, but it fetches a data file with a pinned SHA-256 instead of
# running remote script text in-process, which is what AMSI scores hardest. Bumping the version
# means bumping all 3 hashes:
# curl -sL https://github.com/astral-sh/uv/releases/download/<ver>/uv-<arch>-pc-windows-msvc.zip.sha256
$UvPinnedVersion = "0.12.1"
$UvPinnedAssets = @{
"x86_64" = @{ Asset = "uv-x86_64-pc-windows-msvc.zip"; Sha256 = "8FCB0CB46E1229065E344758980924E569BEF5882EF45F46FADA8FB24E06B74A" }
"arm64" = @{ Asset = "uv-aarch64-pc-windows-msvc.zip"; Sha256 = "9BC7C18E616230FA2DC6FB24BC3AFDE18A95C2B5C9433DE747E9502C66041568" }
"x86" = @{ Asset = "uv-i686-pc-windows-msvc.zip"; Sha256 = "9B51C33D307A8AB9E9DFD88D4AE1491761F63DE0BFFA3CEC96BEC536491C9B97" }
}
# Not Get-HostMachineArch: it answers arm64/other for the VC++ and prebuilt probes, and "other"
# cannot pick between the x86_64 and i686 archives. install.ps1's resolution order.
function Get-UvHostArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
$signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
foreach ($s in $signals) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
foreach ($s in $signals) {
if ([string]::IsNullOrWhiteSpace($s)) { continue }
switch ($s.ToLowerInvariant()) {
"amd64" { return "x86_64" }
"x64" { return "x86_64" }
"x86" { return "x86" }
}
}
return "unknown"
}
# Writes to the pipeline, not the console: under Invoke-SetupCommand a quiet run swallows this
# exactly as it swallowed astral's output, and a verbose run shows it. The console lines around
# the call site are unchanged.
function Get-SetupUvExecutableVerdict {
# Mirrors Get-UvExecutableVerdict in install.ps1: "ok", "failed" or "unknown". Only the
# binary answering non-zero is "failed"; a launch that throws or a wait that times out got
# no verdict, and the digest already proved the bytes are astral's pinned release.
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return "failed" }
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
try {
$proc = Start-Process -FilePath $Path -ArgumentList "--version" -NoNewWindow -PassThru `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile -ErrorAction Stop
if (-not $proc.WaitForExit(20000)) {
try { $proc.Kill() } catch {}
Write-Output "uv did not answer --version within 20s; installing it unprobed."
return "unknown"
}
# The timed overload can return before the exit code is cached, which is how
# arm64 and the Windows containers reported an EMPTY code and had a working uv
# read as broken. The parameterless wait settles it and returns at once, since
# the process has already exited. No code at all is still no verdict.
try { $proc.WaitForExit() } catch {}
$code = $null
try { $code = $proc.ExitCode } catch {}
if ($null -eq $code -or "$code" -eq "") {
Write-Output "uv --version gave no exit code; installing it unprobed."
return "unknown"
}
if ($code -eq 0) { return "ok" }
$detail = ""
try {
$detail = Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue
} catch {}
if ($detail) { $detail = " " + (($detail.Trim()) -replace '\s+', ' ') }
Write-Output "uv --version exited $code.$detail"
return "failed"
} catch {
Write-Output "could not probe uv: $($_.Exception.Message); installing it unprobed."
return "unknown"
} finally {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue
}
}
function Install-UvFromPinnedRelease {
$arch = Get-UvHostArch
if (-not $UvPinnedAssets.ContainsKey($arch)) {
Write-Output "No uv build is published for this architecture ($arch)."
return $false
}
$asset = $UvPinnedAssets[$arch].Asset
$wanted = $UvPinnedAssets[$arch].Sha256
# astral's destination priority, so an existing uv is replaced in place and the Get-Command
# probe after Refresh-Environment still finds it.
$destDir = $null
foreach ($candidate in @($env:UV_INSTALL_DIR, $env:UV_UNMANAGED_INSTALL, $env:XDG_BIN_HOME)) {
if ($candidate) { $destDir = $candidate; break }
}
if (-not $destDir -and $env:XDG_DATA_HOME) { $destDir = Join-Path $env:XDG_DATA_HOME "../bin" }
if (-not $destDir) {
$userHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
if (-not $userHome) {
Write-Output "Could not determine a home directory to install uv into."
return $false
}
$destDir = Join-Path $userHome ".local\bin"
}
# astral's sources in astral's order, each exclusive when set. UV_DOWNLOAD_URL (and its older
# alias INSTALLER_DOWNLOAD_URL) outrank the mirror variables there, and a host that sets one
# usually cannot reach the public endpoints at all, so trying those first would stall. The pin
# still applies: a source serving a different build fails the digest and the caller falls back.
$uvBase = if ($env:UV_DOWNLOAD_URL) {
@("$($env:UV_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:INSTALLER_DOWNLOAD_URL) {
@("$($env:INSTALLER_DOWNLOAD_URL.TrimEnd('/'))")
} elseif ($env:UV_INSTALLER_GHE_BASE_URL) {
@("$($env:UV_INSTALLER_GHE_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
} elseif ($env:UV_INSTALLER_GITHUB_BASE_URL) {
@("$($env:UV_INSTALLER_GITHUB_BASE_URL.TrimEnd('/'))/astral-sh/uv/releases/download/$UvPinnedVersion")
} else {
@("https://releases.astral.sh/github/uv/releases/download/$UvPinnedVersion",
"https://github.com/astral-sh/uv/releases/download/$UvPinnedVersion")
}
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("unsloth-uv-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$zip = Join-Path $work $asset
try {
[System.IO.Directory]::CreateDirectory($work) | Out-Null
# Digest per mirror, as install.ps1 does: a proxy answering 200 with its own body is a
# successful download by every measure Invoke-WebRequest has.
$downloaded = $false
foreach ($base in $uvBase) {
Write-Output "downloading uv $UvPinnedVersion ($arch) from $base..."
try {
Invoke-WebRequest -UseBasicParsing -OutFile $zip -Uri "$base/$asset"
} catch {
Write-Output "uv download failed: $($_.Exception.Message)"
continue
}
$actual = ""
try { $actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash } catch {}
if ($actual -eq $wanted) {
$downloaded = $true
break
}
Write-Output "uv download failed checksum verification -- discarding it."
Write-Output "expected $wanted, got $actual"
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
}
if (-not $downloaded) { return $false }
# The Windows archives are flat: uv.exe, uvx.exe, uvw.exe at the root.
Expand-Archive -LiteralPath $zip -DestinationPath $work -Force
[System.IO.Directory]::CreateDirectory($destDir) | Out-Null
$stagedUv = Join-Path $work "uv.exe"
if (-not (Test-Path -LiteralPath $stagedUv)) {
Write-Output "uv.exe was not present in $asset."
return $false
}
# Run it where it landed, before the destination is touched: a host can have a working
# older uv while a policy refuses this one, and copying first leaves it with neither.
if ((Get-SetupUvExecutableVerdict -Path $stagedUv) -eq "failed") {
Write-Output "the downloaded uv $UvPinnedVersion could not run on this machine."
return $false
}
# uvw.exe is the windowless launcher and has no console to answer a probe on, so the
# staged uv.exe above stands for the set: it came from the same verified archive.
# Copy-Item under Stop so a locked or ACL-denied destination fails the install rather
# than leaving half a set behind quietly.
$haveUv = $true
foreach ($exe in @("uv.exe", "uvx.exe", "uvw.exe")) {
$src = Join-Path $work $exe
if (-not (Test-Path -LiteralPath $src)) { continue }
$dst = Join-Path $destDir $exe
try {
Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop
} catch {
$haveUv = $false
break
}
if ($exe -eq "uv.exe") {
# Invoke-SetupCommand sets ErrorActionPreference to Continue, so compare
# against the archive we verified: a stale uv.exe must not pass for ours.
$copied = $false
try {
$copied = (Test-Path -LiteralPath $dst) -and
(Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash -eq
(Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
} catch { $copied = $false }
if (-not $copied) { $haveUv = $false; break }
}
}
if (-not $haveUv) {
Write-Output "uv.exe was not present in $asset."
return $false
}
} finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}
# astral's PATH treatment and opt-outs: an unmanaged install forces no-modify-path there, so
# it must here too. The user-PATH prepend is what survives the Refresh-Environment below.
if (-not $env:UV_NO_MODIFY_PATH -and -not $env:UV_UNMANAGED_INSTALL) {
Add-ToUserPath -Directory $destDir -Position Prepend | Out-Null
}
$env:PATH = "$destDir;$env:PATH"
# Recorded on the script scope as well as returned: the caller runs this through
# Invoke-SetupCommand, which hands back [int]$LASTEXITCODE rather than the pipeline value,
# so the return alone cannot tell the fallback whether to run.
$script:UvPinnedInstalled = $true
return $true
}
$ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1"
. $ActivateScript
Assert-VenvActivated -VenvDir $VenvDir
@ -4347,7 +4559,18 @@ if (Get-Command uv -ErrorAction SilentlyContinue) {
} else {
substep "installing uv package manager..."
try {
Invoke-SetupCommand { Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1") } | Out-Null
$script:UvPinnedInstalled = $false
Invoke-SetupCommand { Install-UvFromPinnedRelease } | Out-Null
# The merge base ran astral's installer here, so a failed pinned install needs somewhere
# to go: with no fallback the setup drops to pip for torch, bitsandbytes and Triton, which
# is a different resolver rather than a different download. winget, not the remote script,
# which is the shape this branch removes and is what install.ps1 already tries first.
if (-not $script:UvPinnedInstalled -and (Get-Command winget -ErrorAction SilentlyContinue)) {
Invoke-SetupCommand {
winget install --id astral-sh.uv --source winget --accept-source-agreements `
--accept-package-agreements --silent
} | Out-Null
}
Refresh-Environment
# Re-activate venv since Refresh-Environment rebuilds PATH from
# registry and drops the venv's Scripts directory

View file

@ -1477,17 +1477,287 @@ _setup_http_get_timed() {
fi
}
# ── uv from a pinned release ──
# Same archive and destination as astral's installer, but it fetches a data file with a
# pinned SHA-256 instead of piping remote script text into a shell. Mirrors install.sh.
# Bumping the version means bumping every hash:
# curl -sL https://github.com/astral-sh/uv/releases/download/<ver>/<asset>.sha256
#
# Only the four mainstream targets are pinned; the rest fall through to the existing path
# rather than risk a binary for the wrong triple.
_SETUP_UV_PINNED_VERSION="0.12.1"
# Mirrors _uv_glibc_minor in install.sh: "not musl" is not the same as "a glibc new enough to
# run the GNU build", and astral drops to its musl-static archive below its floor.
_setup_uv_glibc_minor() {
_sugm_line=$( (ldd --version 2>/dev/null || true) | head -1 )
case "$_sugm_line" in *[Mm]usl*) return 1 ;; esac
_sugm_ver=$(printf '%s\n' "$_sugm_line" | awk '{print $NF}')
case "$_sugm_ver" in
2.[0-9]*) : ;;
*) _sugm_ver=$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}') ;;
esac
case "$_sugm_ver" in 2.[0-9]*) : ;; *) return 1 ;; esac
_sugm_minor=${_sugm_ver#2.}
_sugm_minor=${_sugm_minor%%.*}
case "$_sugm_minor" in "" | *[!0-9]*) return 1 ;; esac
echo "$_sugm_minor"
return 0
}
_setup_uv_pinned_asset() {
_supa_os=$(uname -s 2>/dev/null || echo unknown)
_supa_arch=$(uname -m 2>/dev/null || echo unknown)
case "$_supa_os" in
Linux)
# A 32-bit userland on a 64-bit kernel still reports x86_64 from uname.
[ "$(getconf LONG_BIT 2>/dev/null || echo 0)" = "64" ] || return 1
_supa_glibc=$(_setup_uv_glibc_minor) || return 1
case "$_supa_arch" in
x86_64|amd64)
[ "$_supa_glibc" -ge 17 ] 2>/dev/null || return 1
echo "uv-x86_64-unknown-linux-gnu.tar.gz 90b2f223fb69d19db49e117da601f64978593417988530aa733d456141b4bcbb" ;;
aarch64|arm64)
[ "$_supa_glibc" -ge 28 ] 2>/dev/null || return 1
echo "uv-aarch64-unknown-linux-gnu.tar.gz 769d373e146692c639b5fbaae33b331c297a32e03d30448772051902df52bbf4" ;;
*) return 1 ;;
esac
;;
Darwin)
# Rosetta 2 reports x86_64 from a translated shell; astral reads the same sysctl.
if [ "$_supa_arch" = "x86_64" ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then
_supa_arch=arm64
fi
case "$_supa_arch" in
x86_64)
echo "uv-x86_64-apple-darwin.tar.gz 69d9f9a00337f25a50dcb13882052da08b8469bac11091c98c5694c3c6721467" ;;
arm64|aarch64)
echo "uv-aarch64-apple-darwin.tar.gz 77d2906988e8074fd43f2f329ec452ebbf9b0c257ba1c66451c71de70a6baf42" ;;
*) return 1 ;;
esac
;;
*) return 1 ;;
esac
return 0
}
_setup_uv_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
fi
}
# Bounded liveness probe: no stdin, so a build that prompts reads EOF, and a ceiling where
# `timeout` exists (stock macOS has none).
_setup_uv_probe_exec() {
if command -v timeout >/dev/null 2>&1; then
timeout 20 "$1" --version >/dev/null 2>&1 </dev/null
else
"$1" --version >/dev/null 2>&1 </dev/null
fi
}
# The function's own cleanup only runs when it returns, so an interrupt left the unpacked
# archive behind plus a staging file inside a directory on PATH. No trap is active here (the
# gitignore EXIT trap is cleared well above), so the pinned install owns these four for its
# duration and hands them back on the way out.
_setup_uv_cleanup_temporaries() {
[ -n "${_SIUP_WORK:-}" ] && rm -rf "$_SIUP_WORK" 2>/dev/null || true
[ -n "${_SIUP_STAGE:-}" ] && rm -f "$_SIUP_STAGE" 2>/dev/null || true
[ -n "${_SIUP_STAGE2:-}" ] && rm -f "$_SIUP_STAGE2" 2>/dev/null || true
}
_setup_uv_on_signal() {
trap - EXIT HUP INT TERM
_setup_uv_cleanup_temporaries
exit "$1"
}
# The PATH a new shell inherits, captured before the uv destination can be prepended to it.
_SETUP_LOGIN_PATH="$PATH"
_SIUP_WORK=""
_SIUP_STAGE=""
_SIUP_STAGE2=""
_setup_install_uv_pinned() {
_siup_spec=$(_setup_uv_pinned_asset) || return 1
[ -n "$_siup_spec" ] || return 1
_siup_asset=${_siup_spec%% *}
_siup_want=${_siup_spec##* }
command -v tar >/dev/null 2>&1 || return 1
[ -n "$(_setup_uv_sha256 /dev/null)" ] || return 1
[ -n "${HOME:-}" ] || return 1
# astral's full destination priority, including the XDG_DATA_HOME tier that sits between
# XDG_BIN_HOME and the home default. Dropping it would leave uv under ~/.local/bin on a
# host that configured an XDG location, where no later shell looks for it.
_siup_dest="${UV_INSTALL_DIR:-${UV_UNMANAGED_INSTALL:-${XDG_BIN_HOME:-}}}"
if [ -z "$_siup_dest" ] && [ -n "${XDG_DATA_HOME:-}" ]; then _siup_dest="$XDG_DATA_HOME/../bin"; fi
[ -n "$_siup_dest" ] || _siup_dest="$HOME/.local/bin"
# 2>/dev/null, as install.sh does: a speculative attempt whose failure falls back.
_siup_work=$(mktemp -d 2>/dev/null) || return 1
_SIUP_WORK="$_siup_work"
trap _setup_uv_cleanup_temporaries EXIT
trap '_setup_uv_on_signal 129' HUP
trap '_setup_uv_on_signal 130' INT
trap '_setup_uv_on_signal 143' TERM
_siup_rc=1
# A configured mirror is EXCLUSIVE, matching astral's installer and the PowerShell side: a
# restricted network sets one because the public hosts are unreachable, so trying those first
# would stall instead of falling through.
if [ -n "${UV_DOWNLOAD_URL:-}" ]; then
_siup_bases="${UV_DOWNLOAD_URL%/}"
elif [ -n "${INSTALLER_DOWNLOAD_URL:-}" ]; then
_siup_bases="${INSTALLER_DOWNLOAD_URL%/}"
elif [ -n "${UV_INSTALLER_GHE_BASE_URL:-}" ]; then
_siup_bases="${UV_INSTALLER_GHE_BASE_URL%/}/astral-sh/uv/releases/download/$_SETUP_UV_PINNED_VERSION"
elif [ -n "${UV_INSTALLER_GITHUB_BASE_URL:-}" ]; then
_siup_bases="${UV_INSTALLER_GITHUB_BASE_URL%/}/astral-sh/uv/releases/download/$_SETUP_UV_PINNED_VERSION"
else
_siup_bases="https://releases.astral.sh/github/uv/releases/download/$_SETUP_UV_PINNED_VERSION
https://github.com/astral-sh/uv/releases/download/$_SETUP_UV_PINNED_VERSION"
fi
for _siup_base in $_siup_bases; do
_setup_http_get "$_siup_base/$_siup_asset" > "$_siup_work/$_siup_asset" 2>/dev/null || continue
[ -s "$_siup_work/$_siup_asset" ] || continue
[ "$(_setup_uv_sha256 "$_siup_work/$_siup_asset")" = "$_siup_want" ] || continue
tar -xzf "$_siup_work/$_siup_asset" -C "$_siup_work" 2>/dev/null || continue
mkdir -p "$_siup_dest" 2>/dev/null || break
# Stage both, then publish both, as install.sh does: the renames sit next to each
# other so the pair is replaced as one.
_siup_ready=1
for _siup_exe in uv uvx; do
# `mv f d` moves f INTO d and reports success, and a searchable directory passes -x
# too, so a directory called uv at the destination would look like a published
# binary and skip the fallback. The installer already refuses one for its own shim.
if [ -d "$_siup_dest/$_siup_exe" ]; then _siup_ready=0; break; fi
_siup_src=$(find "$_siup_work" -type f -name "$_siup_exe" 2>/dev/null | head -1)
if [ -z "$_siup_src" ]; then _siup_ready=0; break; fi
# cp writes through a symlinked destination, and a per-process staging name keeps
# two racing installers from publishing each other's half-written file.
_siup_stage=$(mktemp "$_siup_dest/.$_siup_exe.XXXXXX" 2>/dev/null) || { _siup_ready=0; break; }
if [ "$_siup_exe" = "uv" ]; then _SIUP_STAGE="$_siup_stage"; else _SIUP_STAGE2="$_siup_stage"; fi
if ! cp -f "$_siup_src" "$_siup_stage" 2>/dev/null; then _siup_ready=0; break; fi
# 0755, not +x: the staging file carries the umask default and +x only adds execute
# where read was allowed, so umask 077 would leave uv unusable for every other
# account. astral ships these 0755.
chmod 0755 "$_siup_stage" 2>/dev/null || true
# Validate before publishing: the rename destroys the incumbent, so a binary that
# cannot run here must never replace one that could.
if [ "$_siup_exe" = "uv" ] && ! _setup_uv_probe_exec "$_siup_stage"; then _siup_ready=0; break; fi
done
if [ "$_siup_ready" = "1" ] &&
mv -f "$_SIUP_STAGE" "$_siup_dest/uv" 2>/dev/null &&
mv -f "$_SIUP_STAGE2" "$_siup_dest/uvx" 2>/dev/null; then
_siup_rc=0
fi
rm -f "$_SIUP_STAGE" "$_SIUP_STAGE2" 2>/dev/null || true
_SIUP_STAGE=""
_SIUP_STAGE2=""
break
done
rm -rf "$_siup_work"
_SIUP_WORK=""
_SIUP_STAGE=""
_SIUP_STAGE2=""
trap - EXIT HUP INT TERM
# The staged binary already answered --version above, before it replaced anything.
[ -x "$_siup_dest/uv" ] || _siup_rc=1
if [ "$_siup_rc" = "0" ]; then
export PATH="$_siup_dest:$PATH"
_setup_persist_uv_path "$_siup_dest"
fi
return "$_siup_rc"
}
# astral's installer wrote a profile line for whichever destination it chose. This replaces that
# installer, so setup.sh run directly (local or Colab) has to do the same or the export above
# dies with this shell and every later run reinstalls uv. Both of astral's opt-outs apply, and
# fish is handled on its own terms since it reads none of the POSIX rc files.
# Is $2 one of the colon-separated entries of $1? Field splitting also globs, so pathname
# expansion is off for the walk and restored afterwards.
_setup_path_has_dir() {
_sphd_glob=on
case $- in *f*) _sphd_glob=off ;; esac
set -f
_sphd_found=1
_sphd_old_ifs="$IFS"
IFS=:
for _sphd_entry in $1; do
if [ "$_sphd_entry" = "$2" ]; then _sphd_found=0; break; fi
done
IFS="$_sphd_old_ifs"
[ "$_sphd_glob" = on ] && set +f
return "$_sphd_found"
}
_setup_persist_uv_path() {
_supp_dir="$1"
[ -n "$_supp_dir" ] || return 0
[ -n "${HOME:-}" ] || return 0
[ -z "${UV_NO_MODIFY_PATH:-}" ] || return 0
[ -z "${UV_UNMANAGED_INSTALL:-}" ] || return 0
# The PATH a new shell inherits, not the one this process has already prepended to, and
# compared entry by entry: a directory holding *, ? or [ is a glob inside a case pattern.
_setup_path_has_dir "${_SETUP_LOGIN_PATH:-$PATH}" "$_supp_dir" && return 0
# ~/.config, not XDG_CONFIG_HOME, because that is where astral's installer put its own fish
# file, and it is written regardless of the current shell for the same reason.
_supp_fish_dir="$HOME/.config/fish/conf.d"
if mkdir -p "$_supp_fish_dir" 2>/dev/null; then
_supp_fish="$_supp_fish_dir/unsloth.fish"
# Single-quoted: an unquoted path with a space is two arguments to fish_add_path.
_supp_quoted=$(printf '%s' "$_supp_dir" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
# The exact line, not any occurrence: /opt/uv-old must not pass for /opt/uv.
if ! grep -v '^[[:space:]]*#' "$_supp_fish" 2>/dev/null | grep -qxF "fish_add_path '$_supp_quoted'"; then
echo "# Added by Unsloth setup" >> "$_supp_fish"
echo "fish_add_path '$_supp_quoted'" >> "$_supp_fish"
fi
fi
# An entry has to be active, whole and on a line that SETS PATH: a commented-out export,
# /opt/uv-old when we want /opt/uv, and PYTHONPATH=/opt/uv are none of them, and taking any
# for an entry leaves the next shell unable to resolve uv.
_supp_path_line='(^|[^[:alnum:]_])(PATH[[:space:]]*=|fish_add_path|pathmunge|path_helper)'
_supp_grep=$(printf '%s' "$_supp_dir" | sed 's/[].[\\()*+?{}|^$\/]/\\&/g')
# Escaped: the line is double-quoted, so a path holding $, ` or " would be expanded or
# terminated by the shell that reads it.
_supp_literal=$(printf '%s' "$_supp_dir" | sed 's/[\\"$`]/\\&/g')
# Every startup file astral's installer wired, because it is the installer this replaced:
# ~/.profile always, each bash file that exists, and zsh under ZDOTDIR. Writing only the
# file for the shell that happens to be running would leave a bash user whose .bash_profile
# does not source .bashrc, or anyone who later switches shells, without uv on PATH.
for _supp_profile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.bash_profile" \
"$HOME/.bash_login" "${ZDOTDIR:-$HOME}/.zshrc" "${ZDOTDIR:-$HOME}/.zshenv"; do
if [ "$_supp_profile" != "$HOME/.profile" ] && [ ! -f "$_supp_profile" ]; then continue; fi
# Only lines that actually set PATH count: `UV_CACHE=/opt/uv` and `PYTHONPATH=/opt/uv`
# are not PATH entries, and taking one for an entry leaves the next shell without uv.
if grep -v '^[[:space:]]*#' "$_supp_profile" 2>/dev/null \
| grep -E "$_supp_path_line" \
| grep -qE "(^|[^[:alnum:]_.~/-])$_supp_grep([^[:alnum:]_.~/-]|\$)"; then continue; fi
echo '' >> "$_supp_profile"
echo '# Added by Unsloth setup' >> "$_supp_profile"
echo "export PATH=\"$_supp_literal:\$PATH\"" >> "$_supp_profile"
done
}
USE_UV=false
if command -v uv &>/dev/null; then
USE_UV=true
elif {
if _is_verbose; then
_SETUP_UV_PINNED_OK=false
if _setup_install_uv_pinned; then
_SETUP_UV_PINNED_OK=true
elif _is_verbose; then
_setup_http_get https://astral.sh/uv/install.sh | sh
else
_setup_http_get https://astral.sh/uv/install.sh | sh > /dev/null 2>&1
fi
}; then
export PATH="$HOME/.local/bin:$PATH"
# Only for astral's installer, which writes to ~/.local/bin. The pinned path already put its
# own destination first, and prepending here would let a stale ~/.local/bin/uv shadow the
# 0.12.1 we just verified, so the rest of setup would run the wrong one.
[ "$_SETUP_UV_PINNED_OK" = true ] || export PATH="$HOME/.local/bin:$PATH"
command -v uv &>/dev/null && USE_UV=true
fi

View file

@ -1,7 +1,7 @@
use crate::diagnostics::{self, AttemptLog, DiagnosticsState};
use log::{error, info, warn};
use process_wrap::std::*;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
@ -31,6 +31,8 @@ use crate::process::trim_line_endings;
const FAILURE_CONTEXT_LINES: usize = 8;
const FAILURE_CONTEXT_LINE_BYTES: usize = 1_000;
/// Clear labels are a small fixed set; this only bounds a pathological producer.
const MAX_UNPAIRED_CLEARS: usize = 64;
fn generic_failure_message(code: i32) -> String {
format!(
@ -39,12 +41,111 @@ fn generic_failure_message(code: i32) -> String {
)
}
#[derive(Clone, Copy, PartialEq, Eq)]
/// PowerShell hands the whole top-level script block to AMSI while compiling it, so a security
/// product's verdict arrives as a parse error over the entire file before the installer's first
/// line runs: no `[TAURI:ERROR]` marker, no phase log, just an unexplained stderr tail. Match the
/// stable error id, never the localized message text.
const AMSI_MALWARE_ERROR_ID: &str = "ScriptContainedMaliciousContent";
const AMSI_ADMIN_BLOCK_ERROR_ID: &str = "ScriptHasAdminBlockedContent";
/// The id only means a verdict when it is the VALUE of a `FullyQualifiedErrorId` field, which is
/// how the parse error prints it. Otherwise a scanner log or a diagnostic that merely names the id
/// would attach antivirus guidance to an unrelated failure. A record split across writes loses the
/// guidance rather than inventing one, which is the safe direction.
const POWERSHELL_ERROR_ID_FIELD: &str = "FullyQualifiedErrorId";
/// "Nothing was changed" only holds for a block on install.ps1 itself, which AMSI rejects before
/// its first statement. It also runs `unsloth studio setup`, whose child spawns studio/setup.ps1
/// through the same pipes: a block there arrives with the venv, PyTorch and the packages already
/// on disk. A `[TAURI:STEP]` marker is the dividing line, since a pre-start block produces none.
/// Not "nothing was changed": a diagnostics attempt and its phase log are written before
/// PowerShell is spawned, so the honest claim is that no installation step ran. And not "this is a
/// false positive": classification proves only that the output carries an error id, and install.ps1
/// can sit in a user-writable directory.
const AMSI_MALWARE_GUIDANCE_PRE_START: &str = "Security software blocked the installer before it \
started, so no installation steps ran; only diagnostic logs may have been written. This is \
usually a false positive: reinstall from an official Unsloth package, and if an unmodified \
copy is still blocked, update your security product's definitions or report it to your \
vendor. Do not disable endpoint protection.";
const AMSI_MALWARE_GUIDANCE_IN_PROGRESS: &str = "Security software blocked part of the installer, \
so setup did not finish and some components may already be installed. This is usually a \
false positive: reinstall from an official Unsloth package, and if an unmodified copy is \
still blocked, update your security product's definitions or report it to your vendor. Do \
not disable endpoint protection.";
const AMSI_ADMIN_BLOCK_GUIDANCE_PRE_START: &str = "This machine's security policy blocked the \
installer before it started, so no installation steps ran. Ask whoever manages the device \
to allow it.";
const AMSI_ADMIN_BLOCK_GUIDANCE_IN_PROGRESS: &str = "This machine's security policy blocked part \
of the installer, so setup did not finish and some components may already be installed. Ask \
whoever manages the device to allow it.";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SecurityBlockKind {
Malware,
AdminPolicy,
}
impl SecurityBlockKind {
/// Resolved at message time, not observation time: the two streams are read by independent
/// threads, so a `[TAURI:STEP]` written before the block can be observed after it.
fn guidance(self, started: bool) -> &'static str {
match (self, started) {
(Self::Malware, false) => AMSI_MALWARE_GUIDANCE_PRE_START,
(Self::Malware, true) => AMSI_MALWARE_GUIDANCE_IN_PROGRESS,
(Self::AdminPolicy, false) => AMSI_ADMIN_BLOCK_GUIDANCE_PRE_START,
(Self::AdminPolicy, true) => AMSI_ADMIN_BLOCK_GUIDANCE_IN_PROGRESS,
}
}
}
/// True when `id` is the value of the `FullyQualifiedErrorId` field rather than merely present on
/// the line. PowerShell prints `+ FullyQualifiedErrorId : <id>[,<cmdlet>]`, so it follows the
/// colon; prose naming both does not qualify.
fn is_error_id_value(text: &str, id: &str) -> bool {
let mut rest = text;
while let Some(at) = rest.find(POWERSHELL_ERROR_ID_FIELD) {
let after = &rest[at + POWERSHELL_ERROR_ID_FIELD.len()..];
let after = after.trim_start();
if let Some(value) = after.strip_prefix(':') {
let value = value.trim_start();
if let Some(tail) = value.strip_prefix(id) {
// The id can carry a cmdlet suffix, but nothing else may extend the token.
if tail.is_empty() || tail.starts_with(',') || tail.starts_with(char::is_whitespace)
{
return true;
}
}
}
rest = &rest[at + POWERSHELL_ERROR_ID_FIELD.len()..];
}
false
}
fn security_block_kind(text: &str) -> Option<SecurityBlockKind> {
if is_error_id_value(text, AMSI_MALWARE_ERROR_ID) {
return Some(SecurityBlockKind::Malware);
}
if is_error_id_value(text, AMSI_ADMIN_BLOCK_ERROR_ID) {
return Some(SecurityBlockKind::AdminPolicy);
}
None
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum InstallOutputStream {
Stdout,
Stderr,
}
impl InstallOutputStream {
fn other(self) -> Self {
match self {
Self::Stdout => Self::Stderr,
Self::Stderr => Self::Stdout,
}
}
}
struct InstallOutputLine {
stream: InstallOutputStream,
text: String,
@ -55,13 +156,20 @@ struct InstallFailureContext {
explicit_error: Option<String>,
explicit_error_stream: Option<InstallOutputStream>,
default_error: Option<String>,
security_block: Option<SecurityBlockKind>,
unpaired_clears: HashMap<(String, InstallOutputStream), usize>,
started: bool,
output_tail: VecDeque<InstallOutputLine>,
}
impl InstallFailureContext {
fn observe_stdout(&mut self, text: &str) -> bool {
if text.starts_with("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stdout);
if text.starts_with("[TAURI:STEP] ") {
self.started = true;
}
self.note_security_block(text);
if let Some(message) = text.strip_prefix("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stdout, message);
return true;
}
if text.starts_with("[TAURI:OUTPUT_CLEAR] ") {
@ -94,8 +202,9 @@ impl InstallFailureContext {
}
fn observe_stderr(&mut self, text: &str) -> bool {
if text.starts_with("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stderr);
self.note_security_block(text);
if let Some(message) = text.strip_prefix("[TAURI:ERROR_CLEAR] ") {
self.clear_failure(InstallOutputStream::Stderr, message);
return true;
}
if text.starts_with("[TAURI:OUTPUT_CLEAR] ") {
@ -130,7 +239,53 @@ impl InstallFailureContext {
}
}
fn clear_failure(&mut self, stream: InstallOutputStream) {
/// Both streams, before marker handling: PowerShell writes the parse error to stderr, but a
/// wrapper that folded the streams together would otherwise lose it.
fn note_security_block(&mut self, text: &str) {
if self.security_block.is_none() {
self.security_block = security_block_kind(text);
}
}
fn clear_failure(&mut self, stream: InstallOutputStream, message: &str) {
// Clear-TauriInstallError writes ONE logical clear to BOTH streams (install.ps1:198),
// read by independent threads, so a verdict can land between a clear and its own twin and
// treating the twin as a second clear would discard a real block.
//
// Pair by message, not against the previous clear alone: a reader can lag several clears
// behind, so "is this the message I just saw" answers no and throws the verdict away. Each
// logical clear emits exactly two markers, so the first sighting is the clear and the next
// pairs with it.
//
// Keyed by stream as well as message: the same label can be cleared twice for real (an
// install and then a repair both emit "install PyTorch recovered"), and a reader that got
// ahead would otherwise pair those two same-stream clears with each other. Only the
// opposite stream's copy can consume a pending marker.
let other = stream.other();
let twin = match self.unpaired_clears.get_mut(&(message.to_owned(), other)) {
Some(count) if *count > 0 => {
*count -= 1;
true
}
_ => {
*self
.unpaired_clears
.entry((message.to_owned(), stream))
.or_insert(0) += 1;
false
}
};
self.unpaired_clears.retain(|_, count| *count > 0);
// Producers clear with a small fixed set of labels, and an unbounded map fed by child
// output is a memory sink. Dropping the oldest entries only costs a twin match.
if self.unpaired_clears.len() > MAX_UNPAIRED_CLEARS {
self.unpaired_clears.clear();
}
if !twin {
// A run that cleared its own failure state was never blocked at parse time, so a
// verdict here is stale.
self.security_block = None;
}
if self.explicit_error_stream == Some(stream) {
self.explicit_error = None;
self.explicit_error_stream = None;
@ -172,9 +327,15 @@ impl InstallFailureContext {
.as_deref()
.or(self.default_error.as_deref())
.or_else(|| self.output_tail.back().map(|line| line.text.as_str()));
match detail {
let base = match detail {
Some(detail) => format!("Installation failed: {}", detail),
None => generic_failure_message(code),
};
// Appended, not substituted: the raw id is what a diagnostics report and a vendor
// submission need, the guidance is what the user needs.
match self.security_block {
Some(kind) => format!("{} {}", base, kind.guidance(self.started)),
None => base,
}
}
}
@ -1316,6 +1477,241 @@ mod tests {
assert!(!is_elevation_request(1, &["cmake".to_string()]));
}
/// The exact stderr an AMSI provider produced in #8523. The scan covers the whole script
/// block, so the parse error points at line 1 char 1 and no statement ever runs.
const AMSI_BLOCK_STDERR: [&str; 6] = [
r"At C:\Program Files\Unsloth\install.ps1:1 char:1",
"+ # Unsloth Studio Installer for Windows PowerShell",
"+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
"This script contains malicious content and has been blocked by your antivirus software.",
" + CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException",
" + FullyQualifiedErrorId : ScriptContainedMaliciousContent",
];
#[test]
fn amsi_block_explains_itself_without_losing_the_error_id() {
let mut context = InstallFailureContext::default();
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
let message = context.message(1);
// The raw id survives: diagnostics and vendor submissions need it.
assert!(message.contains("ScriptContainedMaliciousContent"), "{message}");
assert!(message.starts_with("Installation failed: "), "{message}");
assert!(message.contains("blocked the installer before it started"), "{message}");
assert!(message.contains("only diagnostic logs may have been written"), "{message}");
// We cannot know a verdict is wrong, so the text must not assert it.
assert!(!message.contains("This is a false positive"), "{message}");
}
#[test]
fn a_block_after_the_install_started_does_not_claim_nothing_changed() {
// install.ps1 runs `unsloth studio setup` near the end, and that child spawns
// studio/setup.ps1 on the same inherited pipes. A block there lands after the venv and
// PyTorch are on disk, so the pre-start reassurance would be false.
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:STEP] running unsloth studio setup...");
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
let message = context.message(1);
assert!(message.contains("ScriptContainedMaliciousContent"), "{message}");
assert!(message.contains("blocked part of the installer"), "{message}");
assert!(!message.contains("no installation steps ran"), "{message}");
assert!(message.contains("some components may already be installed"), "{message}");
}
#[test]
fn the_wording_follows_the_final_started_state_not_the_arrival_order() {
// stdout and stderr are read by independent threads, so the STEP a child wrote before
// the block can be observed after it. Deciding the wording when the token arrives would
// tell the user nothing was changed on a run that had already installed PyTorch.
let mut context = InstallFailureContext::default();
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
context.observe_stdout("[TAURI:STEP] running unsloth studio setup...");
let message = context.message(1);
assert!(message.contains("blocked part of the installer"), "{message}");
assert!(!message.contains("no installation steps ran"), "{message}");
}
#[test]
fn the_twin_of_an_earlier_clear_does_not_erase_a_later_verdict() {
// Clear-TauriInstallError writes one clear to BOTH streams, and independent readers can
// interleave them around a block that happened afterwards. install.ps1 clears after each
// recovered step, so this is the ordinary shape of a setup.ps1 block late in a run.
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:STEP] installing PyTorch");
context.observe_stderr("[TAURI:ERROR_CLEAR] PyTorch recovered");
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
// the twin of the same clear, arriving late on the other stream
context.observe_stdout("[TAURI:ERROR_CLEAR] PyTorch recovered");
let message = context.message(1);
assert!(message.contains("blocked part of the installer"), "{message}");
}
#[test]
fn two_real_clears_of_one_label_on_one_stream_are_not_twins() {
// The same label can be cleared twice for real: _install_torch_default_index emits its
// recovery during the install and again during the ROCm repair. Pairing by message alone
// treated the second as the twin of the first, so the block that landed between them was
// then erased by the genuinely later clear arriving on the other stream.
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:STEP] installing PyTorch");
context.observe_stderr("[TAURI:ERROR_CLEAR] install PyTorch recovered");
context.observe_stderr("[TAURI:ERROR_CLEAR] install PyTorch recovered");
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
// Both twins arrive late on the other stream and consume the two pending clears.
context.observe_stdout("[TAURI:ERROR_CLEAR] install PyTorch recovered");
context.observe_stdout("[TAURI:ERROR_CLEAR] install PyTorch recovered");
let message = context.message(1);
assert!(message.contains("blocked part of the installer"), "{message}");
}
#[test]
fn a_twin_lagging_several_clears_behind_still_pairs() {
// install.ps1 clears after every recovered step, so a slower reader can be several clears
// behind when a block lands. Pairing only against the previous message would treat this
// delayed twin of A as a fresh recovery and drop the verdict.
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:STEP] installing PyTorch");
context.observe_stderr("[TAURI:ERROR_CLEAR] step A recovered");
context.observe_stderr("[TAURI:ERROR_CLEAR] step B recovered");
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
context.observe_stdout("[TAURI:ERROR_CLEAR] step A recovered");
context.observe_stdout("[TAURI:ERROR_CLEAR] step B recovered");
let message = context.message(1);
assert!(message.contains("blocked part of the installer"), "{message}");
}
#[test]
fn a_genuinely_later_recovery_still_clears_the_verdict() {
// Distinct text means a real subsequent recovery, not a twin, and the guidance must go.
let mut context = InstallFailureContext::default();
context.observe_stderr("[TAURI:ERROR_CLEAR] PyTorch recovered");
for line in AMSI_BLOCK_STDERR {
context.observe_stderr(line);
}
context.observe_stdout("[TAURI:ERROR_CLEAR] studio setup completed");
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
}
#[test]
fn the_id_has_to_be_the_field_value_not_just_on_the_line() {
// Prose that names both the field and the id is not an error record.
let mut context = InstallFailureContext::default();
context.observe_stderr(
"checking FullyQualifiedErrorId handling for ScriptContainedMaliciousContent",
);
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
// A longer token that merely starts with the id is a different id.
assert!(!is_error_id_value(
"+ FullyQualifiedErrorId : ScriptContainedMaliciousContentX",
AMSI_MALWARE_ERROR_ID
));
// The real record, with and without the cmdlet suffix.
assert!(is_error_id_value(
" + FullyQualifiedErrorId : ScriptContainedMaliciousContent",
AMSI_MALWARE_ERROR_ID
));
assert!(is_error_id_value(
" + FullyQualifiedErrorId : ScriptContainedMaliciousContent,Microsoft.PowerShell",
AMSI_MALWARE_ERROR_ID
));
}
#[test]
fn a_flood_of_distinct_clears_cannot_grow_the_pairing_map() {
let mut context = InstallFailureContext::default();
for i in 0..(MAX_UNPAIRED_CLEARS * 4) {
context.observe_stdout(&format!("[TAURI:ERROR_CLEAR] step {i} recovered"));
}
assert!(context.unpaired_clears.len() <= MAX_UNPAIRED_CLEARS, "{}", context.unpaired_clears.len());
}
#[test]
fn merely_naming_the_error_id_is_not_a_verdict() {
// The id is only a verdict as the value of a PowerShell error record's field. A scanner
// log or a test fixture that prints the bare string must not attach antivirus guidance
// to whatever fails next.
let mut context = InstallFailureContext::default();
context.observe_stderr("scanning fixture ScriptContainedMaliciousContent");
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
}
#[test]
fn an_admin_policy_block_after_the_install_started_is_also_neutral() {
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:STEP] installing PyTorch");
context.observe_stderr(" + FullyQualifiedErrorId : ScriptHasAdminBlockedContent");
let message = context.message(1);
assert!(message.contains("blocked part of the installer"), "{message}");
assert!(!message.contains("no installation steps ran"), "{message}");
}
#[test]
fn amsi_block_is_recognised_on_stdout_and_when_the_id_carries_a_cmdlet_suffix() {
// A wrapper folding stderr into stdout, plus the Invoke-Expression form of the id.
let mut context = InstallFailureContext::default();
context.observe_stdout(
" + FullyQualifiedErrorId : ScriptContainedMaliciousContent,\
Microsoft.PowerShell.Commands.InvokeExpressionCommand",
);
assert!(context
.message(1)
.contains("blocked the installer before it started"));
}
#[test]
fn admin_policy_block_gets_its_own_guidance() {
let mut context = InstallFailureContext::default();
context.observe_stderr(" + FullyQualifiedErrorId : ScriptHasAdminBlockedContent");
let message = context.message(1);
assert!(message.contains("security policy blocked the installer"), "{message}");
assert!(!message.contains("report it to your vendor"), "{message}");
}
#[test]
fn an_ordinary_failure_gains_no_security_guidance() {
let mut context = InstallFailureContext::default();
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
}
#[test]
fn a_run_that_clears_its_failure_state_drops_a_stale_verdict() {
let mut context = InstallFailureContext::default();
context.observe_stderr(" + FullyQualifiedErrorId : ScriptContainedMaliciousContent");
context.observe_stdout("[TAURI:ERROR_CLEAR] retrying");
context.observe_stdout("[TAURI:ERROR] Failed to install PyTorch");
assert_eq!(
context.message(7),
"Installation failed: Failed to install PyTorch"
);
}
#[test]
fn explicit_installer_error_beats_stderr_noise() {
let mut context = InstallFailureContext::default();

View file

@ -58,10 +58,6 @@
"copyright": "© 2026 Unsloth AI. All rights reserved.",
"license": "AGPL-3.0-only",
"targets": ["app", "deb", "dmg", "nsis"],
"resources": {
"../../install.sh": "install.sh",
"../../install.ps1": "install.ps1"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View file

@ -0,0 +1,7 @@
{
"bundle": {
"resources": {
"../../install.sh": "install.sh"
}
}
}

View file

@ -1,5 +1,8 @@
{
"bundle": {
"resources": {
"../../install.sh": "install.sh"
},
"macOS": {
"entitlements": "./Entitlements.plist",
"infoPlist": "./Info.plist",

View file

@ -1,5 +1,8 @@
{
"bundle": {
"resources": {
"../../install.ps1": "install.ps1"
},
"windows": {
"signCommand": {
"cmd": "powershell",

View file

@ -1,5 +1,24 @@
; Unsloth NSIS installer hooks
!macro NSIS_HOOK_PREINSTALL
; Windows bundles carry only install.ps1 now. NSIS writes the current resource manifest
; and deletes nothing, so an in-place upgrade from a release that shipped both would keep
; install.sh forever and make the non-recursive RMDir "$INSTDIR" fail at uninstall.
; Gated on our own executable being there: this hook runs before the user can still cancel,
; and the directory can be one they picked themselves, so only a directory that already
; holds an Unsloth install is ours to tidy.
${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe"
Delete "$INSTDIR\install.sh"
${EndIf}
!macroend
!macro NSIS_HOOK_PREUNINSTALL
; Same file, for anyone uninstalling a version that never ran the hook above.
${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe"
Delete "$INSTDIR\install.sh"
${EndIf}
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
; Desktop uninstall must not remove $PROFILE\.unsloth. The CLI/web
; installers also use that tree for environments, models, outputs, and

View file

@ -688,6 +688,56 @@ class TestPinnedIndexClearsUvEnvParity:
"finally"
), "pip fallback must run before the scrub is restored"
def test_windows_installers_probe_uv_before_replacing_an_incumbent(self):
"""A host can have a working older uv while AppLocker, WDAC or endpoint
protection refuses the one we just downloaded. Both PowerShell installers must
run the extracted uv.exe where it landed BEFORE anything at the destination is
touched, and must restore the incumbent if the published copy will not run."""
for path, probe in (
(INSTALL_PS1, "Get-UvExecutableVerdict"),
(SETUP_PS1, "Get-SetupUvExecutableVerdict"),
):
text = path.read_text(encoding = "utf-8")
assert f"function {probe}" in text, f"{path.name} must define {probe}"
# WaitForExit takes a timeout: an unbounded wait on a freshly downloaded
# binary is exactly how an unattended install hangs.
assert "WaitForExit(20000)" in text, f"{path.name}'s uv probe must bound its wait"
probe_at = text.index(f"({probe} -Path $stagedUv)")
# Tri-state, not a boolean. A launch that throws or a wait that times out got no
# verdict, and treating that as a broken binary turned three clean-machine CI legs
# into hard install failures: Start-Process -NoNewWindow with redirected streams
# does not behave in a Windows container or on arm64 as it does on a desktop. Only
# the binary answering non-zero may block the install.
body = text.split(f"function {probe}", 1)[1].split("\n }\n", 1)[0]
# An EMPTY exit code is no verdict either. WaitForExit(ms) can return before the
# code is cached, which is how arm64 and the Windows containers reported "exited ."
# and had a working uv read as broken.
assert (
"try { $proc.WaitForExit() } catch {}" in body
), f"{path.name} must settle the exit code before reading it"
assert (
'$null -eq $code -or "$code" -eq ""' in body
), f"{path.name} must treat a missing exit code as inconclusive"
assert (
body.count('return "unknown"') == 3
), f"{path.name}: a launch failure and a timeout must both be inconclusive"
assert (
'return "failed"' in body and 'return "ok"' in body
), f"{path.name}'s probe must report a real answer as well"
assert (
f'({probe} -Path $stagedUv) -eq "failed"' in text
), f"{path.name} must gate only on a failed verdict"
copy_at = text.index("Copy-Item -LiteralPath $src -Destination $dst -Force")
assert probe_at < copy_at, (
f"{path.name} must probe the extracted uv.exe before copying over the "
"destination"
)
# The publish is not a transaction: a locked or ACL-denied destination fails the
# install rather than being skipped, which is what the caller's fallback is for.
assert (
"Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop" in text
), f"{path.name} must copy each executable under -ErrorAction Stop"
def test_all_installers_disable_uv_config_for_pinned_installs(self):
"""A DISCOVERED uv.toml / pyproject [tool.uv] outranks the CLI pin
(verified with uv 0.10: [pip] torch-backend = "cpu" and a non-default

View file

@ -478,6 +478,53 @@ class TestRenderMarkdown:
assert "Flagging engines" in text
assert "AlphaAV (Trojan)" in text
def test_a_flagged_asset_gets_a_submission_packet(self):
# The build job only ever assembled a packet for the Windows -setup.exe, so the one
# detection that actually arrived -- Trojan:Script/Wacatac.B!ml on the Linux AppImage --
# produced nothing to submit.
text = vt.render_markdown(
[
vt.FileReport(
name = "Unsloth-Desktop-Linux.AppImage",
sha256 = "e3aa9b36",
size = 46193144,
stats = vt.ScanStats(malicious = 1, undetected = 62),
detections = ["Microsoft (Trojan:Script/Wacatac.B!ml)"],
)
],
0,
)
assert "False-positive submission packet" in text
assert "Unsloth-Desktop-Linux.AppImage" in text
assert "e3aa9b36" in text
assert "46193144 bytes" in text
assert "wdsi/filesubmission" in text
def test_a_flagged_asset_with_no_readable_engine_list_still_gets_a_packet(self):
# stats and results are separate fields of the same response. The table reports the
# count, so the packet has to key on the same thing or it skips the one asset that
# needs one.
text = vt.render_markdown(
[
vt.FileReport(
name = "a.exe",
sha256 = "ab",
size = 10,
stats = vt.ScanStats(malicious = 1, undetected = 60),
detections = [],
)
],
0,
)
assert "False-positive submission packet" in text
assert "Flagging engines" not in text
def test_a_clean_run_gets_no_submission_packet(self):
text = vt.render_markdown(
[vt.FileReport(name = "a.exe", sha256 = "ab", stats = vt.ScanStats(undetected = 60))], 0
)
assert "False-positive submission packet" not in text
class TestFailClosedOnMalformedLookup:
"""A 200 whose body does not parse must not be read as 'never seen'.

1120
tests/sh/test_uv_pinned_release.sh Executable file

File diff suppressed because it is too large Load diff

View file

@ -58,5 +58,21 @@ def test_launcher_is_windowless_powershell():
assert "launch-studio.ps1" in text
def test_hidden_window_never_pairs_with_execution_policy_bypass():
# The pair AV detections key on; install.rs already refuses it for the app's own launch.
# launch-studio.ps1 is written locally, so it has no mark-of-the-web and RemoteSigned loads
# it. The hidden window costs nothing to keep, the Bypass costs a detection.
text = _text()
for line in text.splitlines():
if re.search(r"-WindowStyle\s+Hidden", line):
assert not re.search(r"-ExecutionPolicy\s+Bypass", line), (
"a hidden-window PowerShell launch must not also pass -ExecutionPolicy Bypass: "
f"{line.strip()}"
)
assert re.search(
r"-WindowStyle\s+Hidden\s+-ExecutionPolicy\s+RemoteSigned", text
), "the shortcut must launch launch-studio.ps1 under RemoteSigned, not Bypass."
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -0,0 +1,184 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Keep the shipped installers off the shapes antivirus heuristics score.
An AMSI provider blocked install.ps1 at parse time (#8523) and Microsoft flagged the Linux
AppImage `Trojan:Script/Wacatac.B!ml`. PowerShell hands the whole script block to AMSI before
running a line, so every byte counts, comments included.
Nothing here reproduces either verdict; it pins the constructs that were removed. The output
lock at the bottom is the other half: hardening must not change what a user sees.
"""
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
PS_SCRIPTS = ("install.ps1", "studio/setup.ps1", "scripts/uninstall.ps1")
SH_SCRIPTS = ("install.sh", "studio/setup.sh")
ALL_SCRIPTS = PS_SCRIPTS + SH_SCRIPTS
def _text(name: str) -> str:
return (REPO / name).read_text(encoding = "utf-8")
_QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"")
def _code_lines(name: str):
"""Lines reduced to what the script executes: no comments, here-strings or quoted literals.
Most checks here scan the whole file, since AMSI does too. The ones about what the script
*does* use this, so the printed remediation text does not read as an execution.
"""
in_here_string = False
for number, line in enumerate(_text(name).splitlines(), start = 1):
stripped = line.strip()
if in_here_string:
if stripped in ("'@", '"@'):
in_here_string = False
continue
if re.search(r"@[\"']$", stripped):
in_here_string = True
continue
if stripped.startswith("#"):
continue
yield number, _QUOTED.sub('""', line)
@pytest.mark.parametrize("name", ALL_SCRIPTS)
def test_script_exists(name: str) -> None:
assert (REPO / name).is_file(), f"missing {name}"
@pytest.mark.parametrize("name", PS_SCRIPTS)
def test_no_remote_script_is_executed_in_process(name: str) -> None:
# The construct AMSI and cloud ML scanners score hardest. Both files fetch a pinned
# archive instead.
for number, line in _code_lines(name):
assert not re.search(
r"Invoke-Expression\s*\(\s*Invoke-(RestMethod|WebRequest)", line
), f"{name}:{number} runs downloaded script text in-process: {line.strip()}"
assert not re.search(
r"\|\s*(iex|Invoke-Expression)\b", line
), f"{name}:{number} pipes into the engine: {line.strip()}"
assert "scriptblock]::Create" not in line.lower().replace(
" ", ""
), f"{name}:{number} builds a script block from a string: {line.strip()}"
@pytest.mark.parametrize("name", SH_SCRIPTS)
def test_no_remote_script_is_piped_into_a_shell_first(name: str) -> None:
# The astral fallback stays reachable for unpinned hosts, but must never be tried first.
text = _text(name)
if "astral.sh/uv/install.sh" not in text:
return
pinned = min(
(m.start() for m in re.finditer(r"_(setup_install_uv_pinned|uv_install_pinned)\b", text)),
default = None,
)
fallback = text.index("astral.sh/uv/install.sh")
assert pinned is not None, f"{name} has no pinned uv path"
assert pinned < fallback, f"{name} reaches the piped fallback before the pinned release"
@pytest.mark.parametrize("name", ALL_SCRIPTS)
def test_no_encoded_or_base64_command_payloads(name: str) -> None:
text = _text(name)
for banned in ("-EncodedCommand", "FromBase64String", "base64 -d", "base64 --decode"):
assert banned not in text, f"{name} contains {banned}"
@pytest.mark.parametrize("name", ALL_SCRIPTS)
def test_a_hidden_window_never_pairs_with_a_bypassed_policy(name: str) -> None:
# Microsoft's detections key on this pair; install.rs already refuses it for the app's
# own launch.
for number, line in enumerate(_text(name).splitlines(), start = 1):
if re.search(r"-WindowStyle\s+Hidden", line, re.IGNORECASE):
assert not re.search(
r"-ExecutionPolicy\s+Bypass", line, re.IGNORECASE
), f"{name}:{number} pairs a hidden window with a bypassed policy: {line.strip()}"
# Every runtime-compiled P/Invoke left in the installers. Each costs a csc.exe compile and is
# scored, so a new entry needs a reason; a PowerShell equivalent usually exists.
ALLOWED_PINVOKES = {
# Canonicalising linked ancestors of security-relevant paths. No PS 5.1 equivalent:
# ResolveLinkTarget is .NET 6+, and .Target misses a linked ancestor of a non-link leaf.
"CreateFileW",
"GetFinalPathNameByHandleW",
# ANSI colour on legacy conhost.
"GetStdHandle",
"GetConsoleMode",
"SetConsoleMode",
# Per-item Explorer icon refresh; the global broadcast alone does not recover a stale .lnk.
"SHChangeNotify",
# PID -> image path for the venv-holder check. Win32_Process answers the same question,
# but test_windows_installer_concurrency_guard.py bans it and $process.Path there: the
# races #7764 closed came from inferring "in use" from anything but a confirmed executable
# identity. A wrongly blocked install costs more than these imports.
"OpenProcess",
"QueryFullProcessImageNameW",
"CloseHandle",
}
@pytest.mark.parametrize("name", ALL_SCRIPTS)
def test_no_new_native_imports(name: str) -> None:
text = _text(name)
imported = set()
for match in re.finditer(
r"DllImport\(\"[^\"]+\"[^)]*\)\][^;{]*?extern\s+[\w.\[\]]+\s+(\w+)", text
):
imported.add(match.group(1))
# install.ps1's multi-line declarations put the parameter list on later lines.
for match in re.finditer(r"extern\s+[\w.<>\[\]]+\s+(\w+)\s*\(", text):
imported.add(match.group(1))
unexpected = imported - ALLOWED_PINVOKES
assert not unexpected, (
f"{name} imports {sorted(unexpected)} from native code. Prefer a PowerShell or .NET "
f"equivalent; if there genuinely is none, add it to ALLOWED_PINVOKES with the reason."
)
@pytest.mark.parametrize("name", ALL_SCRIPTS)
def test_no_process_memory_apis(name: str) -> None:
# The installer reads image paths, nothing more. Reaching into another process's memory
# has no use here and is what the injection heuristics look for.
for banned in (
"VirtualAllocEx",
"WriteProcessMemory",
"ReadProcessMemory",
"CreateRemoteThread",
"SetWindowsHookEx",
):
assert banned not in _text(name), f"{name} references {banned}"
# What the installers print when they need the user to reinstall. Hardening must not touch
# user-visible output, and a search-and-replace would take exactly these out.
REQUIRED_OUTPUT = {
"install.ps1": ['Write-StudioLine " irm https://unsloth.ai/install.ps1 | iex"'],
"studio/setup.ps1": ['Write-StudioLine " irm https://unsloth.ai/install.ps1 | iex"'],
"install.sh": ["curl -fsSL https://unsloth.ai/install.sh | sh"],
}
@pytest.mark.parametrize("name", sorted(REQUIRED_OUTPUT))
def test_printed_remediation_survives_the_hardening(name: str) -> None:
text = _text(name)
for snippet in REQUIRED_OUTPUT[name]:
assert snippet in text, (
f"{name} no longer prints {snippet!r}. Removing the one-liner from comments is the "
f"point; removing it from what the user is told to run is a regression."
)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))

View file

@ -1,19 +1,35 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Keep Tauri repair helpers from mixing package versions."""
"""Keep Tauri repair helpers from mixing package versions, and keep each desktop bundle carrying
only the installer it can actually run."""
import json
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
TAURI = REPO / "studio/src-tauri"
def _resources(config_name: str) -> dict:
config = json.loads((TAURI / config_name).read_text(encoding = "utf-8"))
return config.get("bundle", {}).get("resources", {})
def _bundled_resources(platform: str) -> dict:
# Tauri merges tauri.<platform>.conf.json over tauri.conf.json for the target being built.
merged = dict(_resources("tauri.conf.json"))
merged.update(_resources(f"tauri.{platform}.conf.json"))
return merged
def test_tauri_never_overlays_install_python_stack() -> None:
config = json.loads((REPO / "studio/src-tauri/tauri.conf.json").read_text(encoding = "utf-8"))
resources = config["bundle"]["resources"]
assert not any("install_python_stack.py" in path for item in resources.items() for path in item)
for platform in ("windows", "linux", "macos"):
resources = _bundled_resources(platform)
assert not any(
"install_python_stack.py" in path for item in resources.items() for path in item
), platform
installer = (REPO / "install.ps1").read_text(encoding = "utf-8")
assert "Overlay Tauri-bundled studio fixes" not in installer
@ -21,3 +37,31 @@ def test_tauri_never_overlays_install_python_stack() -> None:
'"install_python_stack.py" = "Lib\\site-packages\\studio\\install_python_stack.py"'
not in installer
)
def test_each_bundle_ships_only_the_installer_it_runs() -> None:
# resolve_install_script picks install.sh on unix and install.ps1 elsewhere, so the other
# was dead weight in every bundle -- and the largest script body a classifier walking the
# AppImage finds, which is where Trojan:Script/Wacatac.B!ml landed.
assert _bundled_resources("windows") == {"../../install.ps1": "install.ps1"}
assert _bundled_resources("linux") == {"../../install.sh": "install.sh"}
assert _bundled_resources("macos") == {"../../install.sh": "install.sh"}
def test_no_installer_resource_leaks_through_the_shared_config() -> None:
# A resource in the shared config lands in every bundle, which is how the split regresses.
assert _resources("tauri.conf.json") == {}
def test_windows_upgrade_removes_the_installer_it_no_longer_ships() -> None:
# NSIS writes the current resource manifest and deletes nothing, and the uninstaller
# deletes only what is in that manifest. An in-place upgrade from a release that
# bundled both installers would therefore keep install.sh on a Windows machine
# forever, and the non-recursive RMDir "$INSTDIR" would fail at uninstall.
hooks = (REPO / "studio/src-tauri/windows/hooks.nsh").read_text(encoding = "utf-8")
for macro in ("NSIS_HOOK_PREINSTALL", "NSIS_HOOK_PREUNINSTALL"):
assert f"!macro {macro}" in hooks, f"hooks.nsh must define {macro}"
body = hooks.split(f"!macro {macro}", 1)[1].split("!macroend", 1)[0]
assert (
'Delete "$INSTDIR\\install.sh"' in body
), f"{macro} must remove the install.sh a pre-split release left behind"