Summary
resolveBareModeConfig() builds an apiKeyHelper from the Claude Code OAuth token. Per Anthropic's docs, apiKeyHelper must return an API key, so the credential is rejected. Because the helper succeeds and returns a non-empty string, Claude Code treats the resulting 401 as a retryable auth failure, re-invokes the helper, gets the same rejected token, and loops.
The result is a stall of 90+ seconds on every il start before the v0.14.2 fallback kicks in. It is not intermittent: for any user authenticated via /login, bare mode can never succeed, so the doomed attempt runs every time.
This is the same overhead described in #1028, but that issue frames it as an Enterprise-only edge case needing an opt-out config. It is broader than that, and it is a bug rather than a missing option: the condition is deterministically detectable before spawning anything.
Introduced by #977, which added --bare to branch naming and commit message generation.
Environment
@iloom/cli 0.14.2
- Claude Code 2.1.220, macOS
- Auth:
/login with a Claude Max subscription (token in Keychain, sk-ant-oat01…, valid, with a refresh token)
- No
ANTHROPIC_API_KEY set
Root cause
extractOAuthToken() pulls claudeAiOauth.accessToken from the macOS Keychain. resolveBareModeConfig() then does:
const settings = JSON.stringify({ apiKeyHelper: "echo $__ILOOM_OAUTH_TOKEN" });
return { bare: true, settings, oauthToken: token };
The Anthropic docs are explicit that this cannot work:
"Bare mode does not read CLAUDE_CODE_OAUTH_TOKEN. If your script passes --bare, authenticate with ANTHROPIC_API_KEY or an apiKeyHelper instead."
— Authentication → Generate a long-lived token
apiKeyHelper output is sent as an API key. An OAuth access token is a different credential type, so the request 401s.
And on the retry behaviour:
"by default, apiKeyHelper is called after 5 minutes or on HTTP 401 response"
"Helper failures: when the script exits with an error, times out, or prints nothing, requests fail with Your apiKeyHelper script is failing within three attempts."
The three-attempt cap only applies when the helper fails. Here it succeeds and returns a plausible string, so the cap does not apply and the 401 → re-invoke → 401 loop runs long.
Reproduction
Verified by invoking the CLI exactly as launchClaude() builds it. Only the apiKeyHelper output varies:
apiKeyHelper returns |
Result |
echo '' (empty) |
fails in 0.7s — counts as a helper failure, capped at 3 attempts |
echo $UNSET_VAR (empty) |
fails in 0.8s — same |
echo sk-ant-bogus (non-empty, rejected) |
still running at 40s |
| real OAuth token (non-empty, rejected) |
still running at 90s, cap reached before it returned |
Plain headless with the same account works instantly:
$ claude -p 'Reply with exactly: OK' --model haiku
OK
Two smaller problems
1. The fallback message misattributes the cause.
⚠️ Bare mode failed (likely expired OAuth token), retrying without --bare
The token is not expired — in my case it had 5.5 hours left and a valid refresh token, and worked fine on the non-bare path seconds later. This message sends users to /login, which does not help and does not change the outcome.
2. generateBranchName() has an unreachable fallback.
It returns feat/issue-<n> on error, but a hung child process never throws, so the catch is never reached. launchClaude() already accepts a signal option; generateBranchName() never passes one. A timeout would turn this from a stall into an immediate, correct fallback — worth doing regardless of the fix below, since it bounds any future hang in a subprocess.
Suggested fix
Skip bare mode when the only available credential is an OAuth token, rather than attempting and falling back:
async function resolveBareModeConfig() {
if (process.env.ANTHROPIC_API_KEY?.trim()) return { bare: true };
// apiKeyHelper requires an API key; an OAuth access token will 401 and retry-loop.
return { bare: false };
}
That makes #1028's opt-out unnecessary for this case, since the condition is knowable up front. If bare mode should still be reachable for subscription users, it needs a credential path that is not apiKeyHelper — but the docs suggest ANTHROPIC_API_KEY is the only supported route.
Related
Summary
resolveBareModeConfig()builds anapiKeyHelperfrom the Claude Code OAuth token. Per Anthropic's docs,apiKeyHelpermust return an API key, so the credential is rejected. Because the helper succeeds and returns a non-empty string, Claude Code treats the resulting 401 as a retryable auth failure, re-invokes the helper, gets the same rejected token, and loops.The result is a stall of 90+ seconds on every
il startbefore the v0.14.2 fallback kicks in. It is not intermittent: for any user authenticated via/login, bare mode can never succeed, so the doomed attempt runs every time.This is the same overhead described in #1028, but that issue frames it as an Enterprise-only edge case needing an opt-out config. It is broader than that, and it is a bug rather than a missing option: the condition is deterministically detectable before spawning anything.
Introduced by #977, which added
--bareto branch naming and commit message generation.Environment
@iloom/cli0.14.2/loginwith a Claude Max subscription (token in Keychain,sk-ant-oat01…, valid, with a refresh token)ANTHROPIC_API_KEYsetRoot cause
extractOAuthToken()pullsclaudeAiOauth.accessTokenfrom the macOS Keychain.resolveBareModeConfig()then does:The Anthropic docs are explicit that this cannot work:
apiKeyHelperoutput is sent as an API key. An OAuth access token is a different credential type, so the request 401s.And on the retry behaviour:
The three-attempt cap only applies when the helper fails. Here it succeeds and returns a plausible string, so the cap does not apply and the 401 → re-invoke → 401 loop runs long.
Reproduction
Verified by invoking the CLI exactly as
launchClaude()builds it. Only theapiKeyHelperoutput varies:apiKeyHelperreturnsecho ''(empty)echo $UNSET_VAR(empty)echo sk-ant-bogus(non-empty, rejected)Plain headless with the same account works instantly:
Two smaller problems
1. The fallback message misattributes the cause.
The token is not expired — in my case it had 5.5 hours left and a valid refresh token, and worked fine on the non-bare path seconds later. This message sends users to
/login, which does not help and does not change the outcome.2.
generateBranchName()has an unreachable fallback.It returns
feat/issue-<n>on error, but a hung child process never throws, so thecatchis never reached.launchClaude()already accepts asignaloption;generateBranchName()never passes one. A timeout would turn this from a stall into an immediate, correct fallback — worth doing regardless of the fix below, since it bounds any future hang in a subprocess.Suggested fix
Skip bare mode when the only available credential is an OAuth token, rather than attempting and falling back:
That makes #1028's opt-out unnecessary for this case, since the condition is knowable up front. If bare mode should still be reachable for subscription users, it needs a credential path that is not
apiKeyHelper— but the docs suggestANTHROPIC_API_KEYis the only supported route.Related
--bareon branch naming and commit message generation