Private/Resolve-InstallParameter.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# Work out the settings an install should actually use, by merging what was passed with what the previous install of this instance recorded. THIS IS THE MOST DANGEROUS FUNCTION IN THE MODULE, and every rule in it is a bug that happened. Installing is documented as idempotent and safe to re-run, so a bare re-run that quietly changed something the operator never mentioned is a betrayal of that promise -- and each of these did exactly that: -WorkingDirectory omitted, it fell back to ~/<instance>, RELOCATING the instance: new directory created, trust seeded for it, session restarted there, abandoning the real working directory along with its project store, memory and transcripts. Reproduced on two instances at once. -TriggerDelay omitted, it reset a deliberately staggered PT3M to PT1M, un-staggering a multi-instance host. -AdditionalDirectories omitted, it rebuilt the grant list as empty and wrote that through to config.json, the project settings and the launch line, silently revoking access. -ClaudeExe a deliberate choice reverted to auto-detection. -Elevated dropped, leaving an instance that still looks installed and fails later with Access Denied inside a hidden window. Passing a parameter is always authoritative. Omitting it inherits. Clearing is explicit: -AdditionalDirectories @() and -ClaudeExe ''. Returns a settings object; writes nothing. #> function Resolve-InstallParameter { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][hashtable]$Bound, [string]$WorkingDirectory, [string]$ClaudeExe, [string]$TriggerDelay, [string[]]$AdditionalDirectories, [bool]$Elevated, [string]$Model ) $stateDir = Join-Path (Get-GreenroomStateRoot) $Name $cfgPath = Join-Path $stateDir 'config.json' $task = "greenroom-$Name" $prev = $null $prevUnreadable = $false if (Test-Path $cfgPath) { try { $prev = Get-Content $cfgPath -Raw | ConvertFrom-Json } catch { $prevUnreadable = $true } } # A config that EXISTS but will not parse is not the same as no config, and # swallowing the difference reopens the relocation bug through another door: with # nothing to inherit, every omitted parameter falls back to its default. if ($prevUnreadable) { # -Elevated belongs in this list even though its consequence is different. The # others fall back to a default that relocates or un-configures the instance; # this one falls back to NOT ELEVATED, silently demoting a session that was # deliberately given a full admin token. Elevation announces itself on every # ordinary re-run precisely because it is security-relevant, so dropping it # without a word on the one path where nothing can be inherited is the worst # place to be quiet. $omitted = @('WorkingDirectory', 'TriggerDelay', 'AdditionalDirectories', 'Elevated') | Where-Object { -not $Bound.ContainsKey($_) } | ForEach-Object { "-$_" } if ($omitted.Count -gt 0) { throw ("'$cfgPath' exists but cannot be parsed, so this instance's remembered settings are " + "unreadable. Refusing to continue: $($omitted -join ', ') were omitted, and with nothing " + 'to inherit they would fall back to defaults -- relocating the instance to ' + "'$(Join-Path $env:USERPROFILE $Name)' and abandoning its project store, memory and " + 'transcripts, and dropping elevation from any instance that had it. Repair or delete ' + 'that file, or pass every value explicitly.') } } if (-not $Bound.ContainsKey('WorkingDirectory') -and $prev -and $prev.workingDirectory) { $WorkingDirectory = $prev.workingDirectory Write-Verbose "keeping working directory from the previous install: $WorkingDirectory" } if (-not $WorkingDirectory) { $WorkingDirectory = Join-Path $env:USERPROFILE $Name } # Elevation inherits like the rest, but ANNOUNCES itself every time rather than # only under -Verbose, because it is security-relevant. if (-not $Bound.ContainsKey('Elevated') -and $prev -and $prev.elevated) { $Elevated = $true Write-Warning "keeping ELEVATED from the previous install of '$Name'. Pass -Elevated:`$false to drop it." } if (-not $Bound.ContainsKey('TriggerDelay')) { if ($prev -and $prev.triggerDelay) { $TriggerDelay = $prev.triggerDelay Write-Verbose "keeping trigger delay from the previous install ($TriggerDelay)" } else { # config.json only started recording triggerDelay later, so an instance # installed before that has nothing to inherit. The registered task is the # authoritative record of the delay actually in force. $existing = Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue $fromTask = if ($existing -and $existing.Triggers) { $existing.Triggers[0].Delay } else { $null } if ($fromTask) { $TriggerDelay = $fromTask Write-Verbose "keeping trigger delay from the registered task ($TriggerDelay)" } } } if (-not $TriggerDelay) { $TriggerDelay = 'PT1M' } # -ClaudeExe persists a FLAG ABOUT the value, not just the value, because # config.json records the RESOLVED path. Inheriting unconditionally would pin # whatever auto-detection picked and defeat the WinGet Links shim, which is # package-ID-keyed and survives upgrades. # # The flag has to carry forward too. Deriving it from the bound parameters at # write time recorded 'false' on the very run that had just inherited, so the # choice survived exactly one bare re-run. $claudeExplicit = ($Bound.ContainsKey('ClaudeExe') -and [bool]$ClaudeExe) $claudeInherited = $false if (-not $Bound.ContainsKey('ClaudeExe') -and $prev -and $prev.claudeExeExplicit -and $prev.claudeExe) { $ClaudeExe = $prev.claudeExe $claudeExplicit = $true $claudeInherited = $true } # An explicit choice that does not exist must not be silently discarded. The # candidate list filters on Test-Path, so a missing path dropped out and # auto-detection took over -- while claudeExeExplicit then pinned the # AUTO-DETECTED path as though it were the choice. if ($claudeExplicit -and -not (Test-Path -LiteralPath $ClaudeExe)) { $origin = if ($Bound.ContainsKey('ClaudeExe')) { 'was passed on this run' } else { "was inherited from the previous install of '$Name'" } throw ("-ClaudeExe '$ClaudeExe' does not exist. It $origin. Refusing to continue: auto-detection " + 'would silently take over and be recorded as though it were the deliberate choice. Point it ' + "at a binary that exists, or pass -ClaudeExe '' to return to auto-detection.") } if ($claudeInherited) { Write-Verbose "keeping explicitly chosen claude.exe: $ClaudeExe" } # Inherits like the rest. Clearing is explicit -- -Model '' returns the instance to # whatever the CLI would choose on its own. if (-not $Bound.ContainsKey('Model') -and $prev -and $prev.model) { $Model = $prev.model Write-Verbose "keeping model from the previous install: $Model" } if (-not $Bound.ContainsKey('AdditionalDirectories') -and $prev) { $prevGrants = @($prev.additionalDirectories) | Where-Object { $_ } if ($prevGrants.Count -gt 0) { $AdditionalDirectories = $prevGrants Write-Verbose "inheriting $($prevGrants.Count) grant(s); pass -AdditionalDirectories @() to clear" } } $grants = @() foreach ($d in $AdditionalDirectories) { if (-not (Test-Path $d)) { throw "-AdditionalDirectories: '$d' does not exist. Refusing to grant a path that isn't there." } $grants += (Resolve-Path $d).Path } [PSCustomObject]@{ Instance = $Name StateDir = $stateDir TaskName = $task WorkingDirectory = $WorkingDirectory ClaudeExe = $ClaudeExe ClaudeExeExplicit = $claudeExplicit TriggerDelay = $TriggerDelay AdditionalDirectories = $grants Elevated = $Elevated Model = $Model } } |