unsloth/tests/python/test_windows_python_venv_hardening.py
Daniel Han a151ac875c
Make install.ps1 work with the user's PowerShell profile loaded (#8161)
* Make install.ps1 work with the user's PowerShell profile loaded

Installing from a normal console failed where the same install from a
console started with -NoProfile succeeded. A profile runs before
`irm https://unsloth.ai/install.ps1 | iex` does and shares its scope, and
that entry point has no script file to re-launch without it, so the
individual couplings are cut instead.

install.ps1, at the top of Install-UnslothStudio:

- Set-StrictMode -Off. The script tests environment variables that are
  legitimately unset and reads $script: state only some branches assign,
  both of which a profile's `Set-StrictMode -Version Latest` turns into
  terminating errors.
- $PSDefaultParameterValues is filtered down to proxy keys. An entry like
  'Start-Process:WindowStyle' silently rebinds cmdlets here and fails the
  install with an error naming none of it. Proxy entries are kept because
  they can only ever enable a download, and on a locked-down host may be
  the only route to python.org and the uv release.
- $PSNativeCommandUseErrorActionPreference = $false. With a profile
  turning it on, the "Stop" preference makes a failing native command
  throw out of the `unsloth studio setup` handoff instead of reaching
  Exit-InstallFailure, skipping rollback and the Tauri error record.

All three assign without a scope qualifier, so they apply to the
installer and everything it calls and leave the caller's session alone.

uv is resolved once through Resolve-UvExecutable, which uses
`Get-Command uv -CommandType Application -All` and falls back to the bare
token when nothing is on PATH. PowerShell ranks aliases and functions
above PATH, so a profile `Set-Alias uv ...` was answering the version
probe and ending the install at "uv could not be installed" on machines
that had a working uv. Test-UvVersionOk pins the executable that
answered in $script:UvExe, and the 27 install scriptblocks invoke that
path. $script:UvExe and $script:UvInstallDestDir are reset per
invocation, since $script: is the caller's session under irm | iex.

unsloth_cli/commands/studio.py passes -NoProfile to setup.ps1
unconditionally. It was only added when stdout was not a tty, which is
never the case for the console install this fixes, so setup.ps1 ran
under the profile with its own bare uv calls exposed.

tests/test_installer_profile_hardening.py runs the extracted prologue and
uv probe under a hostile profile and checks the caller's session is left
intact. The four existing tests that anchored on the literal
`uv venv $VenvDir` are re-anchored past the command token.

* Plant the real-profile fixture where pwsh actually looks

test_a_real_profile_reproduces_the_same_state failed on ubuntu-latest with
every probed setting at its default, meaning the planted profile never loaded.
It passed here and on macos-14.

PowerShell resolves $PROFILE from $XDG_CONFIG_HOME when that is set and only
falls back to $HOME/.config when it is not, and GitHub's ubuntu image writes
XDG_CONFIG_HOME into /etc/environment. The fixture redirected HOME alone, so on
a hosted runner the inherited value went on naming the real account and the
profile was written to a path pwsh never opened. Setting XDG_CONFIG_HOME to the
same directory HOME already implies reproduces the CI failure exactly on this
machine, and removing it makes the failure go away.

_hostile_env now redirects XDG_CONFIG_HOME alongside HOME, so the two rules
agree whichever one the host applies, and the test asks pwsh for the path
instead of hardcoding the fallback branch. The two guards are precise rather
than blanket: a machine-wide profile loads into the real leg only, and a
$PROFILE that lands outside the fixture cannot be planted into. Neither is
reachable on Linux or macOS with the redirect in place.

The other tests in the file never shared the premise; every other pwsh launch
there passes -NoProfile and dot-sources the profile explicitly.

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

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

* Close the uv wrapper hole, harden winget the same way, and restore module autoloading

Validating the profile hardening against real pwsh turned up three things the
first pass missed. Six of the nineteen hostile-profile scenarios I exercised
are genuinely broken on main and genuinely fixed by this branch, so the shape
of the fix is right -- these are gaps in its coverage, not a change of
direction.

Resolve-UvExecutable still handed back the bare token when nothing named uv
was on PATH. That was meant to keep a working non-Application uv working, but
it reopens the exact hole the function exists to close: a profile
`function uv { Write-Output "uv 99.0.0" }` clears the version gate, gets
pinned into $script:UvExe, and then receives every install command the script
runs, with the user's torch, index URL and venv path as arguments. The
existing test missed it because its hostile alias reports no version at all,
and an alias to a missing file fails loudly. Follow an alias as far as an
Application and return that resolved path, since aliasing uv at a specific
build is a legitimate thing to do; return $null for anything else, which puts
the caller back on its install-uv branch and the gate re-probes against the
real thing.

