Public/Doctor.ps1
|
# Diagnostics for the failure modes that actually bite when two backends share a machine. # Every check is read-only and never triggers an AWS login. function Test-CcsHealth { <# .SYNOPSIS Report configuration problems across all profiles and this shell. .OUTPUTS Objects with Severity (Error|Warning|Info), Check, Message, Fix. #> [CmdletBinding()] param() $findings = [System.Collections.Generic.List[object]]::new() $add = { param($Severity, $Check, $Message, $Fix) $findings.Add([pscustomobject]@{ PSTypeName = 'Ccs.Finding' Severity = $Severity; Check = $Check; Message = $Message; Fix = $Fix }) } # A persisted CLAUDE_CONFIG_DIR defeats per-shell switching: every new shell, # the VS Code extension and scheduled jobs would all inherit one profile. foreach ($scope in @('User', 'Machine')) { $persisted = [Environment]::GetEnvironmentVariable('CLAUDE_CONFIG_DIR', $scope) if ($persisted) { & $add 'Warning' 'persisted-config-dir' ` "CLAUDE_CONFIG_DIR is set permanently in $scope scope ($persisted)" ` "[Environment]::SetEnvironmentVariable('CLAUDE_CONFIG_DIR', `$null, '$scope')" } } foreach ($var in $script:AuthOverrideEnvVars) { if (Test-Path "env:$var") { & $add 'Warning' 'auth-override-env' ` "$var is set in this shell and bypasses profile authentication" ` "Remove-Item env:$var" } } $config = Get-CcsConfig $activeName = Get-CcsActiveProfileName -Config $config if (-not $activeName) { & $add 'Warning' 'unknown-active-profile' ` "CLAUDE_CONFIG_DIR ($env:CLAUDE_CONFIG_DIR) belongs to no registered profile" ` "ccs use <name>, or register it with ccs capture" } foreach ($p in $config.profiles.PSObject.Properties) { $findings.AddRange(@(Test-CcsProfileHealth -Name $p.Name -Config $config)) } # Claude Desktop / Cowork read a machine policy, not CLAUDE_CONFIG_DIR, so no # per-shell switch can move them. Worth stating rather than debugging twice. $policy = Get-ItemProperty 'HKCU:\SOFTWARE\Policies\Claude' -ErrorAction SilentlyContinue if ($policy -and $policy.inferenceProvider) { & $add 'Info' 'desktop-policy' ` "Claude Desktop/Cowork is pinned to '$($policy.inferenceProvider)' by HKCU\SOFTWARE\Policies\Claude (profile: $($policy.inferenceBedrockProfile))" ` 'Machine-wide policy — ccs only switches the CLI. Ask IT before changing it.' } $findings } function Test-CcsProfileHealth { <# .SYNOPSIS Checks scoped to a single profile. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, $Config = (Get-CcsConfig) ) $findings = [System.Collections.Generic.List[object]]::new() $add = { param($Severity, $Check, $Message, $Fix) $findings.Add([pscustomobject]@{ PSTypeName = 'Ccs.Finding' Severity = $Severity; Check = "$Name/$Check"; Message = $Message; Fix = $Fix }) } $resolved = Resolve-CcsProfile -Name $Name -Config $Config if (-not $resolved.Exists) { & $add 'Error' 'missing-dir' "Profile directory does not exist: $($resolved.Dir)" ` "ccs capture $Name" return $findings } if (-not $resolved.Settings) { & $add 'Error' 'missing-settings' "No readable settings.json in $($resolved.Dir)" ` "ccs capture $Name -Force" return $findings } $envMap = $resolved.Env if ($resolved.Backend -eq 'bedrock') { $missing = @($script:RequiredTelemetryKeys | Where-Object { -not $envMap.ContainsKey($_) }) if ($missing.Count -gt 0) { & $add 'Error' 'telemetry-incomplete' ` "Bedrock profile is missing required telemetry keys: $($missing -join ', ')" ` 'Re-capture from the company settings.json — CCWB can refuse credentials when telemetry enforcement is on' } $helper = $resolved.Settings.otelHeadersHelper if (-not $helper) { & $add 'Warning' 'otel-helper-missing' 'No otelHeadersHelper configured' ` 'Re-capture from the company settings.json' } elseif (-not (Test-Path -LiteralPath $helper)) { & $add 'Error' 'otel-helper-path' "otelHeadersHelper points at a missing file: $helper" ` 'Reinstall the company package (install.bat)' } $awsProfile = $envMap['AWS_PROFILE'] if (-not $awsProfile) { & $add 'Error' 'aws-profile-unset' 'Bedrock profile has no AWS_PROFILE' ` 'Re-capture from the company settings.json' } elseif ($awsProfile -notin (Get-CcsAwsProfileNames)) { & $add 'Error' 'aws-profile-unknown' ` "AWS_PROFILE '$awsProfile' is not declared in $(Get-CcsAwsConfigPath)" ` 'Run the company install.bat to write the profile' } $credProcess = Join-Path (Get-CcwbHome) 'credential-process.exe' if (-not (Test-Path -LiteralPath $credProcess)) { & $add 'Error' 'credential-process-missing' "Not found: $credProcess" ` 'Run the company install.bat' } $ccwbConfig = $null try { $ccwbConfig = Read-CcsJson -Path (Join-Path (Get-CcwbHome) 'config.json') } catch { } # The OIDC client the credential process authenticates as. When IT rotates # the package they disable the previous client, and the only symptom is a # "Client disabled" page in the browser — invisible from the Claude side. if ($ccwbConfig -and $awsProfile) { $entry = if ($ccwbConfig.PSObject.Properties[$awsProfile]) { $ccwbConfig.$awsProfile } else { $null } if (-not $entry) { & $add 'Error' 'ccwb-profile-mismatch' ` "CCWB config.json has no entry for AWS profile '$awsProfile'" ` 'Reinstall the company package so both sides name the same profile' } else { $installed = Get-Item -LiteralPath $credProcess -ErrorAction SilentlyContinue $stamp = if ($installed) { $installed.LastWriteTime.ToString('yyyy-MM-dd') } else { 'unknown' } & $add 'Info' 'ccwb-package' ` "CCWB client_id '$($entry.client_id)', credential process built $stamp" ` "If sign-in shows 'Client disabled', IT rotated the OIDC client - reinstall the latest package, then run: ccs adopt" if (-not $entry.PSObject.Properties['expected_otlp_endpoint']) { & $add 'Warning' 'ccwb-outdated' ` 'CCWB config has no expected_otlp_endpoint, which newer packages always set' ` 'Likely older than the package IT currently ships - reinstall from the latest zip' } $expected = $entry.expected_otlp_endpoint $actual = $envMap['OTEL_EXPORTER_OTLP_ENDPOINT'] if ($expected -and $actual -and $expected -ne $actual) { & $add 'Error' 'telemetry-endpoint-mismatch' ` "Telemetry endpoint '$actual' differs from the enforced '$expected'" ` 'Re-capture from the company settings.json' } } } } else { # A stale Bedrock key in a subscription profile silently overrides the shell. foreach ($key in @('CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_BASE_URL')) { if ($envMap.ContainsKey($key)) { & $add 'Warning' 'stale-provider-env' ` "Subscription profile carries $key=$($envMap[$key]) in its settings env block, which wins over shell variables" ` "Remove the key from $($resolved.SettingsPath)" } } } # A lock directory older than Claude Code's staleness window is a leftover from # a killed process; a fresh one means a live session is mid-refresh. $locks = Get-CcsLockDirs -ConfigHome $resolved.Dir foreach ($lockName in @('OauthRefresh', 'Legacy')) { $lock = $locks.$lockName if (Test-Path -LiteralPath $lock) { $age = (Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime if ($age.TotalSeconds -gt 60) { & $add 'Warning' 'stale-lock' ` "Lock $lock is $([int]$age.TotalSeconds)s old — likely left by a killed Claude Code" ` "Remove-Item -LiteralPath '$lock' -Recurse" } else { & $add 'Info' 'active-lock' ` "Claude Code is refreshing credentials right now ($lock)" ` 'Wait a few seconds before switching this profile' } } } $findings } |