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
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.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…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>
… 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>
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>
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
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
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
…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>
Stable clippy 1.96 added manual_is_multiple_of; pre-existing modulo checks in openab-core (format.rs, pre_seed.rs) and openab-gateway (wecom.rs) 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>
e24156b to
f9475b0
Compare
|
CHANGES REQUESTED What This PR DoesAdds native Anthropic OAuth (Claude Pro/Max subscription) login alongside the existing Codex OAuth flow, letting subscribers use How It Works
Findings
Finding Details🟡 F1: 401 retry comment/behaviour mismatchThe comment at But the condition is Suggestion: either add an 🟢 F2–F8: What's done wellAll the positive findings listed above demonstrate thorough, production-quality work — the PKCE flow, refresh handling, Claude Code headers, tool-name round-tripping, and session-state preservation are all correctly implemented with appropriate tests (4 new unit tests + comprehensive manual validation). 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