winget had the identical defect and was left untouched. It is detected with a
bare Get-Command and invoked as a bare token at five sites, and it is what
installs both Python and uv -- so a `function winget` wrapper, which people
write to inject --accept-* or pin a source, owns the whole bootstrap. Same
treatment: resolve once to an Application and invoke through the path.

A profile setting $PSModuleAutoLoadingPreference to 'None' is fatal here and
was not covered. PowerShell 7 loads no modules at startup, so that one line
removes Test-Path, Write-Host, Select-Object, ConvertFrom-Json, Get-FileHash,
Invoke-WebRequest, Expand-Archive, Start-Process and Get-Content, and the
script dies on its first step naming a cmdlet the reader assumes is always
there. Windows PowerShell 5.1 preloads Utility and Management and survives,
which is exactly what makes this reproduce on one machine and not another.

Also: use [regex]::IsMatch in the defaults filter so it leaves no $Matches
behind; add -NoProfile unconditionally in _refresh_desktop_shortcuts, which
launches install.ps1 and had it gated on the hidden branch, so the visible
console path -- the one where a profile IS loaded -- was the one that missed
it; and record that the preserved proxy defaults do not reach setup.ps1,
which is launched with -NoProfile, along with why that trade is accepted.

Tests: the assertion that the bare token must come back now asserts the
opposite, and there are new ones for a convincing uv function, an alias to a
real uv, the winget call sites and the autoloading reset. The two new pwsh
tests execute against a genuinely planted profile rather than reading source.

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

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

* Carry the profile proxy across the setup handoff, not just inside install.ps1

install.ps1 deliberately keeps proxy-shaped $PSDefaultParameterValues entries out of the
profile table it discards, because on a locked-down corporate host that entry can be the
only route out. Adding -NoProfile to the setup launch unconditionally then threw them away
one process later, and setup.ps1 downloads on its own: the VC++ runtime through
Invoke-WebRequest and the uv installer through Invoke-RestMethod.

A PowerShell variable does not cross a process boundary, so the kept entries travel as
JSON in _UNSLOTH_PS_PROXY_DEFAULTS and the child re-applies them before running setup.ps1.
Nothing else from the profile comes with them. A credential is left behind on purpose:
PSCredential does not survive ConvertTo-Json, and the environment is the wrong place for
one. A stale variable is cleared when there is nothing to hand off.

Three tests, one static and two driving real pwsh, covering the round trip, the credential
and non-proxy keys being dropped, and the prelude staying silent when the variable is
absent, empty or corrupt.

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

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

* Harden the proxy handoff: ordering, key casing, uri values, scope, and the standalone update

Five follow-ups, all on the handoff added last round.

The handoff serializes with ConvertTo-Json, from Microsoft.PowerShell.Utility, and ran before
the module-autoloading reset. Under a profile's $PSModuleAutoLoadingPreference = 'None' a fresh
PowerShell 7 session therefore died right there, taking out the one configuration the handoff
exists to support. The reset moves to the front of the prologue.

The key filter was a case-sensitive .NET regex, but cmdlet and parameter names bind
case-insensitively, so 'invoke-webrequest:proxy' was dropped. And [uri] is the type the Proxy
parameter actually takes, so a careful profile assigns one; the serializer accepted only string
and bool, and it disappeared at the process boundary. Both are accepted now, a uri by its
AbsoluteUri. A PSCredential is still deliberately left behind.

Under "irm ... | iex" the prologue runs in the caller's own session, so writing the environment
variable there outlived the install on every path, early returns included, and a later
`unsloth studio update` from that console would reapply stale JSON over a proxy that had since
changed. The prologue now holds the value and it is published around the setup child only,
saved and restored beside the other child-scoped variables.

A standalone `unsloth studio update` has no installer above it, so there was nothing to
restore and -NoProfile left it with no route out. It now asks: a throwaway PowerShell that does
load the profile prints just the proxy-shaped defaults as JSON, validated before use, entirely
best effort. Same filter as install.ps1's.

Five tests, two driving real pwsh, including one against a profile with strict mode on,
autoloading off, a lowercase key and a uri value.

