From 3d0e64290796796436f15ef668efc946e20c93d1 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 6 Nov 2026 16:21:52 +0530 Subject: [PATCH] fix(windows): preserve other applications' startup entries --- .github/workflows/install-checks.yml | 48 +++++++++++++++++++++-- scripts/install-windows.ps1 | 5 ++- tests/contracts/test_windows_packaging.py | 10 +++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index a6cc0d25..a12897ae 100644 --- a/.github/workflows/install-checks.yml +++ b/.github/workflows/install-checks.yml @@ -306,12 +306,31 @@ jobs: - name: Add a sample session run: python .github/scripts/write_sample_trace.py + - name: Seed an unrelated login startup entry + shell: powershell + run: | + $RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if (-not (Test-Path -LiteralPath $RunKey)) { + New-Item -Path $RunKey | Out-Null + } + New-ItemProperty -LiteralPath $RunKey -Name "Token Meter CI sentinel" ` + -PropertyType ExpandString -Value '%TEMP%\token-meter-ci-sentinel.exe' -Force | Out-Null + - name: Install shell: powershell run: | - & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass ` - -File scripts\install-windows.ps1 -BackendOnly -ReadinessTimeoutSeconds 600 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Run twice to cover both initial registration and replacement of + # Token Meter's own existing value without changing another app's entry. + foreach ($Attempt in 1..2) { + & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass ` + -File scripts\install-windows.ps1 -BackendOnly -ReadinessTimeoutSeconds 600 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $RunKey = Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if ($RunKey.GetValue("Token Meter CI sentinel", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) -cne '%TEMP%\token-meter-ci-sentinel.exe' -or + $RunKey.GetValueKind("Token Meter CI sentinel") -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { + throw "installation $Attempt changed another application's startup entry" + } + } - name: Check the installation shell: powershell @@ -351,6 +370,12 @@ jobs: $StillServing = $true try { $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 3 } catch { $StillServing = $false } if ($StillServing) { throw "the server still answers after uninstall" } + $Key = Get-Item -LiteralPath $RunKey + if ($Key.GetValue("Token Meter CI sentinel", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) -cne '%TEMP%\token-meter-ci-sentinel.exe' -or + $Key.GetValueKind("Token Meter CI sentinel") -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { + throw "uninstall changed another application's startup entry" + } + Remove-ItemProperty -LiteralPath $RunKey -Name "Token Meter CI sentinel" Write-Host "uninstall ok" - name: Show server logs @@ -406,6 +431,16 @@ jobs: json.dump({"updates": {"enabled": True, "auto_install": False}}, fh) PY + - name: Seed an unrelated login startup entry + shell: powershell + run: | + $RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if (-not (Test-Path -LiteralPath $RunKey)) { + New-Item -Path $RunKey | Out-Null + } + New-ItemProperty -LiteralPath $RunKey -Name "Token Meter CI sentinel" ` + -PropertyType ExpandString -Value '%TEMP%\token-meter-ci-sentinel.exe' -Force | Out-Null + - name: Install shell: powershell run: | @@ -517,7 +552,12 @@ jobs: $Leftovers = @(Get-ChildItem -LiteralPath (Split-Path -Parent $Runtime) -Directory | Where-Object { $_.Name -like "runtime.*" }) if ($Leftovers.Count -gt 0) { throw "temporary runtime folders remain: $($Leftovers.Name -join ', ')" } - Write-Host "health ok; server restarted ($Before -> $After)" + $RunKey = Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if ($RunKey.GetValue("Token Meter CI sentinel", $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) -cne '%TEMP%\token-meter-ci-sentinel.exe' -or + $RunKey.GetValueKind("Token Meter CI sentinel") -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { + throw "update changed another application's startup entry" + } + Write-Host "health ok; server restarted ($Before -> $After); unrelated startup entry preserved" - name: Show update and server logs if: failure() diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index ec395ffe..65a8ca5f 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -570,7 +570,10 @@ try { $PowerShellExe = $PowerShellCommand.Source $StartScript = Join-Path $InstallRoot "scripts\start-token-meter.ps1" $StartupCommand = "`"$PowerShellExe`" -NoLogo -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartScript`"" - New-Item -Path $RunKey -Force | Out-Null + # Registry New-Item -Force recreates an existing key, discarding other apps' values. + if (-not (Test-Path -LiteralPath $RunKey)) { + New-Item -Path $RunKey | Out-Null + } New-ItemProperty -LiteralPath $RunKey -Name $RunName -PropertyType String -Value $StartupCommand -Force | Out-Null & $StartScript -ReadinessTimeoutSeconds $ReadinessTimeoutSeconds diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index ca407955..ecf81612 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -282,6 +282,16 @@ def test_manifest_is_the_only_windows_staging_inventory(self): self.assertNotIn("$RequiredPaths", installer) self.assertNotIn("$RuntimeDirectory", installer) + def test_windows_installer_never_forces_shared_startup_key_creation(self): + # Registry New-Item -Force recreates an existing key and discards other + # applications' startup values. Property-level -Force is safe and needed + # to replace only Token Meter's own startup command on reinstall. + installer = (ROOT / "scripts" / "install-windows.ps1").read_text(encoding="utf-8") + key_creations = re.findall(r"(?im)^\s*New-Item\s+[^\n]*\$RunKey\b[^\n]*", installer) + self.assertTrue(key_creations, "installer must handle a missing startup key") + for creation in key_creations: + self.assertNotRegex(creation, r"(?i)-Force\b", "shared startup key must never be recreated") + def test_windows_lifecycle_is_per_user_owned_and_local_only(self): installer = (ROOT / "scripts" / "install-windows.ps1").read_text(encoding="utf-8") starter = (ROOT / "scripts" / "start-token-meter.ps1").read_text(encoding="utf-8")