Public/Install-GreenroomInstance.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# .SYNOPSIS Provision a greenroom instance: config, trust, scheduled task, and start it. .DESCRIPTION It provisions an INSTANCE. It does not install the software -- that already happened, or you could not be calling this. The script it replaces did both: it copied its own scripts into a bin directory and generated a cmd shim, which is what a package manager is for. That is roughly 130 lines that no longer exist, along with -InstallDir and the PATH warning that went with them. Idempotent, and that is load-bearing rather than a nicety: omitting a parameter INHERITS what the previous install recorded rather than falling back to a default. Every one of those rules is a bug that happened -- see Resolve-InstallParameter. Passing a parameter is authoritative; clearing is explicit (-AdditionalDirectories @(), -ClaudeExe ''). Order is deliberate. Everything that can refuse does so BEFORE anything is written: the name is validated, elevation is checked against the current token, prerequisites are resolved and the CLI is proved to support --remote-control. Failing at Register-ScheduledTask instead would leave an instance half-built. .PARAMETER Name 1-32 characters, letters/digits/dot/dash/underscore, starting alphanumeric. No spaces: it goes on a command line, is matched back out of one, and becomes part of a scheduled-task name. .PARAMETER WorkingDirectory Where the session runs. Omitted, it inherits from the previous install; on a first install it defaults to ~/<name>. Never a home directory -- Remote Control will not connect from one. .PARAMETER AdditionalDirectories Directory grants for THIS instance, passed as --add-dir. Default none: an instance boots with its working directory and nothing else. .PARAMETER Elevated Run the session with a full admin token (task RunLevel Highest). Off by default because it CHANGES HOW THE INSTANCE IS OPERATED: an elevated session cannot be shown or hidden from an unelevated shell, since UIPI blocks the window calls silently. Requires an elevated caller. -Elevated:$false revokes. .PARAMETER Model The model this instance launches with, passed to the CLI as --model. An alias such as `opus` tracks the newest of that family; a pinned id does not. Omitted, it inherits; -Model '' returns the instance to whatever the CLI would choose on its own. Passed on the LAUNCH LINE because an instance resumes its conversation, and a resumed session keeps the model it was saved with -- so an always-on instance otherwise stays on whatever it last ran, indefinitely, and a settings-file default will not move it. Validated by running the CLI when passed explicitly, because a bad value exits 1 inside a hidden window and crash-loops the instance. That probe is a real request, and it runs under -WhatIf too -- deliberately, so -WhatIf cannot report an install that would then fail. It writes nothing: it runs from a throwaway directory that is removed with the transcript it produces. .PARAMETER TriggerDelay ISO-8601 logon delay, default PT1M. Stagger it across instances on one host. .PARAMETER NoTrustSeed Skip seeding Claude Code's trust. Expect a modal dialog in a hidden window. .PARAMETER NoStart Register everything but do not start the session. .OUTPUTS Greenroom.Instance for the started session, or Greenroom.InstallResult with -NoStart. .EXAMPLE Install-GreenroomInstance -Name desktop-admin .EXAMPLE Install-GreenroomInstance -Name render-admin -WorkingDirectory D:\render-admin -TriggerDelay PT2M .EXAMPLE Install-GreenroomInstance -Name desktop-admin -Elevated:$false Revokes elevation. Takes effect when the instance next restarts. .EXAMPLE Install-GreenroomInstance -Name laptop-admin -Model opus Restart-GreenroomSession laptop-admin Sets the model and makes it take effect. Everything else about the instance is inherited. #> function Install-GreenroomInstance { [CmdletBinding(SupportsShouldProcess)] # Both, because -NoStart returns a different shape: there is no session to describe, # so it reports what was provisioned instead. Declaring only one misleads anything # reading OutputType metadata, including Get-Command -Syntax and editor tooling. [OutputType('Greenroom.Instance', 'Greenroom.InstallResult')] param( # ValueFromPipeline as well as ByPropertyName, so a bare name works and not only # a Greenroom.Instance. Piping into an install is safe precisely because a bare # re-run inherits: `Get-GreenroomInstance | Install-GreenroomInstance` re-registers # every instance without changing any of their settings. [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('Instance')] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$')] [string]$Name, [string]$WorkingDirectory, [string]$ClaudeExe, [string]$Model, [string]$TriggerDelay, [string[]]$AdditionalDirectories, [switch]$Elevated, [switch]$NoTrustSeed, [switch]$NoStart, [ValidateRange(10, 300)] [int]$TimeoutSeconds = 60 ) process { # Resolved from what the previous install recorded, before anything is touched. $s = Resolve-InstallParameter -Name $Name -Bound $PSBoundParameters ` -WorkingDirectory $WorkingDirectory -ClaudeExe $ClaudeExe ` -TriggerDelay $TriggerDelay -AdditionalDirectories $AdditionalDirectories ` -Elevated $Elevated.IsPresent -Model $Model # MEASURED: registering RunLevel Highest from a non-elevated shell fails with # 'Access is denied'. Checked here rather than at registration, which is the last # step -- failing there leaves files written, trust seeded and no task to run any # of it. if ($s.Elevated -and -not (Test-SelfElevated)) { throw ("-Elevated requires an elevated caller. Registering a scheduled task with RunLevel " + 'Highest fails with Access Denied otherwise, verified on the reference host. ' + 'Nothing has been changed.') } $pre = Resolve-GreenroomPrerequisite -ClaudeExe $s.ClaudeExe Test-GreenroomHostSetting # Only for a model passed on THIS run. The probe costs an API call, and a value # that is merely inherited was already proved when it was set -- re-proving it on # every bare re-run would tax the idempotence the rest of this command depends on. # Before ShouldProcess and before anything is written, like every other refusal. if ($PSBoundParameters.ContainsKey('Model') -and $Model) { Assert-ClaudeModel -ClaudeExe $pre.ClaudeExe -Model $Model } if (-not $PSCmdlet.ShouldProcess($Name, 'Install-GreenroomInstance')) { return } foreach ($d in @($s.StateDir, $s.WorkingDirectory)) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } [PSCustomObject]@{ instance = $s.Instance claudeExe = $pre.ClaudeExe # True when chosen on this run OR inherited from a previous choice. It # records a flag ABOUT the value, so it has to be carried forward too. claudeExeExplicit = $s.ClaudeExeExplicit workingDirectory = $s.WorkingDirectory triggerDelay = $s.TriggerDelay additionalDirectories = $s.AdditionalDirectories # Read to decide whether this shell can act on the window AT ALL, so it is # recorded rather than re-derived from the task: it has to be known BEFORE # something tries and silently fails. elevated = $s.Elevated # Empty means "whatever the CLI picks". Recorded either way so the launcher # never has to distinguish absent from cleared. model = $s.Model wt = $pre.WindowsTerminal # The shell the session runs under: pwsh 7 when present, else Windows PowerShell 5.1. shell = $pre.Shell installedUtc = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json | Set-Content -Path (Join-Path $s.StateDir 'config.json') -Encoding UTF8 Set-ProjectGrant -Directory $s.WorkingDirectory -Grants $s.AdditionalDirectories if ($NoTrustSeed) { Write-Warning 'trust seed skipped -- expect a modal dialog inside the hidden window' } else { Set-ProjectTrust -Directory $s.WorkingDirectory -BackupDir $s.StateDir | Out-Null } Register-GreenroomTask -Name $s.Instance -WorkingDirectory $s.WorkingDirectory ` -TriggerDelay $s.TriggerDelay -WScriptPath $pre.WScript -Elevated $s.Elevated if ($s.Elevated) { Write-Warning ("'$($s.Instance)' runs ELEVATED. Showing and hiding it now need an elevated shell too: " + 'UIPI blocks the window calls from a lower integrity level. Nothing on screen marks ' + "the session as elevated -- Get-GreenroomInstance is how you tell. Revoke with " + "Install-GreenroomInstance -Name $($s.Instance) -Elevated:`$false") } if ($NoStart) { return [PSCustomObject]@{ PSTypeName = 'Greenroom.InstallResult' Instance = $s.Instance WorkingDirectory = $s.WorkingDirectory TaskName = $s.TaskName Elevated = $s.Elevated Started = $false } } Start-ScheduledTask -TaskName $s.TaskName $up = Wait-GreenroomSession -Name $s.Instance -TimeoutSeconds $TimeoutSeconds # Verify the seed SURVIVED rather than assuming it did. Any other claude.exe on # the host can rewrite ~/.claude.json between the seed and the launch, and the # consequence is a modal dialog in a window nobody can see. if (-not $NoTrustSeed -and -not (Test-TrustSurvived -Directory $s.WorkingDirectory)) { Write-Warning 'trust seed did NOT survive -- something rewrote ~/.claude.json. Re-seeding.' Set-ProjectTrust -Directory $s.WorkingDirectory -BackupDir $s.StateDir | Out-Null if (Test-TrustSurvived -Directory $s.WorkingDirectory) { Write-Warning 'trust re-seeded; restarting the session so it picks the seed up.' # Trust is read at startup, so a session already sitting on the dialog # will not pick up the re-seed. Restart-GreenroomSession stops the # supervisor BY PROCESS first, which is the only thing that works here -- # Stop-ScheduledTask is a no-op against this architecture. $up = Restart-GreenroomSession -Name $s.Instance -TimeoutSeconds $TimeoutSeconds } else { Write-Warning ('RE-SEED FAILED. Expect a modal trust dialog inside the hidden window: show ' + 'the session and accept it by hand, or close other claude.exe and install again.') } } if (-not $up) { Write-Error -Category OperationTimeout -Message ( "'$($s.Instance)' did not come up within $TimeoutSeconds s. Check: Get-Content " + "`"$(Join-Path $s.StateDir 'watchdog.log')`" -Tail 20") return } return $up } } |