* Ask the profile the caller actually has, and follow a uv alias first

- the standalone update probed powershell.exe only, so a proxy living in the
  PowerShell 7 profile never reached the -NoProfile child; both editions are
  asked now, the caller's first, and their answers merged.
- Resolve-UvExecutable checked PATH before the alias, which is the reverse of
  PowerShell's own resolution and made the alias branch unreachable on any
  machine with some uv on PATH.
- the parity workflow did not run this suite when unsloth_cli/commands/studio.py
  changed, though the suite asserts that module directly. Its own path-filter
  parser also treated a comment inside the list as the end of it, which would
  have hidden the addition.

* Give the parity job the imports it needs, and fold proxy keys the way PowerShell does

Three tests in the profile-hardening suite import unsloth_cli.commands.studio to drive
the profile probe directly, and that pulls typer, pyyaml, pydantic and click. The job
installed pip and pytest only, so on a clean setup-python both matrix legs died with
ModuleNotFoundError before a single test ran. Installed, with a test that keeps the step
in step with what the suite imports.

$PSDefaultParameterValues keys are case-insensitive and a Python dict is not, so
"Invoke-WebRequest:Proxy" from the caller's own host and "invoke-webrequest:proxy" from
the other one both crossed over; the prelude then replayed them in order and the
lower-priority host's value landed last, reversing the earlier-host-wins rule this merge
exists for. Keys are folded now, first spelling seen wins, within one profile's answer
as well as across two.

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

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

* Frame the proxy record, and quote the union so 3.9 can still import the CLI

The probe runs after the profile, and the profile is free to print: a MOTD, a "loading
personal and system profiles took 812ms" line, a corporate banner. With the record bare,
that arrived ahead of the JSON, the parse threw and the whole answer was dropped -- so
the locked-down host that needed the proxy handed the -NoProfile child nothing and every
download failed, which is worse than before, since the old visible-console path loaded
the profile itself. The record is emitted between two markers now and cut out of
whatever else was said.

And `str | list[str]` was evaluated at def time in a module with no postponed
annotations, so on the 3.9 this project still supports it raised TypeError and took the
whole CLI import with it. Quoted, with a test that walks every annotation in the module
for an unquoted PEP 604 union.

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

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

* Let the uv gate move past a stale alias, and decode the probe output lossily

An alias pointing at a real but stale uv was the only binary the version gate ever
probed, so a current uv already on PATH -- or one winget or the pinned release had just
installed -- could not rescue the run and the install ended at "uv could not be
installed" on a machine that had one. The resolver hands back every candidate in the
order the bare token would pick them, alias first, and the gate walks them until one
passes, pinning the one that answered.

The profile probe decoded its child with text=True alone, which is the locale codec with
STRICT errors. A UTF-8 banner on an ANSI console then raised UnicodeDecodeError, which is
neither OSError nor SubprocessError, so it escaped the handler and took the update down
before the -NoProfile child ever ran -- and before the framing could discard the banner.
UTF-8 with replacement now; the record itself is ASCII.

rich is named in the parity job's install line too. It arrives through typer today, but
unsloth_cli imports it directly, and this suite's imports should not rest on somebody
else's dependency list.

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

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

* Assert the proxy handoff by parsing it, not by substring

CodeQL reads the bare membership test as an incomplete URL sanitization, which
is a fair reading of the shape even though this is an assertion on a compressed
JSON payload rather than a check on untrusted input. Parsing it and comparing
the value exactly is the stronger assertion anyway.

* Read the caller edition by order, pin the probe's encoding, claim cmdlets whole

A machine can carry both PowerShell module trees on PSModulePath at once, so
inferring the caller from the absence of the other edition handed precedence to
the wrong profile and let its proxy override the console the command was typed
into. Each host puts its own module directory first, so the earliest tree names
the caller; neither present keeps the previous order.

Windows PowerShell 5.1 writes redirected output in the console code page while
this process decodes UTF-8, so a non-ASCII proxy value came back with
replacement characters, still parsed as JSON, and handed setup a proxy that does
not resolve. The probe pins its own output encoding first.

And the merge claims a cmdlet whole rather than filling missing companion
parameters from the other profile, which built a configuration neither host had
-- one profile's proxy with the other's credential forwarding.

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

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

* Evaluate script-block proxy defaults, read the caller's host profile, drop the secret

