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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
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, 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

Expand Down
4 changes: 3 additions & 1 deletion docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ started, in that app's `%LOCALAPPDATA%\Packages\<app>\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:

Expand Down
31 changes: 31 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ 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; `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);'
[Wts.ChildSessions]::WTSEnableChildSessions($false)
```

## Per-user background rendering

Before creating its RDP viewer, the daemon sets the DWORD
Expand All @@ -67,6 +80,24 @@ 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. Like `anode rendering --restore`, it changes
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"
$key = 'HKCU:\Software\Microsoft\Terminal Server Client'
$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
}
```

## Desktop inspection

Window enumeration and UI Automation execute inside a worker that first verifies
Expand Down
17 changes: 16 additions & 1 deletion scripts/test-install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,32 @@ 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 = [IO.Directory]::CreateDirectory($folder) }
# 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 {
$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
-not (Test-Path -LiteralPath (Join-Path $installation 'uninstall.ps1')) -and
-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((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'
$stray = Join-Path $workspace 'not installed'
$null = New-Item -ItemType Directory -Path $stray
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall.ps1') -Destination $stray
Expand Down
93 changes: 88 additions & 5 deletions scripts/uninstall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
#>
Expand All @@ -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.
Expand All @@ -34,6 +35,82 @@ 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 {
function Read-Value([string]$Key, [string]$Name) { try { (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop).$Name } catch { $null } }
$local = $env:LOCALAPPDATA
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 } 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."
}
"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 {
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, 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'
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.'
}
$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 {
if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) {
Expand Down Expand Up @@ -77,6 +154,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.' }
}
Expand Down Expand Up @@ -138,8 +216,13 @@ 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) {
$other = try { Get-OtherAnode } catch { $null }
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" }
}
Finish 0
} catch {
Write-Host $_.Exception.Message -ForegroundColor Red
Expand Down
Loading