#!/usr/bin/env pwsh # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 # The installer must not depend on the generated unsloth.exe console script (#8490). # # On Windows, packaging materializes `unsloth = unsloth_cli:app` as an unsigned launcher .exe. # AppLocker, WDAC and Smart App Control deny it while the venv's python.exe -- a copy of the # signed CPython binary -- still runs, so the install died at "running unsloth studio setup" # with no exit code to report and no diagnostic worth reading. # # These checks pin the three things that fix has to get right: the failure is classified off the # exception (1260), never off $LASTEXITCODE, which no process was created to set; the CLI is # reached through the interpreter with the trampoline intact as ONE argument; and the .cmd # companion is byte-stable, so a re-run rewrites nothing. # Run: pwsh -NoProfile -File tests/studio/test_application_control_cli_fallback.ps1 $ErrorActionPreference = "Stop" $repo = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, "..", ".."))).Path $install = Join-Path $repo "install.ps1" # The one canonical spelling, repeated in install.ps1, studio/src-tauri/src/process.rs and # unsloth_cli/commands/studio.py. Written out here rather than read from any of them, so a # silent edit on any side fails a check instead of being copied into the expectation. Both # halves are load bearing; the rationale is on WINDOWS_CLI_ENTRYPOINT in process.rs. # Written out for the same reason as the trampoline: an edit on either side has to fail a # check rather than be copied into the expectation. This one gates a recursive delete. $ShimMarker = "unsloth-studio-managed-launcher" $Trampoline = "import sys, os; sys.path[:1] = [x for x in sys.path[:1] if getattr(sys.flags, 'safe_path', False) or x not in ('', os.getcwd())]; sys.argv[0] = 'unsloth'; from unsloth_cli import app; sys.exit(app())" function Get-FunctionText { param([string] $Path, [string] $Name) $tokens = $null; $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors) if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "$Path has parse errors" } $fn = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $Name }, $true) if ($fn.Count -ne 1) { throw "expected exactly one $Name in $Path, found $($fn.Count)" } return $fn[0].Extent.Text } # Every installer function the extracted ones call has to be in the harness, or a # code path that reaches it dies with CommandNotFound and the check above it passes # for the wrong reason. Exactly that shipped twice: Write-StudioLine missing from the # mutex harness in tests/python, and Test-UnslothCmdShimFile missing from Invoke-Write # below the moment the writer started asking it. function Assert-HarnessComplete { param([string] $Extracted, [string[]] $Provided, [string] $Label) $installerFunctions = @([regex]::Matches( (Get-Content -Raw -LiteralPath $install), '(?m)^ function ([\w-]+) \{' ) | ForEach-Object { $_.Groups[1].Value }) $called = @([regex]::Matches($Extracted, '(? null" ($null -eq (Invoke-Relative "C:\s\bin" "D:\v\Scripts\python.exe")) Check "empty input -> null" ($null -eq (Invoke-Relative "" "C:\s\python.exe")) # A descending range counts backwards in PowerShell, so an unguarded tail would answer # "a\C:" here rather than refusing. Check "target above the base -> null" ($null -eq (Invoke-Relative "C:\a\b\bin" "C:\a")) Check "identical paths -> null" ($null -eq (Invoke-Relative "C:\a\bin" "C:\a\bin")) # --- Get-UnslothCmdShimContent -------------------------------------------------------------- function Invoke-Content { param([string] $ShimDir, [string] $PythonPath) $sb = [scriptblock]::Create(@" param(`$ShimDir, `$PythonPath) `$script:UnslothCmdShimMarker = "$ShimMarker" `$script:UnslothCliTrampoline = "$Trampoline" $relFn $contentFn Get-UnslothCmdShimContent -ShimDir `$ShimDir -PythonPath `$PythonPath "@) return (& $sb $ShimDir $PythonPath) } $shimDir = "C:\Users\Jane Doe\.unsloth\studio\bin" $shimPy = "C:\Users\Jane Doe\.unsloth\studio\unsloth_studio\Scripts\python.exe" $body = Invoke-Content $shimDir $shimPy Write-Host "the .cmd is a pure function of %~dp0" Check "it starts with echo off" ($body.StartsWith("@echo off`r`n")) Check "it reaches python via %~dp0" ($body -match '"%~dp0\.\.\\unsloth_studio\\Scripts\\python\.exe"') # The whole reason for %~dp0: a profile path with a space, a '$' or a bracket never has to be # escaped, because it is never written down. Check "the install path is absent" (-not ($body -match 'Jane Doe')) Check "the trampoline is quoted" ($body.Contains('-X utf8 -c "' + $Trampoline + '" %*')) Check "the shim is not isolated" (-not ($body -match '\s-I\s')) # cmd /V:ON, or the machine-wide DelayedExpansion default, eats a '!' out of every # argument before python sees it unless the scope turns it off. Check "delayed expansion is off" ($body.Contains("`r`nsetlocal DisableDelayedExpansion`r`n")) Check "it forwards every argument" ($body -match '%\*') Check "it propagates the exit code" ($body -match '@exit /b %errorlevel%') Check "CRLF line endings" (($body -split "`r`n").Count -ge 6 -and -not ($body -match "(?&2`r`n" [System.IO.File]::WriteAllText($chatty, $body, (New-Object System.Text.UTF8Encoding($false))) } else { $chatty = Join-Path $probeTmp "chatty.sh" $body = "#!/bin/sh`n" $body += "i=0`n" $body += "while [ `$i -lt $repeats ]; do echo '$line'; echo '$line' >&2; i=`$((i+1)); done`n" [System.IO.File]::WriteAllText($chatty, $body, (New-Object System.Text.UTF8Encoding($false))) & chmod +x $chatty } $sb = [scriptblock]::Create(@" param(`$Path) $blockFn $probeFn Test-ShimLaunchBlocked -Path `$Path "@) $sw = [System.Diagnostics.Stopwatch]::StartNew() $blocked = & $sb $chatty $sw.Stop() Write-Host "a launcher that outfills the pipe buffer does not stall the probe" Check "extraction kept the drain" ($probeFn -match 'ReadToEndAsync') # -1 -lt anything, so an absent drain would pass this on order alone. Check "drains before it waits" ($probeFn.IndexOf('ReadToEndAsync') -ge 0 -and $probeFn.IndexOf('ReadToEndAsync') -lt $probeFn.IndexOf('WaitForExit')) # A launcher that ran is not blocked, whatever it printed. Check "a chatty launcher is not blocked" (-not $blocked) Check "and it did not hit the timeout" ($sw.ElapsedMilliseconds -lt 10000) } finally { Remove-Item -LiteralPath $probeTmp -Recurse -Force -ErrorAction SilentlyContinue } # --- Test-UnslothCmdShimPreferred ----------------------------------------------------------- # Which launcher the printed instructions name. Test-ShimLaunchBlocked answers $false for a # file that is not there, because nothing refused to start it, so a quarantined or never-created # .exe used to leave the installer printing a path that does not exist. $prefTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("unsloth-pref-" + [guid]::NewGuid().ToString("N")) $null = New-Item -ItemType Directory -Path $prefTmp try { $prefSb = [scriptblock]::Create(@" param(`$ShimExe, `$ShimCmd) $blockFn $probeFn $preferFn Test-UnslothCmdShimPreferred -ShimExe `$ShimExe -ShimCmd `$ShimCmd "@) $exe = Join-Path $prefTmp "unsloth.exe" $cmd = Join-Path $prefTmp "unsloth.cmd" Write-Host "the instructions name the .cmd only when the .exe cannot serve" # Nothing written yet: no .cmd to name. Check "no .cmd, no preference" (-not (& $prefSb $exe $cmd)) [System.IO.File]::WriteAllText($cmd, "@echo off`r`n") # This is the regression: the .exe is gone, so naming it prints a dead path. Check "quarantined .exe prefers it" (& $prefSb $exe $cmd) # A runnable .exe wins, which is what every unaffected machine has. Copy-Item -LiteralPath (Get-Command pwsh).Source -Destination $exe -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $exe) { Check "a runnable .exe keeps it" (-not (& $prefSb $exe $cmd)) } } finally { Remove-Item -LiteralPath $prefTmp -Recurse -Force -ErrorAction SilentlyContinue } # --- the .cmd ownership marker --------------------------------------------------------------- # _IsStudioRoot accepts a bin\unsloth.cmd as proof that a user-named root is ours, and that # answer gates a recursive delete. `from unsloth_cli import app` alone is a line anyone could # have in a hand-rolled wrapper, so the generated shim carries a marker nobody writes by # accident and every check requires it. $markerSb = [scriptblock]::Create(@" param(`$Path) `$script:UnslothCmdShimMarker = "$ShimMarker" $shimFileFn Test-UnslothCmdShimFile -Path `$Path "@) $markerTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("unsloth-marker-" + [guid]::NewGuid().ToString("N")) $null = New-Item -ItemType Directory -Path $markerTmp try { $ours = Join-Path $markerTmp "ours.cmd" [System.IO.File]::WriteAllText($ours, "@echo off`r`nrem unsloth-studio-managed-launcher`r`n... from unsloth_cli import app ...`r`n") $theirs = Join-Path $markerTmp "theirs.cmd" [System.IO.File]::WriteAllText($theirs, "@echo off`r`npython -c `"from unsloth_cli import app; sys.exit(app())`" %*`r`n") Write-Host "only the shim this installer generated proves ownership" Check "our shim is recognised" (& $markerSb $ours) Check "a hand-rolled wrapper is not" (-not (& $markerSb $theirs)) Check "the generator emits the marker" ($installText -match 'rem \$script:UnslothCmdShimMarker') # The uninstaller gates a recursive delete on the same answer. Check "the uninstaller requires it too" ($uninstallText -match 'unsloth-studio-managed-launcher') } finally { Remove-Item -LiteralPath $markerTmp -Recurse -Force -ErrorAction SilentlyContinue } Write-Host "" if ($failures -gt 0) { Write-Host "$failures check(s) failed" -ForegroundColor Red; exit 1 } Write-Host "All checks passed" -ForegroundColor Green