A profile can set a dynamic default as a script block, which is PowerShell's
supported form and which Invoke-WebRequest evaluates per call. Both
serializers dropped it, so the caller downloaded fine and the -NoProfile
setup child got no proxy at all. Both now invoke the block and hand over the
resulting URI or string; executable code does not cross the handoff.

The probe spawns pwsh.exe or powershell.exe, which load the CONSOLEHOST
profile. A caller in the VS Code Integrated Console or the ISE keeps its
defaults in Microsoft.VSCode_profile.ps1 or Microsoft.PowerShellISE_profile.ps1
instead, so the probe reported no proxy on exactly the host that needed one.
It dot-sources the caller's other CurrentUser host profiles, from their own
directory, before reading the table.

And the prelude clears _UNSLOTH_PS_PROXY_DEFAULTS the moment it has read it.
A profile proxy routinely carries credentials, and every native process
setup.ps1 starts inherited the environment it was launched with.

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

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

* Probe only the caller's own host profile, on one shared budget

Sourcing every Microsoft.*_profile.ps1 in the profile directory ran profiles
belonging to hosts nobody was using: they can overwrite the console's own
$PSDefaultParameterValues, have side effects, or exit before the framed
record is written. The probe now sources exactly one, named by
_UNSLOTH_PS_HOST_PROFILE, and only when the caller identifies itself (VS Code
does, via TERM_PROGRAM). A host we cannot name gets no extra profile rather
than someone else's.

install.ps1 removed the handoff variable when it had no proxy to pass, and
its absence is precisely how the CLI recognises a standalone update -- so an
installer launch, including one started with -NoProfile or by the desktop
app, went and reloaded the profiles it had deliberately discarded. It
publishes an explicit empty handoff instead, and the CLI keys on presence.

And the probe's timeout is one budget for the whole call rather than one per
host, so two installed editions with two hung profiles no longer cost twice
the documented best-effort delay before setup starts.

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

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

* Run the proxy probe with -NoProfile and dot-source the caller's own two

Without -NoProfile the probe host loaded its own ConsoleHost profile before
the script ran, so an unrelated profile could print, rewrite
$PSDefaultParameterValues or exit before the record was written -- and it
still was not the profile a VS Code caller keeps its defaults in.

The child runs with -NoProfile now and dot-sources exactly the two the
caller's session would have loaded: $PROFILE.CurrentUserAllHosts and either
the host profile named in _UNSLOTH_PS_HOST_PROFILE or
$PROFILE.CurrentUserCurrentHost. $PROFILE is fully populated under
-NoProfile, since the paths are computed rather than loaded, so this is exact
rather than incidental.

Checked against pwsh with a fixture profile directory: a VS Code caller picks
up its own profile plus the all-hosts one and never runs the console
profile's banner, and a plain console caller picks up the console profile
plus the all-hosts one.

* Probe the all-users profiles too, and clear profile defaults before emitting

A machine-managed proxy commonly lives in AllUsersAllHosts on a domain-joined
box while the user's own profile never mentions it, so sourcing only the
current-user pair reported no proxy on exactly the host that has one. The probe
now walks PowerShell's own startup order, all-users first, so the user's profile
still gets the last word.

The profile's $PSDefaultParameterValues was also still active when the record
was serialized. ConvertTo-Json:AsArray = $true is a legitimate setting and turns
the payload into a JSON array, which the reader rejects for not being a
dictionary. $out already holds copies by then, so the table is cleared first.

* Harden the proxy probe against profile overrides, and drop the handoff copy

Five fixes from the review round:

install.ps1 kept the serialized proxy defaults in $script:, which under the
documented irm | iex path IS the caller's session scope, so an authenticated
proxy URI stayed readable in that console after the installer returned. Cleared
in the same finally that restores the environment handoff.

A profile setting [Console]::OutputEncoding overrode the probe's UTF-8 pin, and
the parent decodes that stream as UTF-8, so the framed record could come back
corrupted. Re-pinned after the last profile is sourced.

The record was emitted through bare Write-Output and ConvertTo-Json, which a
profile alias or function shadows; clearing $PSDefaultParameterValues does not
cover a command override. Both are module-qualified now.

TERM_PROGRAM=vscode is set by every VS Code integrated terminal, not only the
PowerShell extension's host, so substituting Microsoft.VSCode_profile.ps1 for
the current-host profile missed the proxy a plain pwsh terminal there actually
has. The named host profile is added rather than substituted, with the
current-host profile last.

