From b60c00450c98179862034f3533b7ec0b85f4cf95 Mon Sep 17 00:00:00 2001 From: smoido Date: Fri, 4 Sep 2026 00:49:50 +0300 Subject: [PATCH] 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) --- README.md | 31 ++++++++++++++++++++ VERSION | 2 +- bin/claude-key-helper.ps1 | 39 ++++++++++++++++++++----- claude-mode.ps1 | 60 ++++++++++++++++++++++++++++++++------ install.ps1 | 36 +++++++++++++++++++++-- linux/claude-key-helper.sh | 10 +++++-- linux/claude-mode | 30 ++++++++++++++++--- linux/cm-json.py | 7 ++++- 8 files changed, 188 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4e1a04a..15956a4 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,37 @@ nothing when state says `anthropic` — belt and braces. LM Studio's `lmstudio` token is a placeholder, not a secret, so it's written inline and the helper stays out of it. +### When the helper "is failing" + +Claude Code reports a broken helper as *your apiKeyHelper script is failing* and +shows its stderr under `/status`. Two causes account for nearly all of it. + +**A space in your home directory.** `apiKeyHelper` is a shell *command line*, not +a path, so `C:\Users\Firstname Lastname\.claude-mode\bin\claude-key-helper.cmd` +is split at the space and cmd tries to run `C:\Users\Firstname`. The value is now +quoted when it needs to be, on both ports — `shlex.quote` on POSIX, where a +`/Users/Firstname Lastname` home does the same thing. Paths that need no quoting +are written bare exactly as before, so no existing `settings.json` churns. + +`doctor` used to miss this, because it quoted the path itself before running it +and so tested something Claude Code never sees. It now reads the string out of +`settings.json`, says so when that string is not what a switch would write, and +executes *that* string through a shell. Re-running the switch rewrites it: + +``` +claude-mode openrouter default +claude-mode doctor +``` + +**A key stored by a different Windows account.** The vault is DPAPI `CurrentUser` +scope, so a key stored from an elevated or *run as* shell cannot be decrypted by +the account Claude Code runs as. Re-run `claude-mode set-key ` unelevated, +as yourself. + +Every helper failure path now names itself on stderr rather than exiting 1 in +silence, so `/status` distinguishes these from a missing preset or an empty +vault. The success paths stay silent — stdout carries the key and nothing else. + ## Commands ``` diff --git a/VERSION b/VERSION index f8e233b..9ab8337 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.0 +1.9.1 diff --git a/bin/claude-key-helper.ps1 b/bin/claude-key-helper.ps1 index 44f71aa..96cadd0 100644 --- a/bin/claude-key-helper.ps1 +++ b/bin/claude-key-helper.ps1 @@ -13,15 +13,31 @@ $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' - $state = Get-Content -LiteralPath (Join-Path $root 'state.json') -Raw -Encoding UTF8 | ConvertFrom-Json + $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)) { exit 1 } + 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' @@ -34,15 +50,24 @@ try { 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)) { exit 1 } + if (-not (Test-Path -LiteralPath $credPath)) { + Fail "no key stored for ref '$keyRef'. Run: claude-mode set-key $keyRef" + } - $secure = ConvertTo-SecureString (Get-Content -LiteralPath $credPath -Raw).Trim() - $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) + 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 { - exit 1 + Fail $_.Exception.Message } diff --git a/claude-mode.ps1 b/claude-mode.ps1 index 9c5b933..ded2313 100644 --- a/claude-mode.ps1 +++ b/claude-mode.ps1 @@ -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') { diff --git a/install.ps1 b/install.ps1 index ea27f54..67d2ca7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -18,6 +18,12 @@ param( Set-StrictMode -Version 1.0 $ErrorActionPreference = 'Stop' +# Piped in as `irm ... | iex` the installer itself is never subject to the +# execution policy - but invoking the installed claude-mode.ps1 below is, and +# on a stock Restricted machine that fails. Process scope lasts only for this +# powershell.exe and does not weaken the machine or user policy. +try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction Stop } catch { } + $repoUrl = 'https://git.nebulm.com/smoido/claude-mode' # Running from a checkout, the source is the files next to this script. Piped @@ -144,13 +150,37 @@ if ($current -match [regex]::Escape($startMark)) { Write-Host " ok appended claude-mode block to $profilePath" -ForegroundColor Green } -# --- 7. execution policy check --------------------------------------------- +# --- 7. execution policy ---------------------------------------------------- +# Without this the profile block above never loads, so `claude-mode` and the +# `claude` wrapper simply do not exist in PowerShell. Offer to fix it rather +# than printing a warning the user has to act on later. $pol = Get-ExecutionPolicy -Scope CurrentUser if ($pol -in @('Restricted', 'Undefined', 'AllSigned')) { - Write-Host " warn CurrentUser execution policy is '$pol'; the profile will not load." -ForegroundColor Yellow - Write-Host " Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned" -ForegroundColor Yellow + $effective = Get-ExecutionPolicy + if ($effective -in @('Restricted', 'AllSigned')) { + Write-Host '' + Write-Host " warn execution policy is '$effective'; the profile block will not load," -ForegroundColor Yellow + Write-Host " so 'claude-mode' will not be a command in PowerShell." -ForegroundColor Yellow + $ans = Read-Host ' Set CurrentUser policy to RemoteSigned now? [Y/n]' + if ($ans -eq '' -or $ans -match '^(y|yes)$') { + try { + Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop + Write-Host ' ok CurrentUser execution policy set to RemoteSigned' -ForegroundColor Green + } catch { + Write-Host " warn could not set policy: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host ' Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow + } + } else { + Write-Host ' Skipped. Run manually: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned' -ForegroundColor Yellow + } + } } +# Expand-Archive can carry the mark-of-the-web onto extracted files, which +# RemoteSigned then refuses. Clear it on what was just installed. +Get-ChildItem -LiteralPath $root -Recurse -Filter '*.ps1' -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + # --- 8. key ---------------------------------------------------------------- if (-not $SkipKeyPrompt) { $vault = Join-Path $root 'vault\openrouter.cred' diff --git a/linux/claude-key-helper.sh b/linux/claude-key-helper.sh index 5c76a79..12f6c80 100644 --- a/linux/claude-key-helper.sh +++ b/linux/claude-key-helper.sh @@ -8,6 +8,12 @@ set -u +# Claude Code surfaces a failing helper as "your apiKeyHelper script is failing" +# and shows this stream under /status. Exiting 1 in silence turns several +# distinct faults into one indistinguishable message, so every failure path says +# which it was. Success paths stay silent - stdout carries the key and nothing else. +fail() { printf 'claude-key-helper: %s\n' "$*" >&2; exit 1; } + CM_ROOT="${CM_ROOT:-$HOME/.claude-mode}" # shellcheck source=/dev/null . "$CM_ROOT/bin/cm-vault.sh" @@ -26,7 +32,7 @@ preset_name="$("$PY" "$JSON" get "$state" preset 2>/dev/null)" [ -n "$preset_name" ] || exit 0 preset="$CM_ROOT/presets/$preset_name.json" -[ -f "$preset" ] || exit 1 +[ -f "$preset" ] || fail "state.json names preset '$preset_name' but $preset does not exist. Run: claude-mode presets" auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)" [ -z "$auth_mode" ] && auth_mode="vault" @@ -35,4 +41,4 @@ auth_mode="$("$PY" "$JSON" get "$preset" auth.mode 2>/dev/null)" key_ref="$("$PY" "$JSON" get "$preset" auth.keyRef 2>/dev/null)" [ -n "$key_ref" ] || key_ref="openrouter" -cm_vault_get "$key_ref" || exit 1 +cm_vault_get "$key_ref" || fail "no key readable for ref '$key_ref' from $(cm_vault_backend_label 2>/dev/null || echo 'the vault'). Run: claude-mode set-key $key_ref" diff --git a/linux/claude-mode b/linux/claude-mode index daf6450..847632c 100755 --- a/linux/claude-mode +++ b/linux/claude-mode @@ -1283,10 +1283,32 @@ cmd_doctor() { else err "vault '$ref' missing. Run: claude-mode set-key $ref"; key='' fi - out="$("$CM_HELPER" 2>/dev/null)" - if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key' - elif [ -n "$out" ]; then err 'apiKeyHelper output does not match the vault' - else err 'apiKeyHelper produced no output'; fi + # Check the string settings.json actually holds, then run that + # string through a shell. A path this script can quote correctly is + # no evidence that the recorded one parses. + local stored expected reswitch + stored="$(jget "$CM_SETTINGS" apiKeyHelper)" + expected="$("$PY" -c 'import shlex,sys; sys.stdout.write(shlex.quote(sys.argv[1]))' "$CM_HELPER")" + reswitch="claude-mode $mode $(jget "$CM_STATE" preset)" + if [ -z "$stored" ]; then + err "settings.json has no apiKeyHelper. Run: $reswitch" + elif [ "$stored" != "$expected" ]; then + err "apiKeyHelper reads $stored" + err " but should read $expected - run: $reswitch" + else + ok "apiKeyHelper wired as $stored" + fi + + if [ -n "$stored" ]; then + out="$(sh -c "$stored" 2>&1)" + if [ -n "$out" ] && [ "$out" = "$key" ]; then ok 'apiKeyHelper emits the correct key' + elif printf '%s' "$out" | grep -q '[[:space:]]'; then + # Whitespace means a diagnostic, not a credential; a key is + # one unbroken token and must never be echoed. + err "apiKeyHelper failed: $out" + elif [ -n "$out" ]; then err "apiKeyHelper output does not match the vault (got: $(cm_vault_mask "$out"))" + else err 'apiKeyHelper produced no output'; fi + fi if [ -n "$key" ] && [ "$mode" = "openrouter" ]; then local kinfo diff --git a/linux/cm-json.py b/linux/cm-json.py index 1d17bb0..b4f4163 100644 --- a/linux/cm-json.py +++ b/linux/cm-json.py @@ -20,6 +20,7 @@ import glob import json import os import re +import shlex import sys # Matches both the qualified gateway id (anthropic/claude-opus-5) and the bare @@ -156,7 +157,11 @@ def cmd_apply(argv): if auth.get("mode") == "vault": if not helper: raise SystemExit("apply: vault auth needs the key-helper path") - settings["apiKeyHelper"] = helper + # Claude Code runs this value as a shell command line, so a $HOME + # containing a space (common on macOS) has to arrive quoted or the + # shell splits it and tries to execute the first word. shlex.quote + # leaves an ordinary path untouched, so nothing churns. + settings["apiKeyHelper"] = shlex.quote(helper) else: block["ANTHROPIC_AUTH_TOKEN"] = str(auth.get("token") or "lmstudio")