Skip to content

Commit 47788a0

Browse files
committed
fix(windows): eliminate duplicate case keys in PowerShell launcher proxy hashtables
1 parent 1dc62c2 commit 47788a0

5 files changed

Lines changed: 124 additions & 36 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ jobs:
112112
if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.exe\")) { throw 'bin/opencode-vpn.exe missing from Windows package' }
113113
if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.cmd\")) { throw 'bin/opencode-vpn.cmd missing' }
114114
if (-not (Test-Path \"\$testExtractDir/bin/opencode-vpn.ps1\")) { throw 'bin/opencode-vpn.ps1 missing' }
115+
Get-ChildItem -Path \$testExtractDir -Recurse -Filter *.ps1 | ForEach-Object {
116+
\$toks = \$null; \$errs = \$null
117+
[System.Management.Automation.Language.Parser]::ParseFile(\$_.FullName, [ref]\$toks, [ref]\$errs)
118+
if (\$errs -and \$errs.Count -gt 0) { throw ('AST parse error in packaged ' + \$_.Name + ': ' + (\$errs | Out-String)) }
119+
}
120+
Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object {
121+
\$toks = \$null; \$errs = \$null
122+
[System.Management.Automation.Language.Parser]::ParseFile(\$_.FullName, [ref]\$toks, [ref]\$errs)
123+
if (\$errs -and \$errs.Count -gt 0) { throw ('AST parse error in repo ' + \$_.Name + ': ' + (\$errs | Out-String)) }
124+
}
115125
"
116126
echo "Portable package verified and extracted cleanly."
117127

‎bin/opencode-vpn.ps1‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,28 @@
1010
$httpPort = if ($env:OPENCODE_HTTP_PROXY_PORT) { $env:OPENCODE_HTTP_PROXY_PORT } else { "10809" }
1111
$socksPort = if ($env:OPENCODE_SOCKS_PROXY_PORT) { $env:OPENCODE_SOCKS_PROXY_PORT } else { "10808" }
1212

13+
# Snapshot original proxy environment variables using canonical case-insensitive keys.
14+
# On Windows, environment variable names are case-insensitive; duplicate case variants in hashtables cause parser errors.
1315
$oldEnv = @{
14-
http_proxy = $env:http_proxy
15-
https_proxy = $env:https_proxy
16-
HTTP_PROXY = $env:HTTP_PROXY
17-
HTTPS_PROXY = $env:HTTPS_PROXY
18-
all_proxy = $env:all_proxy
19-
ALL_PROXY = $env:ALL_PROXY
20-
no_proxy = $env:no_proxy
21-
NO_PROXY = $env:NO_PROXY
16+
HTTP_PROXY = [Environment]::GetEnvironmentVariable("HTTP_PROXY", "Process")
17+
HTTPS_PROXY = [Environment]::GetEnvironmentVariable("HTTPS_PROXY", "Process")
18+
ALL_PROXY = [Environment]::GetEnvironmentVariable("ALL_PROXY", "Process")
19+
NO_PROXY = [Environment]::GetEnvironmentVariable("NO_PROXY", "Process")
2220
}
2321

24-
$env:http_proxy = "http://127.0.0.1:$httpPort"
25-
$env:https_proxy = "http://127.0.0.1:$httpPort"
26-
$env:HTTP_PROXY = "http://127.0.0.1:$httpPort"
27-
$env:HTTPS_PROXY = "http://127.0.0.1:$httpPort"
28-
$env:all_proxy = "socks5://127.0.0.1:$socksPort"
29-
$env:ALL_PROXY = "socks5://127.0.0.1:$socksPort"
30-
$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"
31-
$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"
22+
$httpProxyUrl = "http://127.0.0.1:$httpPort"
23+
$socksProxyUrl = "socks5://127.0.0.1:$socksPort"
24+
$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"
25+
26+
$env:HTTP_PROXY = $httpProxyUrl
27+
$env:HTTPS_PROXY = $httpProxyUrl
28+
$env:ALL_PROXY = $socksProxyUrl
29+
$env:NO_PROXY = $noProxyList
30+
31+
$env:http_proxy = $httpProxyUrl
32+
$env:https_proxy = $httpProxyUrl
33+
$env:all_proxy = $socksProxyUrl
34+
$env:no_proxy = $noProxyList
3235

3336
# Check for external server targeting
3437
foreach ($arg in $args) {
@@ -102,10 +105,13 @@ try {
102105
exit $LASTEXITCODE
103106
} finally {
104107
foreach ($k in $oldEnv.Keys) {
105-
if ($null -ne $oldEnv[$k]) {
106-
[Environment]::SetEnvironmentVariable($k, $oldEnv[$k], "Process")
108+
$origVal = $oldEnv[$k]
109+
if ($null -ne $origVal) {
110+
[Environment]::SetEnvironmentVariable($k, $origVal, "Process")
111+
[Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $origVal, "Process")
107112
} else {
108113
[Environment]::SetEnvironmentVariable($k, $null, "Process")
114+
[Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $null, "Process")
109115
}
110116
}
111117
}

‎scripts/windows/verify-release.ps1‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,24 @@ $launcherExists = Test-Path $launcherExe
121121
$results.launcher.exists = $launcherExists
122122
Log-Test "Native Launcher Executable (bin\opencode-vpn.exe)" $launcherExists "Path: $launcherExe"
123123

124+
# 4b. Windows PowerShell Launcher Syntax & AST Validation
125+
Write-Host "`n[>>] Check 4b: Windows PowerShell Launcher (bin\opencode-vpn.ps1)" -ForegroundColor White
126+
$psLauncherFile = Join-Path $binDir "opencode-vpn.ps1"
127+
if (Test-Path $psLauncherFile) {
128+
$tokens = $null
129+
$errors = $null
130+
$ast = [System.Management.Automation.Language.Parser]::ParseFile($psLauncherFile, [ref]$tokens, [ref]$errors)
131+
$hasErrors = ($errors -and $errors.Count -gt 0)
132+
if ($hasErrors) {
133+
$errMsg = ($errors | ForEach-Object { $_.Message }) -join "; "
134+
Log-Test "PowerShell Launcher AST Parse (bin\opencode-vpn.ps1)" $false "Parser Errors: $errMsg"
135+
} else {
136+
Log-Test "PowerShell Launcher AST Parse (bin\opencode-vpn.ps1)" $true "Clean parse; zero syntax or duplicate key errors"
137+
}
138+
} else {
139+
Log-Test "PowerShell Launcher (bin\opencode-vpn.ps1)" $false "Missing bin\opencode-vpn.ps1"
140+
}
141+
124142
# 5. Argument Preservation & Quoting
125143
Write-Host "`n[>>] Check 5: Special-Character Argument Preservation" -ForegroundColor White
126144
$testArgs = @(

‎src/backend/opencode/opencode-service.ts‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -327,25 +327,28 @@ endlocal & exit /b %EXIT_CODE%
327327
$httpPort = if ($env:OPENCODE_HTTP_PROXY_PORT) { $env:OPENCODE_HTTP_PROXY_PORT } else { "10809" }
328328
$socksPort = if ($env:OPENCODE_SOCKS_PROXY_PORT) { $env:OPENCODE_SOCKS_PROXY_PORT } else { "10808" }
329329
330+
# Snapshot original proxy environment variables using canonical case-insensitive keys.
331+
# On Windows, environment variable names are case-insensitive; duplicate case variants in hashtables cause parser errors.
330332
$oldEnv = @{
331-
http_proxy = $env:http_proxy
332-
https_proxy = $env:https_proxy
333-
HTTP_PROXY = $env:HTTP_PROXY
334-
HTTPS_PROXY = $env:HTTPS_PROXY
335-
all_proxy = $env:all_proxy
336-
ALL_PROXY = $env:ALL_PROXY
337-
no_proxy = $env:no_proxy
338-
NO_PROXY = $env:NO_PROXY
333+
HTTP_PROXY = [Environment]::GetEnvironmentVariable("HTTP_PROXY", "Process")
334+
HTTPS_PROXY = [Environment]::GetEnvironmentVariable("HTTPS_PROXY", "Process")
335+
ALL_PROXY = [Environment]::GetEnvironmentVariable("ALL_PROXY", "Process")
336+
NO_PROXY = [Environment]::GetEnvironmentVariable("NO_PROXY", "Process")
339337
}
340338
341-
$env:http_proxy = "http://127.0.0.1:$httpPort"
342-
$env:https_proxy = "http://127.0.0.1:$httpPort"
343-
$env:HTTP_PROXY = "http://127.0.0.1:$httpPort"
344-
$env:HTTPS_PROXY = "http://127.0.0.1:$httpPort"
345-
$env:all_proxy = "socks5://127.0.0.1:$socksPort"
346-
$env:ALL_PROXY = "socks5://127.0.0.1:$socksPort"
347-
$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"
348-
$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"
339+
$httpProxyUrl = "http://127.0.0.1:$httpPort"
340+
$socksProxyUrl = "socks5://127.0.0.1:$socksPort"
341+
$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"
342+
343+
$env:HTTP_PROXY = $httpProxyUrl
344+
$env:HTTPS_PROXY = $httpProxyUrl
345+
$env:ALL_PROXY = $socksProxyUrl
346+
$env:NO_PROXY = $noProxyList
347+
348+
$env:http_proxy = $httpProxyUrl
349+
$env:https_proxy = $httpProxyUrl
350+
$env:all_proxy = $socksProxyUrl
351+
$env:no_proxy = $noProxyList
349352
350353
# Check for external server targeting
351354
foreach ($arg in $args) {
@@ -419,10 +422,13 @@ try {
419422
exit $LASTEXITCODE
420423
} finally {
421424
foreach ($k in $oldEnv.Keys) {
422-
if ($null -ne $oldEnv[$k]) {
423-
[Environment]::SetEnvironmentVariable($k, $oldEnv[$k], "Process")
425+
$origVal = $oldEnv[$k]
426+
if ($null -ne $origVal) {
427+
[Environment]::SetEnvironmentVariable($k, $origVal, "Process")
428+
[Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $origVal, "Process")
424429
} else {
425430
[Environment]::SetEnvironmentVariable($k, $null, "Process")
431+
[Environment]::SetEnvironmentVariable($k.ToLowerInvariant(), $null, "Process")
426432
}
427433
}
428434
}

‎tests/platform-tests.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,54 @@ async function runPlatformTests() {
104104
assert(psContent.includes('exit $LASTEXITCODE'), 'PS launcher must preserve $LASTEXITCODE');
105105
assert(psContent.includes('finally {'), 'PS launcher must clean up process environment');
106106

107+
// Regression Test: PowerShell Hashtable Case-Insensitive Duplicate Keys Check
108+
// PowerShell hash literals @{ ... } strictly disallow duplicate keys ignoring case.
109+
const psScriptsToVerify = [
110+
psLauncher,
111+
...fs.readdirSync(path.join(Platform.getProjectRoot(), 'scripts', 'windows'))
112+
.filter((f: string) => f.endsWith('.ps1'))
113+
.map((f: string) => path.join(Platform.getProjectRoot(), 'scripts', 'windows', f))
114+
];
115+
116+
for (const scriptPath of psScriptsToVerify) {
117+
const content = fs.readFileSync(scriptPath, 'utf-8');
118+
const hashRegex = /@\s*\{([^}]+)\}/g;
119+
let match: RegExpExecArray | null;
120+
while ((match = hashRegex.exec(content)) !== null) {
121+
const body = match[1];
122+
const seenKeys = new Map<string, string>();
123+
for (const rawLine of body.split('\n')) {
124+
const line = rawLine.trim();
125+
if (!line || line.startsWith('#')) continue;
126+
const keyMatch = line.match(/^['"]?([a-zA-Z0-9_\-]+)['"]?\s*=/);
127+
if (keyMatch) {
128+
const originalKey = keyMatch[1];
129+
const lowerKey = originalKey.toLowerCase();
130+
if (seenKeys.has(lowerKey)) {
131+
assert.fail(
132+
`Duplicate case-insensitive key '${originalKey}' collided with '${seenKeys.get(lowerKey)}' in hashtable inside ${path.basename(scriptPath)}`
133+
);
134+
}
135+
seenKeys.set(lowerKey, originalKey);
136+
}
137+
}
138+
}
139+
}
140+
141+
// Regression assertions specifically for bin/opencode-vpn.ps1 $oldEnv snapshotting
142+
const oldEnvMatch = psContent.match(/\$oldEnv\s*=\s*@\{([^}]+)\}/);
143+
assert(oldEnvMatch, 'PS launcher must define $oldEnv hashtable');
144+
const oldEnvBody = oldEnvMatch[1];
145+
assert(!oldEnvBody.includes('http_proxy'), '$oldEnv hashtable must not define lowercase http_proxy key');
146+
assert(!oldEnvBody.includes('https_proxy'), '$oldEnv hashtable must not define lowercase https_proxy key');
147+
assert(!oldEnvBody.includes('all_proxy'), '$oldEnv hashtable must not define lowercase all_proxy key');
148+
assert(!oldEnvBody.includes('no_proxy'), '$oldEnv hashtable must not define lowercase no_proxy key');
149+
assert(oldEnvBody.includes('HTTP_PROXY'), '$oldEnv hashtable must define canonical HTTP_PROXY key');
150+
assert(oldEnvBody.includes('HTTPS_PROXY'), '$oldEnv hashtable must define canonical HTTPS_PROXY key');
151+
assert(oldEnvBody.includes('ALL_PROXY'), '$oldEnv hashtable must define canonical ALL_PROXY key');
152+
assert(oldEnvBody.includes('NO_PROXY'), '$oldEnv hashtable must define canonical NO_PROXY key');
153+
assert(psContent.includes('ToLowerInvariant()'), 'PS launcher must restore both uppercase and lowercase in finally');
154+
107155
// Inspect Bash launcher contents
108156
const bashContent = fs.readFileSync(bashLauncher, 'utf-8');
109157
assert(bashContent.includes('--standalone'), 'Bash launcher must support --standalone isolation');

0 commit comments

Comments
 (0)