The per-cmdlet ownership check compared command strings literally, so a wildcard
key from one host and a literal key for a matching cmdlet from the other were
both merged, which is how one invocation ends up configured from two profiles.
Overlap is matched in either direction now.

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

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

* Hold the proxy handoff in the frame, and treat two wildcards as one family

Three fixes from the review round:

The serialized handoff lived in $script:, which under the documented irm | iex
path is the caller's session scope, and the only cleanup ran after the setup
child. Dozens of exits return earlier -- ShortcutsOnly, an argument error, lock
contention, a failed dependency install -- so an authenticated proxy URI stayed
readable in that console. It is a function-local now, which dies with the frame
on every path including a throw.

install.ps1 serialized that record through a bare ConvertTo-Json, which a
profile alias or function shadows exactly as it does in the probe. Module
qualified.

The cmdlet-ownership check compared two wildcard patterns as strings, and
Invoke-Web* and *-WebRequest both apply to Invoke-WebRequest while neither
matches the other. Two patterns are now assumed to overlap: the cost is a second
host's unrelated wildcard entry going unmerged, against handing setup a
credential setting from a profile that never asked for one.

* Tighten the profile-hardening comments

Comments, docstrings and whitespace only; no code changes. Each comment keeps
the reason it records and drops the retelling.

* Cut the profile-hardening comments down again

Comments, docstrings and whitespace only. The install.ps1 prologue and the
studio.py proxy probe kept one causal claim per decision, with the probe's
profile-loading essay split into a short note beside each line it justifies.

* Pin the probe's add-both profile order in its own test name

* Keep disjoint wildcard proxy families, and give each probed host its own module path

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

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

---------

Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 04:27:28 -07:00

