From d50a2e2d07ce6a92e398ab5a59b31d821df1f312 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 16 Jun 2026 04:00:18 -0700 Subject: [PATCH] Studio: remove the Windows VBS launcher to clear the Kaspersky false positive (#6326) * Studio: drop the VBS launcher to clear the Kaspersky false positive The Windows shortcut launched Unsloth Studio through wscript.exe -> launch-studio.vbs, and that VBS used CreateObject("WScript.Shell").Run to start a hidden -ExecutionPolicy Bypass PowerShell. That wscript + .vbs + bypass-powershell shape is the canonical trigger for generic VBS-dropper heuristics (Kaspersky HEUR:Trojan.VBS.Agent.gen). The launcher is benign; only its shape is the problem. - install.ps1: stop generating launch-studio.vbs and point the Desktop / Start Menu .lnk straight at powershell.exe -WindowStyle Hidden running launch-studio.ps1. The shortcut is saved WindowStyle 7 (minimized) so the brief console flash is muted. launch-studio.ps1 (health poll, port, mutex, browser) is byte-for-byte unchanged. - install.ps1: delete a pre-existing launch-studio.vbs on upgrade, so the flagged file does not linger on machines that already installed it. - install.ps1 / install.sh: run the heavier ie4uinit -ClearIconCache plus StartMenuExperienceHost tile-cache rebuild only on a first install or a real icon change, instead of on every no-op reinstall. That repeated clear-cache plus kill cluster is itself a dropper-like behavioral pattern. - tests: forbid VBS generation and require the legacy-VBS cleanup. Linux, macOS and WSL install paths are unchanged. WSL already targets wsl.exe from its .lnk and never used a VBS; its only change is the same icon-cache gating. * Studio: add launcher-chain smoke coverage to the Windows UI CI The shortcut launch path was previously untested: studio-windows-ui-smoke installed then booted `unsloth studio` directly, so a broken .lnk could ship silently. After install the job now seeds a legacy launch-studio.vbs, asserts the upgrade removed it, asserts the .lnk targets hidden powershell.exe (never wscript.exe), and launches via the shortcut's stored command, waiting for /api/health to report healthy. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/studio-windows-ui-smoke.yml | 74 ++++++++++ install.ps1 | 126 +++++++++++++----- install.sh | 54 +++++--- .../install/test_launch_studio_launcher.py | 65 +++++---- 4 files changed, 241 insertions(+), 78 deletions(-) diff --git a/.github/workflows/studio-windows-ui-smoke.yml b/.github/workflows/studio-windows-ui-smoke.yml index 40d8e530cd..00458d213b 100644 --- a/.github/workflows/studio-windows-ui-smoke.yml +++ b/.github/workflows/studio-windows-ui-smoke.yml @@ -136,6 +136,17 @@ jobs: } } + - name: Seed a legacy launch-studio.vbs (upgrade-cleanup check) + # Simulate a pre-hardening install so the post-install assertion below + # proves the installer DELETES an existing launch-studio.vbs (the exact + # Kaspersky-flagged file), not merely stops generating it. + shell: pwsh + run: | + $appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio' + New-Item -ItemType Directory -Force -Path $appDir | Out-Null + Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode + Write-Host "seeded legacy launch-studio.vbs at $appDir" + - name: Install Studio (--local, --no-torch) # install.ps1 is the supported Windows installer. install.sh # has no Windows branch (apt-get / brew calls). The PS1 @@ -192,6 +203,69 @@ jobs: echo "install.ps1 installed the Windows prebuilt llama.cpp:" cat "$INFO" + - name: Assert Studio launcher chain (no VBS, hidden PowerShell shortcut) + # The shortcut launch path is otherwise untested here (the steps below + # boot `unsloth studio` directly). Guard against re-introducing the VBS + # that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk + # pointing anywhere other than hidden PowerShell over launch-studio.ps1. + shell: pwsh + run: | + $appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio' + if (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.vbs')) { + throw "regression: launch-studio.vbs exists (the Kaspersky VBS-FP shape)" + } + if (-not (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.ps1'))) { + throw "missing launch-studio.ps1 in $appDir" + } + $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk' + if (-not (Test-Path -LiteralPath $lnk)) { + $lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk' + } + if (-not (Test-Path -LiteralPath $lnk)) { throw "no Unsloth Studio.lnk on Desktop or Start Menu" } + $sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk) + Write-Host "shortcut target: $($sc.TargetPath)" + Write-Host "shortcut args: $($sc.Arguments)" + if ($sc.TargetPath -match 'wscript\.exe$') { throw "shortcut still targets wscript.exe (VBS host)" } + if ($sc.TargetPath -notmatch 'powershell\.exe$') { throw "unexpected shortcut target: $($sc.TargetPath)" } + if ($sc.Arguments -notmatch '-WindowStyle Hidden') { + throw "shortcut must launch windowless (-WindowStyle Hidden)" + } + Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)" + + - name: Launch Studio via the shortcut and assert health + # Run the exact command the .lnk stores (hidden PowerShell over + # launch-studio.ps1) and confirm it brings the backend up. This is the + # only step that proves the shortcut launch is not silently broken. + # Default port range is 8888-8908; the later UI tests use 18896/18897, so + # there is no conflict, and we tear this server down before they boot. + shell: pwsh + run: | + $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk' + if (-not (Test-Path -LiteralPath $lnk)) { + $lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk' + } + $sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk) + Write-Host "launching: $($sc.TargetPath) $($sc.Arguments)" + Start-Process -FilePath $sc.TargetPath -ArgumentList $sc.Arguments -WorkingDirectory $sc.WorkingDirectory + $foundPort = 0 + foreach ($i in 1..180) { + foreach ($port in 8888..8908) { + try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/health" -TimeoutSec 1 + if ($r.status -eq 'healthy' -and $r.service -eq 'Unsloth UI Backend') { $foundPort = $port; break } + } catch {} + } + if ($foundPort) { break } + Start-Sleep -Seconds 1 + } + # Tear down the shortcut-launched server before the main UI tests boot. + try { + $owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess + if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null } + } catch {} + if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" } + Write-Host "Studio healthy on port $foundPort (launched via the shortcut)" + - name: Add Studio shim to GITHUB_PATH # install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe # and adds that dir to the User PATH via the Windows registry. diff --git a/install.ps1 b/install.ps1 index 367dbe12f6..c6e7e599cb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -537,7 +537,6 @@ function Install-UnslothStudio { } $appDir = $StudioDataDir $launcherPs1 = Join-Path $appDir "launch-studio.ps1" - $launcherVbs = Join-Path $appDir "launch-studio.vbs" $desktopDir = [Environment]::GetFolderPath("Desktop") $desktopLink = if ($desktopDir -and $desktopDir.Trim()) { Join-Path $desktopDir "Unsloth Studio.lnk" @@ -830,19 +829,30 @@ 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) - # shell.Run(cmd, 0, ...) already hides the window, so -WindowStyle Hidden - # is redundant; omitting it trims an AV-heuristic token (Kaspersky FP). - $vbsContent = @" -Set shell = CreateObject("WScript.Shell") -cmd = "powershell -NoProfile -ExecutionPolicy Bypass -File ""$launcherPs1""" -shell.Run cmd, 0, False -"@ - # WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths. - Set-Content -LiteralPath $launcherVbs -Value $vbsContent -Encoding Unicode -Force + # 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 + # shortcuts instead point straight at powershell.exe running + # launch-studio.ps1 with a hidden window (selected below). + + # Delete any launch-studio.vbs left by a pre-hardening install. New + # installs no longer generate it, but an upgrade that merely stopped + # generating it would leave the exact file AV flags on disk, so remove + # it explicitly. Covers default and env-mode installs (same $appDir). + $legacyLauncherVbs = Join-Path $appDir "launch-studio.vbs" + if (Test-Path -LiteralPath $legacyLauncherVbs) { + Remove-Item -LiteralPath $legacyLauncherVbs -Force -ErrorAction SilentlyContinue + } # Prefer bundled icon from local clone/dev installs. # If not available, best-effort download from raw GitHub. # We only attach the icon if the resulting file has a valid ICO header. + # Snapshot the existing icon first so we can tell whether it actually + # changed and gate the heavier icon-cache refresh on a real change. + $preIconHash = $null + if (Test-Path -LiteralPath $iconPath) { + try { $preIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash } catch {} + } $hasValidIcon = $false if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) { try { @@ -878,6 +888,24 @@ shell.Run cmd, 0, False } } + # Did the icon content actually change vs the previous install? + # Only a real change (or a first/removed icon) should trigger the heavy + # refresh; a no-op reinstall with no icon at all must not. + $iconChanged = $false + if ($hasValidIcon) { + if (-not $preIconHash) { + $iconChanged = $true + } else { + try { + $postIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash + $iconChanged = ($postIconHash -ne $preIconHash) + } catch { $iconChanged = $true } + } + } elseif ($preIconHash) { + # A previously present icon was removed or invalidated. + $iconChanged = $true + } + # Env-mode: skip persistent Desktop / Start Menu .lnk shortcuts # that may point at a deleted workspace; launcher + icon stay. if ($StudioRedirectMode -eq 'env') { @@ -885,8 +913,22 @@ shell.Run cmd, 0, False return } - $wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe" - $shortcutArgs = "//B //Nologo `"$launcherVbs`"" + # Whether this is effectively a first install (no pre-existing .lnk). + # Used to gate the heavier icon-cache refresh below so a no-op reinstall + # does not repeatedly clear caches / restart StartMenuExperienceHost -- + # a behavioral cluster AV heuristics can score as dropper-like. + $firstInstall = -not ( + ($desktopLink -and (Test-Path -LiteralPath $desktopLink)) -or + ($startMenuLink -and (Test-Path -LiteralPath $startMenuLink)) + ) + + # Launch transport for the shortcuts: powershell.exe runs + # 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). + $powershellForLnk = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $shortcutTarget = $powershellForLnk + $shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$launcherPs1`"" try { $wshell = New-Object -ComObject WScript.Shell @@ -896,9 +938,11 @@ shell.Run cmd, 0, False if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue } try { $shortcut = $wshell.CreateShortcut($linkPath) - $shortcut.TargetPath = $wscriptExe + $shortcut.TargetPath = $shortcutTarget $shortcut.Arguments = $shortcutArgs $shortcut.WorkingDirectory = $appDir + # Start minimized so the brief PowerShell console flash is muted. + $shortcut.WindowStyle = 7 $shortcut.Description = "Launch Unsloth Studio" if ($hasValidIcon) { $shortcut.IconLocation = "$iconPath,0" @@ -912,15 +956,13 @@ shell.Run cmd, 0, False } if ($createdShortcutCount -gt 0) { substep "Created Unsloth Studio shortcut" - # Force Explorer to re-read each new shortcut's icon so it renders - # immediately instead of a stale/generic entry (a same-name .lnk - # recreated across reinstalls keeps Explorer's cached per-item icon). - # The reliable, non-disruptive fix (no explorer restart) is a per-item - # SHChangeNotify SHCNE_UPDATEITEM + SHCNF_PATHW per .lnk; the global - # SHCNE_ASSOCCHANGED broadcast alone does NOT recover a stale item. - # Also clear the on-disk icon cache (covers heavier staleness). - try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {} - try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {} + # Always do the cheap, non-disruptive per-item refresh so a + # rewritten same-name .lnk renders with its new target/icon + # immediately (a same-name .lnk recreated across reinstalls keeps + # Explorer's cached per-item icon). The reliable fix (no explorer + # restart) is a per-item SHChangeNotify SHCNE_UPDATEITEM + + # SHCNF_PATHW per .lnk; the global SHCNE_ASSOCCHANGED broadcast + # alone does NOT recover a stale item. try { Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int eventId, uint flags, string item1, System.IntPtr item2);' -ErrorAction SilentlyContinue # SHCNE_UPDATEITEM (0x00002000) + SHCNF_PATHW (0x0005) per shortcut @@ -930,21 +972,31 @@ shell.Run cmd, 0, False # SHCNE_ASSOCCHANGED (0x08000000) global refresh (belt-and-suspenders) [UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, $null, [System.IntPtr]::Zero) } catch {} - # Win11's Start Menu (StartMenuExperienceHost) keeps its OWN - # pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT - # invalidate, so a rewritten same-name shortcut shows the old tile - # until the host restarts. Drop only the render caches (NEVER - # start2.bin -- the pinned layout) and let the host rebuild. - # Best-effort; Win10 has no such host (Test-Path skips it). - try { - $smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState" - if (Test-Path -LiteralPath $smehTemp) { - Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue - Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue - } - } catch {} + # Heavier on-disk icon-cache clear + StartMenuExperienceHost tile + # rebuild only when the icon actually changed or this is a first + # install. Running "clear icon cache + kill StartMenuExperienceHost" + # on every no-op reinstall is a dropper-like behavioral cluster and + # is unnecessary when the icon is unchanged (the per-item notify + # above already refreshes the rewritten shortcut). + if ($firstInstall -or $iconChanged) { + try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {} + try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {} + # Win11's Start Menu (StartMenuExperienceHost) keeps its OWN + # pre-rendered tile-icon cache that ie4uinit/explorer restart do NOT + # invalidate, so a rewritten same-name shortcut shows the old tile + # until the host restarts. Drop only the render caches (NEVER + # start2.bin -- the pinned layout) and let the host rebuild. + # Best-effort; Win10 has no such host (Test-Path skips it). + try { + $smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState" + if (Test-Path -LiteralPath $smehTemp) { + Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue + Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue + } + } catch {} + } } else { substep "no Unsloth Studio shortcuts were created" "Yellow" } diff --git a/install.sh b/install.sh index fa0535a51b..759d87e0b0 100755 --- a/install.sh +++ b/install.sh @@ -1261,6 +1261,10 @@ if (-not \$targetExe) { exit 1 } # native install if one exists) so the WSL shortcut shows the proper icon. \$iconDir = Join-Path \$env:LOCALAPPDATA 'Unsloth Studio' \$iconPath = Join-Path \$iconDir 'unsloth.ico' +\$preIconHash = \$null +if (Test-Path -LiteralPath \$iconPath) { + try { \$preIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash } catch {} +} if (-not (Test-Path -LiteralPath \$iconPath)) { try { New-Item -ItemType Directory -Force -Path \$iconDir | Out-Null @@ -1276,9 +1280,11 @@ if (Test-Path -LiteralPath \$iconPath) { (Join-Path \$env:APPDATA 'Microsoft\Windows\Start Menu\Programs') ) \$created = @() +\$firstShortcut = \$false foreach (\$dir in \$locations) { if (-not \$dir -or -not (Test-Path \$dir)) { continue } \$linkPath = Join-Path \$dir '$_css_lnk_name_ps' + if (-not (Test-Path -LiteralPath \$linkPath)) { \$firstShortcut = \$true } \$shortcut = \$WshShell.CreateShortcut(\$linkPath) \$shortcut.TargetPath = \$targetExe \$shortcut.Arguments = '$_css_sc_args_ps' @@ -1287,27 +1293,43 @@ foreach (\$dir in \$locations) { \$shortcut.Save() \$created += \$linkPath } -# Force Explorer to re-read EACH new shortcut's icon so it renders immediately -# instead of a stale/blank (generic) icon. The reliable, NON-disruptive fix -# (no explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM, -# SHCNF_PATHW, ) -- the global SHCNE_ASSOCCHANGED alone does not recover a -# stale item. Also clear the on-disk icon cache for heavier staleness. -try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {} -try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {} +\$iconChanged = \$false +if (\$hasIcon) { + if (-not \$preIconHash) { + \$iconChanged = \$true + } else { + try { + \$postIconHash = (Get-FileHash -LiteralPath \$iconPath -Algorithm SHA256).Hash + \$iconChanged = (\$postIconHash -ne \$preIconHash) + } catch { \$iconChanged = \$true } + } +} elseif (\$preIconHash) { + \$iconChanged = \$true +} +# Per-item refresh always (cheap, non-disruptive) so the rewritten .lnk renders +# immediately instead of a stale/blank (generic) icon. The reliable fix (no +# explorer restart) is a PER-ITEM SHChangeNotify(SHCNE_UPDATEITEM, SHCNF_PATHW, +# ) -- the global SHCNE_ASSOCCHANGED alone does not recover a stale item. try { Add-Type -Namespace UnslothShell -Name IconRefresh -MemberDefinition '[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] public static extern void SHChangeNotify(int e, uint f, string a, System.IntPtr b);' -ErrorAction SilentlyContinue foreach (\$p in \$created) { try { [UnslothShell.IconRefresh]::SHChangeNotify(0x00002000, 0x0005, \$p, [System.IntPtr]::Zero) } catch {} } [UnslothShell.IconRefresh]::SHChangeNotify(0x08000000, 0, \$null, [System.IntPtr]::Zero) } catch {} -# Win11 Start Menu keeps its own tile-icon cache (preserve start2.bin). -try { - \$smeh = Join-Path \$env:LOCALAPPDATA 'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState' - if (Test-Path -LiteralPath \$smeh) { - Get-ChildItem -LiteralPath \$smeh -Filter 'TileCache_*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath (Join-Path \$smeh 'StartUnifiedTileModelCache.dat') -Force -ErrorAction SilentlyContinue - Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue - } -} catch {} +# Heavier on-disk icon-cache clear + StartMenuExperienceHost tile rebuild +# (preserve start2.bin) only on first install or a real icon change, so a no-op +# WSL reinstall does not run a dropper-like clear-cache + kill cluster each time. +if (\$created.Count -gt 0 -and (\$firstShortcut -or \$iconChanged)) { + try { & "\$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache } catch {} + try { & "\$env:SystemRoot\System32\ie4uinit.exe" -show } catch {} + try { + \$smeh = Join-Path \$env:LOCALAPPDATA 'Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState' + if (Test-Path -LiteralPath \$smeh) { + Get-ChildItem -LiteralPath \$smeh -Filter 'TileCache_*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path \$smeh 'StartUnifiedTileModelCache.dat') -Force -ErrorAction SilentlyContinue + Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue + } + } catch {} +} WSLPS1_EOF # Convert WSL path to Windows path for powershell.exe diff --git a/tests/studio/install/test_launch_studio_launcher.py b/tests/studio/install/test_launch_studio_launcher.py index 8ea9ca7d80..8ea053b783 100644 --- a/tests/studio/install/test_launch_studio_launcher.py +++ b/tests/studio/install/test_launch_studio_launcher.py @@ -1,5 +1,9 @@ -"""Guard install.ps1's launch-studio.vbs against re-introducing the AV-heuristic -shape: a WScript .vbs spawning a hidden, ExecutionPolicy-Bypass PowerShell.""" +"""Guard install.ps1's Studio launcher against re-introducing the AV-heuristic +shape: a WScript .vbs that spawns a hidden, ExecutionPolicy-Bypass PowerShell +(Kaspersky HEUR:Trojan.VBS.Agent.gen). The shortcut must stay windowless via +powershell.exe -WindowStyle Hidden over launch-studio.ps1 -- never a .vbs / +WScript.Shell.Run wrapper. Any pre-existing .vbs from an older install must be +deleted, not merely left behind.""" import re from pathlib import Path @@ -10,40 +14,51 @@ REPO_ROOT = Path(__file__).resolve().parents[3] INSTALL_PS1 = REPO_ROOT / "install.ps1" -def _vbs_block() -> str: - text = INSTALL_PS1.read_text(encoding = "utf-8") - m = re.search(r'\$vbsContent\s*=\s*@"\r?\n(.*?)\r?\n"@', text, re.S) - assert m, "could not locate the $vbsContent here-string in install.ps1" - return m.group(1) +def _text() -> str: + return INSTALL_PS1.read_text(encoding = "utf-8") def test_install_ps1_present(): assert INSTALL_PS1.is_file(), f"missing {INSTALL_PS1}" -def test_vbs_does_not_pass_windowstyle_hidden(): - vbs = _vbs_block() - assert "-WindowStyle Hidden" not in vbs, ( - "launch-studio.vbs must not pass -WindowStyle Hidden to PowerShell: the " - "window is already hidden by shell.Run(cmd, 0, False); the redundant flag " - "only adds the hidden-PowerShell token that AV heuristics flag." +def test_no_vbs_launcher_generated(): + text = _text() + # No here-string that builds a .vbs body, and no .vbs file written. (A + # Remove-Item cleanup of the legacy .vbs is allowed and checked separately.) + assert "$vbsContent" not in text, ( + "install.ps1 must not generate a launch-studio.vbs: a WScript.Shell .vbs " + "spawning a hidden ExecutionPolicy-Bypass PowerShell is the exact shape " + "VBS-dropper heuristics flag (Kaspersky HEUR:Trojan.VBS.Agent.gen)." ) + assert 'CreateObject("WScript.Shell")' not in text + assert "shell.Run" not in text + assert not re.search(r"Set-Content\s+-LiteralPath\s+\$launcherVbs", text) + assert "//B //Nologo" not in text -def test_vbs_stays_windowless_via_shell_run(): - vbs = _vbs_block() - assert re.search(r"shell\.Run\s+cmd\s*,\s*0\s*,\s*False", vbs), ( - "launcher must remain windowless via shell.Run(cmd, 0, False) " - "(intWindowStyle 0 = hidden)." - ) +def test_legacy_vbs_removed_on_upgrade(): + # The whole point: an upgrade must DELETE a pre-existing launch-studio.vbs, + # not just stop generating it, or AV keeps flagging the stale file. + text = _text() + assert re.search( + r"Remove-Item\s+-LiteralPath\s+\$legacyLauncherVbs", text + ), "upgrades must remove a pre-existing launch-studio.vbs so AV stops flagging it" -def test_vbs_keeps_bypass_and_file_invocation(): - # Bypass lets the unsigned local .ps1 run under the default Restricted policy. - vbs = _vbs_block() - assert "-ExecutionPolicy Bypass" in vbs - assert "-File" in vbs - assert "powershell" in vbs +def test_shortcut_target_is_not_wscript(): + # The .lnk must not be launched through wscript.exe (the VBS script host). + text = _text() + assert "wscript.exe" not in text.lower() + + +def test_launcher_is_windowless_powershell(): + # The shortcut runs powershell.exe with a hidden window over launch-studio.ps1. + text = _text() + assert re.search( + r"-WindowStyle\s+Hidden", text + ), "the launcher must run powershell.exe with -WindowStyle Hidden over launch-studio.ps1." + assert "launch-studio.ps1" in text if __name__ == "__main__":