<# .SYNOPSIS Install the Ring agent on the current Windows user account. .DESCRIPTION Per ADR 0020, ringagent runs as the interactive user (NOT LocalSystem) so subscription Claude can read ~\.claude\credentials.json. We register a Task Scheduler task triggered "At log on" of the current user. Install layout: %LOCALAPPDATA%\Ring\bin\ringagent.exe %LOCALAPPDATA%\Ring\bin\os64\conpty.dll %LOCALAPPDATA%\Ring\bin\appsettings.json %LOCALAPPDATA%\Ring\agent.json (created on first run) %LOCALAPPDATA%\Ring\agent-token.bin (created on pair) The agent reads its config from appsettings.json next to the exe, so a per-machine override of CloudBaseUrl + RootPath lives there. Token + fingerprint live one level up so an in-place upgrade (M5.1+ overwrites bin\) doesn't disturb the paired identity. .PARAMETER CloudUrl Base URL of cloud Ring, e.g. https://theonering.brandongrossutti.com. Required if -ZipPath is not provided. Used both to download the zip AND written into appsettings.json's Agent:CloudBaseUrl. .PARAMETER ZipPath Local path to a ringagent-win-x64.zip from publish-agent.ps1. Bypasses the network download path; useful for offline installs. .PARAMETER RootPath Filesystem root the agent exposes via the FS broker. Defaults to the user's profile directory. Should point at the parent of all adopted projects. .PARAMETER Start After install, immediately start the scheduled task so the agent comes up without waiting for the next interactive logon. .PARAMETER TaskName Scheduled-task name. Defaults to "RingAgent". .EXAMPLE .\install-agent.ps1 -CloudUrl https://theonering.brandongrossutti.com -RootPath C:\Users\brand\source -Start .EXAMPLE .\install-agent.ps1 -ZipPath .\publish\ringagent-win-x64.zip -CloudUrl http://localhost:5550 -Start #> [CmdletBinding(DefaultParameterSetName = "Download")] param( [Parameter(Mandatory, ParameterSetName = "Download")] [Parameter(Mandatory, ParameterSetName = "Local")] [string]$CloudUrl, [Parameter(Mandatory, ParameterSetName = "Local")] [string]$ZipPath, [string]$RootPath = $env:USERPROFILE, [switch]$Start, [string]$TaskName = "RingAgent", # ADR 0017 D1b — proceed even though live sessions will be destroyed. [switch]$Force ) $ErrorActionPreference = "Stop" # Suppress the progress reporter for Invoke-WebRequest. PowerShell's default # progress UI on a 34MB download takes minutes; with this set, seconds. # Well-known PS perf footgun. $ProgressPreference = "SilentlyContinue" $installRoot = Join-Path $env:LOCALAPPDATA "Ring" $binDir = Join-Path $installRoot "bin" $exePath = Join-Path $binDir "ringagent.exe" $settings = Join-Path $binDir "appsettings.json" $tokenFile = Join-Path $installRoot "agent-token.bin" $fpFile = Join-Path $installRoot "agent.json" # Shortcut targets — Start Menu + Desktop pointing at the local dashboard. $shortcutName = "Ring Agent Dashboard.url" $startMenuDir = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs" $desktopDir = [Environment]::GetFolderPath('Desktop') # Capture operator-set Agent:* sub-blocks BEFORE the self-purge below # deletes bin\ (appsettings.json lives there, and the zip deliberately # doesn't bundle one). # # These are per-machine operational state set AFTER install — the Drive # cutover (VfsPathPrefix + AutoSyncIntervalSeconds), transcript mirroring # (ADR 0009), and whatever comes next. A re-pair reinstall must not # silently revert them to defaults. That regression happened for real on # the 2026-07-20 re-pair: the installer rewrote appsettings with only # CloudBaseUrl + RootPath, erasing the July-19 Drive cutover, and the live # Drive went stale until someone noticed. # # Carry forward every sub-object the script does not itself write, rather # than naming them one at a time — the named-block version is what let # Transcripts nearly repeat the same bug the moment it was added. $managedKeys = @('CloudBaseUrl', 'HeartbeatIntervalSeconds', 'ForceRepair', 'RootPath') $priorSections = [ordered]@{} if (Test-Path $settings) { try { $prior = Get-Content $settings -Raw | ConvertFrom-Json if ($prior.Agent) { foreach ($prop in $prior.Agent.PSObject.Properties) { if ($managedKeys -contains $prop.Name) { continue } $priorSections[$prop.Name] = $prop.Value } if ($priorSections.Count -gt 0) { Write-Host "==> Captured existing Agent config for carry-over: $($priorSections.Keys -join ', ')" } } } catch { # Swallowing this silently is how the carry-forward fails invisibly: an # unreadable appsettings.json reverts every operator-set section to # defaults and the install still reports success. Say so loudly. Write-Warning "Could not read existing $settings ($($_.Exception.Message))." Write-Warning "Agent:Drive / Agent:Transcripts and any other operator config will NOT be carried over." } } # ADR 0017 D1b — this script kills ringagent by name a few lines down, and # PtyManager owns every PTY, so every live `ring claude` / `ring shell` # session dies with it. On 2026-08-03 the self-updater did exactly that to a # session ten seconds past merging a PR, and the operator found out from a # corrupted terminal rather than from us. # # The self-updater now parks and waits (D1). An operator running the installer # by hand is present and can decide — the point is that they get to decide. if (-not $Force) { $liveInfo = $null try { $liveInfo = Invoke-RestMethod -Uri "http://127.0.0.1:5599/api/live-sessions" -TimeoutSec 3 } catch { # No agent running, no dashboard, or an older agent without this endpoint: # nothing we can prove is at risk, so carry on. This is also the path on a # first install, which is the common case. $liveInfo = $null } if ($liveInfo -and $liveInfo.count -gt 0) { Write-Host "" Write-Warning "$($liveInfo.count) live session(s) would be killed by this install:" foreach ($s in $liveInfo.sessions) { $label = if ($s.name) { $s.name } else { '(unnamed)' } $tag = if ($s.isClaude) { ' <- Claude session, not restarted by anything' } else { '' } Write-Host " $label $($s.cwd)$tag" } Write-Host "" Write-Host 'Finish or exit them first. A Claude session started with `ring claude` is not' Write-Host 'restarted by this script, so anything it was part-way through is lost.' Write-Host 'Re-run with -Force to install anyway.' exit 1 } } # 0. Self-purge — every install is a fresh install. Stops + unregisters the # scheduled task, removes bin/, removes the saved token + fingerprint. # All silent if missing. This eliminates the "stale token from a previous # cloud" failure mode where the agent loops on 401 against the new cloud's # signing key. New pair is one click on the claim page. $existingForPurge = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existingForPurge) { try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch { } Start-Sleep -Milliseconds 500 try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch { } Write-Host "==> Removed prior $TaskName task" } # Kill any running ringagent.exe process by name. Task Scheduler's # Stop-ScheduledTask above can leave the process running if the task is # in some odd state, and the user might have an interactive `ringagent # shell` CLI session open too. Both hold the binary open and block # extraction. -Force suppresses confirm, -ErrorAction so absent procs # don't fail the script. $lingering = Get-Process -Name ringagent -ErrorAction SilentlyContinue if ($lingering) { Write-Host "==> Stopping $($lingering.Count) lingering ringagent process(es)" $lingering | Stop-Process -Force -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 800 } if (Test-Path $binDir) { $tries = 0 while ($tries -lt 5 -and (Test-Path $binDir)) { try { Remove-Item -Recurse -Force $binDir -ErrorAction SilentlyContinue } catch { } if (Test-Path $binDir) { Start-Sleep -Milliseconds 400; $tries++ } else { break } } if (-not (Test-Path $binDir)) { Write-Host "==> Removed prior $binDir" } } foreach ($f in @($tokenFile, $fpFile)) { if (Test-Path $f) { try { Remove-Item -Force $f -ErrorAction SilentlyContinue; Write-Host "==> Cleared $f" } catch { } } } # Remove prior shortcuts so a reinstall rewrites them cleanly (icon paths # and target URLs may differ between cloud envs, etc.). foreach ($d in @($startMenuDir, $desktopDir)) { if ($d -and (Test-Path $d)) { $existing = Join-Path $d $shortcutName if (Test-Path $existing) { try { Remove-Item -Force $existing -ErrorAction SilentlyContinue } catch { } } } } New-Item -ItemType Directory -Force -Path $installRoot, $binDir | Out-Null # 1. Get the zip - either download or use the provided local path. $tempZip = $null $sourceZip = $ZipPath if (-not $sourceZip) { $tempZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ringagent-" + [Guid]::NewGuid().ToString("N") + ".zip") $url = $CloudUrl.TrimEnd('/') + "/agent/ringagent-win-x64.zip" Write-Host "==> Downloading $url" Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing $sourceZip = $tempZip } # 2. Extract. # Why this iterates entries manually instead of using # ZipFile.ExtractToDirectory(zip, dir, overwrite=true): # - Windows PowerShell 5.1 binds the third positional arg to the # (Encoding entryNameEncoding) overload first and chokes on the # Boolean → Encoding cast. # - ZipFileExtensions.ExtractToFile(entry, dest, overwrite:bool) has # existed since .NET 4.5 and unambiguously takes a bool, so the loop # works on every PowerShell that's actually in the wild. # Tested with a real zip containing nested directories — overwrites # pre-existing files in place. Write-Host "==> Extracting to $binDir" New-Item -ItemType Directory -Path $binDir -Force | Out-Null Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [System.IO.Compression.ZipFile]::OpenRead($sourceZip) try { foreach ($entry in $archive.Entries) { $destinationPath = Join-Path $binDir $entry.FullName $destinationDir = Split-Path $destinationPath -Parent if (-not (Test-Path $destinationDir)) { New-Item -ItemType Directory -Force -Path $destinationDir | Out-Null } # entry.Name is empty for pure-directory entries; skip those. if ($entry.Name) { [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destinationPath, $true) } } } finally { $archive.Dispose() } if ($tempZip -and (Test-Path $tempZip)) { Remove-Item -Force $tempZip } # 3. Ensure %LOCALAPPDATA%\Ring\bin is on the User PATH so `ring shell ...` # works from any terminal. Idempotent: skip if already present. New # PowerShell windows pick it up automatically; existing ones won't. $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $onPath = $userPath -and ($userPath.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) -contains $binDir) if (-not $onPath) { $newPath = if ([string]::IsNullOrEmpty($userPath)) { $binDir } else { "$userPath;$binDir" } [Environment]::SetEnvironmentVariable("Path", $newPath, "User") Write-Host "==> Added $binDir to your User PATH (open a fresh terminal to pick it up)" } # 4. Write per-machine appsettings.json (CloudBaseUrl + RootPath). The # bundled appsettings.json from the publish output is overwritten so # every install lands with the right per-machine config. # $priorSections was captured before the step-0 purge (see top of script). Write-Host "==> Writing $settings" $cfg = [ordered]@{ Agent = [ordered]@{ CloudBaseUrl = $CloudUrl.TrimEnd('/') HeartbeatIntervalSeconds = 30 ForceRepair = $false RootPath = $RootPath } Logging = [ordered]@{ LogLevel = [ordered]@{ Default = "Information" "Microsoft.AspNetCore.SignalR.Client" = "Information" } } } foreach ($name in $priorSections.Keys) { $cfg.Agent.Add($name, $priorSections[$name]) Write-Host "==> Preserved existing Agent:$name config from prior install" } # Seed defaults for optional sections the operator has never configured. # Without this the key is simply ABSENT after a fresh install, which makes # the feature undiscoverable — you cannot turn on something you cannot see, # and "add this JSON by hand" is not a setup step anyone should need. Values # match the agent's own defaults, so seeding changes no behaviour; carried- # forward config above always wins because it is added first. $seedDefaults = [ordered]@{ Transcripts = [ordered]@{ Enabled = $false # ADR 0009 — OFF until the operator opts in PollIntervalSeconds = 3 } } foreach ($name in $seedDefaults.Keys) { if ($cfg.Agent.Contains($name)) { continue } $cfg.Agent.Add($name, $seedDefaults[$name]) Write-Host "==> Seeded default Agent:$name config" } $json = $cfg | ConvertTo-Json -Depth 6 [System.IO.File]::WriteAllText($settings, $json, [System.Text.UTF8Encoding]::new($false)) # 5. Register the scheduled task. (Self-purge above already cleared any # prior task, so no Unregister needed here.) Write-Host "==> Registering scheduled task '$TaskName'" $action = New-ScheduledTaskAction -Execute $exePath -WorkingDirectory $binDir $trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME # WindowStyle Hidden so a logon doesn't pop a console; the agent logs to # stdout which Task Scheduler discards (M5.5+: redirect to %LOCALAPPDATA%\Ring\logs\). $settingsObj = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -ExecutionTimeLimit ([TimeSpan]::Zero) ` -RestartCount 3 ` -RestartInterval ([TimeSpan]::FromMinutes(1)) ` -Hidden # Run as the current interactive user, NOT LocalSystem. This is the # load-bearing piece per ADR 0020: subscription Claude reads creds from # the user profile, which LocalSystem can't reach. $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited Register-ScheduledTask ` -TaskName $TaskName ` -Action $action ` -Trigger $trigger ` -Settings $settingsObj ` -Principal $principal ` -Description "Ring agent - keeps a SignalR connection to cloud Ring; brokers FS + PTY ops on this machine." ` | Out-Null # 6. Optionally kick it off now. if ($Start) { Write-Host "==> Starting $TaskName" Start-ScheduledTask -TaskName $TaskName } # 7. Write Start Menu + Desktop .url shortcuts pointing at the local # dashboard (PD.64/65). .url is the simplest "open a URL when clicked" # format Windows ships — no COM, no scripts, opens in the user's # default browser. Icon pulled from the agent exe so it stays consistent # across cloud envs. $dashUrl = "http://localhost:5599/" $shortcutContent = @" [InternetShortcut] URL=$dashUrl IconFile=$exePath IconIndex=0 "@ foreach ($d in @($startMenuDir, $desktopDir)) { if (-not $d) { continue } try { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null } $shortcutPath = Join-Path $d $shortcutName Set-Content -Path $shortcutPath -Value $shortcutContent -Encoding ASCII -Force Write-Host "==> Shortcut: $shortcutPath" } catch { Write-Host "==> Skipped shortcut at $d ($($_.Exception.Message))" } } Write-Host "" Write-Host "Installed." Write-Host " binary: $exePath" Write-Host " config: $settings" Write-Host " task: $TaskName [at-logon, run as $env:USERNAME]" Write-Host " cloud: $CloudUrl" if ($Start) { Write-Host " status: started" Write-Host " first run prints pair URL on agent console; Task Scheduler discards stdout." Write-Host " after pairing, check $installRoot\agent.json for the fingerprint." } else { Write-Host " status: not started - log out + back in, or run: Start-ScheduledTask -TaskName $TaskName" }