427 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Focused contracts for Windows Python wrapper and venv validation."""
from __future__ import annotations
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALL_PS1 = REPO_ROOT / "install.ps1"
POWERSHELLS = [shell for shell in ("pwsh", "powershell") if shutil.which(shell)]
def _extract(pattern: str, source: str) -> str:
match = re.search(pattern, source, flags = re.DOTALL)
assert match is not None, f"install.ps1 block not found: {pattern}"
return match.group(0)
def _link_dir(link: Path, target: Path) -> None:
"""Directory link, without needing SeCreateSymbolicLinkPrivilege on Windows.
A junction is also the reparse point a Windows venv actually runs into, so this
is the faithful construct there rather than a stand-in.
"""
if os.name == "nt":
subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
check = True,
capture_output = True,
text = True,
)
else:
os.symlink(target, link, target_is_directory = True)
def _run_powershell(shell: str, script: str, env: dict[str, str]) -> str:
result = subprocess.run(
[shell, "-NoProfile", "-NonInteractive", "-Command", script],
check = True,
capture_output = True,
text = True,
env = env,
timeout = 30,
)
return result.stdout.strip()
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_path_python_wrapper_resolves_to_real_executable(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
finder = _extract(r" function Find-CompatiblePython \{.*?\n \}\n", source)
(tmp_path / "sitecustomize.py").write_text('print("STARTUP_BANNER")\n', encoding = "utf-8")
if os.name == "nt":
wrapper = tmp_path / "python.bat"
wrapper.write_text(f'@"{sys.executable}" %*\n', encoding = "utf-8")
else:
wrapper = tmp_path / "python-wrapper"
wrapper.write_text(
f'#!/bin/sh\nexec {shlex.quote(sys.executable)} "$@"\n', encoding = "utf-8"
)
wrapper.chmod(0o755)
script = f"""
$ErrorActionPreference = "Stop"
$PythonVersion = "3.13"
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda)'
function Get-HostMachineArch {{ return "x86_64" }}
function Test-IsCondaPython {{ param([string]$Exe) return $false }}
function Get-PythonPlatformTag {{ param([string]$Exe) return "win-amd64" }}
function Get-Command {{
param([Parameter(Position = 0)][string]$Name,
[Parameter(ValueFromRemainingArguments = $true)]$Rest)
if ($Name -eq "python") {{
return @([pscustomobject]@{{ Source = $env:TEST_PYTHON_WRAPPER }})
}}
return @()
}}
{finder}
$found = Find-CompatiblePython
Write-Output $found.Path
"""
env = os.environ.copy()
env["TEST_PYTHON_WRAPPER"] = str(wrapper)
env["PYTHONPATH"] = str(tmp_path)
assert Path(_run_powershell(shell, script, env)).resolve() == Path(sys.executable).resolve()
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_arch_probe_ignores_startup_output(tmp_path: Path, shell: str):
"""Startup output must not reach the arch tag.
The caller compares the tag with -eq "win-amd64", so a contaminated answer reads
as "unknown" and Windows on ARM silently settles for a native ARM64 interpreter.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
probe = _extract(r" function Get-PythonPlatformTag \{.*?\n \}\n", source)
(tmp_path / "sitecustomize.py").write_text('print("STARTUP_BANNER")\n', encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
{probe}
Write-Output (Get-PythonPlatformTag $env:TEST_PYTHON)
"""
env = os.environ.copy()
env["TEST_PYTHON"] = sys.executable
env["PYTHONPATH"] = str(tmp_path)
tag = _run_powershell(shell, script, env)
assert tag and "\n" not in tag and "startup_banner" not in tag, tag
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_venv_base_home_comes_from_pyvenv_config(tmp_path: Path, shell: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
reader = _extract(r" function Get-VenvBaseHome \{.*?\n \}\n", source)
expected = tmp_path / "removed-base-python"
(tmp_path / "pyvenv.cfg").write_text(f"home = {expected}\n", encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
{reader}
Write-Output (Get-VenvBaseHome -VenvRoot $env:TEST_VENV_ROOT)
"""
env = os.environ.copy()
env["TEST_VENV_ROOT"] = str(tmp_path)
assert _run_powershell(shell, script, env) == str(expected)
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
@pytest.mark.parametrize("case", ["partial", "clean"])
def test_rollback_keeps_state_when_the_move_stops_partway(tmp_path: Path, shell: str, case: str):
"""A half-finished rename must not be read as "the rename never happened".
On Windows an open handle inside the tree fails Move-Item after it has already
walked part of it, so entries exist at both paths. Testing only the source then
clears StudioVenvRollbackDir -- the sole record of where the other half went --
and the environment is stranded with no way to restore or even name it.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
rollback = _extract(r" function Start-StudioVenvRollback \{.*?\n \}\n", source)
existing = tmp_path / "unsloth_studio"
(existing / "Scripts").mkdir(parents = True)
(existing / "Scripts" / "unsloth.exe").write_text("locked", encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
$StudioHome = $env:TEST_STUDIO_HOME
function substep {{ param([string]$Text, [string]$Color) }}
# The split-move warning goes through install.ps1's UTF-8 stdout sink. Echo it so
# the assertions below can read it; without this the call is a command-not-found
# terminating error that the try/catch swallows, and the warning is simply lost.
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
function Move-Item {{
param([string]$LiteralPath, [string]$Destination, [string]$ErrorAction, [switch]$Force)
if ($env:TEST_ROLLBACK_CASE -eq "partial") {{
# The entries walked before the locked one are already at the destination.
[System.IO.Directory]::CreateDirectory((Join-Path $Destination "Lib")) | Out-Null
}}
throw "The process cannot access the file because it is being used by another process."
}}
{rollback}
try {{ Start-StudioVenvRollback -ExistingDir $env:TEST_EXISTING_DIR }} catch {{ }}
Write-Output ("active=" + $script:StudioVenvRollbackActive)
Write-Output ("dir=" + [string]$script:StudioVenvRollbackDir)
"""
env = os.environ.copy()
env["TEST_STUDIO_HOME"] = str(tmp_path)
env["TEST_EXISTING_DIR"] = str(existing)
env["TEST_ROLLBACK_CASE"] = case
out = _run_powershell(shell, script, env)
state = dict(
line.split("=", 1) for line in out.splitlines() if line.startswith(("active=", "dir="))
)
if case == "clean":
# Nothing moved, so the original is intact and there is nothing to restore.
assert state["active"] == "False", out
assert state["dir"] == "", out
return
assert state["active"] == "True", out
assert state["dir"].startswith(os.path.join(str(tmp_path), "unsloth_studio.rollback.")), out
assert Path(state["dir"]).is_dir(), out
# Both halves are named, so the user is not left hunting for the moved tree.
# Match the warning lines themselves rather than the bare paths: $existing is a
# prefix of the rollback dir, so "str(existing) in out" alone is satisfied by the
# dir= line and would stay green even with the warning missing entirely.
assert f"still in place: {existing}" in out, out
assert f"moved aside: {state['dir']}" in out, out
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_restoring_a_split_move_never_deletes_the_half_left_behind(tmp_path: Path, shell: str):
"""Restoration must merge the two halves, not clear the destination first.
After a partway move the target holds the entries the move never got to -- not
an incomplete *new* environment. The committed-replacement path removes the
target before moving the backup back, which for a split tree deletes files that
exist nowhere else. Keeping the rollback active is only safe if restoration
takes a merge path, so this pins the file that never moved to being still there.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
blocks = "".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source)
for name in (
"Remove-StudioVenvTreeWithRetry",
"Merge-StudioVenvRollbackTree",
"Restore-StudioVenvRollback",
)
)
target = tmp_path / "unsloth_studio"
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
# The half the interrupted move left behind, and the half that got across.
(target / "Scripts").mkdir(parents = True)
(target / "Scripts" / "unsloth.exe").write_text("irreplaceable", encoding = "utf-8")
(backup / "Lib" / "site-packages").mkdir(parents = True)
(backup / "Lib" / "site-packages" / "marker.txt").write_text("moved", encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
function substep {{ param([string]$Text, [string]$Color) }}
# The merge/restore helpers warn through install.ps1's UTF-8 stdout sink on their
# conflict branches. This run does not take one, but leaving the sink undefined
# means any future case that does would die on a command-not-found instead.
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
{blocks}
$script:StudioVenvRollbackActive = $true
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
$script:StudioVenvRollbackPartial = $true
Restore-StudioVenvRollback
Write-Output ("active=" + $script:StudioVenvRollbackActive)
"""
env = os.environ.copy()
env["TEST_BACKUP_DIR"] = str(backup)
env["TEST_TARGET_DIR"] = str(target)
out = _run_powershell(shell, script, env)
# The file that never moved is the whole point: the pre-merge path deleted it.
assert (target / "Scripts" / "unsloth.exe").read_text(encoding = "utf-8") == "irreplaceable", out
# ...and the half that did move comes back rather than being stranded.
assert (target / "Lib" / "site-packages" / "marker.txt").is_file(), out
assert not backup.exists(), out
assert "active=False" in out, out
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
def test_merging_a_split_move_keeps_every_sibling_at_its_own_path(tmp_path: Path, shell: str):
"""Each entry must land at its own path, not nested under the previous one.
PowerShell variable names are case-insensitive, so a per-entry $destination
reassigns the $Destination parameter. Only the first sibling at a level then
lands correctly and the rest are appended to its path, so a restored venv comes
back with pyvenv.cfg buried inside Lib. One entry per level hides it, so this
uses several.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
blocks = "".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source)
for name in (
"Remove-StudioVenvTreeWithRetry",
"Merge-StudioVenvRollbackTree",
"Restore-StudioVenvRollback",
)
)
target = tmp_path / "unsloth_studio"
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
# The half left behind, and a moved half with several siblings at two levels.
(target / "Scripts").mkdir(parents = True)
(target / "Scripts" / "unsloth.exe").write_text("irreplaceable", encoding = "utf-8")
(target / "Lib").mkdir()
(target / "Lib" / "stayed.py").write_text("stayed", encoding = "utf-8")
(backup / "Lib" / "site-packages").mkdir(parents = True)
(backup / "Lib" / "site-packages" / "marker.txt").write_text("moved", encoding = "utf-8")
(backup / "Lib" / "other.py").write_text("other", encoding = "utf-8")
(backup / "pyvenv.cfg").write_text("cfg", encoding = "utf-8")
(backup / "unsloth_install_manifest.json").write_text("{}", encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
function substep {{ param([string]$Text, [string]$Color) }}
# The merge/restore helpers warn through install.ps1's UTF-8 stdout sink on their
# conflict branches. This run does not take one, but leaving the sink undefined
# means any future case that does would die on a command-not-found instead.
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
{blocks}
$script:StudioVenvRollbackActive = $true
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
$script:StudioVenvRollbackPartial = $true
Restore-StudioVenvRollback
Write-Output ("active=" + $script:StudioVenvRollbackActive)
"""
env = os.environ.copy()
env["TEST_BACKUP_DIR"] = str(backup)
env["TEST_TARGET_DIR"] = str(target)
out = _run_powershell(shell, script, env)
restored = sorted(
str(p.relative_to(target)).replace("\\", "/") for p in target.rglob("*") if p.is_file()
)
assert restored == [
"Lib/other.py",
"Lib/site-packages/marker.txt",
"Lib/stayed.py",
"Scripts/unsloth.exe",
"pyvenv.cfg",
"unsloth_install_manifest.json",
], out
assert not backup.exists(), out
assert "active=False" in out, out
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
@pytest.mark.parametrize("side", ["destination", "source"])
def test_merging_a_split_move_never_walks_through_a_link(tmp_path: Path, shell: str, side: str):
"""A junction on either side is a leaf, not a subtree to recurse into.
Recursing through one moves venv files to wherever the link points, outside
$StudioHome, and replaces the link with a real directory. Either half can carry
the link, so both directions are pinned here.
"""
source = INSTALL_PS1.read_text(encoding = "utf-8")
blocks = "".join(
_extract(rf" function {name} \{{.*?\n \}}\n", source)
for name in (
"Remove-StudioVenvTreeWithRetry",
"Merge-StudioVenvRollbackTree",
"Restore-StudioVenvRollback",
)
)
target = tmp_path / "unsloth_studio"
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
# A sibling of the environment, never under it.
outside = tmp_path / "outside"
outside.mkdir()
(outside / "keep.txt").write_text("untouched", encoding = "utf-8")
target.mkdir()
backup.mkdir()
linked, real = (target, backup) if side == "destination" else (backup, target)
_link_dir(linked / "Lib", outside)
(real / "Lib").mkdir()
(real / "Lib" / "payload.txt").write_text("venv-only", encoding = "utf-8")
script = f"""
$ErrorActionPreference = "Stop"
function substep {{ param([string]$Text, [string]$Color) }}
# Restore-StudioVenvRollback warns through install.ps1's UTF-8 stdout sink. Echo it
# rather than swallowing it, so a warning stays visible in the assertion message.
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
{blocks}
$script:StudioVenvRollbackActive = $true
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
$script:StudioVenvRollbackPartial = $true
Restore-StudioVenvRollback
Write-Output ("active=" + $script:StudioVenvRollbackActive)
"""
env = os.environ.copy()
env["TEST_BACKUP_DIR"] = str(backup)
env["TEST_TARGET_DIR"] = str(target)
out = _run_powershell(shell, script, env)
# Nothing from the environment may be written through the link.
assert not (outside / "payload.txt").exists(), out
assert sorted(p.name for p in outside.iterdir()) == ["keep.txt"], out
assert (outside / "keep.txt").read_text(encoding = "utf-8") == "untouched", out
# An unresolved conflict keeps both copies, so the rollback stays tracked.
assert "active=True" in out, out
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
@pytest.mark.parametrize("shell", POWERSHELLS)
@pytest.mark.parametrize("case", ["missing", "unlaunchable", "working"])
def test_managed_python_readiness_probe(tmp_path: Path, shell: str, case: str):
source = INSTALL_PS1.read_text(encoding = "utf-8")
readiness = _extract(r" function Test-VenvPythonReady \{.*?\n \}\n", source)
python_exe = tmp_path / "broken-python.cmd"
expected = "False"
if case == "unlaunchable":
python_exe.write_text("@exit /b 17\n", encoding = "utf-8")
elif case == "working":
python_exe = Path(sys.executable)
expected = "True"
script = f"""
$ErrorActionPreference = "Stop"
{readiness}
Write-Output (Test-VenvPythonReady -PythonExe $env:TEST_MANAGED_PYTHON)
"""
env = os.environ.copy()
env["TEST_MANAGED_PYTHON"] = str(python_exe)
assert _run_powershell(shell, script, env) == expected
def test_readiness_gate_precedes_installs_and_names_both_interpreters():
source = INSTALL_PS1.read_text(encoding = "utf-8")
gate = source.index("if (-not (Test-VenvPythonReady -PythonExe $VenvPython))")
marker = source.index(
'[System.IO.File]::WriteAllText((Join-Path $VenvDir ".unsloth-studio-owned"), "")'
)
# Anchored past the command token: uv is invoked as the resolved $script:UvExe.
first_uv_pip = source.index("pip install --python $VenvPython")
gpu_detection = source.index("function Invoke-AmdSmiNoElevate")
assert marker < gate < gpu_detection < first_uv_pip
assert 'Write-StudioLine " Managed Python: $VenvPython"' in source
assert 'Write-StudioLine " Recorded base Python home: $recordedBaseHome"' in source
assert 'return (Exit-InstallFailure "Managed Python is unavailable' in source