From 4c912f5efac9ee1e98501a277d1bc933b857cee6 Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 23:28:34 +0100 Subject: [PATCH 1/5] Uninstaller: list what stays, with how to remove each without anode.exe (B-010) After removing anode.exe, the uninstaller pointed at SECURITY.md, whose answer is 'anode setup --undo', to turn child sessions off, and named only %LOCALAPPDATA%\Anode. It now ends with what really stays, read without anode.exe: Anode state folders (a packaged app's too) and any rendering backup in them, the seat's browser and Android Studio profiles (still signed in), child sessions (WTSIsChildSessionsEnabled via Add-Type), the Remote Desktop host and the --fps 60/--gpu values, each with how to remove it. A failing probe is skipped, never failing a finished removal. A rendering restore that fails during removal is now reported instead of ignored. SECURITY.md shows how to turn child sessions off and restore the rendering preference without anode.exe; INSTALL.md links it. test-install.ps1 checks the list with a stand-in %LOCALAPPDATA%. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++ docs/INSTALL.md | 4 ++- docs/SECURITY.md | 23 ++++++++++++++++ scripts/test-install.ps1 | 14 +++++++++- scripts/uninstall.ps1 | 57 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c64ad6..8d5e2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ client has no name for the AppBar control type of Explorer's command bar, or for SemanticZoom. Such controls now have the role `Unknown`, and `seat_element` acts on them as on any other control. A text control that provides no document range or selection is read without them. +- **The uninstaller lists what it leaves.** After removing `anode.exe`, it pointed at `anode setup + --undo` to turn child sessions off, and named only the logs. It now ends with what really stays on + the machine, each with how to remove it without Anode: logs and settings (a packaged app's too), a + Remote Desktop rendering preference not yet restored, the seat's browser and Android Studio + profiles, still signed in, child sessions, the Remote Desktop host, and the `--fps 60` and `--gpu` + values. The security page shows how to turn child sessions off and restore the rendering + preference without `anode.exe`, and a rendering restore that fails during removal is reported. ## 0.11.1 — 2026-10-02 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c19adb8..2da413f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -193,7 +193,9 @@ started, in that app's `%LOCALAPPDATA%\Packages\\LocalCache\Local\Anode`. T `%LOCALAPPDATA%\AnodeEdge`, and Android Studio's seat profile, `%LOCALAPPDATA%\AnodeAndroidStudio`, also remain, still signed in to any site or account you signed in to there; delete them to sign those out. To turn child sessions off, do it during removal (answer yes, or pass -`-UndoSetup`): once `anode.exe` is gone, `anode setup --undo` is no longer available. +`-UndoSetup`): once `anode.exe` is gone, `anode setup --undo` is no longer available, and +[a PowerShell command](SECURITY.md#turning-child-sessions-off-without-anode) does it instead. The +uninstaller ends by listing what stays on this machine and how to remove each without Anode. For portable, Scoop and plugin installs, or to remove Anode by hand: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a6ed605..05f22a1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -52,6 +52,16 @@ Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Servi Leave `bEnumerateHWBeforeSW` alone if your organization's Group Policy sets it. +### Turning child sessions off without Anode + +`anode setup --undo` needs `anode.exe`. After an uninstall that left child sessions on, run this +from an administrator PowerShell while no Anode runs. It prints `True` once they are off: + +```powershell +Add-Type -Namespace Wts -Name ChildSessions -MemberDefinition '[DllImport("wtsapi32.dll")] public static extern bool WTSEnableChildSessions(bool enable);' +[Wts.ChildSessions]::WTSEnableChildSessions($false) +``` + ## Per-user background rendering Before creating its RDP viewer, the daemon sets the DWORD @@ -67,6 +77,19 @@ inside such an app it refuses, because the app may keep its file and registry ch The next daemon startup configures rendering again unless `--no-background-rendering` is supplied. A configured value alone does not prove that capture works. +Without `anode.exe`, for example after an uninstall, set the value back from the backup in your own +PowerShell, naming the backup the uninstaller listed. If the value is no longer `2`, something +changed it after Anode did; leave it, and only delete the backup. + +```powershell +$backup = "$env:LOCALAPPDATA\Anode\rdp-rendering-backup.json" +$previous = (Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json).PreviousValue +$key = 'HKCU:\Software\Microsoft\Terminal Server Client' +if ($null -eq $previous) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } +else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $previous -Type DWord } +Remove-Item -LiteralPath $backup +``` + ## Desktop inspection Window enumeration and UI Automation execute inside a worker that first verifies diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index da63ec1..d248f02 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -203,10 +203,17 @@ Write-Output 'connector-output-ok' # Uninstall with no agent CLI visible, so no real client configuration is read or changed, and # -Quiet, so nothing asks, quits Anode or undoes machine setup. function Get-Command { param([string]$Name, [string]$ErrorAction) if ($Name -notin @('codex', 'claude')) { Microsoft.PowerShell.Core\Get-Command @PSBoundParameters } } + # A stand-in %LOCALAPPDATA% holds leftovers the closing message must name, with how to remove them without anode.exe. + $appData = Join-Path $workspace 'LocalAppData' + $packagedState = Join-Path $appData 'Packages\Test.App_x\LocalCache\Local\Anode' + foreach ($folder in @((Join-Path $appData 'Anode'), $packagedState, (Join-Path $appData 'AnodeChrome'))) { $null = New-Item -ItemType Directory -Path $folder } + Set-Content -LiteralPath (Join-Path $packagedState 'rdp-rendering-backup.json') -Value '{"PreviousValue":null}' + $originalAppData = $env:LOCALAPPDATA try { + $env:LOCALAPPDATA = $appData $uninstallOutput = @(& (Join-Path $installation 'uninstall.ps1') -Quiet 6>&1 | ForEach-Object { "$_" }) -join "`n" $uninstallCode = $LASTEXITCODE - } finally { Remove-Item -LiteralPath Function:\Get-Command } + } finally { Remove-Item -LiteralPath Function:\Get-Command; $env:LOCALAPPDATA = $originalAppData } Write-Host $uninstallOutput Assert ($uninstallCode -eq 0 -and $uninstallOutput.Contains('Anode is uninstalled.')) "Uninstall failed: $uninstallOutput" Assert (-not (Test-Path -LiteralPath $exe) -and -not (Test-Path -LiteralPath (Join-Path $installation 'docs')) -and @@ -214,6 +221,11 @@ Write-Output 'connector-output-ok' -not (Test-Path -LiteralPath (Join-Path $installation '.anode-install.json'))) 'Uninstall left installed files.' Assert ((Get-Content -LiteralPath $sentinel -Raw).Trim() -eq 'keep this' -and $uninstallOutput.Contains('you added')) 'Uninstall removed a file the user added.' Assert (-not (Test-Path -LiteralPath (Join-Path $startMenu 'Anode.lnk')) -and -not (Test-Path -LiteralPath $registration)) 'Uninstall left its shortcut or Installed apps entry.' + Assert ($uninstallOutput.Contains("Logs and saved settings in $(Join-Path $appData 'Anode'):") -and $uninstallOutput.Contains("Logs and saved settings in ${packagedState}:") -and + $uninstallOutput.Contains("saved in $(Join-Path $packagedState 'rdp-rendering-backup.json')") -and $uninstallOutput.Contains('#per-user-background-rendering') -and + $uninstallOutput.Contains("still signed in to any site or account you signed in to there: $(Join-Path $appData 'AnodeChrome'). ") -and + -not $uninstallOutput.Contains('setup --undo')) "The closing message does not list what stays and how to remove it without anode.exe: $uninstallOutput" + Write-Host '[ok] uninstall lists what stays, with how to remove each without anode.exe' $stray = Join-Path $workspace 'not installed' $null = New-Item -ItemType Directory -Path $stray Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall.ps1') -Destination $stray diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index e3dbc75..83642fa 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -6,8 +6,9 @@ Windows Settings runs this from Installed apps. It removes the files install.ps1 copied, its PATH entry, Start menu shortcut and Installed apps entry, and unregisters Codex and Claude Code servers that run this installation's anode.exe, with their unmodified anode-desktop skill. - Files you added to the folder, logs in %LOCALAPPDATA%\Anode and machine setup stay. It asks - before quitting a running Anode or undoing machine setup; -Quiet never asks and does neither. + Files you added to the folder, logs in %LOCALAPPDATA%\Anode and machine setup stay; it ends by + listing what stays and how to remove each without Anode. It asks before quitting a running Anode + or undoing machine setup; -Quiet never asks and does neither. .EXAMPLE powershell -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\Anode\uninstall.ps1" #> @@ -22,7 +23,7 @@ $ErrorActionPreference = 'Stop' $InstallDirectory = $PSScriptRoot.TrimEnd('\') $exe = Join-Path $InstallDirectory 'anode.exe' $markerPath = Join-Path $InstallDirectory '.anode-install.json' -$setupGuide = 'https://github.com/skulitom/Anode/blob/main/docs/SECURITY.md#what-anode-setup-changes' +$securityGuide = 'https://github.com/skulitom/Anode/blob/main/docs/SECURITY.md' $interactive = -not $Quiet -and [Environment]::UserInteractive -and -not [Console]::IsInputRedirected function Ask([string]$Question) { $interactive -and ((Read-Host "$Question [y/N]") -match '^\s*y(es)?\s*$') } # Settings opens a console for this script; keep it open long enough to read the outcome. @@ -34,6 +35,48 @@ function Get-Running { @(Get-CimInstance Win32_Process -Filter "Name = 'anode.exe'" | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDirectory + '\', [StringComparison]::OrdinalIgnoreCase) }) } +# What stays on this machine once anode.exe is gone, each with how to remove it without Anode. Read after +# removal, so it names only what is really left; a probe that fails is skipped rather than failing the removal. +function Get-Leftovers { + $local = $env:LOCALAPPDATA + $folders = @(Join-Path $local 'Anode') + @(Get-Item -Path (Join-Path $local 'Packages\*\LocalCache\Local\Anode') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) + foreach ($folder in @($folders | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) { + $backup = Join-Path $folder 'rdp-rendering-backup.json' + if (Test-Path -LiteralPath $backup -PathType Leaf) { + "The Remote Desktop rendering preference Anode changed. Its earlier value is saved in $backup; " + + "set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" + } + "Logs and saved settings in ${folder}: delete the folder when you no longer need them." + } + $profiles = @('AnodeChrome', 'AnodeEdge', 'AnodeAndroidStudio' | ForEach-Object { Join-Path $local $_ } | Where-Object { Test-Path -LiteralPath $_ -PathType Container }) + if ($profiles.Count) { + "The seat's browser and Android Studio profiles, still signed in to any site or account you signed in to there: " + + "$($profiles -join ', '). Delete them to sign those out." + } + $childSessions = $null + try { + if (-not ('AnodeUninstall.ChildSessions' -as [type])) { + Add-Type -Namespace AnodeUninstall -Name ChildSessions -MemberDefinition '[DllImport("wtsapi32.dll")] public static extern bool WTSIsChildSessionsEnabled(out bool enabled);' + } + $enabled = $false + if ([AnodeUninstall.ChildSessions]::WTSIsChildSessionsEnabled([ref]$enabled)) { $childSessions = $enabled } + } catch { } + if ($childSessions -ne $false) { + $state = if ($childSessions) { 'Windows child sessions are on.' } else { "If you ran 'anode setup', Windows child sessions are on." } + "$state To turn them off, run the commands in $securityGuide#turning-child-sessions-off-without-anode from an administrator PowerShell." + } + $terminalServer = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' + function Read-Dword([string]$Key, [string]$Name) { try { (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop).$Name } catch { $null } } + if ((Read-Dword $terminalServer 'fDenyTSConnections') -eq 0) { + "The Remote Desktop host is on: 'anode setup' turns it on, and nothing in Anode turns it off. If nothing else needs it, " + + 'turn it off in Settings > System > Remote Desktop.' + } + if ((Read-Dword "$terminalServer\WinStations" 'DWMFRAMEINTERVAL') -eq 15 -or + (Read-Dword 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' 'bEnumerateHWBeforeSW') -eq 1) { + "The seat's 60 fps frame cap or hardware graphics setting ('anode setup --fps 60' or '--gpu') is set; unless your " + + "organization set it, remove it as $securityGuide#what-anode-setup-changes shows." + } +} try { if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { @@ -77,6 +120,7 @@ try { Write-Warning 'Another Anode is running, so machine setup was left on; undoing it would sign out that seat.' } else { & $exe rendering --restore | Out-Host + if ($LASTEXITCODE -ne 0) { Write-Warning 'The Remote Desktop rendering preference was not restored; see the message above.' } & $exe setup --undo | Out-Host if ($LASTEXITCODE -ne 0) { $undo = $false; Write-Warning 'Child sessions were not turned off; see the messages above.' } } @@ -138,8 +182,11 @@ try { else { Remove-Item -LiteralPath $InstallDirectory } Write-Host "`nAnode is uninstalled." -ForegroundColor Green - Write-Host "Logs and saved settings remain in $(Join-Path $env:LOCALAPPDATA 'Anode'); delete that folder if you no longer need them." - if (-not $undo) { Write-Host "If you ran 'anode setup', child sessions stay on; to turn them off, see $setupGuide" } + $leftovers = @(try { Get-Leftovers } catch { "Could not check what stays on this machine ($($_.Exception.Message)); see $securityGuide." }) + if ($leftovers.Count) { + Write-Host 'These stay on this machine. anode.exe is gone, so each says how to remove it without Anode:' + foreach ($line in $leftovers) { Write-Host "- $line" } + } Finish 0 } catch { Write-Host $_.Exception.Message -ForegroundColor Red From 40109f669ab157c99466863811c88e892a4ffc7c Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 23:32:10 +0100 Subject: [PATCH 2/5] Install test: expect the uninstaller's spelling of an 8.3 TEMP path (B-010) GitHub's Windows runners have a short TEMP (C:\Users\RUNNER~1\...). The uninstaller finds packaged state folders through a wildcard, which spells the path out in full, so the test now expects the packaged folder the same way. Windows PowerShell 5.1's New-Item also refuses nested folders under an 8.3 path ("is not a subdirectory of"), so the stand-in folders are made with [IO.Directory]::CreateDirectory. Both were replayed here with a short workspace path. Co-Authored-By: Claude Opus 5.5 --- scripts/test-install.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index d248f02..a9854a5 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -206,7 +206,9 @@ Write-Output 'connector-output-ok' # A stand-in %LOCALAPPDATA% holds leftovers the closing message must name, with how to remove them without anode.exe. $appData = Join-Path $workspace 'LocalAppData' $packagedState = Join-Path $appData 'Packages\Test.App_x\LocalCache\Local\Anode' - foreach ($folder in @((Join-Path $appData 'Anode'), $packagedState, (Join-Path $appData 'AnodeChrome'))) { $null = New-Item -ItemType Directory -Path $folder } + foreach ($folder in @((Join-Path $appData 'Anode'), $packagedState, (Join-Path $appData 'AnodeChrome'))) { $null = [IO.Directory]::CreateDirectory($folder) } + # The uninstaller finds packaged folders through a wildcard, which spells out an 8.3 TEMP such as RUNNER~1; expect the same. + $packagedState = (Get-Item -Path (Join-Path $appData 'Packages\*\LocalCache\Local\Anode')).FullName Set-Content -LiteralPath (Join-Path $packagedState 'rdp-rendering-backup.json') -Value '{"PreviousValue":null}' $originalAppData = $env:LOCALAPPDATA try { From 7870fbb6095c8d92ac42b470989681b741df7688 Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 23:41:16 +0100 Subject: [PATCH 3/5] Uninstaller list: second review fixes (B-010) - The by-hand rendering restore, like 'anode rendering --restore', changes the value only while it is still Anode's 2, and the list says a backup is out of date when it isn't, with each backup's saved value. - When another Anode is on the machine (a running process or anode.exe on PATH), the list says the items are shared with it; the child-session line says "with no Anode running". - The copies of agent client settings connect-agents.ps1 saved, which can hold other servers' tokens, are listed. - Packaged folders are found with Get-ChildItem -LiteralPath, as rendering --restore does; a missing LOCALAPPDATA skips only the folder lines; the Remote Desktop line hedges for a host that was on before setup; the frame-cap and GPU values are named separately. - SECURITY.md: what False means, and restart first if a seat may still be signed in. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++-- docs/SECURITY.md | 17 ++++++--- scripts/test-install.ps1 | 4 +-- scripts/uninstall.ps1 | 76 +++++++++++++++++++++++++++++----------- 4 files changed, 74 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5e2cb..40ccd00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,11 @@ --undo` to turn child sessions off, and named only the logs. It now ends with what really stays on the machine, each with how to remove it without Anode: logs and settings (a packaged app's too), a Remote Desktop rendering preference not yet restored, the seat's browser and Android Studio - profiles, still signed in, child sessions, the Remote Desktop host, and the `--fps 60` and `--gpu` - values. The security page shows how to turn child sessions off and restore the rendering - preference without `anode.exe`, and a rendering restore that fails during removal is reported. + profiles, still signed in, the copies of agent client settings saved before Anode changed them, + child sessions, the Remote Desktop host, and the `--fps 60` and `--gpu` values. It says when + another Anode on the machine shares them. The security page shows how to turn child sessions off + and restore the rendering preference without `anode.exe`, and a rendering restore that fails + during removal is reported. ## 0.11.1 — 2026-10-02 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 05f22a1..4355c9d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -55,7 +55,10 @@ Leave `bEnumerateHWBeforeSW` alone if your organization's Group Policy sets it. ### Turning child sessions off without Anode `anode setup --undo` needs `anode.exe`. After an uninstall that left child sessions on, run this -from an administrator PowerShell while no Anode runs. It prints `True` once they are off: +from an administrator PowerShell while no Anode runs. It prints `True` once they are off; `False` +usually means the PowerShell wasn't run as administrator. `setup --undo` signs out the seat first, +which this doesn't, so if a seat may still be signed in, for example after Anode was ended by +force, restart Windows first. ```powershell Add-Type -Namespace Wts -Name ChildSessions -MemberDefinition '[DllImport("wtsapi32.dll")] public static extern bool WTSEnableChildSessions(bool enable);' @@ -78,15 +81,19 @@ The next daemon startup configures rendering again unless `--no-background-rende is supplied. A configured value alone does not prove that capture works. Without `anode.exe`, for example after an uninstall, set the value back from the backup in your own -PowerShell, naming the backup the uninstaller listed. If the value is no longer `2`, something -changed it after Anode did; leave it, and only delete the backup. +PowerShell, naming the backup the uninstaller listed. Like `anode rendering --restore`, it changes +the value only while it is still Anode's `2`, since anything else means something changed it +later. If the uninstaller listed several backups, each saved by a different app's Anode, use the +one with the earlier value you want and delete the others. ```powershell $backup = "$env:LOCALAPPDATA\Anode\rdp-rendering-backup.json" $previous = (Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json).PreviousValue $key = 'HKCU:\Software\Microsoft\Terminal Server Client' -if ($null -eq $previous) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } -else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $previous -Type DWord } +if ((Get-ItemProperty -LiteralPath $key).RemoteDesktop_SuppressWhenMinimized -eq 2) { + if ($null -eq $previous) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } + else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $previous -Type DWord } +} Remove-Item -LiteralPath $backup ``` diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index a9854a5..10838ee 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -207,8 +207,8 @@ Write-Output 'connector-output-ok' $appData = Join-Path $workspace 'LocalAppData' $packagedState = Join-Path $appData 'Packages\Test.App_x\LocalCache\Local\Anode' foreach ($folder in @((Join-Path $appData 'Anode'), $packagedState, (Join-Path $appData 'AnodeChrome'))) { $null = [IO.Directory]::CreateDirectory($folder) } - # The uninstaller finds packaged folders through a wildcard, which spells out an 8.3 TEMP such as RUNNER~1; expect the same. - $packagedState = (Get-Item -Path (Join-Path $appData 'Packages\*\LocalCache\Local\Anode')).FullName + # The uninstaller lists packaged folders with Get-ChildItem, which spells out an 8.3 TEMP such as RUNNER~1; expect the same. + $packagedState = Join-Path (Get-ChildItem -LiteralPath (Join-Path $appData 'Packages') -Directory).FullName 'LocalCache\Local\Anode' Set-Content -LiteralPath (Join-Path $packagedState 'rdp-rendering-backup.json') -Value '{"PreviousValue":null}' $originalAppData = $env:LOCALAPPDATA try { diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 83642fa..737c691 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -38,20 +38,42 @@ function Get-Running { # What stays on this machine once anode.exe is gone, each with how to remove it without Anode. Read after # removal, so it names only what is really left; a probe that fails is skipped rather than failing the removal. function Get-Leftovers { + function Read-Value([string]$Key, [string]$Name) { try { (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop).$Name } catch { $null } } $local = $env:LOCALAPPDATA - $folders = @(Join-Path $local 'Anode') + @(Get-Item -Path (Join-Path $local 'Packages\*\LocalCache\Local\Anode') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) - foreach ($folder in @($folders | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) { - $backup = Join-Path $folder 'rdp-rendering-backup.json' - if (Test-Path -LiteralPath $backup -PathType Leaf) { - "The Remote Desktop rendering preference Anode changed. Its earlier value is saved in $backup; " + - "set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" + if ($local) { + # Where 'anode rendering --restore' looks: Anode's own folder, then each packaged app's. + $folders = @(Join-Path $local 'Anode') + @(Get-ChildItem -LiteralPath (Join-Path $local 'Packages') -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalCache\Local\Anode' }) + $rendering = Read-Value 'HKCU:\Software\Microsoft\Terminal Server Client' 'RemoteDesktop_SuppressWhenMinimized' + foreach ($folder in @($folders | Where-Object { Test-Path -LiteralPath $_ -PathType Container })) { + $backup = Join-Path $folder 'rdp-rendering-backup.json' + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { } + elseif ($rendering -eq 2) { + $saved = try { (Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json).PreviousValue } catch { 'unreadable' } + "The Remote Desktop rendering preference Anode changed is not restored. Its earlier value, $(if ($null -eq $saved) { 'none' } else { $saved }), " + + "is saved in $backup; set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" + } else { + "$backup is out of date: the Remote Desktop rendering preference no longer has the value Anode set, so leave the preference as it is." + } + "Logs and saved settings in ${folder}: delete the folder when you no longer need them." + } + $profiles = @('AnodeChrome', 'AnodeEdge', 'AnodeAndroidStudio' | ForEach-Object { Join-Path $local $_ } | Where-Object { Test-Path -LiteralPath $_ -PathType Container }) + if ($profiles.Count) { + "The seat's browser and Android Studio profiles, still signed in to any site or account you signed in to there: " + + "$($profiles -join ', '). Delete them to sign those out." } - "Logs and saved settings in ${folder}: delete the folder when you no longer need them." } - $profiles = @('AnodeChrome', 'AnodeEdge', 'AnodeAndroidStudio' | ForEach-Object { Join-Path $local $_ } | Where-Object { Test-Path -LiteralPath $_ -PathType Container }) - if ($profiles.Count) { - "The seat's browser and Android Studio profiles, still signed in to any site or account you signed in to there: " + - "$($profiles -join ', '). Delete them to sign those out." + # The copies connect-agents.ps1 made before changing a client's settings, this removal's included. + $copies = @(try { + foreach ($config in @((Join-Path $(if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' }) 'config.toml'), + (Join-Path $(if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { $env:USERPROFILE }) '.claude.json'))) { + Get-ChildItem -LiteralPath (Split-Path -Parent $config) -Filter ((Split-Path -Leaf $config) + '.anode-backup-*') -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.FullName } + } + } catch { }) + if ($copies.Count) { + "Copies of agent client settings, saved before Anode changed them, which can hold other servers' settings and tokens: " + + "$($copies -join ', '). Delete them once those clients work as you want." } $childSessions = $null try { @@ -63,19 +85,29 @@ function Get-Leftovers { } catch { } if ($childSessions -ne $false) { $state = if ($childSessions) { 'Windows child sessions are on.' } else { "If you ran 'anode setup', Windows child sessions are on." } - "$state To turn them off, run the commands in $securityGuide#turning-child-sessions-off-without-anode from an administrator PowerShell." + "$state To turn them off, with no Anode running, run the commands in $securityGuide#turning-child-sessions-off-without-anode " + + 'from an administrator PowerShell.' } $terminalServer = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' - function Read-Dword([string]$Key, [string]$Name) { try { (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop).$Name } catch { $null } } - if ((Read-Dword $terminalServer 'fDenyTSConnections') -eq 0) { - "The Remote Desktop host is on: 'anode setup' turns it on, and nothing in Anode turns it off. If nothing else needs it, " + - 'turn it off in Settings > System > Remote Desktop.' + if ((Read-Value $terminalServer 'fDenyTSConnections') -eq 0) { + "The Remote Desktop host is on, and nothing in Anode turns it off. If it was off before 'anode setup' and nothing else " + + 'needs it, turn it off in Settings > System > Remote Desktop.' } - if ((Read-Dword "$terminalServer\WinStations" 'DWMFRAMEINTERVAL') -eq 15 -or - (Read-Dword 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' 'bEnumerateHWBeforeSW') -eq 1) { - "The seat's 60 fps frame cap or hardware graphics setting ('anode setup --fps 60' or '--gpu') is set; unless your " + - "organization set it, remove it as $securityGuide#what-anode-setup-changes shows." + $tuning = @() + if ((Read-Value "$terminalServer\WinStations" 'DWMFRAMEINTERVAL') -eq 15) { $tuning += "the 60 fps frame cap (DWMFRAMEINTERVAL, from --fps 60)" } + if ((Read-Value 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' 'bEnumerateHWBeforeSW') -eq 1) { + $tuning += 'the hardware graphics setting (bEnumerateHWBeforeSW, from --gpu)' } + if ($tuning.Count) { + "Remote Desktop settings 'anode setup' can make are set: $($tuning -join ' and '). Unless your organization set them, " + + "remove them as $securityGuide#what-anode-setup-changes shows." + } +} +# Another Anode on this machine (a portable, Scoop or plugin copy) shares that log folder, the seat profiles and machine setup. +function Get-OtherAnode { + $paths = @(Get-Process -Name anode -ErrorAction SilentlyContinue | ForEach-Object { try { $_.Path } catch { } }) + + @(Get-Command -Name anode.exe -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }) + @($paths | Where-Object { $_ -and -not $_.StartsWith($InstallDirectory + '\', [StringComparison]::OrdinalIgnoreCase) })[0] } try { @@ -184,7 +216,9 @@ try { Write-Host "`nAnode is uninstalled." -ForegroundColor Green $leftovers = @(try { Get-Leftovers } catch { "Could not check what stays on this machine ($($_.Exception.Message)); see $securityGuide." }) if ($leftovers.Count) { - Write-Host 'These stay on this machine. anode.exe is gone, so each says how to remove it without Anode:' + $other = try { Get-OtherAnode } catch { $null } + Write-Host $(if ($other) { "These stay on this machine and are shared with another Anode, ${other}: keep them while you use it. Without Anode, each goes like this:" } + else { 'These stay on this machine. anode.exe is gone, so each says how to remove it without Anode:' }) foreach ($line in $leftovers) { Write-Host "- $line" } } Finish 0 From 359d3c0374e572d204aadcab640d1f9305d01178 Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 23:45:36 +0100 Subject: [PATCH 4/5] Install test: check only the rendering backup's path, not advice that depends on the machine (B-010) CI failed on 7870fbb: the runner's own rendering value isn't Anode's 2, so the uninstaller rightly called the stand-in backup out of date, while the test expected the "set it back" wording, which holds only where a daemon has run. Both wordings name the backup's path, which is what the test now checks. Co-Authored-By: Claude Opus 5.5 --- scripts/test-install.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 10838ee..4fecea3 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -223,8 +223,9 @@ Write-Output 'connector-output-ok' -not (Test-Path -LiteralPath (Join-Path $installation '.anode-install.json'))) 'Uninstall left installed files.' Assert ((Get-Content -LiteralPath $sentinel -Raw).Trim() -eq 'keep this' -and $uninstallOutput.Contains('you added')) 'Uninstall removed a file the user added.' Assert (-not (Test-Path -LiteralPath (Join-Path $startMenu 'Anode.lnk')) -and -not (Test-Path -LiteralPath $registration)) 'Uninstall left its shortcut or Installed apps entry.' + # The backup's advice depends on this machine's own rendering value, so only its path is checked. Assert ($uninstallOutput.Contains("Logs and saved settings in $(Join-Path $appData 'Anode'):") -and $uninstallOutput.Contains("Logs and saved settings in ${packagedState}:") -and - $uninstallOutput.Contains("saved in $(Join-Path $packagedState 'rdp-rendering-backup.json')") -and $uninstallOutput.Contains('#per-user-background-rendering') -and + $uninstallOutput.Contains((Join-Path $packagedState 'rdp-rendering-backup.json')) -and $uninstallOutput.Contains("still signed in to any site or account you signed in to there: $(Join-Path $appData 'AnodeChrome'). ") -and -not $uninstallOutput.Contains('setup --undo')) "The closing message does not list what stays and how to remove it without anode.exe: $uninstallOutput" Write-Host '[ok] uninstall lists what stays, with how to remove each without anode.exe' From 50e6977b397defee8c4c1936f2e05dcc3cd2d493 Mon Sep 17 00:00:00 2001 From: skulitom Date: Wed, 7 Oct 2026 23:50:45 +0100 Subject: [PATCH 5/5] Uninstaller list: second-pass review fixes (B-010) - SECURITY.md's by-hand rendering restore reads the backup into one object and acts only when it read one and the value is still 2, deleting the backup only then. A wrong path or an empty file, or a $previous left from an earlier paste, can no longer delete the value instead of restoring it (checked: missing, empty, changed-later and both saved values). - The list says "unreadable" for a backup it can't read, and "none (the value was absent)" as rendering --restore does. - With another Anode present, the heading says it still uses the logs, profiles and machine settings among the items, which no longer takes in the client-settings copies. Co-Authored-By: Claude Opus 5.5 --- docs/SECURITY.md | 15 ++++++++------- scripts/uninstall.ps1 | 10 ++++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 4355c9d..db1fd56 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -82,19 +82,20 @@ is supplied. A configured value alone does not prove that capture works. Without `anode.exe`, for example after an uninstall, set the value back from the backup in your own PowerShell, naming the backup the uninstaller listed. Like `anode rendering --restore`, it changes -the value only while it is still Anode's `2`, since anything else means something changed it -later. If the uninstaller listed several backups, each saved by a different app's Anode, use the +the value, and deletes the backup, only while the value is still Anode's `2`. Anything else means +something changed it later, so the backup is out of date: delete it yourself. A wrong path changes +nothing. If the uninstaller listed several backups, each saved by a different app's Anode, use the one with the earlier value you want and delete the others. ```powershell $backup = "$env:LOCALAPPDATA\Anode\rdp-rendering-backup.json" -$previous = (Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json).PreviousValue $key = 'HKCU:\Software\Microsoft\Terminal Server Client' -if ((Get-ItemProperty -LiteralPath $key).RemoteDesktop_SuppressWhenMinimized -eq 2) { - if ($null -eq $previous) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } - else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $previous -Type DWord } +$saved = Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json +if ($saved -and (Get-ItemProperty -LiteralPath $key).RemoteDesktop_SuppressWhenMinimized -eq 2) { + if ($null -eq $saved.PreviousValue) { Remove-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized } + else { Set-ItemProperty -LiteralPath $key -Name RemoteDesktop_SuppressWhenMinimized -Value $saved.PreviousValue -Type DWord } + Remove-Item -LiteralPath $backup } -Remove-Item -LiteralPath $backup ``` ## Desktop inspection diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index 737c691..33ae65d 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -49,9 +49,11 @@ function Get-Leftovers { $backup = Join-Path $folder 'rdp-rendering-backup.json' if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { } elseif ($rendering -eq 2) { - $saved = try { (Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json).PreviousValue } catch { 'unreadable' } - "The Remote Desktop rendering preference Anode changed is not restored. Its earlier value, $(if ($null -eq $saved) { 'none' } else { $saved }), " + - "is saved in $backup; set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" + $saved = try { Get-Content -LiteralPath $backup -Raw | ConvertFrom-Json } catch { $null } + $value = if (-not $saved -or 'PreviousValue' -notin $saved.PSObject.Properties.Name) { 'unreadable' } + elseif ($null -eq $saved.PreviousValue) { 'none (the value was absent)' } else { $saved.PreviousValue } + "The Remote Desktop rendering preference Anode changed is not restored. Its backup, $backup, holds the earlier " + + "value: $value. Set it back by hand before deleting that folder: $securityGuide#per-user-background-rendering" } else { "$backup is out of date: the Remote Desktop rendering preference no longer has the value Anode set, so leave the preference as it is." } @@ -217,7 +219,7 @@ try { $leftovers = @(try { Get-Leftovers } catch { "Could not check what stays on this machine ($($_.Exception.Message)); see $securityGuide." }) if ($leftovers.Count) { $other = try { Get-OtherAnode } catch { $null } - Write-Host $(if ($other) { "These stay on this machine and are shared with another Anode, ${other}: keep them while you use it. Without Anode, each goes like this:" } + Write-Host $(if ($other) { "These stay on this machine. Another Anode, $other, still uses the logs, profiles and machine settings among them, so keep those while you use it. Without Anode, each goes like this:" } else { 'These stay on this machine. anode.exe is gone, so each says how to remove it without Anode:' }) foreach ($line in $leftovers) { Write-Host "- $line" } }