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
Loopback listen callbacks can precede connection readiness on some hosts,
which makes otherwise deterministic media fixtures fail with refused fetches.
Reuse one TCP readiness probe before issuing requests in each affected test.
Runtime identity normalization previously ran before local OAuth provider
detection, so display-name matching could no longer select the managed
backend endpoint. Defer identity normalization until after detection and
cover the ordering with a regression test.
Provider compilation replaces display and capability aliases with stable
runtime identifiers, but plugin targets and fallback headers could retain
the precompiled names. Normalize both paths so routing and authentication
refer to the provider identity that the gateway actually registers.
Public OpenCode imports wrote strict providerPlugins auth with
authorization: Bearer public, which overrode a later real Providers
api_key edit and caused upstream 401s. Put the public token on the
provider api key instead and skip the plugin override.
Fixes#1562
The hardcoded CLAUDE_APP_FALLBACK_MODEL ("claude-sonnet-4-5") that issue
#1535 reported was already removed in 7b0e203 ("Refine Claude app model
discovery and fallback routing"), so GET /v1/models no longer surfaces the
unroutable bare fallback as the first/default model, and the model registry
now resolves a bare "claude-sonnet-4-5" to its configured provider.
Add a regression test using the issue's exact three-provider config that
asserts both invariants: the discovered default round-trips through the
request rewrite path to a provider-prefixed selector, and a bare fallback id
resolves to a provider rather than falling into model-chain fallback.
Fixes#1535