# Tlamatini Auto-Updater # ===================================================================== # Runs OUTSIDE the install directory (a copy is placed under # %LOCALAPPDATA%\Tlamatini\updater by self_update.py) so it can safely # replace the whole install — including python\, jre\, git\ and # Tlamatini.exe — without locking the files it is overwriting. # # Sequence: # 1. Validate install/staging boundaries and every staged file's receipt. # 2. Close the running Tlamatini (and its child processes). # 3. Verify a WAL-aware database backup, then rename agents -> agents_backup. # 4. Delete the old install EXCEPT the preserved user-data set. # 5. Move the new build in EXCEPT the preserved set (so the user's # config.json / DB / etc. are never overwritten). # 6. Relaunch the new Tlamatini. # # It is launched by agent/self_update.py — do not run it by hand. # ===================================================================== param( [Parameter(Mandatory = $true)][string]$InstallDir, [Parameter(Mandatory = $true)][string]$StagingDir, [Parameter(Mandatory = $true)][int]$ParentPid, [string]$RelaunchExe = "", [string]$LogPath = "" ) $ErrorActionPreference = "Stop" try { $Host.UI.RawUI.WindowTitle = "Tlamatini Updater" } catch {} # Top-level names in the install root that must NEVER be deleted or # overwritten. 'agents' is intentionally NOT here: it is renamed to # 'agents_backup' (step 3) and then replaced by the new version (step 5). # Keep the installed removal client until the incoming, verified client can replace it. # # The comment sits ABOVE the array on purpose. agent/test_preserved_user_state.py # parses this block with a quoted-string regex, so an apostrophe INSIDE it - the # old comment read "the user's uninstaller" - opened a phantom string that ate # the 'Uninstaller.exe' entry, and the guard reported it missing from a file that # was perfectly correct. Keep apostrophes out of this block. $Preserve = @( 'config.json', 'external_mcps.json', 'contacts.json', 'data.keys', 'DB', 'application', 'applications', 'content_generated', 'Temp', 'context_files', 'doc_generated', 'documentation', 'Templates', 'Uninstaller.exe' ) function Write-Log { param([string]$Message, [string]$Color = "Gray") $line = "[{0}] {1}" -f (Get-Date -Format "HH:mm:ss"), $Message try { Write-Host $line -ForegroundColor $Color } catch { Write-Host $line } if ($LogPath) { try { Add-Content -Path $LogPath -Value $line -Encoding UTF8 } catch {} } } function Invoke-WithRetry { # Windows can hold file handles for a moment after a process dies, so # delete/rename/move operations are retried instead of failing outright. param([scriptblock]$Action, [int]$Attempts = 15, [int]$DelayMs = 600) for ($i = 1; $i -le $Attempts; $i++) { try { & $Action; return } catch { if ($i -eq $Attempts) { throw } Start-Sleep -Milliseconds $DelayMs } } } function Test-Preserved { param([string]$Name) foreach ($p in $Preserve) { if ($Name -ieq $p) { return $true } } return $false } function Install-UpdateUninstaller { param([string]$InstallDir, [string]$StagingDir, [string]$UpdateRoot) $incomingRemoval = Join-Path $StagingDir "Uninstaller.exe" if (Test-Path -LiteralPath $incomingRemoval -PathType Leaf) { $installedRemoval = Join-Path $InstallDir "Uninstaller.exe" Assert-NoReparseAncestors $installedRemoval if (Test-Path -LiteralPath $installedRemoval) { $previousRemoval = Join-Path $UpdateRoot ("previous-uninstaller-" + [guid]::NewGuid() + ".exe") Invoke-WithRetry { [IO.File]::Replace($incomingRemoval, $installedRemoval, $previousRemoval) } } else { Invoke-WithRetry { [IO.File]::Move($incomingRemoval, $installedRemoval) } } } } function Assert-NoReparseAncestors { param([string]$Path) $cursor = [IO.Path]::GetFullPath($Path) while ($cursor) { if (Test-Path -LiteralPath $cursor) { $item = Get-Item -LiteralPath $cursor -Force if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Updater path traverses a reparse point: '$cursor'. Aborting." } } $cursor = [IO.Path]::GetDirectoryName($cursor) } } Write-Host "" Write-Host " ==================================================" -ForegroundColor Cyan Write-Host " TLAMATINI -- APPLYING UPDATE" -ForegroundColor Cyan Write-Host " ==================================================" -ForegroundColor Cyan Write-Host "" Write-Log "Install dir : $InstallDir" Write-Log "Staging dir : $StagingDir" Write-Log "Parent PID : $ParentPid" Write-Host "" try { # 1) Validate the staged new version BEFORE touching anything. $InstallDir = [IO.Path]::GetFullPath($InstallDir).TrimEnd('\', '/') $StagingDir = [IO.Path]::GetFullPath($StagingDir).TrimEnd('\', '/') $updateRoot = Join-Path $InstallDir "Temp\_update" $expectedStaging = Join-Path $updateRoot "staging" if ($StagingDir -ine $expectedStaging -or $InstallDir -ieq [IO.Path]::GetPathRoot($InstallDir).TrimEnd('\', '/') -or $InstallDir -ieq $env:USERPROFILE -or $InstallDir -ieq $env:WINDIR) { throw "Unsafe install/staging boundary. Expected staging strictly at InstallDir\Temp\_update\staging." } Assert-NoReparseAncestors $InstallDir Assert-NoReparseAncestors $StagingDir if (-not (Test-Path -LiteralPath (Join-Path $InstallDir "Tlamatini.exe") -PathType Leaf)) { throw "Install directory does not contain the existing Tlamatini.exe. Aborting." } if ($ParentPid -le 0 -or $ParentPid -eq $PID) { throw "Invalid application PID." } $applicationProcess = Get-CimInstance Win32_Process -Filter "ProcessId=$ParentPid" -ErrorAction Stop $installedExe = Join-Path $InstallDir "Tlamatini.exe" if ($applicationProcess -and $applicationProcess.ExecutablePath -ine $installedExe) { throw "The supplied PID does not belong to this installation. Nothing was replaced." } $newExe = Join-Path $StagingDir "Tlamatini.exe" if (-not (Test-Path -LiteralPath $newExe)) { throw "Staged update is invalid -- Tlamatini.exe not found at '$newExe'. Aborting; nothing was changed." } if (-not (Test-Path -LiteralPath $InstallDir)) { throw "Install directory '$InstallDir' does not exist. Aborting." } # Database preservation must be available before closing the running app. # Prefer the staged helper/runtime so the swap uses the new release's code. $backupPython = Join-Path $StagingDir "python\python.exe" $backupHelper = Join-Path $StagingDir "sqlite_copy.py" $integrityHelper = Join-Path $StagingDir "build_runtime_assets.py" foreach ($required in @($backupPython, $backupHelper, $integrityHelper)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required staged updater asset missing: '$required'. Nothing was replaced." } } Write-Log "Verifying every staged file before closing Tlamatini..." # -B prevents the verifier from adding .pyc files to the tree it is checking; # -S avoids unrelated site-package startup hooks. Both helpers use stdlib only. & $backupPython -I -B -S $integrityHelper --verify-tree $StagingDir if ($LASTEXITCODE -ne 0) { throw "Staged release integrity check failed. Nothing was replaced." } $userDb = Join-Path $InstallDir "_internal\db.sqlite3" if ((Test-Path -LiteralPath $userDb -PathType Leaf) -and (-not (Test-Path -LiteralPath $backupPython -PathType Leaf) -or -not (Test-Path -LiteralPath $backupHelper -PathType Leaf))) { throw "Database backup helper or carried Python is missing. Aborting before closing Tlamatini." } Write-Log "Staged build and database-backup assets validated." "Green" # 2) Close the running Tlamatini and its whole process tree -- but NEVER # this updater process. The updater is launched by self_update.py as a # child of $ParentPid (the Tlamatini.exe). A blind # `taskkill /T /PID $ParentPid` walks the recorded parent/child tree -- # which CREATE_BREAKAWAY_FROM_JOB does NOT change -- so it would also # kill US, and the update would abort right here after "Closing # Tlamatini..." (the exact symptom of the 1.19.5 -> 1.20.0 failure). # So we enumerate the descendant tree ourselves and skip our own $PID. Write-Log "Closing Tlamatini (PID $ParentPid) and its child processes..." Start-Sleep -Seconds 2 try { $procs = Get-CimInstance Win32_Process -ErrorAction Stop $parentNow = $procs | Where-Object { [int]$_.ProcessId -eq $ParentPid } if ($parentNow -and (!$applicationProcess -or $parentNow.CreationDate -ne $applicationProcess.CreationDate -or $parentNow.ExecutablePath -ine $installedExe)) { throw "The application PID was reused. Update stopped before terminating any process." } $toKill = New-Object System.Collections.Generic.List[int] $queue = New-Object System.Collections.Generic.Queue[int] $seen = New-Object System.Collections.Generic.HashSet[int] [void]$queue.Enqueue([int]$ParentPid) [void]$seen.Add([int]$ParentPid) while ($queue.Count -gt 0) { $cur = $queue.Dequeue() foreach ($p in $procs) { if ([int]$p.ParentProcessId -eq $cur) { $cid = [int]$p.ProcessId if ($cid -eq $PID) { continue } # never our own updater if ($seen.Add($cid)) { [void]$queue.Enqueue($cid) $toKill.Add($cid) # children, collected first } } } } foreach ($k in $toKill) { try { & taskkill.exe /PID $k /F 2>&1 | Out-Null } catch {} } try { & taskkill.exe /PID $ParentPid /F 2>&1 | Out-Null } catch {} Write-Log ("Killed parent + {0} descendant process(es), kept updater (PID {1})." -f $toKill.Count, $PID) } catch { # Never fall back to /T: it can kill this updater, leaving a half-run swap. throw "Could not safely enumerate the application process tree; application files were not replaced." } # Let Windows release the file handles (python\, git\, jre\, exe). Start-Sleep -Seconds 3 Write-Log "Tlamatini closed." "Green" # 3a) Preserve the user's DATABASE across the update. The live db.sqlite3 # lives INSIDE _internal\ (PyInstaller _MEIPASS), which step 4 deletes, # so the top-level $Preserve set cannot protect it. Use SQLite's backup # API to include committed WAL pages in one verified file, staged into # the preserved DB\ToLoad folder and flag a post-update migrate: on the # next launch manage.py's _apply_pending_db_swap restores it over the # freshly shipped DB, then _run_post_update_migrate_if_flagged applies # the new migrations (new agents / tools / demo prompts) to the user's # data -- so chat history + custom toggles are KEPT, not wiped. if (Test-Path -LiteralPath $userDb) { $toLoadDir = Join-Path $InstallDir "DB\ToLoad" if (-not (Test-Path -LiteralPath $toLoadDir)) { Invoke-WithRetry { New-Item -ItemType Directory -Path $toLoadDir -Force | Out-Null } } $backupTarget = Join-Path $toLoadDir "db.sqlite3" & $backupPython -I -B -S $backupHelper $userDb $backupTarget if ($LASTEXITCODE -ne 0) { throw "Database backup failed. Application files have not been replaced; the original database is intact." } Set-Content -LiteralPath (Join-Path $InstallDir "DB\post_update_migrate.flag") -Value (Get-Date -Format o) -Encoding UTF8 Write-Log "Preserved your database including WAL commits -> DB\ToLoad and flagged a post-update migrate." "Green" } else { Write-Log "No existing database at _internal\db.sqlite3 (fresh install?) -- skipping DB preserve." "Yellow" } # 3b) Back up the old agents directory: agents -> agents_backup. $agentsDir = Join-Path $InstallDir "agents" $agentsBak = Join-Path $InstallDir "agents_backup" if (Test-Path -LiteralPath $agentsDir) { if (Test-Path -LiteralPath $agentsBak) { Write-Log "Removing previous agents_backup..." Invoke-WithRetry { Remove-Item -LiteralPath $agentsBak -Recurse -Force } } Write-Log "Renaming agents -> agents_backup ..." Invoke-WithRetry { Rename-Item -LiteralPath $agentsDir -NewName "agents_backup" } Write-Log "Old agents backed up to 'agents_backup'." "Green" } else { Write-Log "No existing 'agents' directory to back up." "Yellow" } # 3c) Preserve the Blue-hat toolkit's EVIDENCE across the update. # security\ is application code: the new release MUST replace the # defender/whitelist scripts. But security\security_logs\ is the # operator's own evidence -- alerts.log, monitor.log and the visible # asset-test proof -- and it lives INSIDE security\, so like the # database it cannot be protected by the top-level $Preserve set. # A blind delete-and-replace destroys incident history at exactly the # moment it matters. Stash it under the preserved Temp\ (same volume, # so this is a rename, not a copy) and restore it in step 5b. # Fail CLOSED before deletion if evidence cannot be moved safely. $securityLogs = Join-Path $InstallDir "security\security_logs" $logsCarryover = Join-Path $InstallDir ("Temp\_security_logs_carryover_" + [Guid]::NewGuid().ToString("N")) try { if (Test-Path -LiteralPath $securityLogs) { $carryParent = Split-Path -Parent $logsCarryover if (-not (Test-Path -LiteralPath $carryParent)) { New-Item -ItemType Directory -Path $carryParent -Force | Out-Null } Invoke-WithRetry { Move-Item -LiteralPath $securityLogs -Destination $logsCarryover -Force } Write-Log "Preserved your security evidence -> $logsCarryover." "Green" } else { Write-Log "No security\security_logs to preserve (toolkit never run?)." "Yellow" } } catch { throw "Could not preserve security evidence; application deletion aborted: $($_.Exception.Message)" } # Fix literal NTFS cache aliases while the verified staged Python is still in place. $recoveryHelper = Join-Path $StagingDir "setup_recovery.py" & $backupPython -I -B -S $recoveryHelper --clean-update-browser-caches $InstallDir if ($LASTEXITCODE -ne 0) { throw "Could not clear a legacy browser cache. Update stopped." } # Older clients stage only the inner ZIP. Replace its receipted uninstaller # atomically; when a compatible older payload lacks it, keep the installed copy. Install-UpdateUninstaller -InstallDir $InstallDir -StagingDir $StagingDir -UpdateRoot $updateRoot # 4) Delete the old install -- everything except the preserved set and # the agents_backup we just created. Write-Log "Removing old application files (keeping your data)..." Get-ChildItem -LiteralPath $InstallDir -Force | ForEach-Object { $name = $_.Name if ($name -ieq "agents_backup") { return } if (Test-Preserved $name) { Write-Log " keep $name"; return } $full = $_.FullName Write-Log " remove $name" Invoke-WithRetry { Remove-Item -LiteralPath $full -Recurse -Force } } # 5) Move the new version in -- everything except the preserved set, so # the user's config.json / DB / runtime dirs are never overwritten. Write-Log "Installing the new version..." Get-ChildItem -LiteralPath $StagingDir -Force | ForEach-Object { $name = $_.Name $src = $_.FullName $dest = Join-Path $InstallDir $name if ((Test-Preserved $name) -and (Test-Path -LiteralPath $dest)) { Write-Log " skip $name (kept yours)"; return } if (Test-Path -LiteralPath $dest) { Invoke-WithRetry { Remove-Item -LiteralPath $dest -Recurse -Force } } Write-Log " install $name" Invoke-WithRetry { Move-Item -LiteralPath $src -Destination $dest -Force } } # 5b) Put the Blue-hat evidence back inside the NEW security\ tree. # The new release ships security\ without security_logs\ (build.py # ignores it on purpose), so this simply moves the operator's history # back where the defender expects to append to it. FAIL-OPEN: on any # error the unique stash is LEFT in Temp\ rather than # deleted, so the evidence still exists and can be recovered by hand. try { if (Test-Path -LiteralPath $logsCarryover) { $securityDir = Join-Path $InstallDir "security" if (-not (Test-Path -LiteralPath $securityDir)) { New-Item -ItemType Directory -Path $securityDir -Force | Out-Null } if (Test-Path -LiteralPath $securityLogs) { throw "New evidence already exists at '$securityLogs'; retaining both copies." } Invoke-WithRetry { Move-Item -LiteralPath $logsCarryover -Destination $securityLogs -Force } Write-Log "Restored your security evidence -> security\security_logs." "Green" } } catch { Write-Log "WARN: security evidence kept at '$logsCarryover' -- move it back by hand: $($_.Exception.Message)" "Yellow" } # Repair owned flow registrations after files have been replaced. Explicitly # unregistered types and Windows default-app choices remain untouched. $flowRegistration = Join-Path $InstallDir 'flow_file_associations.ps1' if (Test-Path -LiteralPath $flowRegistration -PathType Leaf) { try { & $flowRegistration -Action Repair -InstallDir $InstallDir Write-Log 'Flow-file registration repair completed.' } catch { Write-Log "Flow-file registration repair needs attention: $_" 'Yellow' } } # 6) Clean up the staging area (best effort). try { # Exact validated directory from step 1, never a caller-derived parent. if ($updateRoot -and (Test-Path -LiteralPath $updateRoot)) { Remove-Item -LiteralPath $updateRoot -Recurse -Force -ErrorAction SilentlyContinue } } catch {} Write-Host "" Write-Log "UPDATE COMPLETE." "Green" # 7) Relaunch Tlamatini. if (-not $RelaunchExe) { $RelaunchExe = Join-Path $InstallDir "Tlamatini.exe" } if (Test-Path -LiteralPath $RelaunchExe) { Write-Log "Starting the new Tlamatini..." "Cyan" Start-Process -FilePath $RelaunchExe -WorkingDirectory $InstallDir -WindowStyle Hidden } else { Write-Log "Tlamatini.exe not found after update -- please start it manually." "Yellow" } Start-Sleep -Seconds 4 exit 0 } catch { Write-Host "" Write-Log "UPDATE FAILED: $($_.Exception.Message)" "Red" Write-Log "Your previous agents are preserved in 'agents_backup'." "Yellow" if ($LogPath) { Write-Log "Full log: $LogPath" "Yellow" } Write-Host "" try { Read-Host "Press Enter to close" } catch {} exit 1 }