From c48171e2f06644b40f4a4d0bd29bf7e5fe05d1a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 17 Jul 2026 23:38:59 +0800 Subject: [PATCH] fix(desktop): harden Windows upgrades (#1028, #1029, #1036) --- desktop/build/check-install-processes.ps1 | 79 ++++++ desktop/build/installer.nsh | 214 +++++++++++++-- desktop/build/recover-legacy-install-data.ps1 | 247 ++++++++++++------ desktop/scripts/windows-installer-smoke.ps1 | 142 +++++++++- scripts/pr/release-workflow.test.ts | 3 +- .../windows-installer-process-check.test.ts | 75 ++++++ scripts/pr/windows-installer-recovery.test.ts | 94 +++++++ 7 files changed, 760 insertions(+), 94 deletions(-) create mode 100644 desktop/build/check-install-processes.ps1 create mode 100644 scripts/pr/windows-installer-process-check.test.ts create mode 100644 scripts/pr/windows-installer-recovery.test.ts diff --git a/desktop/build/check-install-processes.ps1 b/desktop/build/check-install-processes.ps1 new file mode 100644 index 00000000..54133451 --- /dev/null +++ b/desktop/build/check-install-processes.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$ProcessName, + [ValidateSet('Find', 'Kill', 'KillForce')][string]$Action = 'Find', + [int]$InstallerPid = 0, + [int]$InstallerParentPid = 0 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-PathInsideInstallDirectory { + param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][string]$Candidate + ) + + $resolvedRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + $resolvedCandidate = [IO.Path]::GetFullPath($Candidate) + $rootWithSeparator = $resolvedRoot + [IO.Path]::DirectorySeparatorChar + return $resolvedCandidate.StartsWith( + $rootWithSeparator, + [StringComparison]::OrdinalIgnoreCase) +} + +try { + $matches = New-Object 'System.Collections.Generic.List[object]' + $unknownPathMatches = New-Object 'System.Collections.Generic.List[object]' + foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + if ($process.ProcessId -eq $InstallerPid) { + continue + } + + $executablePath = [string]$process.ExecutablePath + if ([string]::IsNullOrWhiteSpace($executablePath)) { + if (([string]$process.Name).Equals($ProcessName, [StringComparison]::OrdinalIgnoreCase)) { + $unknownPathMatches.Add($process) + } + continue + } + + if (Test-PathInsideInstallDirectory -Root $InstallDir -Candidate $executablePath) { + $matches.Add($process) + } + } + + foreach ($process in $matches) { + $path = if ([string]::IsNullOrWhiteSpace([string]$process.ExecutablePath)) { + '' + } else { + [string]$process.ExecutablePath + } + [Console]::Out.WriteLine( + "Matched protected install process: PID=$($process.ProcessId); Name=$($process.Name); Path=$path") + } + + foreach ($process in $unknownPathMatches) { + [Console]::Out.WriteLine( + "Blocked unknown-path application process: PID=$($process.ProcessId); Name=$($process.Name); close it manually") + } + + if ($matches.Count -eq 0 -and $unknownPathMatches.Count -eq 0) { + exit 1 + } + if ($Action -eq 'Find') { + exit 0 + } + + $force = $Action -eq 'KillForce' + foreach ($process in $matches) { + Stop-Process -Id $process.ProcessId -Force:$force -ErrorAction Stop + } + exit 0 +} catch { + $message = ([string]$_.Exception.Message) -replace '[\r\n]+', ' ' + [Console]::Out.WriteLine("Install process check failed closed: $message") + exit 0 +} diff --git a/desktop/build/installer.nsh b/desktop/build/installer.nsh index 8d1a1699..420698dc 100644 --- a/desktop/build/installer.nsh +++ b/desktop/build/installer.nsh @@ -3,6 +3,7 @@ !define /ifndef INSTALL_REGISTRY_KEY "Software\${APP_GUID}" !define /ifndef UNINSTALL_REGISTRY_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}" Var pid +Var ccHahaProcessDiagnostic !ifndef BUILD_UNINSTALLER Var ccHahaRecoveryDone @@ -93,6 +94,71 @@ Function CcHahaFinalInstallDir Exch $R0 FunctionEnd +Function CcHahaCanSkipLegacyRecovery + Push $R3 + Push $R0 + Push $R1 + Push $R2 + + StrCpy $R0 "0" + ${If} $8 != "trusted-user" + Goto cc_haha_skip_recovery_done + ${EndIf} + ${If} $ccHahaPerUserInstallLocation == "" + Goto cc_haha_skip_recovery_done + ${EndIf} + ${If} $ccHahaPerMachineInstallLocation != "" + Goto cc_haha_skip_recovery_done + ${EndIf} + ${If} $ccHahaPerMachineUninstallString != "" + Goto cc_haha_skip_recovery_done + ${EndIf} + StrCmp $ccHahaPerUserInstallLocation $INSTDIR 0 cc_haha_skip_recovery_done + + ReadEnvStr $R1 APPDATA + ${If} $R1 == "" + Goto cc_haha_skip_recovery_done + ${EndIf} + ReadEnvStr $R0 CLAUDE_CONFIG_DIR + ${If} $R0 != "" + StrCpy $R0 "0" + Goto cc_haha_skip_recovery_done + ${EndIf} + StrCpy $R0 "0" + + IfFileExists "$ccHahaPerUserInstallLocation\CLAUDE_CONFIG_DIR\*.*" cc_haha_skip_recovery_done 0 + IfFileExists "$R1\Claude Code Haha\app-mode.json" cc_haha_check_default_mode 0 + StrCpy $R0 "1" + Goto cc_haha_skip_recovery_done + + cc_haha_check_default_mode: + ClearErrors + FileOpen $R2 "$R1\Claude Code Haha\app-mode.json" r + IfErrors cc_haha_skip_recovery_done 0 + FileRead $R2 $R3 + StrCmp $R3 '{$\n' 0 cc_haha_close_mode_file + FileRead $R2 $R3 + StrCmp $R3 ' "mode": "default",$\n' 0 cc_haha_close_mode_file + FileRead $R2 $R3 + StrCmp $R3 ' "portable_dir": null$\n' 0 cc_haha_close_mode_file + FileRead $R2 $R3 + StrCmp $R3 '}' 0 cc_haha_close_mode_file + ClearErrors + FileRead $R2 $R3 + IfErrors 0 cc_haha_close_mode_file + StrCpy $R0 "1" + + cc_haha_close_mode_file: + FileClose $R2 + + cc_haha_skip_recovery_done: + StrCpy $R3 $R0 + Pop $R2 + Pop $R1 + Pop $R0 + Exch $R3 +FunctionEnd + Function CcHahaRecoverLegacy ReadRegStr $4 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation ReadRegStr $5 HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation @@ -184,34 +250,152 @@ FunctionEnd ${AndIfNot} ${UAC_IsInnerInstance} StrCpy $8 "untrusted-elevated" ${EndIf} - ${If} ${UAC_IsInnerInstance} StrCpy $8 "trusted-uac-outer" - !insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR} - ${Else} - Call CcHahaRecoverLegacy ${EndIf} - ${If} $0 != "0" - DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)" - ${If} $1 == "" - StrCpy $1 "Recovery helper failed without diagnostic output (exit code $0)" + Call CcHahaCanSkipLegacyRecovery + Pop $R0 + ${If} $R0 == "1" + StrCpy $ccHahaRecoveryDone "1" + DetailPrint "No legacy data candidates found for the registered per-user installation" + ${Else} + ${If} ${UAC_IsInnerInstance} + !insertmacro UAC_AsUser_Call Function CcHahaRecoverLegacy ${UAC_SYNCREGISTERS}|${UAC_SYNCOUTDIR}|${UAC_SYNCINSTDIR} + ${Else} + Call CcHahaRecoverLegacy ${EndIf} - StrCpy $R2 "$1" 360 - MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha stopped setup before removing the old version. Reason: $R2$\r$\n$\r$\nClose the app and retry. If the reason mentions an elevated installer, launch setup normally instead of using Run as administrator.$\r$\n$\r$\nClaude Code Haha 已在删除旧版本前停止安装。原因:$R2$\r$\n$\r$\n请关闭旧程序后重试;如果原因提到安装器权限过高,请直接双击运行,不要使用“以管理员身份运行”。旧版本和原数据尚未删除。" /SD IDOK - SetErrorLevel 20 - Quit + + ${If} $0 != "0" + DetailPrint "Legacy data recovery stopped the installer (helper exit code: $0; output: $1)" + ${If} $1 == "" + StrCpy $1 "Recovery helper failed without diagnostic output (exit code $0)" + ${EndIf} + StrCpy $R2 "$1" 360 + MessageBox MB_ICONSTOP|MB_OK "Claude Code Haha stopped setup before removing the old version. Reason: $R2$\r$\n$\r$\nClose the app and retry. If the reason mentions an elevated installer, launch setup normally instead of using Run as administrator.$\r$\n$\r$\nClaude Code Haha 已在删除旧版本前停止安装。原因:$R2$\r$\n$\r$\n请关闭旧程序后重试;如果原因提到安装器权限过高,请直接双击运行,不要使用“以管理员身份运行”。旧版本和原数据尚未删除。" /SD IDOK + SetErrorLevel 20 + Quit + ${EndIf} + StrCpy $ccHahaRecoveryDone "1" + DetailPrint "Legacy Claude Code Haha data safety check completed" ${EndIf} - StrCpy $ccHahaRecoveryDone "1" - DetailPrint "Legacy Claude Code Haha data safety check completed" ${EndIf} ${EndIf} !macroend !endif +!macro CcHahaFindInstallProcess _FILE _RETURN + ${If} $IsPowerShellAvailable == 0 + nsExec::ExecToStack '"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\check-install-processes.ps1" -InstallDir "$INSTDIR" -ProcessName "${_FILE}" -Action Find -InstallerPid "$pid" -InstallerParentPid "$1"' + Pop ${_RETURN} + Pop $ccHahaProcessDiagnostic + ${If} $ccHahaProcessDiagnostic != "" + DetailPrint "$ccHahaProcessDiagnostic" + ${EndIf} + ${Else} + Delete "$PLUGINSDIR\cc-haha-processes.csv" + !ifdef INSTALL_MODE_PER_ALL_USERS + nsExec::Exec '"$CmdPath" /D /C tasklist /FO CSV /NH > "$PLUGINSDIR\cc-haha-processes.csv"' + !else + nsExec::Exec '"$CmdPath" /D /C tasklist /FI "USERNAME eq %USERNAME%" /FO CSV /NH > "$PLUGINSDIR\cc-haha-processes.csv"' + !endif + Pop ${_RETURN} + ${If} ${_RETURN} != 0 + StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable and tasklist process enumeration failed (exit code ${_RETURN}); blocking setup." + StrCpy ${_RETURN} 0 + ${Else} + nsExec::Exec '"$SYSDIR\findstr.exe" /I /L /C:"${_FILE}" /C:"claude-sidecar-x86_64-pc-windows-msvc.exe" /C:"claude-sidecar-aarch64-pc-windows-msvc.exe" /C:"claude-sidecar.exe" /C:"OpenConsole.exe" /C:"winpty-agent.exe" /C:"rg.exe" "$PLUGINSDIR\cc-haha-processes.csv"' + Pop ${_RETURN} + ${If} ${_RETURN} == 0 + StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; the main app, a known sidecar, or a bundled terminal/search helper is running with an unknown path. Close it manually." + ${ElseIf} ${_RETURN} == 1 + StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; exact-image fallback found no main app, known sidecar, or bundled terminal/search helper. Differently named child processes cannot be attributed without path data." + ${Else} + StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable and fallback process filtering failed (exit code ${_RETURN}); blocking setup." + StrCpy ${_RETURN} 0 + ${EndIf} + ${EndIf} + DetailPrint "$ccHahaProcessDiagnostic" + ${EndIf} +!macroend + +!macro CcHahaKillInstallProcess _FILE _FORCE + Push $0 + ${If} ${_FORCE} == 1 + StrCpy $0 "KillForce" + ${Else} + StrCpy $0 "Kill" + ${EndIf} + + ${If} $IsPowerShellAvailable == 0 + nsExec::ExecToStack '"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\check-install-processes.ps1" -InstallDir "$INSTDIR" -ProcessName "${_FILE}" -Action "$0" -InstallerPid "$pid" -InstallerParentPid "$1"' + Pop $0 + Pop $ccHahaProcessDiagnostic + ${If} $ccHahaProcessDiagnostic != "" + DetailPrint "$ccHahaProcessDiagnostic" + ${EndIf} + ${Else} + StrCpy $ccHahaProcessDiagnostic "PowerShell unavailable; refusing to terminate by image name because the executable path is unknown. Close the app manually." + DetailPrint "$ccHahaProcessDiagnostic" + ${EndIf} + Pop $0 +!macroend + !macro customCheckAppRunning + InitPluginsDir + File /oname=$PLUGINSDIR\check-install-processes.ps1 "${BUILD_RESOURCES_DIR}\check-install-processes.ps1" !insertmacro IS_POWERSHELL_AVAILABLE - !insertmacro _CHECK_APP_RUNNING + StrCpy $ccHahaProcessDiagnostic "" + ${GetProcessInfo} 0 $pid $1 $2 $3 $4 + ${If} $3 != "${APP_EXECUTABLE_FILENAME}" + ${If} ${isUpdated} + Sleep 300 + ${EndIf} + + !insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0 + ${If} $R0 == 0 + ${If} ${isUpdated} + Sleep 1000 + Goto cc_haha_stop_process + ${EndIf} + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDOK cc_haha_stop_process + SetErrorLevel 22 + Quit + + cc_haha_stop_process: + DetailPrint "$(appClosing)" + !insertmacro CcHahaKillInstallProcess "${APP_EXECUTABLE_FILENAME}" 0 + Sleep 300 + StrCpy $R1 0 + + cc_haha_process_retry: + IntOp $R1 $R1 + 1 + !insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0 + ${If} $R0 == 0 + Sleep 1000 + !insertmacro CcHahaKillInstallProcess "${APP_EXECUTABLE_FILENAME}" 1 + !insertmacro CcHahaFindInstallProcess "${APP_EXECUTABLE_FILENAME}" $R0 + ${If} $R0 == 0 + DetailPrint `Waiting for "${PRODUCT_NAME}" to close.` + Sleep 2000 + ${Else} + Goto cc_haha_process_not_running + ${EndIf} + ${Else} + Goto cc_haha_process_not_running + ${EndIf} + + ${If} $R1 > 1 + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)$\r$\n$\r$\n$ccHahaProcessDiagnostic" /SD IDCANCEL IDRETRY cc_haha_process_retry + SetErrorLevel 22 + Quit + ${Else} + Goto cc_haha_process_retry + ${EndIf} + + cc_haha_process_not_running: + ${EndIf} + ${EndIf} !ifndef BUILD_UNINSTALLER !insertmacro CcHahaRunLegacyRecovery !endif diff --git a/desktop/build/recover-legacy-install-data.ps1 b/desktop/build/recover-legacy-install-data.ps1 index 664a2063..ff9b53e5 100644 --- a/desktop/build/recover-legacy-install-data.ps1 +++ b/desktop/build/recover-legacy-install-data.ps1 @@ -17,83 +17,113 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -if (-not ('CcHahaRecoveryNativePath' -as [type])) { - Add-Type @' -using System; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using Microsoft.Win32.SafeHandles; +function Assert-NoUnsupportedPathAlias { + param([Parameter(Mandatory = $true)][string]$Path) -public static class CcHahaRecoveryNativePath -{ - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateFile( - string fileName, - uint desiredAccess, - uint shareMode, - IntPtr securityAttributes, - uint creationDisposition, - uint flagsAndAttributes, - IntPtr templateFile); + if ($Path.StartsWith('\\?\', [StringComparison]::OrdinalIgnoreCase) -or + $Path.StartsWith('\\.\', [StringComparison]::OrdinalIgnoreCase)) { + throw "Extended device or volume aliases cannot be recovered safely without a final-path helper: $Path" + } - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint GetFinalPathNameByHandle( - SafeFileHandle file, - StringBuilder path, - uint pathLength, - uint flags); + $fullPath = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($root) -or $root -notmatch '^[A-Za-z]:\\$') { + throw "Only a local volume drive path can be recovered safely: $Path" + } - public static string Resolve(string path) - { - const uint shareReadWriteDelete = 0x00000007; - const uint openExisting = 3; - const uint backupSemantics = 0x02000000; - using (SafeFileHandle handle = CreateFile( - path, - 0, - shareReadWriteDelete, - IntPtr.Zero, - openExisting, - backupSemantics, - IntPtr.Zero)) - { - if (handle.IsInvalid) { - throw new Win32Exception( - Marshal.GetLastWin32Error(), - "Cannot resolve the final path for " + path); - } - StringBuilder result = new StringBuilder(32768); - uint length = GetFinalPathNameByHandle(handle, result, (uint)result.Capacity, 0); - if (length == 0) { - throw new Win32Exception( - Marshal.GetLastWin32Error(), - "Cannot resolve the final path for " + path); - } - if (length >= (uint)result.Capacity) { - throw new InvalidOperationException("Resolved path is too long: " + path); - } - string value = result.ToString(); - if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) { - return @"\\" + value.Substring(8); - } - return value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase) - ? value.Substring(4) - : value; - } + $relative = $fullPath.Substring($root.Length) + $current = $root + foreach ($segment in $relative.Split( + [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries)) { + $next = [IO.Path]::Combine($current, $segment) + $exists = [IO.Directory]::Exists($next) -or [IO.File]::Exists($next) + if ($exists) { + try { + $exactEntry = Get-ChildItem -LiteralPath $current -Force -ErrorAction Stop | + Where-Object { ([string]$_.Name).Equals($segment, [StringComparison]::OrdinalIgnoreCase) } | + Select-Object -First 1 + } catch { + throw "Path segment identity cannot be proven because its parent cannot be enumerated: $next" + } + if ($null -eq $exactEntry) { + $item = Get-Item -LiteralPath $next -Force -ErrorAction SilentlyContinue + $resolvedName = if ($null -eq $item) { '' } else { [string]$item.Name } + throw "Alternate path alias cannot be recovered safely without a final-path helper: $next (resolved item name: $resolvedName)" + } + } elseif ($segment -match '^[^\\/:*?"<>|.~]{1,6}~[0-9]{1,6}(?:\.[^\\/:*?"<>|]{1,3})?$') { + throw "Possible 8.3 path alias cannot be proven safe because the segment does not exist: $next" } + $current = $next + } + + $subst = Join-Path $env:SystemRoot 'System32\subst.exe' + $substOutput = @(& $subst 2>$null) + $substDrive = $root.Substring(0, 2) + $substMatch = $substOutput | Where-Object { + ([string]$_).TrimStart().StartsWith($substDrive, [StringComparison]::OrdinalIgnoreCase) -and + ([string]$_).Contains('=>') + } | Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $null -ne $substMatch) { + throw "SUBST aliases cannot be recovered safely without a final-path helper: $Path" + } } -'@ + +function Assert-NoReparsePointInPath { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($root)) { + throw "Cannot determine the path root for $Path" + } + + $current = $root + $rootAttributes = [IO.File]::GetAttributes($current) + if (($rootAttributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Path root is a reparse point and cannot be recovered safely: $current" + } + $relative = $fullPath.Substring($root.Length) + $segments = $relative.Split( + [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), + [StringSplitOptions]::RemoveEmptyEntries) + foreach ($segment in $segments) { + $current = [IO.Path]::Combine($current, $segment) + $attributes = [IO.File]::GetAttributes($current) + if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Path contains a reparse point and cannot be recovered safely: $current" + } + } +} + +function Convert-ToCanonicalVolumePathIdentity { + param([Parameter(Mandatory = $true)][string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($fullPath) + $mountvol = Join-Path $env:SystemRoot 'System32\mountvol.exe' + $output = @(& $mountvol $root /L 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "Cannot resolve the canonical volume for $Path" + } + $volumeRoots = @($output | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { $_ -match '^\\\\\?\\Volume\{[0-9A-Fa-f-]+\}\\$' }) + if ($volumeRoots.Count -ne 1) { + throw "Cannot resolve one unambiguous canonical volume for $Path" + } + $relative = $fullPath.Substring($root.Length) + return [IO.Path]::Combine($volumeRoots[0], $relative) } function Resolve-CanonicalPath { param([Parameter(Mandatory = $true)][string]$Path) + Assert-NoUnsupportedPathAlias -Path $Path $fullPath = [IO.Path]::GetFullPath($Path) $existingPath = $fullPath $missingSegments = New-Object 'System.Collections.Generic.List[string]' - while (-not (Test-Path -LiteralPath $existingPath)) { + while (-not ([IO.Directory]::Exists($existingPath) -or [IO.File]::Exists($existingPath))) { $parent = [IO.Path]::GetDirectoryName($existingPath) if ([string]::IsNullOrEmpty($parent) -or $parent -eq $existingPath) { throw "Cannot resolve an existing ancestor for $Path" @@ -102,21 +132,30 @@ function Resolve-CanonicalPath { $existingPath = $parent } - $resolved = [CcHahaRecoveryNativePath]::Resolve($existingPath) + Assert-NoReparsePointInPath -Path $existingPath + $resolved = [IO.Path]::GetFullPath($existingPath) foreach ($segment in $missingSegments) { - $resolved = Join-Path $resolved $segment + $resolved = [IO.Path]::Combine($resolved, $segment) } return [IO.Path]::GetFullPath($resolved) } +function Get-CanonicalPathIdentity { + param([Parameter(Mandatory = $true)][string]$Path) + + # Keep the DOS path for filesystem operations; use the volume GUID form only as a comparison key. + $resolved = Resolve-CanonicalPath -Path $Path + return Convert-ToCanonicalVolumePathIdentity -Path $resolved +} + function Test-PathAtOrBelow { param( [Parameter(Mandatory = $true)][string]$Parent, [Parameter(Mandatory = $true)][string]$Candidate ) - $resolvedParent = (Resolve-CanonicalPath $Parent).TrimEnd('\', '/') - $resolvedCandidate = (Resolve-CanonicalPath $Candidate).TrimEnd('\', '/') + $resolvedParent = (Get-CanonicalPathIdentity $Parent).TrimEnd('\', '/') + $resolvedCandidate = (Get-CanonicalPathIdentity $Candidate).TrimEnd('\', '/') if ($resolvedCandidate.Equals($resolvedParent, [StringComparison]::OrdinalIgnoreCase)) { return $true } @@ -131,6 +170,8 @@ function Test-LexicalPathAtOrBelow { [Parameter(Mandatory = $true)][string]$Candidate ) + Assert-NoUnsupportedPathAlias -Path $Parent + Assert-NoUnsupportedPathAlias -Path $Candidate $fullParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/') $fullCandidate = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') if ($fullCandidate.Equals($fullParent, [StringComparison]::OrdinalIgnoreCase)) { @@ -156,8 +197,8 @@ function Test-SamePath { [Parameter(Mandatory = $true)][string]$Left, [Parameter(Mandatory = $true)][string]$Right ) - return (Resolve-CanonicalPath $Left).TrimEnd('\', '/').Equals( - (Resolve-CanonicalPath $Right).TrimEnd('\', '/'), + return (Get-CanonicalPathIdentity $Left).TrimEnd('\', '/').Equals( + (Get-CanonicalPathIdentity $Right).TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase) } @@ -284,9 +325,10 @@ function Get-ExistingInstallDirs { if ([string]::IsNullOrWhiteSpace($installDir) -or -not (Test-Path -LiteralPath $installDir -PathType Container)) { continue } - $canonical = Resolve-CanonicalPath $installDir - if ($seen.Add($canonical)) { - $result.Add($canonical) + $resolved = Resolve-CanonicalPath $installDir + $identity = Convert-ToCanonicalVolumePathIdentity $resolved + if ($seen.Add($identity)) { + $result.Add($resolved) } } return $result.ToArray() @@ -304,6 +346,7 @@ function Get-PotentialInstallDirs { if (-not [IO.Path]::IsPathRooted($installDir)) { throw "Application install directory is relative and cannot be checked safely: $installDir" } + Assert-NoUnsupportedPathAlias -Path $installDir $fullPath = [IO.Path]::GetFullPath($installDir) if ($seen.Add($fullPath)) { $result.Add($fullPath) @@ -339,7 +382,7 @@ function Get-UnsafeLegacySource { throw "The active data directory is the application install root itself: $active" } if (Test-Path -LiteralPath $active -PathType Container) { - $canonicalActive = Resolve-CanonicalPath $active + $canonicalActive = Get-CanonicalPathIdentity $active if (-not $sources.ContainsKey($canonicalActive)) { $sources.Add($canonicalActive, $active) } @@ -379,7 +422,7 @@ function Get-UnsafeLegacySource { if (Test-SamePath -Left $possiblyDeletedRoot -Right $source) { throw "The active data directory is the application install root itself: $source" } - $canonicalSource = Resolve-CanonicalPath $source + $canonicalSource = Get-CanonicalPathIdentity $source if (-not $sources.ContainsKey($canonicalSource)) { $sources.Add($canonicalSource, $source) } @@ -726,6 +769,60 @@ function Run-SelfTest { $testRoot = Join-Path ([IO.Path]::GetTempPath()) "cc-haha-storage-recovery-$([Guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Path $testRoot | Out-Null try { + $canonicalTestRoot = Get-CanonicalPathIdentity $testRoot + Assert-SelfTest ` + -Condition ($canonicalTestRoot.StartsWith('\\?\Volume{', [StringComparison]::OrdinalIgnoreCase)) ` + -Message 'ordinary drive path did not receive a canonical volume identity' + + $volumeAliasFailed = $false + try { + Resolve-CanonicalPath -Path $canonicalTestRoot | Out-Null + } catch { + $volumeAliasFailed = $_.Exception.Message.Contains('Extended device or volume aliases') + } + Assert-SelfTest -Condition $volumeAliasFailed -Message 'extended volume alias did not fail closed' + + $shortAliasFailed = $false + try { + Resolve-CanonicalPath -Path (Join-Path ([IO.Path]::GetPathRoot($testRoot)) 'PROGRA~1') | Out-Null + } catch { + $shortAliasFailed = $_.Exception.Message.Contains('8.3 path alias') + } + Assert-SelfTest -Condition $shortAliasFailed -Message 'possible 8.3 alias did not fail closed' + + $legalTildePath = Join-Path $testRoot 'project~notes' + New-Item -ItemType Directory -Path $legalTildePath | Out-Null + $resolvedLegalTildePath = Resolve-CanonicalPath -Path $legalTildePath + Assert-SelfTest ` + -Condition ($resolvedLegalTildePath.EndsWith('project~notes', [StringComparison]::OrdinalIgnoreCase)) ` + -Message 'legal long directory name containing a tilde was blocked as an 8.3 alias' + + $substDrive = $null + foreach ($codePoint in (90..68)) { + $candidateDrive = "$([char]$codePoint):" + if (-not (Test-Path -LiteralPath "$candidateDrive\")) { + $substDrive = $candidateDrive + break + } + } + Assert-SelfTest -Condition ($null -ne $substDrive) -Message 'no drive letter was available for the SUBST alias test' + $subst = Join-Path $env:SystemRoot 'System32\subst.exe' + & $subst $substDrive $testRoot + if ($LASTEXITCODE -ne 0) { + throw "Self-test could not create SUBST alias $substDrive for $testRoot" + } + try { + $substAliasFailed = $false + try { + Resolve-CanonicalPath -Path "$substDrive\" | Out-Null + } catch { + $substAliasFailed = $_.Exception.Message.Contains('SUBST aliases') + } + Assert-SelfTest -Condition $substAliasFailed -Message 'SUBST alias did not fail closed' + } finally { + & $subst $substDrive /D | Out-Null + } + $install = Join-Path $testRoot 'old install' $legacy = Join-Path $install 'CLAUDE_CONFIG_DIR' $userData = Join-Path $testRoot 'app data' diff --git a/desktop/scripts/windows-installer-smoke.ps1 b/desktop/scripts/windows-installer-smoke.ps1 index faf403a2..00d45388 100644 --- a/desktop/scripts/windows-installer-smoke.ps1 +++ b/desktop/scripts/windows-installer-smoke.ps1 @@ -27,12 +27,45 @@ $userProfile = Join-Path $testRoot 'UserProfile' $appExe = Join-Path $installDir 'Claude Code Haha.exe' $uninstaller = Join-Path $installDir 'Uninstall Claude Code Haha.exe' $recoveryHelper = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\build\recover-legacy-install-data.ps1')).Path +$processHelper = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\build\check-install-processes.ps1')).Path +$siblingProcess = $null +$installProcess = $null +$bundledHelperProcess = $null $savedEnvironment = @{} -foreach ($name in @('APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CC_HAHA_APP_PORTABLE_DIR')) { +foreach ($name in @('APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CC_HAHA_APP_PORTABLE_DIR', 'COMPLUS_Version')) { $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } +function Invoke-ProcessExpectFailure { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string]$Stage, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [int]$ExpectedExitCode, + [int]$TimeoutSeconds = 180 + ) + + [Console]::Out.WriteLine("$Stage starting...") + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -PassThru + try { + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "$Stage timed out after $TimeoutSeconds seconds." + } + if ($PSBoundParameters.ContainsKey('ExpectedExitCode')) { + if ($process.ExitCode -ne $ExpectedExitCode) { + throw "$Stage expected process exit code $ExpectedExitCode, received $($process.ExitCode)." + } + } elseif ($process.ExitCode -eq 0) { + throw "$Stage unexpectedly succeeded with process exit code 0." + } + [Console]::Out.WriteLine("$Stage failed safely with process exit code $($process.ExitCode).") + } finally { + $process.Dispose() + } +} + function Invoke-CheckedProcess { param( [Parameter(Mandatory = $true)][string]$FilePath, @@ -57,6 +90,32 @@ function Invoke-CheckedProcess { } } +function Test-IsProcessElevated { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + try { + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) + } finally { + $identity.Dispose() + } +} + +function Invoke-ProcessHelperExpectExit { + param( + [Parameter(Mandatory = $true)][string]$Stage, + [Parameter(Mandatory = $true)][int]$ExpectedExitCode, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + $windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + [Console]::Out.WriteLine("$Stage starting...") + & $windowsPowerShell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $processHelper @Arguments + if ($LASTEXITCODE -ne $ExpectedExitCode) { + throw "$Stage expected exit code $ExpectedExitCode, received $LASTEXITCODE." + } + [Console]::Out.WriteLine("$Stage completed with expected exit code $ExpectedExitCode.") +} + function Invoke-LegacyRecoveryDiagnostic { $windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $arguments = @( @@ -106,14 +165,91 @@ try { throw "Fresh install did not create the application executable: $appExe" } + $processProbeSource = Join-Path $env:SystemRoot 'System32\ping.exe' + $siblingDir = "$installDir Tools" + $siblingProbe = Join-Path $siblingDir 'Claude Code Haha.exe' + New-Item -ItemType Directory -Path $siblingDir -Force | Out-Null + Copy-Item -LiteralPath $processProbeSource -Destination $siblingProbe + $siblingProcess = Start-Process -FilePath $siblingProbe -ArgumentList @('-t', '127.0.0.1') -PassThru + Start-Sleep -Milliseconds 500 + Invoke-CheckedProcess -FilePath $installer -Stage 'Sibling-prefix process reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + if ($siblingProcess.HasExited) { + throw 'Sibling-prefix process was mistaken for an install-directory process.' + } + [Console]::Out.WriteLine('Sibling-prefix process remains running after reinstall.') + + $installProbe = Join-Path $installDir 'install-process-probe.exe' + Copy-Item -LiteralPath $processProbeSource -Destination $installProbe + $installProcess = Start-Process -FilePath $installProbe -ArgumentList @('-t', '127.0.0.1') -PassThru + Start-Sleep -Milliseconds 500 + Invoke-ProcessHelperExpectExit ` + -Stage 'Install-directory parent process detection' ` + -ExpectedExitCode 0 ` + -Arguments @( + '-InstallDir', $installDir, + '-ProcessName', 'Claude Code Haha.exe', + '-Action', 'Find', + '-InstallerPid', [string]$PID, + '-InstallerParentPid', [string]$installProcess.Id + ) + Invoke-CheckedProcess -FilePath $installer -Stage 'Install-directory process reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + if (-not $installProcess.HasExited) { + throw 'Install-directory process was not terminated before reinstall.' + } + Invoke-LegacyRecoveryDiagnostic - Invoke-CheckedProcess -FilePath $installer -Stage 'Default-mode reinstall' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + Stop-Process -Id $siblingProcess.Id -Force + $siblingProcess.WaitForExit() + $siblingProcess.Dispose() + $siblingProcess = $null + + $bundledHelperProbe = Join-Path $siblingDir 'OpenConsole.exe' + Copy-Item -LiteralPath $processProbeSource -Destination $bundledHelperProbe + $bundledHelperProcess = Start-Process -FilePath $bundledHelperProbe -ArgumentList @('-t', '127.0.0.1') -PassThru + Start-Sleep -Milliseconds 500 + $env:COMPLUS_Version = 'v0.0.0-test-invalid-clr' + Invoke-ProcessExpectFailure -FilePath $installer -Stage 'No-CLR external bundled-helper process reinstall' -ExpectedExitCode 22 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + if ($bundledHelperProcess.HasExited) { + throw 'No-CLR exact-image fallback terminated an external bundled-helper process.' + } + Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue + Stop-Process -Id $bundledHelperProcess.Id -Force + $bundledHelperProcess.WaitForExit() + $bundledHelperProcess.Dispose() + $bundledHelperProcess = $null + + $env:COMPLUS_Version = 'v0.0.0-test-invalid-clr' + if (Test-IsProcessElevated) { + Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Elevated default-mode reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + } else { + Invoke-CheckedProcess -FilePath $installer -Stage 'Trusted-user default-mode reinstall without CLR' -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + } + Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { throw "Reinstall removed the application executable: $appExe" } - [Console]::Out.WriteLine('Windows installer fresh-install and default-mode reinstall smoke passed.') + $legacyDir = Join-Path $installDir 'CLAUDE_CONFIG_DIR' + $legacySentinel = Join-Path $legacyDir 'settings.json' + New-Item -ItemType Directory -Path $legacyDir -Force | Out-Null + Set-Content -LiteralPath $legacySentinel -Value 'must-survive-failed-upgrade' -NoNewline + $env:COMPLUS_Version = 'v0.0.0-test-invalid-clr' + Invoke-ProcessExpectFailure -FilePath $installer -Stage 'Portable reinstall without CLR' -ExpectedExitCode 20 -Arguments @('--updated', '/S', '/currentuser', "/D=$installDir") + Remove-Item Env:COMPLUS_Version -ErrorAction SilentlyContinue + if ((Get-Content -LiteralPath $legacySentinel -Raw) -ne 'must-survive-failed-upgrade') { + throw 'Portable reinstall without CLR modified legacy data instead of failing closed.' + } + + [Console]::Out.WriteLine('Windows installer fresh-install, no-CLR default reinstall, and fail-closed portable reinstall smoke passed.') } finally { + foreach ($probeProcess in @($installProcess, $siblingProcess, $bundledHelperProcess)) { + if ($null -ne $probeProcess) { + if (-not $probeProcess.HasExited) { + Stop-Process -Id $probeProcess.Id -Force -ErrorAction SilentlyContinue + } + $probeProcess.Dispose() + } + } if (Test-Path -LiteralPath $uninstaller -PathType Leaf) { Invoke-CheckedProcess -FilePath $uninstaller -Stage 'Cleanup uninstall' -Arguments @('/S', '/KEEP_APP_DATA', '/currentuser') -TimeoutSeconds 120 } diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index b8f53e53..27971fcf 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -543,7 +543,8 @@ describe('release desktop workflow', () => { expect(recoveryHelper).toContain('function Assert-TreeManifestsEqual') expect(recoveryHelper).toContain('function Write-AppModeAtomically') expect(recoveryHelper).toContain('[IO.File]::Replace') - expect(recoveryHelper).toContain('GetFinalPathNameByHandle') + expect(recoveryHelper).not.toContain('Add-Type') + expect(recoveryHelper).toContain('function Assert-NoReparsePointInPath') expect(recoveryHelper).toContain('robocopy.exe') expect(recoveryHelper).not.toMatch(/\/XC|\/XN|\/XO/) expect(recoveryHelper).toContain('Multiple distinct legacy data sources') diff --git a/scripts/pr/windows-installer-process-check.test.ts b/scripts/pr/windows-installer-process-check.test.ts new file mode 100644 index 00000000..2096e415 --- /dev/null +++ b/scripts/pr/windows-installer-process-check.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +describe('Windows installer process matching', () => { + test('uses a directory-boundary-aware process helper', () => { + const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8') + const processHelper = readFileSync( + 'desktop/build/check-install-processes.ps1', + 'utf8', + ) + + expect(installerHook).not.toContain('!insertmacro _CHECK_APP_RUNNING') + expect(installerHook).toContain('check-install-processes.ps1') + expect(installerHook).toContain('!macro CcHahaFindInstallProcess') + expect(installerHook).toContain('!macro CcHahaKillInstallProcess') + expect(installerHook).toContain('-InstallerPid "$pid"') + expect(installerHook).toContain('-InstallerParentPid "$1"') + expect(installerHook).toContain('tasklist /FI "USERNAME eq %USERNAME%" /FO CSV /NH >') + expect(installerHook).toContain('tasklist process enumeration failed') + expect(installerHook).toContain('fallback process filtering failed') + expect(installerHook).toMatch( + /tasklist process enumeration failed[\s\S]*StrCpy \$\{_RETURN\} 0/, + ) + expect(installerHook).toMatch( + /fallback process filtering failed[\s\S]*StrCpy \$\{_RETURN\} 0/, + ) + expect(installerHook).toContain('claude-sidecar-x86_64-pc-windows-msvc.exe') + expect(installerHook).toContain('claude-sidecar-aarch64-pc-windows-msvc.exe') + expect(installerHook).toContain('/C:"OpenConsole.exe"') + expect(installerHook).toContain('/C:"winpty-agent.exe"') + expect(installerHook).toContain('/C:"rg.exe"') + expect(installerHook).toContain('bundled terminal/search helper') + expect(installerHook).toContain('Differently named child processes cannot be attributed') + expect(installerHook).not.toContain('| "$FindPath"') + + expect(processHelper).toContain('function Test-PathInsideInstallDirectory') + expect(processHelper).toContain( + '$rootWithSeparator = $resolvedRoot + [IO.Path]::DirectorySeparatorChar', + ) + expect(processHelper).toContain('[StringComparison]::OrdinalIgnoreCase') + expect(processHelper).toContain('$process.ProcessId -eq $InstallerPid') + expect(processHelper).not.toContain('$process.ProcessId -eq $InstallerParentPid') + expect(processHelper).toContain('Matched protected install process:') + expect(processHelper).toContain('Blocked unknown-path application process:') + expect(processHelper).toContain('$unknownPathMatches.Add($process)') + expect(installerHook).not.toContain('taskkill') + expect(installerHook).toContain('refusing to terminate by image name') + expect(installerHook.match(/SetErrorLevel 22/g)).toHaveLength(2) + expect(installerHook).toMatch( + /MessageBox MB_OKCANCEL[\s\S]*SetErrorLevel 22\s+Quit/, + ) + expect(installerHook).toMatch( + /MessageBox MB_RETRYCANCEL[\s\S]*SetErrorLevel 22\s+Quit/, + ) + }) + + test('keeps sibling-prefix and real install process cases in Windows smoke', () => { + const installerSmoke = readFileSync( + 'desktop/scripts/windows-installer-smoke.ps1', + 'utf8', + ) + + expect(installerSmoke).toContain("$siblingDir = \"$installDir Tools\"") + expect(installerSmoke).toContain("$siblingProbe = Join-Path $siblingDir 'Claude Code Haha.exe'") + expect(installerSmoke).toContain('Sibling-prefix process remains running') + expect(installerSmoke).toContain('Install-directory parent process detection') + expect(installerSmoke).toContain('Install-directory process was not terminated') + expect(installerSmoke).toContain("$bundledHelperProbe = Join-Path $siblingDir 'OpenConsole.exe'") + expect(installerSmoke).toContain('No-CLR external bundled-helper process reinstall') + expect(installerSmoke).toMatch( + /No-CLR external bundled-helper process reinstall' -ExpectedExitCode 22/, + ) + expect(installerSmoke).toContain('No-CLR exact-image fallback terminated an external bundled-helper') + }) +}) diff --git a/scripts/pr/windows-installer-recovery.test.ts b/scripts/pr/windows-installer-recovery.test.ts new file mode 100644 index 00000000..a8886278 --- /dev/null +++ b/scripts/pr/windows-installer-recovery.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' + +describe('Windows installer recovery prerequisites', () => { + test('does not compile native path helpers at install time', () => { + const recoveryHelper = readFileSync( + 'desktop/build/recover-legacy-install-data.ps1', + 'utf8', + ) + + expect(recoveryHelper).not.toContain('Add-Type') + expect(recoveryHelper).toContain('function Assert-NoReparsePointInPath') + expect(recoveryHelper).toContain('$rootAttributes = [IO.File]::GetAttributes($current)') + expect(recoveryHelper).toContain( + 'contains a reparse point and cannot be recovered safely', + ) + expect(recoveryHelper).toContain('function Get-CanonicalPathIdentity') + expect(recoveryHelper).toContain('System32\\mountvol.exe') + expect(recoveryHelper).toContain('SUBST aliases cannot be recovered safely') + expect(recoveryHelper).toContain('Possible 8.3 path alias cannot be proven safe') + expect(recoveryHelper).toContain('Alternate path alias cannot be recovered safely') + expect(recoveryHelper).toContain('Get-ChildItem -LiteralPath $current -Force') + expect(recoveryHelper).toContain("([string]$_.Name).Equals($segment") + expect(recoveryHelper).toContain("Join-Path $testRoot 'project~notes'") + expect(recoveryHelper).toContain('legal long directory name containing a tilde') + expect(recoveryHelper).toContain('extended volume alias did not fail closed') + expect(recoveryHelper).toContain('SUBST alias did not fail closed') + }) + + test('skips PowerShell only for a proven default per-user installation', () => { + const installerHook = readFileSync('desktop/build/installer.nsh', 'utf8') + const fastPathStart = installerHook.indexOf( + 'Function CcHahaCanSkipLegacyRecovery', + ) + const recoveryCall = installerHook.indexOf( + 'UAC_AsUser_Call Function CcHahaRecoverLegacy', + ) + + expect(fastPathStart).toBeGreaterThan(-1) + expect(fastPathStart).toBeLessThan(recoveryCall) + expect(installerHook).toMatch( + /Function CcHahaCanSkipLegacyRecovery[\s\S]*\$8 != "trusted-user"/, + ) + expect(installerHook).toMatch( + /StrCpy \$8 "trusted-user"[\s\S]*UAC_IsAdmin[\s\S]*StrCpy \$8 "untrusted-elevated"[\s\S]*UAC_IsInnerInstance[\s\S]*StrCpy \$8 "trusted-uac-outer"[\s\S]*Call CcHahaCanSkipLegacyRecovery/, + ) + expect(installerHook).toContain( + '$ccHahaPerUserInstallLocation == ""', + ) + expect(installerHook).toContain( + '$ccHahaPerMachineInstallLocation != ""', + ) + expect(installerHook).toContain( + '$ccHahaPerMachineUninstallString != ""', + ) + expect(installerHook).toContain( + 'StrCmp $ccHahaPerUserInstallLocation $INSTDIR', + ) + expect(installerHook).toContain('ReadEnvStr $R0 CLAUDE_CONFIG_DIR') + expect(installerHook).toContain( + 'IfFileExists "$ccHahaPerUserInstallLocation\\CLAUDE_CONFIG_DIR\\*.*"', + ) + expect(installerHook).toContain( + 'FileOpen $R2 "$R1\\Claude Code Haha\\app-mode.json" r', + ) + expect(installerHook).toContain('StrCmp $R3 \' "mode": "default",$\\n\'') + expect(installerHook).toContain('StrCmp $R3 \' "portable_dir": null$\\n\'') + expect(installerHook).toContain('FileClose $R2') + expect(installerHook).toMatch( + /Call CcHahaCanSkipLegacyRecovery[\s\S]*No legacy data candidates found for the registered per-user installation[\s\S]*UAC_AsUser_Call Function CcHahaRecoverLegacy/, + ) + }) + + test('keeps no-CLR default and portable upgrade cases in Windows smoke', () => { + const installerSmoke = readFileSync( + 'desktop/scripts/windows-installer-smoke.ps1', + 'utf8', + ) + + expect(installerSmoke).toContain("$env:COMPLUS_Version = 'v0.0.0-test-invalid-clr'") + expect(installerSmoke).toContain('Test-IsProcessElevated') + expect(installerSmoke).toContain('Elevated default-mode reinstall without CLR') + expect(installerSmoke).toMatch( + /Elevated default-mode reinstall without CLR' -ExpectedExitCode 20/, + ) + expect(installerSmoke).toContain('Trusted-user default-mode reinstall without CLR') + expect(installerSmoke).toContain('Portable reinstall without CLR') + expect(installerSmoke).toMatch( + /Portable reinstall without CLR' -ExpectedExitCode 20/, + ) + expect(installerSmoke).toContain('Invoke-ProcessExpectFailure') + expect(installerSmoke).toContain('must-survive-failed-upgrade') + }) +})