feat(native-agent): Anthropic OAuth (Claude Pro/Max) login for openab-agent#1187
feat(native-agent): Anthropic OAuth (Claude Pro/Max) login for openab-agent#1187canyugs wants to merge 24 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds first-class Anthropic (Claude Pro/Max) OAuth login support to openab-agent, storing credentials as a new anthropic-oauth tenant alongside the existing codex tenant in ~/.openab/agent/auth.json. This extends provider auto-detection and request shaping so Claude subscription users can run the native agent without an ANTHROPIC_API_KEY.
Changes:
- Introduces
openab-agent auth anthropic-oauth [--no-browser]PKCE login flow and namespaced token load/save/refresh helpers. - Extends
AnthropicProviderto support OAuth auth mode (Bearer + Claude Code identity headers/system block + tool-name casing normalization). - Updates ACP provider/model selection and available-model listing to recognize Anthropic OAuth credentials; bumps default Anthropic model to
claude-opus-4-8.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| openab-agent/src/main.rs | Adds the auth anthropic-oauth CLI subcommand wiring. |
| openab-agent/src/auth.rs | Implements Anthropic PKCE/OAuth flow and namespaced token storage/refresh. |
| openab-agent/src/llm.rs | Adds Anthropic OAuth request behavior (headers/system/tool name normalization) and provider selection updates. |
| openab-agent/src/acp.rs | Uses Anthropic auto* selection (API key or OAuth), updates default model and model availability gating. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). | ||
| pub fn load_tokens_for(namespace: &str) -> Result<TokenStore> { | ||
| let path = auth_path(); | ||
| let map = read_auth_file(&path).map_err(|_| { | ||
| anyhow!( | ||
| "No credentials found at {}. Run `openab-agent auth codex-oauth` first.", | ||
| "No credentials found at {}. Run `openab-agent auth` first.", | ||
| path.display() | ||
| ) | ||
| })?; | ||
| match map.get(CODEX_NAMESPACE) { | ||
| match map.get(namespace) { | ||
| Some(AuthEntry::Token(t)) => Ok(t.clone()), | ||
| _ => Err(anyhow!( | ||
| "No codex credentials in {}. Run `openab-agent auth codex-oauth` first.", | ||
| "No {namespace} credentials in {}. Run `openab-agent auth` first.", | ||
| path.display() | ||
| )), | ||
| } |
| /// Block on the loopback listener for the OAuth redirect, reply 200, return the | ||
| /// authorization code. ponytail: the Codex flow above predates this helper and | ||
| /// still inlines the same logic; fold it in if that path is ever touched again. |
| _ => match AnthropicProvider::auto() { | ||
| Ok(p) => Ok(Box::new(p)), | ||
| Err(_) => match OpenAiProvider::from_auth_store() { | ||
| Ok(p) => Ok(Box::new(p)), | ||
| Err(e) => Err(format!( | ||
| "No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}" | ||
| "No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `auth codex-oauth`. {e}" | ||
| )), | ||
| }, |
| return self.error_response( | ||
| id, | ||
| -32000, | ||
| &format!("No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"), | ||
| &format!("No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `auth codex-oauth`. {e}"), | ||
| ) |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Thanks for the thorough review — addressed in F1 (🔴 CI workspace) — fixed, with a root-cause correction. The "rebase onto main" suggestion doesn't apply: this branch is already based on current F2 (🟡 PKCE state) — fixed + verified live. Now uses an independent 32-byte random F3 (🟡 error UX) — fixed. Credential errors now name fully-qualified subcommands ( F4 (🟡 comment tag) — fixed. Also confirmed the canonical native image still builds: |
|
Follow-up: the With the workspace |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Supplementary architectural review (forward-looking — this PR is already LGTM'd and the line-level items Findings
Detail — F1: cross-process auth.json race (follow-up)
This is not introduced by this PR (Codex already shares the same store), but OAuth widens the exposure, Detail — F4: default model stalenessThis PR replaces the old default Recommendation — no hardcoded model default; require it via config/env and fail loud. Since Messages V1 Note this is a behavior change: today's zero-config default goes away, so a model must be set Detail — F2: support CLAUDE_CODE_OAUTH_TOKEN
For ops-managed deployments this is arguably the primary path; interactive PKCE is for local self-service. Detail — F3: per-vendor descriptorclient_id / client_secret / endpoints / scope / token-body-format are the only things that vary between Direction / roadmap (tracked in a forthcoming ADR)A short ADR is being drafted for multi-vendor LLM-provider OAuth + credential storage; this PR is the first
|
|
Follow-up on F4 — one thing worth doing in this PR before it merges: please don't pin This PR exists because the previous hardcoded default ( Since Messages V1 mandates a
It's a small change, and it also removes the silent Opus cost bump for API-key users. Behavior note: this drops the zero-config default, so a model must be set — deployments via values.yaml/env already do; worth a clear error message + CHANGELOG line for zero-config/local users. |
Proposed ADR for the openab-agent LLM-provider OAuth revamp: a two-axis OAuthVendor adapter (auth flow vs inference transport), a cross-process flock-guarded credential-store invariant for auth.json, the CLAUDE_CODE_OAUTH_TOKEN env route, a 14-variant vendor feasibility matrix, and the /auth (PR openabdev#1185) auth-trigger model. Surfaced while reviewing PR openabdev#1187 (first OAuth vendor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@brettchien Great call — this is exactly the right framing, thank you. The dateless 4.6+ IDs being fixed canonical IDs (not evergreen pointers) makes any hardcoded default a recurring 404 timebomb, and pinning Opus also quietly raised costs for API-key users. Implemented your fail-loud approach in
Tests: |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…nthropic-oauth # Conflicts: # openab-agent/src/auth.rs
Design update — landing the ADR's
|
| Dimension | OpenClaw (req'd) | Hermes (req'd) | pi | opencode |
|---|---|---|---|---|
| Config format / location | openclaw.json (JSON5), global+project merge |
~/.hermes/config.yaml + .env |
~/.pi/agent/ — 3 files (settings/models/auth) |
opencode.json (JSONC), deep-merge layers |
| Default model | single model.primary:"prov/model" |
model.default:"prov/model" (+model.provider) |
two fields defaultProvider+defaultModel |
single model:"prov/model" |
| Provider registry | built-in + models.providers{} (custom) |
ProviderProfile dataclass registry (in code) |
models.json + registerProvider() hook |
Models.dev + provider{} block |
| Auth-method encoding | profile key prov:label + type:oauth|apikey |
auth_type string field on profile |
cred type:api_key|oauth |
cred type:oauth|api in auth store |
| Secrets location | separate auth-profiles.json |
separate .env / auth.json |
separate auth.json (0600) |
separate auth.json (0600) |
| Per-model params | models[].params passthrough |
fixed_temperature/default_max_tokens |
structured model fields (no temp) | models.<id>.options passthrough |
| Precedence | flag/slash → config order → env → default | CLI → config.yaml → .env → default | flag → auth.json → env → registry | env → auth.json → config |
Three signals are unanimous across all four — we adopt them:
- Secrets always live in a file separate from config (config stays commitable). We already have this:
auth.json. - The credential record is tagged with a
typediscriminator (oauthvsapi_key). We already have this:AuthEntry/TokenStore. - Static provider facts in code, dynamic selection in config, resolved in one place. Hermes is the cleanest exemplar (
ProviderProfileregistry +resolve_runtime_provider()), and it maps 1:1 onto ADR §5.1'sOAuthVendordescriptor + a resolver.
Where we diverge: unlike all four (TS/Python), we keep the cross-process flock-based locked-RMW invariant (ADR §5.4) — OpenClaw and Hermes both file-lock but only for the single-process case; OpenClaw's open refresh-persistence bug cluster (#52037/#48153/#71026) is direct evidence that getting the lock + atomic write-back right matters.
4. Proposed Solution
OAuthVendortrait + descriptors (ADR §5.1):CodexVendor,AnthropicVendortoday; new vendor = descriptor only. Collapse the three login flows into one shared PKCE/device driver on the in-treeoauth2crate; fold therefresh_tokenprovider-branch intovendor.token_body(). Anthropic's JSON-no-scope quirk via the crate's custom-http hook.- Thin
config.json(next toauth.json), singleprovider/modelstring + optionalmax_tokens/ per-providerbase_url. No secrets. Resolution:model_override → config.json → OPENAB_AGENT_MODEL → error(env still overrides; fail-loud per §9 Q1). - Credential precedence (§5.3):
ANTHROPIC_API_KEY → CLAUDE_CODE_OAUTH_TOKEN → stored anthropic-oauth tenant. - Storage: Anthropic save/refresh already routed through §5.4 locking in this branch (merge with
maindone — see commit resolving theauth.rsconflict;save_tokens_for → with_auth_locked,get/force_refresh_for → lock_tenant_refresh(namespace)), so the per-tenant RTR race is closed for the Anthropic tenant too.
// ~/.openab/agent/config.json — commitable, secrets stay in auth.json
{
"model": "anthropic/claude-sonnet-4-6", // single provider/model string (reuses ModelRef)
"max_tokens": 8192, // optional
"providers": { "anthropic": { "base_url": "https://api.anthropic.com" } } // optional, custom only
}5. Why This Approach
- Single
provider/modelstring: 3 of 4 surveyed projects use it, and openab already parses it —ModelRef::parse(llm.rs) +resolve_provider_choice()extract the provider from theprovider/prefix today. Two fields (pi) would add coupling for no gain. - Thin config / secrets-in-auth.json: unanimous prior-art signal; reuses the hardened §5.4 store instead of inventing a second secret file.
OAuthVendornow, not later: with only codex+anthropic in tree this is the lowest-cost moment to abstract; doing it after gemini/grok land means refactoring more call sites.- Linked fix — F4: a single
provider/modelconfig string makes theModelRef::parseHuggingFace-ID bug load-bearing (anthropic/claude-…must split;some-org/some-modelmust not). F4's fix (split only on known provider prefixes) ships with the config change, not separately.
6. Alternatives Considered
- Two fields (
defaultProvider+defaultModel, pi-style) — rejected: couples two values openab already derives from one string; only 1/4 projects do it. - Secrets in the config file — rejected: contradicts all four references and would bypass the §5.4 locked store.
- Build all vendors now (gemini/grok/agy) — out of scope per ADR §9 Q3 (incremental landing); this PR lands the abstraction + keeps codex/anthropic green.
- Layer-3 auto-trigger (auto-login on mid-turn 401) — deferred per ADR §2 / Brett 2026-06-24;
/authis sufficient.
7. Validation
- Rust:
cargo check,cargo clippy --all-targets,cargo testgreen after themainmerge — 56/56 auth+llm+mcp tests pass, incl.test_anthropic_save_uses_provider_as_key_disjoint_from_codex,with_auth_locked_merges_concurrent_tenants_no_lost_update,lock_tenant_refresh_fails_closed_when_contended,save_uses_rotated_refresh_token_when_present. - To add with the implementation: config.json load + layering test (
model_override > config > env), precedence test (ANTHROPIC_API_KEY > CLAUDE_CODE_OAUTH_TOKEN > tenant), F4ModelRef::parseHuggingFace-ID regression test, and anOAuthVendorround-trip test per descriptor. - Helm / CI / docs: docs update to
docs/native-agent.md(vendor model + config.json + env route) lands with the change; no Helm surface.
|
Now that #1190 has merged, here's a concrete path to rebase this PR onto the new Blast radius: the lock layer ( #1190 did the hard part generically, so most of this is wiring the new tenant
1. Route the four unlocked writers through #1190's locksThese four sites in
2. One locked implementation, not two — and stage it as two commitsAfter (1), pub async fn get_valid_token() -> Result<String> { get_valid_token_for(CODEX_NAMESPACE).await }
pub async fn force_refresh() -> Result<String> { force_refresh_for(CODEX_NAMESPACE).await }To keep the codex path's review surface clean, please split this into two commits:
That lets a reviewer verify the refactor changes no behaviour separately from the new vendor. 3. Add a concurrency test for the Anthropic tenant#1190 ships 4. Shape the new vendor as a descriptor, not inlined constantsRather than hand-rolling the Anthropic flow with inline constants alongside the codex one, To be clear on scope: the descriptor struct belongs in this PR; building the shared One deployment note (not blocking): for pod/fleet use, the race-free path is the |
… §5.1) Collapse the hand-rolled per-vendor OAuth surface into a single OAuthVendor trait + CodexVendor/AnthropicVendor descriptors. Adding a subscription-OAuth vendor is now a descriptor, not a new hand-rolled flow: - Unify the codex + anthropic PKCE login flows into one shared `login_pkce_flow` driven by the descriptor; fold the codex flow into the `accept_callback_code` / `code_from_redirect` helpers (the long-standing TODO) and unify the 127.0.0.1 bind. - Drive `refresh_token` and the authorization-code exchange off `vendor.token_body()` instead of an `if provider == ANTHROPIC_NAMESPACE` branch. - Shared pure builders `build_authorize_url` / `token_store_from_payload`, pinned by wire-format unit tests — the login authorize-URL/exchange hit live OAuth servers, so no integration test covers them. The driver keeps the proven reqwest flows; swapping the engine onto `oauth2::BasicClient` (as `mcp/runtime.rs` already does via a custom http hook) is a follow-up internal change invisible to vendor authors — only the descriptor surface lands here. `client_secret()`/`grant()` are the ADR §5.1 surface for later vendors (gemini/agy bundled secret; copilot/kiro device-code). 205 tests pass (4 new wire-format locks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ADR §5.3) Add `AnthropicAuth::OAuthEnv` for the pre-provisioned long-lived subscription token route (`CLAUDE_CODE_OAUTH_TOKEN`) — the recommended fleet mode (ops mints once, injects as a k8s secret; no interactive flow, no auth.json write, no refresh race). - `AnthropicProvider::auto[_with_model]()` now resolves in ADR §5.3 precedence: `ANTHROPIC_API_KEY` → `CLAUDE_CODE_OAUTH_TOKEN` → stored `anthropic-oauth` tenant. Each source surfaces its own errors rather than falling through to a lower-precedence credential error. - `OAuthEnv` shares the OAuth `Bearer` + Claude Code identity path (extracted into `oauth_headers`); `is_oauth()` covers it so the system block + tool-name casing apply. The 401 force-refresh is gated to the stored tenant only — the env token has no tenant to refresh, so a 401 there surfaces (re-mint) instead of erroring on a missing tenant. 207 tests pass (+2 precedence tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (review F4) `provider/model` previously split on the first `/`, mis-parsing HuggingFace-style `org/model` ids (e.g. `meta-llama/Llama-3-8B`) for custom/OpenAI-compatible endpoints — `org` became the "provider" and the real id was truncated. Now the prefix is split off only when it's a `KNOWN_PROVIDERS` entry; otherwise the whole string is the model id. Load-bearing for the planned single-string `provider/model` config field. Known prefixes still split unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reds (review F3)
`select_provider`'s auto-detect arm discarded the `AnthropicProvider::auto()`
error and fell through to Codex. A present Anthropic credential that failed for a
config reason (e.g. a key set but no model) was silently masked — the user got a
Codex provider, or a Codex-only error that hid the real cause.
Now: if any Anthropic credential source exists (API key / CLAUDE_CODE_OAUTH_TOKEN /
stored tenant), an `auto()` failure surfaces as a config error ("credential
present but unusable"). Codex fallthrough happens only when no Anthropic
credential exists at all, and that final error names both credential routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(ADR §5.5)
Add a small JSON config file next to auth.json so a deployment can declare the
default provider/model (and max_tokens) in a file instead of only via env vars —
the centralized-config gap the multi-vendor work surfaced.
- New `config` module: `{ model, max_tokens }` (single `provider/model` string,
reusing ModelRef). Unknown keys tolerated (forward-compat for `providers`/etc.);
malformed JSON fails loud; a missing file is an empty config.
- Resolution is env-over-config: `anthropic_model` / `anthropic_max_tokens` /
`resolve_provider_choice` now fall `OPENAB_AGENT_*` env → config.json → built-in.
A pod's injected env stays authoritative over a baked config.
- Secrets never live here — they stay in the locked `auth.json` store.
Per-provider `base_url` routing is a deliberate follow-up; this lands the default
provider/model/params surface. 213 tests pass (+5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ig.json (review F5) - Anthropic credentials section: the ADR §5.3 precedence (ANTHROPIC_API_KEY → CLAUDE_CODE_OAUTH_TOKEN fleet route → interactive anthropic-oauth) + the `openab-agent auth anthropic-oauth` login. - New "Configuration file (config.json)" section: schema, env-over-config precedence, secrets-stay-in-auth.json. - "Adding an OAuth vendor": the OAuthVendor descriptor model (ADR §5.1). - Env table: CLAUDE_CODE_OAUTH_TOKEN, OPENAB_AGENT_ANTHROPIC_CLIENT_ID, OPENAB_CONFIG_PATH. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…auth tenant Brett asked to cover the new tenant the same way openabdev#1190's codex lock tests do, so single-flight / fail-closed is proven for it rather than only structurally similar: - `with_auth_locked_merges_anthropic_tenant_no_lost_update` — a concurrent codex write does not clobber a just-written `anthropic-oauth` token (the new tenant rides the same locked RMW funnel). - `lock_tenant_refresh_fails_closed_for_anthropic_and_is_per_tenant` — a held anthropic refresh lock makes a second anthropic acquire fail closed (`TimedOut`), and does NOT block codex (per-tenant isolation — the reason §5.4 uses a per-tenant lock, not the global one). 215 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Architecture — Before / AfterA visual companion to the design-update comment above: how auth/provider selection is shaped before this PR's multi-vendor refactor vs after (ADR §4/§5.1/§5.3/§5.5). Before — hand-rolled per-vendor, env-only, unlocked new tenantAfter — two-axis model, 3-tier precedence, locked, config fileCore deltas
All of @brettchien's rebase roadmap and the review findings are addressed: routed the new tenant through #1190's |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
F1/F2: select_provider and ACP model-switch now check CLAUDE_CODE_OAUTH_TOKEN before falling back to the stored anthropic-oauth tenant in auth.json. Fleet pods that only set the env token can switch models without an auth.json file. Adds from_oauth_auto() / from_oauth_auto_with_model() that implement the env-over-store precedence for OAuth rebuild paths. F5: config.rs logs malformed config at error level (was warn) so production monitoring catches a typo'd config.json.
This comment has been minimized.
This comment has been minimized.
chaodu-agent
left a comment
There was a problem hiding this comment.
LGTM ✅ — All group review findings addressed in 7d32cf7. Fleet route model-switch fixed, ready to merge.
|
This should be ready for maintainer review now. |
|
LGTM ✅ — Well-structured Anthropic OAuth (Claude Pro/Max) login, cleanly layered onto the existing Codex tenant pattern with strong refresh-safety and ADR traceability. What This PR DoesAdds native Anthropic OAuth (Claude Pro/Max PKCE) login to How It Works
Findings
Baseline Check
What's Good (🟢)
|
What problem does this solve?
openab-agentcan only reach Anthropic viaANTHROPIC_API_KEY(pay-per-token). Codex already supports subscription login, but Claude Pro/Max subscribers cannot use their subscription with the native agent. This adds native Anthropic OAuth (Claude Pro/Max) so users runopenab-agenton the Claude subscription they already pay for — no API key — matching the existing Codex experience.Closes #1186
Discord Discussion URL: https://discord.com/channels/1491295327620169908/1519271476002291752
At a Glance
Prior Art & Industry Research
I looked at how two comparable self-hosted agents authenticate to Anthropic and Codex. The headline finding: neither implements a native Anthropic (Claude Pro/Max) OAuth login — both avoid the PKCE flow and instead lean on a setup-token or on reusing Claude Code's local credentials. This PR (following Pi) does the full native PKCE login, which is strictly more self-contained for pod deployments. Their surrounding architecture, however, validates the storage/refresh choices here.
OpenClaw — supports API keys and subscription OAuth.
claude -p).auth.openai.com/oauth/authorize→ callbackhttp://127.0.0.1:1455/auth/callback(or manual paste) → token exchange →accountIdextracted from the access token. This is byte-for-byte the same flow openab-agent already uses for Codex, corroborating our approach as the de-facto standard.~/.openclaw/agents/<id>/agent/auth-profiles.json, one{access, refresh, expires, accountId}tuple per profile.Hermes Agent —
PROVIDER_REGISTRYdataclasses inhermes_cli/auth.pydeclare each provider's auth type + base URLs + env vars;resolve_runtime_provider()is the single resolution entry point.ANTHROPIC_API_KEY, and if absent reads~/.claude/.credentials.json(reuses Claude Code's store). Docs explicitly note Anthropic here is "straightforward API key authentication without refresh token complexity."~/.hermes/auth.json(OAuth tokens + active provider),credential_pool.json(rotation),.env(API keys);auth.jsonguarded withfcntl/msvcrtfile locks.Primary source ported: Pi (
earendil-works/pi) —packages/ai/src/utils/oauth/anthropic.ts(PKCE flow, endpoints, scopes; verifier doubles asstate) andpackages/ai/src/api/anthropic-messages.ts(OAuth headers, Claude Code system block, tool-name normalisation). The OAuth client is Claude Code's public client.How this PR compares: like both systems, openab-agent keeps a single namespaced credential file (
~/.openab/agent/auth.json) with atomic writes + per-refresh rotation handling, and an existing Codex tenant identical to OpenClaw's Codex flow. Unlike both, it adds a native Anthropic PKCE login so subscribers need neither a setup-token nor a local Claude Code install.Proposed Solution
Add an
anthropic-oauthtenant alongside the existingcodextenant in~/.openab/agent/auth.json:auth.rs—login_anthropic_browser_flow()(PKCE; verifier doubles asstateper Claude's flow); namespaced token store (load/save/get_valid_token/force_refresh_for(provider)); per-provider refresh encoding (Anthropic = JSON, noscope; Codex = form); shared loopback-callback helpers;show_statuslists all tenants.llm.rs—AnthropicProvidergainsAnthropicAuth { ApiKey | OAuth }. OAuth mode sendsBearer+ Claude Code identity headers (anthropic-beta: claude-code-20250219,oauth-2025-04-20,x-app: cli), prepends the required"You are Claude Code…"system block, normalises built-in tool names to Claude Code casing (read↔Read), and refreshes once on a mid-flight 401.select_providergainsanthropic-oauth;anthropic/auto fall back API-key → OAuth.acp.rs— session/model selection viaAnthropicProvider::auto*()(covers both auth modes); model catalog shows Anthropic models when an API key or OAuth token is present.main.rs—openab-agent auth anthropic-oauth [--no-browser].Also bumps the stale default model
claude-sonnet-4-20250514→claude-opus-4-8(the old dated snapshot returns 404 on the subscription endpoint).Why this approach?
auth.json, so the two subscription logins coexist without new storage mechanisms.Tradeoffs / limitations: depends on Claude Code's public OAuth client and the
claude-code-20250219,oauth-2025-04-20beta headers — if Anthropic changes these, the OAuth path needs updating (API-key path is unaffected). Theclaude-opus-4-8default now also applies to API-key mode (Opus is pricier per-token; overridable viaOPENAB_AGENT_MODEL). The legacyDockerfile.nativeis unrelated and intentionally out of scope (canonicalDockerfile.unifiedbuilds the native variant correctly).Alternatives Considered
~/.claude/.credentials.json, and OpenClaw viaclaude -p): rejected — openab-agent runs in a pod with no Claude Code install and no~/.claude. Owning ananthropic-oauthtenant in our ownauth.jsonkeeps it self-contained and matches the Codex tenant already present.auth.jsonalready serves Codex + MCP; a new file would fragment storage and duplicate the atomic-write/rotation logic.Validation
Rust:
cargo check/cargo buildpass (0 warnings)cargo testpasses — 194 passed, incl. 4 new (authorize-URL, namespaced storage disjointness, OAuth request-body identity+tool-casing, name round-trip)cargo clippy— no new warnings from this change (6 pre-existing in test-moduleENV_LOCK-across-await +mcp/runtime.rs; the OAuth code is clippy-clean)Manual (real Claude Pro/Max account):
auth anthropic-oauth --no-browserlogin → token stored underanthropic-oauth,auth statusshows validclaude-opus-4-8(default) /claude-sonnet-4-6/4-5→ correct responsesbashexecutes in-sandbox (echo …$((6*7))→42), confirming tool-name normalisation round-tripsDockerfile.unified --target native; ran the in-image agent end-to-end (chat + tool call) via the stored OAuth token