# Remediate-TATERAgent.ps1 - Intune Proactive Remediation (Go agent v2.x) # Self-healing + self-reporting installer. # # DEPLOYMENT ROLES (per CB field experience): # PRIMARY install path: # - AVD / Windows Server multi-session hosts (Win32 App not supported) # - Anywhere Intune Win32 App deployment is unavailable # ONGOING HEALTH MONITORING (layer on top of Win32 App deployment): # - Catches drift Win32 App can't: stale binary version, dead apiBase # URL (ADO #569), broken config, stopped service, never-uploaded # installs. Auto-heals via the same field-verified binary swap # pattern as Verify-TATERAgent.ps1 -Fix. # For standard Windows workstations, deploy via Win32 App FIRST (see # Docs/Help/agent-deployment.html#intune-win32 - field-tested as the # most reliable initial-install path), then layer this on for drift. # # Fix-forward improvements vs the legacy script: # * Placeholder API key/tenant detection - fails fast with clear message # * Reachability check (DNS + HTTPS to api/agent/version) BEFORE install # * Writes C:\ProgramData\TATER\install-status.json on EVERY run so admins # can collect via Endpoint Central RDP / scripting even when the agent # itself is broken (silent-fleet telemetry) # * Force re-install when service exists but config is invalid / agent has # never had a successful upload (broken-state recovery) # * Best-effort beacon to /api/agent/diagnostics with the install-status # payload so TATER's Activity Log gets a "via=agent-installer" entry - # gives us fleet-side visibility even before the main agent works # * Phase 3b stale-version fast path: in-place binary swap with SHA verify # when only the version is stale (avoids the MSI 1603 trap from ADO #568) # # Edit the $Config block, then upload to Intune Proactive Remediations. # # 7-BIT ASCII ONLY, in code and comments, and no PowerShell 7-only syntax. # Intune runs this under Windows PowerShell 5.1, which reads a BOM-less file as # the ANSI codepage: a UTF-8 em-dash inside a double-quoted string then decodes # to a character PowerShell accepts as a closing quote, and the script stops # parsing. Installer/tests/Test-ParsePs1.ps1 checks for both. # --- EDIT THESE VALUES -------------------------------------------------------- $Config = @{ apiBase = "https://api.tatersecurity.com/api" apiKey = "REPLACE_ME" # WARNING: from Manage > Connections > API Keys tenantId = "51707517-6d54-48b7-b8e3-8fdd4447773d" organizationId = "org-9c305e0e-cf02-4028-8ef1-edead8fdaee5" products = "All" appId = "" # optional, for cloud scans certificateThumbprint = "" # optional, for cloud scans organization = "" # optional (e.g. tenant.onmicrosoft.com) tenantEnvironment = "commercial" } # ----------------------------------------------------------------------------- $ErrorActionPreference = 'Stop' $TATERDir = Join-Path $env:ProgramData 'TATER' $ConfigPath = Join-Path $TATERDir 'config.json' $SecretPath = Join-Path $TATERDir 'config.secret.json' # ADO #1828 sidecar - see Get-TATERSecretSidecarState $LogDir = Join-Path $TATERDir 'Logs' $StatusPath = Join-Path $TATERDir 'install-status.json' $SentinelPath = Join-Path $TATERDir 'last-upload.json' $MSI_URL = "https://www.tatersecurity.com/Agent/TATER-Agent.msi" $BIN_URL = "https://www.tatersecurity.com/Agent/tater-agent.exe" $VersionUrl = "$($Config.apiBase)/agent/version" $TempDir = "C:\Windows\Temp\TATERInstall" $MSIPath = Join-Path $TempDir 'TATER-Agent.msi' $RemediateVersion = '2026.10.11-remediate-a' $BrickProneFloor = [Version]"2.5.2" # ADO #1145: pre-v2.5.2 in-place updater bricks ~1-in-7 devices - never fast-path these, force full MSI reinstall $Status = [ordered]@{ timestamp = (Get-Date).ToUniversalTime().ToString('o') hostname = $env:COMPUTERNAME remediateVersion = $RemediateVersion phase = 'starting' success = $false errors = @() actions = @() networkReach = @{} serviceState = @{ before = $null; after = $null } versions = @{ installed = $null; latestAdvertised = $null } apiKeyLocation = $null # where this run left the key: inline | sidecar (ADO #1828). Never the key itself. secretSidecar = @{ before = $null; after = $null } } function Add-Action([string]$msg) { $Status.actions += "$(Get-Date -Format 'HH:mm:ss') $msg" } function Add-Error([string]$msg) { $Status.errors += "$(Get-Date -Format 'HH:mm:ss') $msg"; Write-Output "ERROR: $msg" } function Save-Status { if (-not (Test-Path $TATERDir)) { New-Item -ItemType Directory -Path $TATERDir -Force | Out-Null } try { $Status | ConvertTo-Json -Depth 6 | Out-File -FilePath $StatusPath -Encoding utf8 -Force } catch { Write-Output "WARN: could not write install-status.json: $($_.Exception.Message)" } } function Test-Placeholder($v) { return ($null -eq $v) -or ($v -eq '') -or ($v -in @('REPLACE_ME', 'your-api-key', 'your-azure-tenant-id', 'your-org-id', 'TODO')) } # --- ADO #1828 / #2735 - the split config ------------------------------------ # From the release that arms ADO #1828 the installer stops writing apiKey into # config.json (which BUILTIN\Users can read) and writes it to config.secret.json # beside it, readable only by SYSTEM and Administrators. On such a device # config.json carrying NO key is the CORRECT state, not a broken one. # # Presence is decided WITHOUT reading the file: a context that is not SYSTEM or # an Administrator cannot read it, and nothing here has any business holding # the key. The file's contents are never read, logged or written anywhere. # # Returns one of: # absent no sidecar (every device installed before the split) # empty zero-length file - carries no key # trusted present, owned by SYSTEM or BUILTIN\Administrators # present-unverified present, but this context could not read its owner # untrusted a reparse point, or owned by someone else. The agent # refuses such a file (verifySecretFile in # Agent/go/internal/config), so it does not count as a key. # %ProgramData%\TATER lets standard users create files, # so "a file with that name exists" proves nothing alone. # KEEP THIS FUNCTION IDENTICAL in Detect-TATERAgent.ps1, Remediate-TATERAgent.ps1, # Collect-TATERStatus.ps1 and Agent\Verify-TATERAgent.ps1 - each is uploaded or # fetched on its own, so they cannot share a module. # Installer/tests/Test-SplitConfigConsumers.ps1 asserts the four copies match. function Get-TATERSecretSidecarState { param([string]$Path) try { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return 'absent' } $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return 'absent' } if ($item.Length -le 0) { return 'empty' } if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'untrusted' } $ownerSid = $null try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop $ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { return 'present-unverified' } if (-not $ownerSid) { return 'present-unverified' } # S-1-5-18 = LocalSystem, S-1-5-32-544 = BUILTIN\Administrators. SIDs, not # names: the names are localised and the SIDs are not. if ($ownerSid -eq 'S-1-5-18' -or $ownerSid -eq 'S-1-5-32-544') { return 'trusted' } return 'untrusted' } # --- end ADO #2735 shared helper --------------------------------------------- trap { Add-Error "Unhandled: $($_.Exception.Message)" Save-Status exit 1 } # --- PHASE 1: Validate config (fail fast on placeholders) -------------------- $Status.phase = 'validate-config' foreach ($k in @('apiBase', 'apiKey', 'tenantId', 'organizationId')) { if (Test-Placeholder $Config[$k]) { Add-Error "Config has placeholder/empty value for '$k' - edit the Remediate script before deploying" Save-Status exit 1 } } Add-Action "config validated (apiBase=$($Config.apiBase), tenant=$($Config.tenantId.Substring(0,8))..., org=$($Config.organizationId))" # --- PHASE 2: Reachability --------------------------------------------------- $Status.phase = 'check-reachability' try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $apiHost = ([Uri]$Config.apiBase).Host $dns = Resolve-DnsName -Name $apiHost -Type A -ErrorAction Stop | Select-Object -First 1 $Status.networkReach.dns = "$apiHost -> $($dns.IPAddress)" Add-Action "DNS OK: $apiHost -> $($dns.IPAddress)" } catch { Add-Error "DNS resolution failed for $apiHost - corporate proxy/DNS issue. Detail: $($_.Exception.Message)" Save-Status exit 1 } try { $resp = Invoke-WebRequest -Uri $VersionUrl -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $ver = ($resp.Content | ConvertFrom-Json).version $Status.networkReach.versionEndpoint = "HTTP $($resp.StatusCode), latest v$ver" $Status.versions.latestAdvertised = $ver Add-Action "API reachable: $VersionUrl -> HTTP $($resp.StatusCode), advertises v$ver" } catch { Add-Error "Could not reach version endpoint ($VersionUrl): $($_.Exception.Message)" Save-Status exit 1 } # --- PHASE 3: Assess current state ------------------------------------------- $Status.phase = 'assess-state' $svc = Get-Service -Name 'TATERAgent' -ErrorAction SilentlyContinue $Status.serviceState.before = if ($svc) { "$($svc.Status) ($($svc.StartType))" } else { 'NotInstalled' } $sidecarBefore = Get-TATERSecretSidecarState $SecretPath $sidecarHoldsKeyBefore = ($sidecarBefore -eq 'trusted' -or $sidecarBefore -eq 'present-unverified') $Status.secretSidecar.before = $sidecarBefore $brokenState = $false $brokenReasons = @() if (-not $svc) { $brokenState = $true; $brokenReasons += 'service missing' } else { # Service exists - is config valid + has it ever uploaded? if (-not (Test-Path $ConfigPath)) { $brokenState = $true; $brokenReasons += 'config.json missing' } else { try { $raw = Get-Content $ConfigPath -Raw -Encoding UTF8 $raw = $raw.TrimStart([char]0xFEFF) $existing = $raw | ConvertFrom-Json # ADO #2735: on a split device (ADO #1828) config.json carries NO # key by design and config.secret.json holds it. That is healthy. # Treating it as broken is what used to reinstall the MSI and then # write the key straight back inline in Phase 5. if (-not $existing.apiKey -and -not $existing.encryptedApiKey -and -not $sidecarHoldsKeyBefore) { $brokenState = $true; $brokenReasons += "no api key in config.json and no usable config.secret.json (sidecar: $sidecarBefore)" } } catch { $brokenState = $true; $brokenReasons += "config invalid JSON: $($_.Exception.Message)" } } # Check installed binary version foreach ($p in @('C:\ProgramData\TATER\tater-agent.exe', "$env:ProgramFiles\TATER\tater-agent.exe")) { if (Test-Path $p) { try { $Status.versions.installed = (Get-Item $p).VersionInfo.ProductVersion; break } catch { } } } } # No "??" anywhere in this file: Intune runs it under Windows PowerShell 5.1, # where the null-coalescing operator is a parse error that stops the script. $shownVersion = if ($Status.versions.installed) { $Status.versions.installed } else { 'unknown' } Add-Action "current state: $($Status.serviceState.before), version=$shownVersion, sidecar=$sidecarBefore, broken=$brokenState ($($brokenReasons -join '; '))" # --- PHASE 3b: Stale-version-only fast path (ADO #569 + #568) ---------------- # If the service is HEALTHY (not broken) but the version is stale, do an # in-place binary swap instead of running msiexec. msiexec fails with 1603 # (ADO #568) on any device that previously went through auto-update because # the MSI database is out of sync with the on-disk binary. The binary swap # pattern matches what Verify-TATERAgent.ps1's -Fix mode does and is the # same field-verified upgrade path used on X1-01-2025-08 earlier today. $staleVersionUpgradeDone = $false $forceReinstallStaleFloor = $false if (-not $brokenState -and $svc -and $Status.versions.installed) { $latestVersion = $null $latestBinSha = $null try { $manifest = Invoke-RestMethod -Uri $VersionUrl -UseBasicParsing -TimeoutSec 10 $latestVersion = $manifest.version $winFile = $manifest.files | Where-Object { $_.platform -eq 'windows' } | Select-Object -First 1 if ($winFile) { $latestBinSha = $winFile.sha256 $latestBinUrl = ($manifest.downloadBaseUrl -replace '/+$', '') + '/' + $winFile.path } $Status.versions.latest = $latestVersion } catch { Add-Action "could not fetch version manifest for stale-check: $($_.Exception.Message)" } # ADO #1145 fix: "is stale" must be evaluated independently of whether # $latestBinUrl/$latestBinSha resolved (that pair is ONLY needed for the # binary-swap fast path below) - otherwise a manifest fetch that succeeds # but omits a Windows file entry silently skips the floor check entirely, # leaving $forceReinstallStaleFloor false and Phase 4 never running for # exactly the brick-prone devices this ticket exists to protect. $isStale = $latestVersion -and ([Version]$Status.versions.installed -lt [Version]$latestVersion) if ($isStale -and ([Version]$Status.versions.installed -lt $BrickProneFloor)) { Add-Action "installed v$($Status.versions.installed) predates the v$BrickProneFloor in-place-updater fix (ADO #1145, brick-prone ~1-in-7) - skipping binary-swap fast path, forcing full MSI reinstall instead" $forceReinstallStaleFloor = $true } elseif ($isStale -and $latestBinUrl -and $latestBinSha) { Add-Action "STALE-VERSION FAST PATH: v$($Status.versions.installed) -> v$latestVersion via binary swap (avoids MSI 1603 per ADO #568)" $binPath = 'C:\ProgramData\TATER\tater-agent.exe' if (-not (Test-Path $binPath)) { $binPath = "$env:ProgramFiles\TATER\tater-agent.exe" } if (Test-Path $binPath) { try { Stop-Service -Name 'TATERAgent' -Force -ErrorAction Stop Start-Sleep -Seconds 3 $bakPath = "$binPath.bak-v$($Status.versions.installed)" if (Test-Path $bakPath) { Remove-Item $bakPath -Force -ErrorAction SilentlyContinue } Move-Item $binPath $bakPath -Force -ErrorAction Stop $tmpPath = "$binPath.new" Invoke-WebRequest -Uri $latestBinUrl -UseBasicParsing -OutFile $tmpPath -TimeoutSec 120 -ErrorAction Stop $gotSha = (Get-FileHash $tmpPath -Algorithm SHA256).Hash if ($gotSha -ne $latestBinSha) { Remove-Item $tmpPath -Force -ErrorAction SilentlyContinue Move-Item $bakPath $binPath -Force -ErrorAction Stop Start-Service -Name 'TATERAgent' -ErrorAction SilentlyContinue throw "SHA256 mismatch (expected $latestBinSha, got $gotSha) - restored previous binary, upgrade aborted" } Move-Item $tmpPath $binPath -Force -ErrorAction Stop Start-Service -Name 'TATERAgent' -ErrorAction Stop Start-Sleep -Seconds 5 $newVer = (Get-Item $binPath).VersionInfo.ProductVersion $Status.versions.installed = $newVer Add-Action "stale-version upgrade complete: now v$newVer (previous binary kept at $bakPath)" $staleVersionUpgradeDone = $true $Status.success = $true $Status.phase = 'complete-via-binary-swap' $svc = Get-Service -Name 'TATERAgent' -ErrorAction SilentlyContinue $Status.serviceState.after = if ($svc) { "$($svc.Status) ($($svc.StartType))" } else { 'NotInstalled' } } catch { Add-Error "stale-version upgrade failed: $($_.Exception.Message) - falling through to full MSI install" # Restore service if we stopped it but didn't successfully restart try { Start-Service -Name 'TATERAgent' -ErrorAction SilentlyContinue } catch {} } } else { Add-Action "no binary at expected paths - skipping stale-version path, falling through to full install" } } } # --- PHASE 4: Install / Repair ----------------------------------------------- # Skip if Phase 3b already did a stale-version upgrade via binary swap, and # ALSO run for the below-floor case (ADO #1145): a healthy-but-stale agent # older than $BrickProneFloor is neither "broken" nor eligible for the fast # path, so without this it would fall through to a silent no-op. Treat # $forceReinstallStaleFloor as equivalent to $brokenState here. $Status.phase = 'install' if (-not $staleVersionUpgradeDone -and (-not $svc -or $brokenState -or $forceReinstallStaleFloor)) { try { if ($forceReinstallStaleFloor) { Add-Action "full MSI reinstall path taken because installed version is below the ADO #1145 brick-prone-updater floor v$BrickProneFloor (not because the service is broken)" } if (-not (Test-Path $TempDir)) { New-Item -ItemType Directory -Path $TempDir -Force | Out-Null } Add-Action "downloading MSI from $MSI_URL" Invoke-WebRequest -Uri $MSI_URL -OutFile $MSIPath -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop Add-Action "MSI downloaded ($([math]::Round((Get-Item $MSIPath).Length/1MB,1)) MB)" # If service already exists (broken state OR healthy-but-below-floor), stop it first so we get a clean install if ($svc) { if ($forceReinstallStaleFloor -and -not $brokenState) { Add-Action "stopping healthy-but-below-floor service for clean reinstall" } else { Add-Action "broken service detected - stopping for clean reinstall" } try { Stop-Service -Name 'TATERAgent' -Force -ErrorAction SilentlyContinue } catch { } Start-Sleep -Seconds 2 Get-Process -Name tater-agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } # ADO #2735: the key is handed to the INSTALLER as the APIKEY property # (Hidden="yes" in Package.wxs, so MSI masks it in this log) and the # installer decides where it goes - config.secret.json when the bundled # agent supports the split config, inline otherwise (GenerateConfig in # Installer/CustomActions/CustomActions.cs). This script never chooses. $logPath = Join-Path $TempDir 'tater-install.log' $msiArgs = @( '/i', $MSIPath, '/qn', '/l*v', $logPath, "APIBASE=$($Config.apiBase)", "APIKEY=$($Config.apiKey)", "TENANTID=$($Config.tenantId)", "ORGANIZATIONID=$($Config.organizationId)" ) Add-Action "running msiexec /i" $proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList $msiArgs -Wait -PassThru if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) { throw "MSI install failed exit=$($proc.ExitCode) - see $logPath" } Add-Action "MSI install OK (exit=$($proc.ExitCode))" } catch { Add-Error "MSI install failed: $($_.Exception.Message)" Save-Status exit 1 } } else { Add-Action "service exists and not broken - skipping reinstall" } # --- PHASE 5: Write config (no BOM) + ensure data dirs ------------------------ $Status.phase = 'write-config' foreach ($dir in @($TATERDir, $LogDir)) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } # ADO #2735 - WHERE DOES THE KEY GO? # This phase used to write $Config, key included, into config.json on every # run. On a device whose key lives in config.secret.json (ADO #1828) that put a # working credential back into a file every standard user can read, with every # health signal green. So: re-read the state the installer just left, and never # INTRODUCE an inline key next to a sidecar that holds one. # # sidecar trusted -> write config.json WITHOUT apiKey # sidecar present, owner unreadable here -> keep whatever is there now: no # inline key unless one already exists # no usable sidecar -> write it inline, as before. That is # the pre-split state, or the # installer's own fallback, or an # install that provisioned nothing # (a same-version msiexec /i runs in # maintenance mode and skips # GenerateConfig) - in which case # inline is the only credential the # device would have at all. $sidecarAfter = Get-TATERSecretSidecarState $SecretPath $Status.secretSidecar.after = $sidecarAfter $inlineKeyNow = $false try { if (Test-Path -LiteralPath $ConfigPath) { $rawNow = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 if ($rawNow) { $cfgNow = $rawNow.TrimStart([char]0xFEFF) | ConvertFrom-Json $inlineKeyNow = [bool]($cfgNow.apiKey -or $cfgNow.encryptedApiKey) } } } catch { $inlineKeyNow = $false } $writeKeyInline = $true if ($sidecarAfter -eq 'trusted') { $writeKeyInline = $false } elseif ($sidecarAfter -eq 'present-unverified' -and -not $inlineKeyNow) { $writeKeyInline = $false } try { $toWrite = @{} foreach ($k in @($Config.Keys)) { if ($k -eq 'apiKey' -and -not $writeKeyInline) { continue } $toWrite[$k] = $Config[$k] } if ($writeKeyInline -and ($sidecarAfter -eq 'untrusted' -or $sidecarAfter -eq 'empty')) { # Same rule as GenerateConfig: when the key goes inline, a leftover # sidecar must not survive beside it. File.Delete removes a link itself, # never its target, and the helper only reports these states for a FILE. try { [System.IO.File]::Delete($SecretPath) Add-Action "removed an unusable config.secret.json (state: $sidecarAfter) - the agent refuses it" } catch { Add-Action "could not remove the unusable config.secret.json (state: $sidecarAfter): $($_.Exception.Message)" } } $json = $toWrite | ConvertTo-Json -Depth 3 [System.IO.File]::WriteAllText($ConfigPath, $json, [System.Text.UTF8Encoding]::new($false)) if ($writeKeyInline) { $Status.apiKeyLocation = 'inline' Add-Action "config.json written (no BOM) WITH the api key inline - no usable config.secret.json on this device (sidecar: $sidecarAfter)" } else { $Status.apiKeyLocation = 'sidecar' Add-Action "config.json written (no BOM) WITHOUT an api key - the key is held in config.secret.json (ADO #1828, sidecar: $sidecarAfter)" } } catch { Add-Error "config write failed: $($_.Exception.Message)" Save-Status exit 1 } # --- PHASE 6: Start / restart service ---------------------------------------- $Status.phase = 'service-restart' try { Set-Service -Name 'TATERAgent' -StartupType Automatic -ErrorAction Stop $svc = Get-Service -Name 'TATERAgent' if ($svc.Status -eq 'Running') { Restart-Service -Name 'TATERAgent' -Force -ErrorAction Stop Add-Action "service restarted" } else { Start-Service -Name 'TATERAgent' -ErrorAction Stop Add-Action "service started" } Start-Sleep -Seconds 3 $svc = Get-Service -Name 'TATERAgent' $Status.serviceState.after = "$($svc.Status) ($($svc.StartType))" } catch { Add-Error "service start failed: $($_.Exception.Message)" Save-Status exit 1 } # --- PHASE 7: Refresh installed-version stamp -------------------------------- $Status.phase = 'verify-version' foreach ($p in @('C:\ProgramData\TATER\tater-agent.exe', "$env:ProgramFiles\TATER\tater-agent.exe")) { if (Test-Path $p) { try { $Status.versions.installed = (Get-Item $p).VersionInfo.ProductVersion; break } catch { } } } $shownVersion = if ($Status.versions.installed) { $Status.versions.installed } else { 'unknown' } Add-Action "post-install version: $shownVersion" # --- PHASE 8: Best-effort install beacon to TATER API ------------------------ # Gives the central server "I exist" telemetry even if the main agent can't # upload yet (e.g. wrong API key, network blocked at scan time). Failure here # is non-fatal - script still reports SUCCESS based on service state. $Status.phase = 'beacon' try { $beacon = @{ deviceName = $env:COMPUTERNAME report = @{ runAt = $Status.timestamp agentVersion = $Status.versions.installed deviceName = $env:COMPUTERNAME platform = "windows/$($env:PROCESSOR_ARCHITECTURE)" installSource = 'intune-remediate' remediateVersion = $RemediateVersion summary = @{ pass = 1; warn = 0; fail = $Status.errors.Count; overall = if ($Status.errors.Count -eq 0) { 'healthy' } else { 'degraded' } } checks = @( @{ name = 'Install Beacon'; status = 'pass'; message = "Remediate script v$RemediateVersion completed"; detail = $Status.actions[-1] } ) } } | ConvertTo-Json -Depth 6 -Compress $hdrs = @{ 'X-Api-Key' = $Config.apiKey; 'Content-Type' = 'application/json' } $resp = Invoke-WebRequest -Uri "$($Config.apiBase)/agent/diagnostics" -Method Post -Headers $hdrs -Body $beacon -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop Add-Action "beacon posted: HTTP $($resp.StatusCode)" } catch { Add-Action "beacon failed (non-fatal): $($_.Exception.Message)" } # --- DONE -------------------------------------------------------------------- $Status.phase = 'complete' $Status.success = ($svc.Status -eq 'Running' -and $Status.errors.Count -eq 0) Save-Status if ($Status.success) { Write-Output "SUCCESS: TATERAgent v$($Status.versions.installed) running ($($Status.serviceState.after))" exit 0 } else { Write-Output "FAILED: $($Status.errors -join ' | ')" exit 1 }