Quote the apiKeyHelper path, and say why the helper failed

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>
This commit is contained in:
smoido
2026-09-04 00:49:50 +03:00
co-authored by Claude Opus 5
parent b32651fe00
commit b60c00450c
8 changed files with 188 additions and 27 deletions
+51 -9
View File
@@ -50,6 +50,18 @@ $script:SettingsDir = Join-Path $env:USERPROFILE '.claude'
$script:Settings = Join-Path $script:SettingsDir 'settings.json'
$script:LmStudioDir = Join-Path $env:USERPROFILE '.lmstudio'
# Claude Code runs apiKeyHelper as a shell command line, not as a bare argv[0],
# so the value in settings.json is parsed by cmd before anything is executed. A
# profile path containing a space therefore has to arrive already quoted:
# C:\Users\Firstname Lastname\... otherwise splits and cmd tries to run
# C:\Users\Firstname. Paths with nothing cmd cares about are written bare,
# exactly as before, so no existing settings.json churns on the next switch.
function Get-HelperCommandLine {
param([string] $Path = $script:HelperCmd)
if ($Path -match '[\s&()^;,]') { return '"' + $Path + '"' }
return $Path
}
# Baseline set of settings.json env keys this tool owns. On every switch these
# are deleted first, together with whatever a previous switch actually wrote
# (tracked in state.json), so no value can survive a mode change.
@@ -543,7 +555,7 @@ function Set-ClaudeMode {
if (-not (Test-Path -LiteralPath $script:HelperCmd)) {
throw "key helper missing at $($script:HelperCmd). Re-run install.ps1"
}
$settings['apiKeyHelper'] = $script:HelperCmd
$settings['apiKeyHelper'] = Get-HelperCommandLine
} else {
$tok = 'lmstudio'
if ($auth.Contains('token') -and $auth['token']) { $tok = [string]$auth['token'] }
@@ -1277,7 +1289,7 @@ function Invoke-Doctor {
Write-Head "doctor - mode '$mode'"
try {
[void](Read-JsonFile $script:Settings)
$liveSettings = Read-JsonFile $script:Settings
Write-Ok 'settings.json parses'
} catch {
Write-Err2 "settings.json does not parse: $($_.Exception.Message)"
@@ -1303,13 +1315,43 @@ function Invoke-Doctor {
}
else { Write-Err2 "vault '$keyRef' missing or undecryptable. Run: claude-mode set-key $keyRef" }
try {
$out = (& cmd.exe /c "`"$($script:HelperCmd)`"" 2>&1 | Out-String).Trim()
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
elseif ($out) { Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))" }
else { Write-Err2 'apiKeyHelper produced no output' }
} catch {
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
# The stored string is what Claude Code hands to a shell, and a
# path this process can quote correctly is not evidence that the
# recorded one parses. Check the value, then run that value.
$stored = ''
if ($liveSettings.Contains('apiKeyHelper')) { $stored = [string]$liveSettings['apiKeyHelper'] }
$expected = Get-HelperCommandLine
$reSwitch = "claude-mode $mode $([string]$state['preset'])"
if (-not $stored) {
Write-Err2 "settings.json has no apiKeyHelper. Run: $reSwitch"
} elseif ($stored -ne $expected) {
Write-Err2 "apiKeyHelper reads $stored"
Write-Err2 " but should read $expected - run: $reSwitch"
} else {
Write-Ok "apiKeyHelper wired as $stored"
}
if ($stored) {
# Run it through cmd the way a shell would, via a batch file, so
# PowerShell's own native-argument quoting cannot paper over a
# value that a real shell would split.
$probe = Join-Path $env:TEMP ('cm-helper-probe-' + [IO.Path]::GetRandomFileName().Replace('.', '') + '.cmd')
try {
Set-Content -LiteralPath $probe -Value ("@echo off`r`n" + $stored) -Encoding ASCII
$out = (& cmd.exe /c "`"$probe`"" 2>&1 | Out-String).Trim()
if ($out -and $key -and $out -eq $key) { Write-Ok 'apiKeyHelper emits the correct key' }
elseif ($out -match '^\S+$') {
# One unbroken token: a credential, just the wrong one. Never echo it.
Write-Err2 "apiKeyHelper output does not match vault key (got: $(Format-KeyMask $out))"
}
elseif ($out) { Write-Err2 "apiKeyHelper failed: $out" }
else { Write-Err2 'apiKeyHelper produced no output' }
} catch {
Write-Err2 "apiKeyHelper failed to run: $($_.Exception.Message)"
} finally {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
}
}
if ($key -and $mode -eq 'openrouter') {