Private/Test-GreenroomPrerequisite.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# Resolve and verify everything an instance needs before anything is registered. All of these fail SILENTLY inside a hidden window if they are wrong, which is why they are checked here rather than discovered later as "it just doesn't work". Returns the resolved paths; throws on anything fatal. #> function Resolve-GreenroomPrerequisite { [CmdletBinding()] param([string]$ClaudeExe) # Windows Terminal is a HARD requirement. conhost does no font fallback and no # console-registerable font carries the glyphs the TUI draws, so under conhost the # interface renders as boxes. $wt = @( (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\wt.exe'), (Get-Command wt.exe -ErrorAction SilentlyContinue | ForEach-Object Source) ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 if (-not $wt) { throw 'Windows Terminal (wt.exe) not found and is required. winget install Microsoft.WindowsTerminal' } # The shell that runs the watchdog and the session. pwsh 7 is PREFERRED -- WinGet's # version-independent alias first, then the real install path, then anything on PATH. # Windows PowerShell 5.1 ships in-box on every Windows host and is the last resort, so a # machine with no pwsh 7 still installs. greenroom's code runs on both editions, and this # is the same ladder the watchdog .vbs walks at logon. $shell = @( (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\pwsh.exe'), (Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'), (Get-Command pwsh.exe -ErrorAction SilentlyContinue | ForEach-Object Source), (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 if (-not $shell) { throw 'no PowerShell found: neither pwsh.exe nor Windows PowerShell 5.1 (powershell.exe).' } $wscript = Join-Path $env:SystemRoot 'System32\wscript.exe' if (-not (Test-Path $wscript)) { throw "wscript.exe not found at $wscript" } # Candidate ORDER matters. WinGet's Links shim goes first because it is keyed on # PACKAGE ID rather than version, so the path survives upgrades and what lands in # config.json stays valid. $candidates = @() if ($ClaudeExe) { $candidates += $ClaudeExe } $candidates += (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\claude.exe') $candidates += (Get-Command claude.exe -All -ErrorAction SilentlyContinue | ForEach-Object Source) $candidates += (Join-Path $env:USERPROFILE '.local\bin\claude.exe') # Claude DESKTOP ships its own claude.exe plus a private bundled CLI; neither is a # valid target, and its location depends on install method. $desktopPatterns = @( '*\Program Files\WindowsApps\*', '*\AnthropicClaude\*', '*\AppData\Roaming\Claude\claude-code\*' ) $seen = @{} $claude = $candidates | Where-Object { if (-not $_) { return $false } if (-not (Test-Path $_)) { return $false } foreach ($pat in $desktopPatterns) { if ($_ -like $pat) { return $false } } $k = $_.ToLower() if ($seen.ContainsKey($k)) { return $false } $seen[$k] = $true return $true } | Select-Object -First 1 if (-not $claude) { throw 'Claude Code CLI (claude.exe) not found. Pass -ClaudeExe, or install Claude Code first.' } # Deliberately NOT resolved through the symlink: the WinGet Links entry points at a # versioned target, and following it bakes a path that breaks on the next upgrade. $claude = [System.IO.Path]::GetFullPath($claude) $ver = (& $claude --version 2>&1 | Out-String).Trim() if ($ver -notmatch 'Claude Code') { throw "'$claude' does not look like the Claude Code CLI (--version said: $ver). Pass -ClaudeExe explicitly." } # THE WORST FAILURE GREENROOM HAS. Without --remote-control the session dies # instantly inside a hidden window, the watchdog restarts it, the crash-loop guard # trips, and nothing surfaces anywhere. # # The '[' anchor matters: older builds carry --remote-control-session-name-prefix # without the flag itself, and a bare substring match passes on those. $help = (& $claude --help 2>&1 | Out-String) if ($help -notmatch '--remote-control\s*\[') { throw ("'$claude' ($ver) does not support --remote-control. Verified absent in 2.1.92, present " + 'from 2.1.218. Note --remote-control-session-name-prefix is a DIFFERENT flag that may be ' + 'present without this one. Upgrade Claude Code, or pass -ClaudeExe at a build that has it.') } [PSCustomObject]@{ WindowsTerminal = $wt Shell = $shell WScript = $wscript ClaudeExe = $claude ClaudeVersion = $ver } } <# Prove a model value works before it can reach a launch line. An unusable --model is FATAL and silent: claude exits 1 at once, the launcher records it, the watchdog never sees a session come up, and the crash-loop guard eventually trips -- all inside a hidden window. Same failure class as a CLI without --remote-control, so it is checked the same way, by running the thing. MEASURED on the reference host: claude --model definitely-not-a-real-model-xyz --print -> exit 1, "There's an issue with the selected model" claude --model opus --print -> exit 0 claude --model <anything> --version -> exit 0 EITHER WAY That last line is why this costs an API call: --version short-circuits and validates nothing, so there is no free probe. The caller therefore runs this only when -Model was passed EXPLICITLY, never when it was inherited from the previous install. #> function Assert-ClaudeModel { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ClaudeExe, [Parameter(Mandatory)][string]$Model ) # PROBED FROM A THROWAWAY DIRECTORY, and that is not tidiness. # # --print writes a transcript into the project store of the CURRENT directory, and # Install- is most naturally run from the instance's own working directory -- the one # store greenroom must not litter, because greenroom-launch.ps1 resumes the NEWEST # transcript there. A probe stub landing in it could become the conversation the # instance comes back to at its next restart. # # Measured: probing from ~/src/greenroom-win left two 11 KB transcripts in that # project store whose only user message was "ok". Probing from a temp directory puts # them under that path's slug instead, which is then removed with it. $probeDir = Join-Path ([IO.Path]::GetTempPath()) "greenroom-model-probe-$([guid]::NewGuid())" New-Item -ItemType Directory -Path $probeDir -Force | Out-Null # Same slug rule the launcher uses: the literal path with every non-alphanumeric # character replaced by a dash. $store = Join-Path $env:USERPROFILE (".claude\projects\" + ($probeDir -replace '[^A-Za-z0-9]', '-')) Push-Location $probeDir try { $out = (& $ClaudeExe --model $Model --print 'ok' 2>&1 | Out-String).Trim() $code = $LASTEXITCODE } finally { Pop-Location Remove-Item $probeDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $store -Recurse -Force -ErrorAction SilentlyContinue } if ($code -eq 0) { Write-Verbose "model '$Model' accepted by $ClaudeExe" return } $first = @($out -split "`r?`n" | Where-Object { $_ }) | Select-Object -First 1 throw ("-Model '$Model' was rejected by the CLI, so it is not written and nothing has been " + "changed. A bad model would exit 1 inside a hidden window and crash-loop the instance. " + "The CLI said: $first") } <# Warn about host settings that break a supervised session invisibly. Advisory only -- none of these are fatal, and none are greenroom's to fix. They go to the Warning stream so a caller can suppress or capture them. #> function Test-GreenroomHostSetting { [CmdletBinding()] param() $settings = Join-Path $env:USERPROFILE '.claude\settings.json' if (-not (Test-Path $settings)) { return } $s = $null try { $s = Get-Content $settings -Raw | ConvertFrom-Json } catch { Write-Warning "could not parse $settings"; return } # Git for Windows puts Git\cmd and Git\mingw64\bin on PATH but NOT Git\bin, which # is where bash.exe lives -- so a stock install satisfies `git` and fails `bash`, # and every Bash tool call then fails silently in a hidden window. if ($s.defaultShell -eq 'bash') { $inProc = Get-Command bash -ErrorAction SilentlyContinue # Build the PATH a NEW process would get, from the registry. This process may # hold a stale environment block, and that false negative is otherwise # indistinguishable from a real failure. $machine = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -Name Path).Path $user = (Get-ItemProperty 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue).Path $fresh = [Environment]::ExpandEnvironmentVariables(($machine.TrimEnd(';') + ';' + $user)) $freshHit = ($fresh -split ';') | Where-Object { $_ -and (Test-Path (Join-Path $_ 'bash.exe')) } | Select-Object -First 1 if ($inProc -and $freshHit) { Write-Verbose "shell: bash -> $($inProc.Source)" } elseif ($freshHit) { Write-Warning ("bash resolves in the registry PATH ($freshHit) but not in THIS process, which " + 'holds a stale environment block. A task-launched session will be fine.') } else { $msg = 'defaultShell is "bash" but bash.exe does not resolve on PATH. Every Bash tool call will fail silently in a hidden window.' $git = (Get-Command git.exe -ErrorAction SilentlyContinue).Source if ($git) { $gitBin = Join-Path (Split-Path (Split-Path $git)) 'bin' if (Test-Path (Join-Path $gitBin 'bash.exe')) { $msg += " Add '$gitBin' to your USER PATH. Do NOT add Git\usr\bin -- it also has bash but shadows Windows echo/find/sort/tee and breaks scripts." } } Write-Warning $msg } } # A marketplace whose source directory is missing throws a plugin load error at # every session start -- invisible, same as everything else here. if ($s.extraKnownMarketplaces) { foreach ($p in $s.extraKnownMarketplaces.PSObject.Properties) { $path = $p.Value.source.path if ($p.Value.source.source -eq 'directory' -and $path -and -not (Test-Path $path)) { $m = "marketplace '$($p.Name)' points at a path that does not exist: $path. Remove extraKnownMarketplaces.$($p.Name) from $settings" if ($s.enabledPlugins) { $refs = @($s.enabledPlugins.PSObject.Properties.Name | Where-Object { $_ -like "*@$($p.Name)" }) if ($refs) { $m += ", plus enabledPlugins: $($refs -join ', ')" } } Write-Warning $m } } } # A host-wide grant defeats per-instance scoping. Filter falsy entries: @($null) # has Count 1, so an absent key would otherwise report a grant of nothing. $hostWide = @($s.permissions.additionalDirectories | Where-Object { $_ }) if ($hostWide.Count -gt 0) { Write-Warning ("$settings grants these to EVERY instance on this host: $($hostWide -join ', '). " + 'Move them into a per-instance -AdditionalDirectories grant instead.') } } |