apiKeyHelper is a shell command line, not a path, so the raw value written into settings.json was split at the first space. A Windows profile named "Mohammed Ahmed" produced an attempt to run C:\Users\Mohammed, surfacing as "your apiKeyHelper script is failing" with nothing to go on. Quote the value when it contains anything a shell cares about, and leave it bare otherwise so no existing settings.json churns on the next switch. The POSIX port had the same bug against a /Users/First Last home; shlex.quote has exactly the wanted "leave ordinary paths alone" behaviour. doctor could not see any of this. It quoted the path itself before running it, so it exercised a command line Claude Code never uses and passed while the real one failed. It now reads the string out of settings.json, reports it when it is not what a switch would write, and runs that string through a shell. The helper itself exited 1 in silence on four distinct faults - no state, no preset, no key, undecryptable key - collapsing them into one indistinguishable message. Each now names itself on stderr, which is what /status displays. The DPAPI case says what it actually means: a key stored by a different Windows account than the one Claude Code runs as. Success paths stay silent, so stdout still carries the key and nothing else. Also make install.ps1 survive a Restricted execution policy: piped through iex it is not subject to the policy, but invoking the installed script for the key prompt is, which is where a fresh install died. Set Process scope for the install, offer to set CurrentUser to RemoteSigned, and clear the mark-of-the-web that Expand-Archive can leave on the extracted scripts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
74 lines
3.4 KiB
PowerShell
74 lines
3.4 KiB
PowerShell
# claude-key-helper.ps1
|
|
#
|
|
# Invoked by Claude Code via the `apiKeyHelper` setting. Prints the API key for
|
|
# the currently active preset to stdout and nothing else.
|
|
#
|
|
# Only presets whose auth mode is "vault" have a secret to emit. In anthropic
|
|
# mode, or for a preset using an inline placeholder token (LM Studio), this
|
|
# exits silently so no token can leak into a context that must not have one.
|
|
#
|
|
# The key is stored DPAPI-encrypted (CurrentUser scope), so decryption only
|
|
# succeeds for the Windows account that ran `claude-mode set-key`. Reading the
|
|
# .cred file as another user - or copying it to another machine - yields nothing.
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Claude Code surfaces a failing helper as "your apiKeyHelper script is failing"
|
|
# and shows this stream under /status. Exiting 1 in silence turns four different
|
|
# faults into one indistinguishable message, so every failure path says which it
|
|
# was. Success paths stay silent - stdout carries the key and nothing else.
|
|
function Fail {
|
|
param([string] $Message)
|
|
[Console]::Error.WriteLine("claude-key-helper: $Message")
|
|
exit 1
|
|
}
|
|
|
|
try {
|
|
$root = Join-Path $env:USERPROFILE '.claude-mode'
|
|
$statePath = Join-Path $root 'state.json'
|
|
if (-not (Test-Path -LiteralPath $statePath)) {
|
|
Fail "no state.json at $statePath - claude-mode is not installed for this Windows account. Run install.ps1"
|
|
}
|
|
$state = Get-Content -LiteralPath $statePath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
if ($state.mode -eq 'anthropic') { exit 0 }
|
|
if (-not $state.preset) { exit 0 }
|
|
|
|
$presetPath = Join-Path $root ('presets\' + $state.preset + '.json')
|
|
if (-not (Test-Path -LiteralPath $presetPath)) {
|
|
Fail "state.json names preset '$($state.preset)' but $presetPath does not exist. Run: claude-mode presets"
|
|
}
|
|
$preset = Get-Content -LiteralPath $presetPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
$authMode = 'vault'
|
|
$keyRef = 'openrouter'
|
|
if ($preset.PSObject.Properties.Name -contains 'auth' -and $preset.auth) {
|
|
if ($preset.auth.PSObject.Properties.Name -contains 'mode' -and $preset.auth.mode) { $authMode = [string]$preset.auth.mode }
|
|
if ($preset.auth.PSObject.Properties.Name -contains 'keyRef' -and $preset.auth.keyRef) { $keyRef = [string]$preset.auth.keyRef }
|
|
}
|
|
|
|
if ($authMode -ne 'vault') { exit 0 } # inline token: nothing for us to emit
|
|
|
|
$credPath = Join-Path $root ('vault\' + $keyRef + '.cred')
|
|
if (-not (Test-Path -LiteralPath $credPath)) {
|
|
Fail "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef"
|
|
}
|
|
|
|
try {
|
|
$secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim()
|
|
} catch {
|
|
# DPAPI is CurrentUser scope, so this is almost always a key stored by a
|
|
# different Windows account than the one Claude Code is running as -
|
|
# typically set-key run from an elevated or "run as" shell.
|
|
Fail "cannot decrypt $credPath as $env:USERDOMAIN\$env:USERNAME. The key is DPAPI-encrypted for whichever account stored it; re-run 'claude-mode set-key $keyRef' as this user, unelevated."
|
|
}
|
|
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
|
try {
|
|
[Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr))
|
|
} finally {
|
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
|
}
|
|
} catch {
|
|
Fail $_.Exception.Message
|
|
}
|