From 1c99b813ee393ef6f539d611443d4527c5761e36 Mon Sep 17 00:00:00 2001 From: Ehsan Shahriyari Date: Tue, 10 Nov 2026 15:08:36 +0330 Subject: [PATCH] fix(windows): eliminate duplicate case keys in PowerShell launcher proxy hashtables --- .github/workflows/ci.yml | 10 +++++ bin/opencode-vpn.ps1 | 42 ++++++++++++--------- scripts/windows/verify-release.ps1 | 18 +++++++++ src/backend/opencode/opencode-service.ts | 42 ++++++++++++--------- tests/platform-tests.ts | 48 ++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b089a8..0547017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,16 @@ jobs: if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.exe\")) { throw 'bin/opencode-vpn.exe missing from Windows package' } if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.cmd\")) { throw 'bin/opencode-vpn.cmd missing' } if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.ps1\")) { throw 'bin/opencode-vpn.ps1 missing' } + Get-ChildItem -Path \$testExtractDir -Recurse -Filter *.ps1 | ForEach-Object { + \$toks = \$null; \$errs = \$null + [System.Management.Automation.Language.Parser]::ParseFile(\$_.FullName, [ref]\$toks, [ref]\$errs) + if (\$errs -and \$errs.Count -gt 0) { throw ('AST parse error in packaged ' + \$_.Name + ': ' + (\$errs | Out-String)) } + } + Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { + \$toks = \$null; \$errs = \$null + [System.Management.Automation.Language.Parser]::ParseFile(\$_.FullName, [ref]\$toks, [ref]\$errs) + if (\$errs -and \$errs.Count -gt 0) { throw ('AST parse error in repo ' + \$_.Name + ': ' + (\$errs | Out-String)) } + } " echo "Portable package verified and extracted cleanly." diff --git a/bin/opencode-vpn.ps1 b/bin/opencode-vpn.ps1 index 899cb6a..9a7d8e8 100644 --- a/bin/opencode-vpn.ps1 +++ b/bin/opencode-vpn.ps1 @@ -10,25 +10,28 @@ $httpPort = if ($env:OPENCODE_HTTP_PROXY_PORT) { $env:OPENCODE_HTTP_PROXY_PORT } else { "10809" } $socksPort = if ($env:OPENCODE_SOCKS_PROXY_PORT) { $env:OPENCODE_SOCKS_PROXY_PORT } else { "10808" } +# Snapshot original proxy environment variables using canonical case-insensitive keys. +# On Windows, environment variable names are case-insensitive; duplicate case variants in hashtables cause parser errors. $oldEnv = @{ - http_proxy = $env:http_proxy - https_proxy = $env:https_proxy - HTTP_PROXY = $env:HTTP_PROXY - HTTPS_PROXY = $env:HTTPS_PROXY - all_proxy = $env:all_proxy - ALL_PROXY = $env:ALL_PROXY - no_proxy = $env:no_proxy - NO_PROXY = $env:NO_PROXY + HTTP_PROXY = [Environment]::GetEnvironmentVariable("HTTP_PROXY", "Process") + HTTPS_PROXY = [Environment]::GetEnvironmentVariable("HTTPS_PROXY", "Process") + ALL_PROXY = [Environment]::GetEnvironmentVariable("ALL_PROXY", "Process") + NO_PROXY = [Environment]::GetEnvironmentVariable("NO_PROXY", "Process") } -$env:http_proxy = "http://127.0.0.1:$httpPort" -$env:https_proxy = "http://127.0.0.1:$httpPort" -$env:HTTP_PROXY = "http://127.0.0.1:$httpPort" -$env:HTTPS_PROXY = "http://127.0.0.1:$httpPort" -$env:all_proxy = "socks5://127.0.0.1:$socksPort" -$env:ALL_PROXY = "socks5://127.0.0.1:$socksPort" -$env:no_proxy = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" -$env:NO_PROXY = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" +$httpProxyUrl = "http://127.0.0.1:$httpPort" +$socksProxyUrl = "socks5://127.0.0.1:$socksPort" +$noProxyList = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" + +$env:HTTP_PROXY = $httpProxyUrl +$env:HTTPS_PROXY = $httpProxyUrl +$env:ALL_PROXY = $socksProxyUrl +$env:NO_PROXY = $noProxyList + +$env:http_proxy = $httpProxyUrl +$env:https_proxy = $httpProxyUrl +$env:all_proxy = $socksProxyUrl +$env:no_proxy = $noProxyList # Check for external server targeting foreach ($arg in $args) { @@ -102,10 +105,13 @@ try { exit $LASTEXITCODE } finally { foreach ($k in $oldEnv.Keys) { - if ($null -ne $oldEnv[$k]) { - [Environment]::SetEnvironmentVariable($k, $oldEnv[$k], "Process") + $origVal = $oldEnv[$k] + if ($null -ne $origVal) { + [Environment]::SetEnvironmentVariable($k, $origVal, "Process") + [Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $origVal, "Process") } else { [Environment]::SetEnvironmentVariable($k, $null, "Process") + [Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $null, "Process") } } } diff --git a/scripts/windows/verify-release.ps1 b/scripts/windows/verify-release.ps1 index 2c0f9ca..2e04a23 100644 --- a/scripts/windows/verify-release.ps1 +++ b/scripts/windows/verify-release.ps1 @@ -121,6 +121,24 @@ $launcherExists = Test-Path $launcherExe $results.launcher.exists = $launcherExists Log-Test "Native Launcher Executable (bin\opencode-vpn.exe)" $launcherExists "Path: $launcherExe" +# 4b. Windows PowerShell Launcher Syntax & AST Validation +Write-Host "`n[>>] Check 4b: Windows PowerShell Launcher (bin\opencode-vpn.ps1)" -ForegroundColor White +$psLauncherFile = Join-Path $binDir "opencode-vpn.ps1" +if (Test-Path $psLauncherFile) { + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($psLauncherFile, [ref]$tokens, [ref]$errors) + $hasErrors = ($errors -and $errors.Count -gt 0) + if ($hasErrors) { + $errMsg = ($errors | ForEach-Object { $_.Message }) -join "; " + Log-Test "PowerShell Launcher AST Parse (bin\opencode-vpn.ps1)" $false "Parser Errors: $errMsg" + } else { + Log-Test "PowerShell Launcher AST Parse (bin\opencode-vpn.ps1)" $true "Clean parse; zero syntax or duplicate key errors" + } +} else { + Log-Test "PowerShell Launcher (bin\opencode-vpn.ps1)" $false "Missing bin\opencode-vpn.ps1" +} + # 5. Argument Preservation & Quoting Write-Host "`n[>>] Check 5: Special-Character Argument Preservation" -ForegroundColor White $testArgs = @( diff --git a/src/backend/opencode/opencode-service.ts b/src/backend/opencode/opencode-service.ts index ba66cf9..519a3e6 100644 --- a/src/backend/opencode/opencode-service.ts +++ b/src/backend/opencode/opencode-service.ts @@ -327,25 +327,28 @@ endlocal & exit /b %EXIT_CODE% $httpPort = if ($env:OPENCODE_HTTP_PROXY_PORT) { $env:OPENCODE_HTTP_PROXY_PORT } else { "10809" } $socksPort = if ($env:OPENCODE_SOCKS_PROXY_PORT) { $env:OPENCODE_SOCKS_PROXY_PORT } else { "10808" } +# Snapshot original proxy environment variables using canonical case-insensitive keys. +# On Windows, environment variable names are case-insensitive; duplicate case variants in hashtables cause parser errors. $oldEnv = @{ - http_proxy = $env:http_proxy - https_proxy = $env:https_proxy - HTTP_PROXY = $env:HTTP_PROXY - HTTPS_PROXY = $env:HTTPS_PROXY - all_proxy = $env:all_proxy - ALL_PROXY = $env:ALL_PROXY - no_proxy = $env:no_proxy - NO_PROXY = $env:NO_PROXY + HTTP_PROXY = [Environment]::GetEnvironmentVariable("HTTP_PROXY", "Process") + HTTPS_PROXY = [Environment]::GetEnvironmentVariable("HTTPS_PROXY", "Process") + ALL_PROXY = [Environment]::GetEnvironmentVariable("ALL_PROXY", "Process") + NO_PROXY = [Environment]::GetEnvironmentVariable("NO_PROXY", "Process") } -$env:http_proxy = "http://127.0.0.1:$httpPort" -$env:https_proxy = "http://127.0.0.1:$httpPort" -$env:HTTP_PROXY = "http://127.0.0.1:$httpPort" -$env:HTTPS_PROXY = "http://127.0.0.1:$httpPort" -$env:all_proxy = "socks5://127.0.0.1:$socksPort" -$env:ALL_PROXY = "socks5://127.0.0.1:$socksPort" -$env:no_proxy = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" -$env:NO_PROXY = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" +$httpProxyUrl = "http://127.0.0.1:$httpPort" +$socksProxyUrl = "socks5://127.0.0.1:$socksPort" +$noProxyList = "localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.18.1" + +$env:HTTP_PROXY = $httpProxyUrl +$env:HTTPS_PROXY = $httpProxyUrl +$env:ALL_PROXY = $socksProxyUrl +$env:NO_PROXY = $noProxyList + +$env:http_proxy = $httpProxyUrl +$env:https_proxy = $httpProxyUrl +$env:all_proxy = $socksProxyUrl +$env:no_proxy = $noProxyList # Check for external server targeting foreach ($arg in $args) { @@ -419,10 +422,13 @@ try { exit $LASTEXITCODE } finally { foreach ($k in $oldEnv.Keys) { - if ($null -ne $oldEnv[$k]) { - [Environment]::SetEnvironmentVariable($k, $oldEnv[$k], "Process") + $origVal = $oldEnv[$k] + if ($null -ne $origVal) { + [Environment]::SetEnvironmentVariable($k, $origVal, "Process") + [Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $origVal, "Process") } else { [Environment]::SetEnvironmentVariable($k, $null, "Process") + [Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $null, "Process") } } } diff --git a/tests/platform-tests.ts b/tests/platform-tests.ts index ae1e7ea..f9aabcf 100644 --- a/tests/platform-tests.ts +++ b/tests/platform-tests.ts @@ -104,6 +104,54 @@ async function runPlatformTests() { assert(psContent.includes('exit $LASTEXITCODE'), 'PS launcher must preserve $LASTEXITCODE'); assert(psContent.includes('finally {'), 'PS launcher must clean up process environment'); + // Regression Test: PowerShell Hashtable Case-Insensitive Duplicate Keys Check + // PowerShell hash literals @{ ... } strictly disallow duplicate keys ignoring case. + const psScriptsToVerify = [ + psLauncher, + ...fs.readdirSync(path.join(Platform.getProjectRoot(), 'scripts', 'windows')) + .filter((f: string) => f.endsWith('.ps1')) + .map((f: string) => path.join(Platform.getProjectRoot(), 'scripts', 'windows', f)) + ]; + + for (const scriptPath of psScriptsToVerify) { + const content = fs.readFileSync(scriptPath, 'utf-8'); + const hashRegex = /@\s*\{([^}]+)\}/g; + let match: RegExpExecArray | null; + while ((match = hashRegex.exec(content)) !== null) { + const body = match[1]; + const seenKeys = new Map(); + for (const rawLine of body.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const keyMatch = line.match(/^['"]?([a-zA-Z0-9_\-]+)['"]?\s*=/); + if (keyMatch) { + const originalKey = keyMatch[1]; + const lowerKey = originalKey.toLowerCase(); + if (seenKeys.has(lowerKey)) { + assert.fail( + `Duplicate case-insensitive key '${originalKey}' collided with '${seenKeys.get(lowerKey)}' in hashtable inside ${path.basename(scriptPath)}` + ); + } + seenKeys.set(lowerKey, originalKey); + } + } + } + } + + // Regression assertions specifically for bin/opencode-vpn.ps1 $oldEnv snapshotting + const oldEnvMatch = psContent.match(/\$oldEnv\s*=\s*@\{([^}]+)\}/); + assert(oldEnvMatch, 'PS launcher must define $oldEnv hashtable'); + const oldEnvBody = oldEnvMatch[1]; + assert(!oldEnvBody.includes('http_proxy'), '$oldEnv hashtable must not define lowercase http_proxy key'); + assert(!oldEnvBody.includes('https_proxy'), '$oldEnv hashtable must not define lowercase https_proxy key'); + assert(!oldEnvBody.includes('all_proxy'), '$oldEnv hashtable must not define lowercase all_proxy key'); + assert(!oldEnvBody.includes('no_proxy'), '$oldEnv hashtable must not define lowercase no_proxy key'); + assert(oldEnvBody.includes('HTTP_PROXY'), '$oldEnv hashtable must define canonical HTTP_PROXY key'); + assert(oldEnvBody.includes('HTTPS_PROXY'), '$oldEnv hashtable must define canonical HTTPS_PROXY key'); + assert(oldEnvBody.includes('ALL_PROXY'), '$oldEnv hashtable must define canonical ALL_PROXY key'); + assert(oldEnvBody.includes('NO_PROXY'), '$oldEnv hashtable must define canonical NO_PROXY key'); + assert(psContent.includes('ToLowerInvariant()'), 'PS launcher must restore both uppercase and lowercase in finally'); + // Inspect Bash launcher contents const bashContent = fs.readFileSync(bashLauncher, 'utf-8'); assert(bashContent.includes('--standalone'), 'Bash launcher must support --standalone isolation');