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 10 commits into
Conversation
…ider Phase 1 of porting Pi's auth methods into openab-agent. Adds an `anthropic-oauth` tenant alongside Codex: PKCE browser/paste login against platform.claude.com, JSON token exchange + scope-less refresh, and an OAuth mode on AnthropicProvider (Bearer + Claude Code identity headers/system block, tool-name normalisation). Wires provider selection in acp.rs/llm.rs and a new `auth anthropic-oauth` CLI subcommand. Verified: cargo build clean (0 warnings), 194 tests pass incl. 4 new. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fallback default was claude-sonnet-4-20250514 (Sonnet 4.0, ~13mo old), which 404s on Claude Pro/Max OAuth subscriptions. Bump the three default-model fallbacks to the current claude-opus-4-8 (verified live via OAuth). The model catalog already listed it; only the fallback was stale. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
… UX) - F1 (blocker): root Cargo.toml `exclude = ["openab-agent"]` so `cd openab-agent && cargo fmt/clippy/test` resolves standalone. The workspace restructure left openab-agent neither a member nor excluded; CI openab-agent only runs on openab-agent/** so it was dormant on main — this PR is the first change to trigger it. Also ran `cargo fmt`. - F2: use an independent 32-byte random PKCE `state` instead of reusing the verifier, keeping the verifier back-channel-only (claude.ai rejects a short state as "Invalid request format"; 32 bytes matches the verifier length). Verified end-to-end with a real Pro/Max login + chat. - F3: credential-error messages now name fully-qualified subcommands (`openab-agent auth anthropic-oauth` / `... codex-oauth`) and preserve the underlying read/parse error. - F4: drop the `ponytail:` placeholder tag from a comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
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: |
The dispatch loop fed responses to a detached stdout-drain task; on stdin EOF the loop ended and `#[tokio::main]` aborted the drain before it flushed the last queued line, so a one-shot `initialize` could return nothing. This was a latent race (main wins it by timing); this branch's slightly different startup timing made the binary lose it ~85% locally, surfacing as the red `CI openab-agent` ACP smoke test. Capture the drain handle and, after the loop, drop the senders and bounded-await the drain so queued output is flushed before return. Race test: 20/20 after (was 3/20). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up: the With the workspace |
This comment has been minimized.
This comment has been minimized.
Non-blocking polish from the PR re-review: - #6 (acp.rs): on ACP model switch, an OAuth-forced session was rebuilt via `auto_with_model`, which prefers ANTHROPIC_API_KEY and silently dropped the forced anthropic-oauth provider when a key was also present. Rebuild now preserves the session's auth mode via a new LlmProvider::is_oauth() (Agent::provider_is_oauth()). - #7 (llm.rs): the OAuth 401 branch swallowed force_refresh_for errors (`let _ = ...`) and retried with the stale token. Bubble the error. - #11 (auth.rs): refresh_token failure message named bare `openab-agent auth`; now names the tenant subcommand via a shared auth_subcommand() helper (also dedupes load_tokens_for). Deferred as follow-up (noted in PR): #8 --no-browser state validation, #9 save_tokens_for keying, #10 non-Unix atomic write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
`--no-browser` bare-code paste defaulted the pasted state to the expected value when no `#state` was present, so the `st != state` check passed trivially and CSRF state was never verified. Require the `code#state` form (or a full redirect URL) and reject a bare code with a clear message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
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>
Per @brettchien: dateless 4.6+ model IDs are fixed canonical IDs, not evergreen pointers, so a hardcoded default (claude-opus-4-8) is a per-generation 404 timebomb — the same failure that retired the previous claude-sonnet-4-20250514 default. It also silently bumped API-key users onto pricier Opus (review #5). Resolve the Anthropic model as: explicit override → OPENAB_AGENT_MODEL → error ("no model configured; set OPENAB_AGENT_MODEL or select a model"). - llm.rs: `anthropic_model()` is now fallible (no default); constructors refactored (`build`/`api_key_from_env`/`ensure_oauth_token`) so a model override never requires OPENAB_AGENT_MODEL, and credential errors still precede the model error. `auto()` only falls through to OAuth when no API key is present. - acp.rs: session new/load report the provider's resolved model instead of a hardcoded fallback. Removed the opus/gpt default sites. - Kept the claude-opus-4-8 entry in the model catalog (offering ≠ default). - docs/native-agent.md: document OPENAB_AGENT_MODEL is required for Anthropic (zero-config now fails loud). Behavior change: no zero-config default model. Deployments set it via env/values.yaml; local/zero-config users must export OPENAB_AGENT_MODEL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
|
@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.
The doc comment on login_anthropic_browser_flow still said the verifier doubles as `state` (Pi's old convention); since the PKCE fix the state is an independent 32-byte random value. Correct the comment to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…ODEL ModelRef::parse + resolve_provider_choice let OPENAB_AGENT_MODEL carry `provider/model_id` (e.g. anthropic/claude-sonnet-4-6) as a single source of truth for both provider and model. Bare model ids and the existing OPENAB_AGENT_PROVIDER var remain fully backward compatible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
LGTM ✅ — Well-structured Anthropic OAuth integration with thorough iteration on reviewer feedback. What This PR DoesAdds native Anthropic OAuth (Claude Pro/Max subscription) login to How It Works
Findings
Baseline Check
What's Good (🟢)
Addressing External Reviewer Feedback@brettchien (Architectural Review)
ℹ️ Accepted: Not introduced by this PR (Codex already shares the same store). Tracked for follow-up ADR/revamp — not merge-blocking.
✅ Addressed in
ℹ️ Accepted as follow-ups: None are merge-blocking. F5 doc comment is minor; F6 @Copilot (Automated Review)Findings addressed in commits CI Status
|
Stable clippy 1.96 added manual_is_multiple_of; pre-existing modulo checks in openab-core and openab-gateway now fail `clippy --workspace -D warnings`. Mechanical fix; unblocks CI. Unrelated to the OAuth change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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