fix(desktop): harden Windows upgrades (#1028, #1029, #1036)

This commit is contained in:
程序员阿江(Relakkes) 2026-07-17 23:38:59 +08:00
parent ef841c2962
commit c48171e2f0
7 changed files with 760 additions and 94 deletions

View File

@ -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)) {
'<path unavailable>'
} 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
}

View File

@ -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

View File

@ -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) { '<unavailable>' } 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'

View File

@ -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
}

View File

@ -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')

View File

@ -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')
})
})

View File

@ -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')
})
})