Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
42 changes: 24 additions & 18 deletions bin/opencode-vpn.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
}
}
18 changes: 18 additions & 0 deletions scripts/windows/verify-release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @(
Expand Down
42 changes: 24 additions & 18 deletions src/backend/opencode/opencode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions tests/platform-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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');
Expand Down
Loading