# lib/health.ps1 - health.json, and persistent environment variables that would override a switch. # # Part of claude-mode.ps1, which dot-sources it into its own script scope after # the settings at its top. Not meant to run on its own. ASCII only: Windows # PowerShell 5.1 reads a .ps1 without a BOM as ANSI. $PSScriptRoot here would be # lib\, so paths beside the main script go through $script:Here. # --------------------------------------------------------------------------- # Stray environment variable detection # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # health.json - the machine-readable state a fleet reader (or anything else) # reads. Written on every switch and on every `doctor`. # # Contract, deliberately narrow: # * NO key material, ever. `keysConfigured` is names only; `keyBackend` says # how they are stored, never what they are. # * Model-id lists are [{id, anthropic}] rather than bare strings, so a reader # never has to re-derive the Anthropic matcher. A tagged *Anthropic* id is # normal (that is how the 1M variant is selected) and must not render as a # fault; a tagged gateway id is the breakage. # * `guardrailStatus` is tri-state (active|not_set|unknown) or null when not # probed. Never collapse to a boolean - "unknown" must not read as safe. # --------------------------------------------------------------------------- $script:HealthPath = Join-Path $script:Root 'health.json' $script:HealthGuardrail = $null # set by Show-GuardrailStatus when it probes # Must serialise as a JSON array for 0, 1 and many entries. PS 5.1 unrolls a # single-element array on return (rendering it as a bare object) and renders a # comma-wrapped empty array as [[]]. A generic List survives both: `,` stops the # unroll, and ConvertTo-Json always emits a List as an array. function ConvertTo-ModelEntry { param([string[]] $Ids) $out = New-Object 'System.Collections.Generic.List[object]' foreach ($i in @($Ids)) { $out.Add([ordered]@{ id = $i; anthropic = [bool](Test-AnthropicModelId $i) }) } return , $out } function Write-HealthFile { param([string] $Mode, [string] $PresetName) $h = [ordered]@{ schema = 1 tool = 'claude-mode' version = $script:Version updatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') os = 'windows' mode = $Mode preset = '' } $ids = Get-CachedModelIds $stale = @($ids | Where-Object { Test-AnthropicModelId $_ }) $tagged = @($ids | Where-Object { Test-TaggedModelId $_ }) if ($Mode -eq 'anthropic') { # Nothing gateway-shaped is meaningful here, and a cached Anthropic id is # simply the model in use - not a finding. $h['staleModelIds'] = (ConvertTo-ModelEntry @()) $h['taggedModelIds'] = ConvertTo-ModelEntry $tagged } else { $h['preset'] = $PresetName try { $p = Get-Preset $PresetName $h['provider'] = [string]$p['provider'] $h['baseUrl'] = [string]$p['baseUrl'] $models = [ordered]@{} foreach ($t in $script:Tiers) { if ($p['models'].Contains($t)) { $models[$t] = [string]$p['models'][$t] } } $h['models'] = $models $h['subagentModel'] = [string]$p['subagentModel'] $h['contextTokens'] = if ($p['contextTokens']) { [int]$p['contextTokens'] } else { $null } $h['gatewayDiscovery'] = [bool]$p['gatewayModelDiscovery'] $auth = Get-PresetAuth $p if ([string]$auth['mode'] -eq 'vault') { $ref = [string]$auth['keyRef'] $h['keyBackend'] = 'dpapi' $kc = New-Object 'System.Collections.Generic.List[object]' if (Get-VaultKey $ref) { $kc.Add($ref) } $h['keysConfigured'] = $kc } else { $h['keyBackend'] = 'inline' $h['keysConfigured'] = (New-Object 'System.Collections.Generic.List[object]') } # It passed the guard, or Set-ClaudeMode would have thrown. $h['costGuardPassed'] = $true } catch { $h['costGuardPassed'] = $null } $h['guardrailStatus'] = $script:HealthGuardrail $h['staleModelIds'] = ConvertTo-ModelEntry $stale $h['taggedModelIds'] = ConvertTo-ModelEntry $tagged } try { Write-JsonFile $script:HealthPath $h } catch { } } function Get-PersistentEnv { param([string] $Name) return [pscustomobject]@{ Name = $Name User = [Environment]::GetEnvironmentVariable($Name, 'User') Machine = [Environment]::GetEnvironmentVariable($Name, 'Machine') } } function Test-StrayEnvVars { param([string] $Mode) $problems = 0 $ak = Get-PersistentEnv 'ANTHROPIC_API_KEY' if ($Mode -ne 'anthropic') { if ($ak.User) { Write-Err2 'ANTHROPIC_API_KEY is set at User scope - it will bypass the gateway. Fix: claude-mode doctor'; $problems++ } if ($ak.Machine) { Write-Err2 'ANTHROPIC_API_KEY is set at Machine scope - it will bypass the gateway. Remove it (admin required).'; $problems++ } if ($env:ANTHROPIC_API_KEY -and -not $ak.User -and -not $ak.Machine) { Write-Warn2 'ANTHROPIC_API_KEY is set in THIS shell only. The `claude` wrapper strips it; other shells are unaffected.' } } foreach ($name in $script:BaseManagedEnvKeys) { if ($name -eq 'ANTHROPIC_API_KEY') { continue } $v = Get-PersistentEnv $name if ($v.User) { Write-Err2 "$name is set at User scope - it overrides claude-mode. Fix: claude-mode doctor"; $problems++ } if ($v.Machine) { Write-Err2 "$name is set at Machine scope - remove it (admin required)."; $problems++ } } return $problems } function Repair-StrayEnvVars { $fixed = 0 foreach ($name in $script:BaseManagedEnvKeys) { $v = [Environment]::GetEnvironmentVariable($name, 'User') if ($v) { $bak = Join-Path $script:BackupDir ("userenv-$name-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '.txt') Set-Content -LiteralPath $bak -Value $v -Encoding UTF8 Protect-FileAcl $bak [Environment]::SetEnvironmentVariable($name, $null, 'User') Write-Ok "removed User-scope $name (old value saved to $bak)" $fixed++ } } if ($fixed -eq 0) { Write-Ok 'no User-scope overrides to remove' } }