The gateway API key authorizer matched the presented token against every
configured key with `item.key === token`, a variable-time string
comparison that returns on the first differing byte. The repository
already avoids that shape for its other secrets: context-archive.ts,
management-server.ts and media/service.ts each compare tokens through a
local timingSafeEqual helper with a length guard.
Route the gateway API key lookup through the same helper shape so all
secret comparisons behave identically. The length guard is kept so a
token of a different length is rejected rather than making
timingSafeEqual throw.
Behaviour is unchanged: valid keys authorize, unknown keys still return
401 "Invalid API key.", expired keys still return 401 "API key is
expired.", and the refresh-on-miss reload path is preserved.
Local-agent login imports (Codex API et al.) declare the provider protocol
at the top level of the payload (protocol: "openai_responses") and carry no
capabilities array. parseProviders dropped that field, so the saved provider
ended up with neither protocol nor capabilities and the gateway fell back to
the default openai_chat_completions adapter. For Responses-only backends
(chatgpt.com/backend-api/codex) every request then 404s against a
non-existent /chat/completions route.
Synthesize a single capability from the top-level protocol (and the
provider's base URL) when no explicit capabilities are configured, reusing
the existing protocol normalization used for capability items. Explicit
capabilities still win. Fixes#1619.
Review on #1604: preferring `claudeAiOauth` was not enough. A record holding
only `mcpOAuth` entries still produced a token, because the fallback ran a
recursive `findOauthTokenSet()` over the whole record and `mcpOAuth`
per-plugin records carry their own `accessToken`. That imports a plugin
token as a Claude Code provider and recreates the 401 the change was meant
to avoid. It also short-circuited the plaintext-file fallback, so a usable
`~/.claude/.credentials.json` was ignored in favour of the plugin token.
Restrict both lookups to root-level token fields instead of reordering the
recursive search. Root-level excludes *any* nested container, so a future
sibling of `mcpOAuth` cannot reintroduce the hole, and the pre-`claudeAiOauth`
layout that kept tokens at the record root still resolves.
Extract `readOauthTokenSetFields()` out of `findOauthTokenSet()` in shared.ts
rather than duplicating the field list. `findOauthTokenSet()` keeps its
behaviour, so codex/grok/kimi/opencode/zcode are unaffected.
Apply the same restriction to the credentials-file path: Claude Code writes
that file with the identical record shape, mcpOAuth included, so the hazard
was present there too.
Adds the two regression tests from the review verbatim.
Refs #1601, #1567
Claude Code >= 2.1 writes its credential item under the current $USER and
leaves any pre-2.1 item (account "unknown") in place on the *same* service
name, `Claude Code-credentials`. `security find-generic-password -s <svc> -w`
with no `-a` matches an arbitrary one of them, so CCR read the stale legacy
item: the read exits 0, the JSON parses, and it carries only `mcpOAuth`.
`findOauthTokenSet()` then correctly returns undefined and the candidate is
silently dropped by the `status !== "missing"` filter in service.ts.
Resolve the item through tiered candidates, evaluated lazily so the normal
case stays at one `security` call:
1. the expected service name, derived the way Claude Code derives it
(`Claude Code${oauthSuffix}-credentials${configSuffix}`, where
configSuffix is `-${sha256(NFC(configDir)).slice(0, 8)}` once
CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set), read under
the current account;
2. `security dump-keychain` enumeration, newest-modified first, for items
under another account or a config dir this process cannot reconstruct.
Without `-d` that prints item metadata only: it never decrypts a
password and never prompts;
3. the previous accountless read, as a last resort.
Prefer an explicit top-level `claudeAiOauth` across every candidate and fall
back to the recursive search only if none has one. `mcpOAuth` holds
per-plugin OAuth records that also carry `accessToken`, and key order puts
it first, so a recursive match could import an MCP plugin token as an
Anthropic provider that then 401s on every request.
Surface diagnostics instead of swallowing them: capture `security` stderr
rather than discarding it via `stdio: ["ignore","pipe","ignore"]`, and
return a `locked` candidate naming the reason when login state exists but
yields no token, so it survives the `status !== "missing"` filter. Treat
errSecItemNotFound (exit 44) as absence rather than an error, so a machine
with no Claude Code login still reports `missing`.
Also fix the file fallback: continue past a credentials file that holds no
token instead of returning it, and check the secure-storage config dir,
which the env vars above can relocate away from ~/.claude.
Refs #1601, #1567