fix(install): normalize Windows temp short paths (#108050)

* Normalize Windows installer temp paths

* fix(install): canonicalize Windows temp root

Co-authored-by: luyifan <al3060388206@gmail.com>

* fix(install): resolve Windows temp aliases with Get-Item

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ooiuuii
2026-07-16 08:02:31 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 83d17e34dd
commit f12b4af021
2 changed files with 203 additions and 5 deletions
+85 -5
View File
@@ -104,6 +104,84 @@ function Complete-Install {
throw "OpenClaw installation failed with exit code $($script:InstallExitCode)."
}
function Resolve-InstallerTempDirectory {
param([scriptblock]$LongPathResolver)
$candidates = New-Object System.Collections.Generic.List[string]
foreach ($candidate in @($env:TEMP, $env:TMP, [System.IO.Path]::GetTempPath())) {
if (-not [string]::IsNullOrWhiteSpace($candidate)) {
$candidates.Add($candidate)
}
}
$localAppData = [Environment]::GetFolderPath("LocalApplicationData")
if (-not [string]::IsNullOrWhiteSpace($localAppData)) {
$candidates.Add((Join-Path $localAppData "Temp"))
}
$userHome = [Environment]::GetFolderPath("UserProfile")
if (-not [string]::IsNullOrWhiteSpace($userHome)) {
$candidates.Add((Join-Path $userHome "AppData\Local\Temp"))
}
foreach ($candidate in $candidates) {
$pathToResolve = $candidate
$candidateHasShortAlias = $candidate -match '(?i)~\d'
$requiresCanonicalization = (
$candidateHasShortAlias -or
$candidate.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase)
)
if ($pathToResolve.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)) {
$pathToResolve = "\\" + $pathToResolve.Substring(8)
} elseif ($pathToResolve.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase)) {
$pathToResolve = $pathToResolve.Substring(4)
}
try {
if ($LongPathResolver) {
$resolvedCandidate = & $LongPathResolver $pathToResolve
} else {
# Windows PowerShell 5.1: FSO Folder.Path echoes 8.3 aliases; Get-Item.FullName expands them.
$resolvedCandidate = (Get-Item -LiteralPath $pathToResolve -ErrorAction Stop).FullName
}
} catch {
if ($requiresCanonicalization) {
continue
}
$resolvedCandidate = $pathToResolve
}
if ([string]::IsNullOrWhiteSpace($resolvedCandidate)) {
continue
}
if ($candidateHasShortAlias -and $resolvedCandidate -match '(?i)~\d') {
continue
}
if ($resolvedCandidate.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)) {
$resolvedCandidate = "\\" + $resolvedCandidate.Substring(8)
} elseif ($resolvedCandidate.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase)) {
$resolvedCandidate = $resolvedCandidate.Substring(4)
}
if (
Test-Path -LiteralPath $resolvedCandidate -PathType Container
) {
return $resolvedCandidate
}
# Try the next existing root; archive and extraction children do not exist yet.
}
throw "Could not find a usable Windows temporary directory."
}
function Initialize-InstallerTempDirectory {
param([scriptblock]$LongPathResolver)
$tempDirectory = Resolve-InstallerTempDirectory -LongPathResolver $LongPathResolver
$script:InstallerTempDirectory = $tempDirectory
$env:TEMP = $tempDirectory
$env:TMP = $tempDirectory
}
Initialize-InstallerTempDirectory
Write-Host ""
Write-Host " OpenClaw Installer" -ForegroundColor Cyan
Write-Host ""
@@ -408,7 +486,7 @@ function Install-PortableNode {
$download = Resolve-PortableNodeDownload
$portableRoot = Get-PortableNodeRoot
$portableParent = Split-Path -Parent $portableRoot
$tmpZip = Join-Path $env:TEMP $download.Name
$tmpZip = Join-Path $script:InstallerTempDirectory $download.Name
New-Item -ItemType Directory -Force -Path $portableParent | Out-Null
if (Test-Path $portableRoot) {
@@ -717,8 +795,9 @@ function Install-PortableGit {
$download = Resolve-PortableGitDownload
$portableRoot = Get-PortableGitRoot
$portableParent = Split-Path -Parent $portableRoot
$tmpZip = Join-Path $env:TEMP $download.Name
$tmpExtract = Join-Path $env:TEMP ("openclaw-portable-git-" + [guid]::NewGuid().ToString("N"))
$tempName = "openclaw-portable-git-" + [guid]::NewGuid().ToString("N")
$tmpZip = Join-Path $script:InstallerTempDirectory ($tempName + ".zip")
$tmpExtract = Join-Path $script:InstallerTempDirectory $tempName
New-Item -ItemType Directory -Force -Path $portableParent | Out-Null
if (Test-Path $portableRoot) {
@@ -734,6 +813,7 @@ function Install-PortableGit {
$downloadTimeouts = Get-WebRequestTimeoutParameters -CommandName "Invoke-WebRequest" -LegacyTimeoutSec 600
Invoke-WebRequest -Uri $download.Url -OutFile $tmpZip @downloadTimeouts
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null
Move-Item -Path (Join-Path $tmpExtract "*") -Destination $portableRoot -Force
} finally {
if (Test-Path $tmpZip) {
@@ -858,8 +938,8 @@ function Get-WindowsCommandSafeDirectory {
if (-not [string]::IsNullOrWhiteSpace($userHome) -and (Test-Path $userHome)) {
return $userHome
}
if (-not [string]::IsNullOrWhiteSpace($env:TEMP) -and (Test-Path $env:TEMP)) {
return $env:TEMP
if (Test-Path -LiteralPath $script:InstallerTempDirectory -PathType Container) {
return $script:InstallerTempDirectory
}
return $null
}
+118
View File
@@ -126,6 +126,82 @@ describe("install.ps1 failure handling", () => {
"",
].join("\n"),
},
{
name: "canonical-temp-root",
source: [
scriptWithoutEntryPoint,
"",
"$originalTemp = $env:TEMP",
"$originalTmp = $env:TMP",
'$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-install-temp-test-" + [guid]::NewGuid().ToString("N"))',
'$longTemp = Join-Path $sandbox "Long Temp"',
"try {",
" New-Item -ItemType Directory -Force -Path $longTemp | Out-Null",
" $env:TEMP = $longTemp",
" $env:TMP = $longTemp",
" $resolved = Resolve-InstallerTempDirectory",
" $expected = (Get-Item -LiteralPath $longTemp -ErrorAction Stop).FullName",
' if ($resolved -ne $expected) { throw "default=$resolved expected=$expected" }',
" $env:TEMP = '\\\\?\\' + $longTemp",
" $env:TMP = $env:TEMP",
' $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -ne $longTemp) { throw "prefix not stripped: $candidate" }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }',
' if ($resolved -ne $expected) { throw "extended=$resolved expected=$expected" }',
" # Windows PowerShell 5.1 proof: FSO Folder.Path echoes 8.3; Get-Item.FullName expands it.",
" $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'",
" $env:TMP = $longTemp",
" $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }",
' if ($resolved -ne $longTemp) { throw "short=$resolved" }',
" $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Missing'",
" $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { throw 'unresolvable short alias' }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }",
' if ($resolved -ne $longTemp) { throw "fallback=$resolved" }',
" $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'",
" $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) return $candidate }",
' if ($resolved -ne $longTemp) { throw "unchanged-short=$resolved" }',
" $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'",
" Initialize-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }",
' if ($script:InstallerTempDirectory -ne $longTemp) { throw "canonical=$script:InstallerTempDirectory" }',
' if ($env:TEMP -ne $longTemp) { throw "TEMP=$env:TEMP" }',
' if ($env:TMP -ne $longTemp) { throw "TMP=$env:TMP" }',
"} finally {",
" $env:TEMP = $originalTemp",
" $env:TMP = $originalTmp",
" if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }",
"}",
"",
].join("\n"),
},
{
name: "portable-git-layout",
source: [
scriptWithoutEntryPoint,
"",
'$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-portable-git-test-" + [guid]::NewGuid().ToString("N"))',
'$portableRoot = Join-Path $sandbox "portable-git"',
"try {",
" New-Item -ItemType Directory -Force -Path $sandbox | Out-Null",
" $script:InstallerTempDirectory = $sandbox",
" function Get-PortableGitRoot { return $portableRoot }",
" function Resolve-PortableGitDownload { return @{ Tag = 'test'; Name = 'MinGit.zip'; Url = 'https://example.test/MinGit.zip' } }",
" function Ensure-PortableGitOnUserPath { }",
" function Use-PortableGitIfPresent { return (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe')) }",
" function Invoke-WebRequest { param($Uri, $OutFile) New-Item -ItemType File -Force -Path $OutFile | Out-Null }",
" function Expand-Archive {",
" param($Path, $DestinationPath, [switch]$Force)",
" New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'cmd') | Out-Null",
" New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'etc') | Out-Null",
" New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'cmd/git.exe') | Out-Null",
" New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'etc/gitconfig') | Out-Null",
" }",
" Install-PortableGit",
" if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe'))) { throw 'missing cmd/git.exe' }",
" if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'etc/gitconfig'))) { throw 'missing etc/gitconfig' }",
" if (@(Get-ChildItem -LiteralPath $sandbox -Filter 'openclaw-portable-git-*').Count -ne 0) { throw 'temporary Git files remain' }",
"} finally {",
" if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }",
"}",
"",
].join("\n"),
},
{
name: "sqlite-versions",
source: [
@@ -515,6 +591,14 @@ describe("install.ps1 failure handling", () => {
expectBatchedPowerShellCase("sqlite-versions");
});
runIfPowerShell("normalizes and exports one installer temp root", () => {
expectBatchedPowerShellCase("canonical-temp-root");
});
runIfPowerShell("installs portable Git from multiple archive roots without collisions", () => {
expectBatchedPowerShellCase("portable-git-layout");
});
runIfPowerShell("upgrades and validates Node installed by Windows package managers", () => {
expectBatchedPowerShellCase("winget-node-delayed-path");
expectBatchedPowerShellCase("chocolatey-node-upgrade");
@@ -597,6 +681,30 @@ describe("install.ps1 failure handling", () => {
);
});
it("selects one canonical temp root for installer and child process paths", () => {
const resolveBody = extractFunctionBody(source, "Resolve-InstallerTempDirectory");
const initializeBody = extractFunctionBody(source, "Initialize-InstallerTempDirectory");
const portableNodeBody = extractFunctionBody(source, "Install-PortableNode");
const portableGitBody = extractFunctionBody(source, "Install-PortableGit");
const commandSafeBody = extractFunctionBody(source, "Get-WindowsCommandSafeDirectory");
expect(resolveBody).toContain("Get-Item -LiteralPath $pathToResolve -ErrorAction Stop");
expect(resolveBody).toContain(".FullName");
expect(resolveBody).toContain("FSO Folder.Path echoes 8.3 aliases");
expect(resolveBody).not.toContain("Scripting.FileSystemObject");
expect(resolveBody).toContain("$resolvedCandidate.Substring(8)");
expect(resolveBody).toContain("$resolvedCandidate.Substring(4)");
expect(resolveBody).toContain("Test-Path -LiteralPath $resolvedCandidate -PathType Container");
expect(initializeBody).toContain("$script:InstallerTempDirectory = $tempDirectory");
expect(initializeBody).toContain("$env:TEMP = $tempDirectory");
expect(initializeBody).toContain("$env:TMP = $tempDirectory");
expect(portableNodeBody).toContain("Join-Path $script:InstallerTempDirectory");
expect(portableGitBody).toContain("Join-Path $script:InstallerTempDirectory");
expect(commandSafeBody).toContain("return $script:InstallerTempDirectory");
expect(source.match(/^Initialize-InstallerTempDirectory$/gm)).toHaveLength(1);
expect(source).not.toContain("Get-InstallerTempDirectory");
});
it("rejects OpenClaw GitHub source targets for npm installs", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
const sourceTargetBody = extractFunctionBody(source, "Test-OpenClawSourcePackageInstallSpec");
@@ -735,6 +843,16 @@ describe("install.ps1 failure handling", () => {
expect(portableGitBody).toContain("@downloadTimeouts");
expect(portableGitDownloadBody).toContain("'^MinGit-.*-arm64\\.zip$'");
expect(portableGitDownloadBody).toContain("'^MinGit-.*-64-bit\\.zip$'");
expect(portableGitBody).toContain(
'$tempName = "openclaw-portable-git-" + [guid]::NewGuid().ToString("N")',
);
expect(portableGitBody).toContain(
'Join-Path $script:InstallerTempDirectory ($tempName + ".zip")',
);
expect(portableGitBody).toContain("Join-Path $script:InstallerTempDirectory $tempName");
expect(portableGitBody).toContain(
"New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null",
);
});
runIfPowerShell("selects native ARM64 MinGit when the release publishes it", () => {