diff --git a/Cargo.toml b/Cargo.toml index 042ca6223..69b6f01ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = ["crates/openab-core", "crates/openab-gateway"] +exclude = ["openab-agent"] [package] name = "openab" diff --git a/crates/openab-core/src/format.rs b/crates/openab-core/src/format.rs index d39410f15..3c5f3ea4f 100644 --- a/crates/openab-core/src/format.rs +++ b/crates/openab-core/src/format.rs @@ -319,7 +319,7 @@ mod tests { for (i, chunk) in chunks.iter().enumerate() { let fence_count = chunk.lines().filter(|l| l.starts_with("```")).count(); assert!( - fence_count % 2 == 0, + fence_count.is_multiple_of(2), "chunk {i} has unbalanced fences ({fence_count}):\n{chunk}" ); } diff --git a/crates/openab-gateway/src/adapters/wecom.rs b/crates/openab-gateway/src/adapters/wecom.rs index 7b96c27f8..b5c8ae5fb 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -137,7 +137,7 @@ fn decrypt_message( .decode(encrypted) .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; - if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 { + if cipher_bytes.is_empty() || !cipher_bytes.len().is_multiple_of(16) { anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len()); } diff --git a/docs/native-agent.md b/docs/native-agent.md index be6b00f89..f396d5e39 100644 --- a/docs/native-agent.md +++ b/docs/native-agent.md @@ -27,17 +27,41 @@ openab-agent env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" } ``` +### Configuration file (config.json) + +A small JSON file next to `auth.json` (default `/config.json`, +overridable with `OPENAB_CONFIG_PATH`) declares the default model and params, so +a deployment can set them in a file instead of only via env vars. **Secrets never +go here** — credentials stay in the locked `auth.json` store. + +```jsonc +{ + "model": "anthropic/claude-sonnet-4-6", // single provider/model string + "max_tokens": 8192 // optional +} +``` + +Resolution is **env-over-config**: `OPENAB_AGENT_MODEL` / `OPENAB_AGENT_MAX_TOKENS` +override the file, so a pod's injected env stays authoritative over a baked +config. A missing file is fine (empty config); a malformed file is logged and +ignored (the agent then falls back to env / built-in defaults). Unknown keys are +tolerated for forward-compatibility. + ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| +| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id, optionally `provider/`-qualified (e.g. `claude-opus-4-8`, `anthropic/claude-opus-4-8`). No hardcoded default — dateless 4.6+ IDs are fixed canonical IDs that retire each generation, so the agent fails loud if unset rather than pin a model that will eventually 404. Overrides `model` in [config.json](#configuration-file-configjson). | | `OPENAB_AGENT_OPENAI_MODEL` | `gpt-5.4-mini` | Model to use (must be supported by your ChatGPT plan — see [Supported Models](#supported-models-chatgpt-subscription)) | | `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL | | `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) | -| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens | -| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom OAuth client ID | +| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens. Overrides `max_tokens` in config.json. | +| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom Codex OAuth client ID | +| `OPENAB_AGENT_ANTHROPIC_CLIENT_ID` | Claude Code's client | Custom Anthropic OAuth client ID | | `OPENAB_AGENT_MAX_TOOL_LOOPS` | `50` | Max tool-call iterations per prompt before the agent gives up | -| `ANTHROPIC_API_KEY` | — | Anthropic API key (alternative to OAuth) | +| `ANTHROPIC_API_KEY` | — | Anthropic API key. Highest-precedence Anthropic credential (see [Anthropic credentials](#anthropic-credentials)). | +| `CLAUDE_CODE_OAUTH_TOKEN` | — | Pre-provisioned long-lived Claude Pro/Max subscription token (from `claude setup-token`). Fleet route — no interactive login, no `auth.json` write. | +| `OPENAB_CONFIG_PATH` | `/config.json` | Override the config-file path. | ## Authentication @@ -69,13 +93,33 @@ openab-agent auth codex-device Note: Device flow currently has limited scopes and may not work with all models. -### API Key (Anthropic) +### Anthropic credentials -```bash -export ANTHROPIC_API_KEY=sk-ant-... -``` +Three ways to authenticate Anthropic, resolved in this **precedence** (ADR §5.3): + +1. **API key** — `export ANTHROPIC_API_KEY=sk-ant-...`. No login; auto-detected. +2. **Pre-provisioned subscription token (fleet route)** — `export CLAUDE_CODE_OAUTH_TOKEN=...` + (mint once with `claude setup-token`; ~1-year Claude Pro/Max token). Sent as a + `Bearer` subscription token with the Claude Code identity headers — no + interactive login, no `auth.json` write, no refresh. Recommended for pods (inject + as a k8s secret). +3. **Interactive Claude Pro/Max OAuth** — browser PKCE login, refreshed from the + stored `anthropic-oauth` tenant in `auth.json`: + + ```bash + openab-agent auth anthropic-oauth # browser + openab-agent auth anthropic-oauth --no-browser # paste code#state + ``` + +A higher-precedence source's own errors (e.g. a key set but no model) surface +rather than silently falling through to a lower one. + +### Adding an OAuth vendor -No login needed — set the env var and the agent auto-detects it. +Subscription-OAuth providers are declared as a single `OAuthVendor` descriptor +(`auth.rs`, ADR §5.1) — namespace, client id, authorize/token URLs, redirect, +scope, token-body encoding. The shared PKCE/device/refresh driver reads the +descriptor, so a new vendor is a new descriptor, not a new hand-rolled flow. ## Custom System Prompt diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index 1113c8ad4..cf4ca551b 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -204,7 +204,7 @@ impl AcpServer { // through `out_tx` into this one drain task, preserving the // one-writer invariant the HostBridge relies on. let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - tokio::spawn(async move { + let drain = tokio::spawn(async move { let mut stdout = io::stdout(); while let Some(line) = out_rx.recv().await { let _ = writeln!(stdout, "{}", line); @@ -268,6 +268,16 @@ impl AcpServer { let _ = out_tx.send(line); } } + + // Shutdown: stdin hit EOF and the dispatch loop ended. Drop our senders + // so the drain task can flush any queued output and finish before this + // returns — otherwise `#[tokio::main]` aborts the detached drain on + // return and the last response can be lost (the ACP `initialize` smoke + // test depends on this). Bounded await so a lingering sender (e.g. an + // MCP background task holding an `out_tx` clone) can't wedge shutdown. + drop(bridge); + drop(out_tx); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), drain).await; } fn handle_initialize(&self, id: u64) -> String { @@ -297,15 +307,18 @@ impl AcpServer { let provider_choice = self .active_provider .clone() - .or_else(|| std::env::var("OPENAB_AGENT_PROVIDER").ok()) - .unwrap_or_default(); + .unwrap_or_else(crate::llm::resolve_provider_choice); let model_override = self.active_model.as_deref(); let (provider, active_provider): (Box, &str) = match provider_choice.as_str() { - "anthropic" => { - let res = match model_override { - Some(m) => AnthropicProvider::from_env_with_model(m), - None => AnthropicProvider::from_env(), + // `auto*` covers both ANTHROPIC_API_KEY and a stored Claude + // subscription OAuth token; `anthropic-oauth` forces the latter. + "anthropic" | "anthropic-oauth" | "claude" => { + let res = match (provider_choice.as_str(), model_override) { + ("anthropic", Some(m)) => AnthropicProvider::auto_with_model(m), + ("anthropic", None) => AnthropicProvider::auto(), + (_, Some(m)) => AnthropicProvider::from_oauth_auto_with_model(m), + (_, None) => AnthropicProvider::from_oauth_auto(), }; match res { Ok(p) => (Box::new(p), "anthropic"), @@ -323,10 +336,10 @@ impl AcpServer { } } _ => { - // Auto-detect: try API key first, then OAuth token + // Auto-detect: Anthropic (API key or OAuth) first, then codex. let anthropic_res = match model_override { - Some(m) => AnthropicProvider::from_env_with_model(m), - None => AnthropicProvider::from_env(), + Some(m) => AnthropicProvider::auto_with_model(m), + None => AnthropicProvider::auto(), }; match anthropic_res { Ok(p) => (Box::new(p), "anthropic"), @@ -343,7 +356,7 @@ impl AcpServer { 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` / `openab-agent auth codex-oauth`. {e}"), ) } } @@ -352,27 +365,13 @@ impl AcpServer { } }; + // The provider already resolved its model (explicit override → + // OPENAB_AGENT_MODEL, validated at construction). Use it as the + // authoritative reported model instead of a separate hardcoded default. + let model_name = provider.model().to_string(); let agent = Agent::new_boxed(provider, self.working_dir.clone(), self.mcp_manager.clone()); self.sessions.insert(session_id.clone(), agent); - let model_name = self - .active_model - .clone() - .or_else(|| { - if active_provider == "openai" { - std::env::var("OPENAB_AGENT_OPENAI_MODEL").ok() - } else { - None - } - }) - .or_else(|| std::env::var("OPENAB_AGENT_MODEL").ok()) - .unwrap_or_else(|| { - if active_provider == "anthropic" { - "claude-sonnet-4-20250514".to_string() - } else { - "gpt-5.4-mini".to_string() - } - }); self.active_model = Some(model_name.clone()); self.active_provider = Some(active_provider.to_string()); self.model_options = Self::available_models().await; @@ -425,13 +424,11 @@ impl AcpServer { self.model_options = Self::available_models().await; } - let model_name = self.active_model.clone().unwrap_or_else(|| { - if self.active_provider.as_deref() == Some("openai") { - "gpt-5.4-mini".to_string() - } else { - "claude-sonnet-4-20250514".to_string() - } - }); + // Report the loaded session's actual model (no hardcoded default). + let model_name = self + .active_model + .clone() + .unwrap_or_else(|| self.sessions[session_id].provider_model()); self.ok_response( id, @@ -457,7 +454,9 @@ impl AcpServer { fn static_available_models() -> Vec { let mut models = Vec::new(); - if std::env::var("ANTHROPIC_API_KEY").is_ok() { + if std::env::var("ANTHROPIC_API_KEY").is_ok() + || crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE).is_ok() + { models.extend(Self::static_anthropic_models()); } if crate::auth::load_tokens().is_ok() { @@ -595,11 +594,15 @@ impl AcpServer { // Rebuild the current session's provider so the switch takes effect immediately if !session_id.is_empty() && self.sessions.contains_key(session_id) { + // Preserve the session's auth mode: an OAuth-forced session must not + // silently fall back to ANTHROPIC_API_KEY (which `auto_*` prefers). + let session_is_oauth = self.sessions[session_id].provider_is_oauth(); let new_provider: Result, String> = match provider_name { - "anthropic" => { - AnthropicProvider::from_env_with_model(value).map(|p| Box::new(p) as _) + "anthropic" if session_is_oauth => { + AnthropicProvider::from_oauth_auto_with_model(value).map(|p| Box::new(p) as _) } + "anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _), _ => crate::llm::OpenAiProvider::from_auth_store_with_model(value) .map(|p| Box::new(p) as _), }; @@ -679,10 +682,14 @@ mod tests { #[tokio::test] async fn test_session_new() { let _guard = ENV_LOCK.lock().unwrap(); - // Set a fake key so from_env() succeeds in CI - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + // Set a fake key + model so provider construction succeeds in CI + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); let resp_str = server.handle_session_new(2).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let resp: Value = serde_json::from_str(&resp_str).unwrap(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 2); @@ -692,6 +699,7 @@ mod tests { assert!(!config_options.is_empty()); assert_eq!(config_options[0]["id"], "model"); assert_eq!(config_options[0]["category"], "model"); + assert_eq!(config_options[0]["currentValue"], "claude-sonnet-4-6"); assert!(!config_options[0]["options"].as_array().unwrap().is_empty()); } @@ -789,6 +797,30 @@ mod tests { .contains("ANTHROPIC_API_KEY")); } + #[tokio::test] + async fn test_session_new_requires_model() { + // No hardcoded default: a forced anthropic provider without + // OPENAB_AGENT_MODEL must fail loud. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("OPENAB_AGENT_PROVIDER", "anthropic"); + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::remove_var("OPENAB_AGENT_MODEL"); + } + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(7).await; + unsafe { + std::env::remove_var("ANTHROPIC_API_KEY"); + std::env::remove_var("OPENAB_AGENT_PROVIDER"); + } + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert!(resp["error"].is_object()); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("no model configured")); + } + #[test] fn test_set_config_option_accepts_cached_dynamic_model() { let mut server = AcpServer::new(); @@ -847,11 +879,15 @@ mod tests { #[tokio::test] async fn test_model_switch_preserves_session_history() { let _guard = ENV_LOCK.lock().unwrap(); - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); // Create a session let resp_str = server.handle_session_new(10).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let resp: Value = serde_json::from_str(&resp_str).unwrap(); let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); @@ -918,7 +954,7 @@ mod tests { // Insert a dummy session using anthropic key unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; - let provider = AnthropicProvider::from_env_with_model("claude-sonnet-4-20250514").unwrap(); + let provider = AnthropicProvider::auto_with_model("claude-sonnet-4-20250514").unwrap(); let agent = Agent::new_boxed(Box::new(provider), "/tmp".to_string(), None); server.sessions.insert("test-session".to_string(), agent); @@ -954,11 +990,15 @@ mod tests { #[tokio::test] async fn test_session_load_returns_config_options() { let _guard = ENV_LOCK.lock().unwrap(); - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); // Create a session first let new_resp_str = server.handle_session_new(10).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let new_resp: Value = serde_json::from_str(&new_resp_str).unwrap(); let session_id = new_resp["result"]["sessionId"].as_str().unwrap(); diff --git a/openab-agent/src/agent.rs b/openab-agent/src/agent.rs index 7d50c005a..cc7c5fb53 100644 --- a/openab-agent/src/agent.rs +++ b/openab-agent/src/agent.rs @@ -107,6 +107,18 @@ impl Agent { self.provider = provider; } + /// True if the current provider authenticates via OAuth. Used on model + /// switch to rebuild with the same auth mode. + pub fn provider_is_oauth(&self) -> bool { + self.provider.is_oauth() + } + + /// The model id the current provider will use. Authoritative source for the + /// session's reported model (avoids a separate hardcoded default). + pub fn provider_model(&self) -> String { + self.provider.model().to_string() + } + /// Update working directory and rebuild system prompt. pub fn set_working_dir(&mut self, cwd: String) { self.system_prompt = Self::build_system_prompt(&cwd, self.mcp_manager.as_ref()); diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index e57baa122..965d5e4ac 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -12,6 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// Namespace key for the existing Codex single-tenant credential. /// Lives next to future `mcp:` entries inside `auth.json`. const CODEX_NAMESPACE: &str = "codex"; +/// Namespace key for the Anthropic (Claude Pro/Max) OAuth credential. +pub const ANTHROPIC_NAMESPACE: &str = "anthropic-oauth"; const REFRESH_SKEW_SECONDS: u64 = 120; @@ -22,13 +24,256 @@ const CODEX_DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/devic const CODEX_DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; const REDIRECT_PORT: u16 = 1455; -fn codex_client_id() -> String { - std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") - .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) +// Anthropic OAuth (Claude Pro/Max). Values mirror Claude Code's public client so +// `platform.claude.com` accepts the flow. Token bodies are JSON (Codex uses form) +// and the refresh body omits `scope` (Pi #2169). +const ANTHROPIC_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize"; +const ANTHROPIC_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; +const ANTHROPIC_REDIRECT_PORT: u16 = 53692; +const ANTHROPIC_SCOPE: &str = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; + +// ── OAuthVendor (auth axis — ADR §5.1) ────────────────────────────────────── +// +// A subscription-OAuth provider is one static `OAuthVendor` descriptor; the +// shared driver below (`build_authorize_url`, `exchange_authorization_code`, +// `refresh_token`) does PKCE/CSRF/exchange/refresh by reading the descriptor, so +// adding a vendor is a new descriptor — not a new hand-rolled flow. Token bodies +// and a few authorize-URL quirks are the only per-vendor variation, expressed as +// trait methods rather than forked code paths. +// +// NOTE (ADR §4.2): the ADR specifies building this driver on the official +// `oauth2` crate (as `mcp/runtime.rs` already does via `BasicClient` + a custom +// reqwest http hook). This pass keeps the proven reqwest flows and only +// parameterises them by descriptor; swapping the engine onto `oauth2::BasicClient` +// is a follow-up internal change invisible to vendor authors (the descriptor +// surface is unchanged). The device-code grant (non-standard `device_auth_id`) +// and Anthropic's JSON token body are why the swap is staged, not done blind. + +/// Token-request body encoding. Codex/OpenAI use form-encoding; Anthropic's AS +/// takes JSON (and rejects a `scope` field on refresh — Pi #2169). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenBodyFormat { + Form, + Json, +} + +/// OAuth grant a vendor's *primary* login uses. Codex additionally exposes a +/// device-code subcommand, but its browser login — like Anthropic's — is PKCE. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] // `DeviceCode` lands with the first device-primary vendor (copilot/kiro). +enum AuthGrant { + Pkce, + DeviceCode, } -fn redirect_uri() -> String { - format!("http://localhost:{REDIRECT_PORT}/auth/callback") +/// Static per-vendor OAuth descriptor (ADR §5.1, auth axis). Signatures mirror +/// the ADR verbatim so future vendors (gemini/grok/agy) slot in as descriptors. +/// `Send + Sync` so a boxed vendor can be held across the refresh `await` inside +/// the `Send` provider futures. +trait OAuthVendor: Send + Sync { + /// `auth.json` tenant key (`codex` / `anthropic-oauth` / …). + fn namespace(&self) -> &str; + fn client_id(&self) -> String; + /// Bundled installed-app secret (gemini/agy); `None` for public PKCE clients. + /// ADR §5.1 surface — first consumer is the gemini/agy vendor (encode-at-rest + /// per §9 Q2); unused until then. + #[allow(dead_code)] + fn client_secret(&self) -> Option { + None + } + fn authorize_url(&self) -> &str; + fn token_url(&self) -> &str; + /// Loopback `(port, path)` for PKCE; `None` for device flow (no redirect endpoint). + fn redirect(&self) -> Option<(u16, &'static str)> { + None + } + fn scope(&self) -> &str; + /// Extra authorize-URL query params (Codex's simplified-flow hints; Anthropic's `code=true`). + fn extra_authorize_params(&self) -> &'static [(&'static str, &'static str)] { + &[] + } + fn token_body(&self) -> TokenBodyFormat { + TokenBodyFormat::Form + } + /// ADR §5.1 surface — `DeviceCode` lands with the first device-primary vendor + /// (copilot/kiro); both current vendors log in via PKCE, so unused until then. + #[allow(dead_code)] + fn grant(&self) -> AuthGrant { + AuthGrant::Pkce + } + /// Full loopback redirect URI, derived from `redirect()`. + fn redirect_uri(&self) -> Option { + self.redirect() + .map(|(port, path)| format!("http://localhost:{port}{path}")) + } +} + +struct CodexVendor; +impl OAuthVendor for CodexVendor { + fn namespace(&self) -> &str { + CODEX_NAMESPACE + } + fn client_id(&self) -> String { + std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") + .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) + } + fn authorize_url(&self) -> &str { + CODEX_AUTHORIZE_URL + } + fn token_url(&self) -> &str { + CODEX_TOKEN_URL + } + fn redirect(&self) -> Option<(u16, &'static str)> { + Some((REDIRECT_PORT, "/auth/callback")) + } + fn scope(&self) -> &str { + "openid profile email offline_access" + } + fn extra_authorize_params(&self) -> &'static [(&'static str, &'static str)] { + &[ + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ("originator", "openab-agent"), + ] + } +} + +struct AnthropicVendor; +impl OAuthVendor for AnthropicVendor { + fn namespace(&self) -> &str { + ANTHROPIC_NAMESPACE + } + fn client_id(&self) -> String { + std::env::var("OPENAB_AGENT_ANTHROPIC_CLIENT_ID") + .unwrap_or_else(|_| "9d1c250a-e61b-44d9-88ed-5944d1962f5e".to_string()) + } + fn authorize_url(&self) -> &str { + ANTHROPIC_AUTHORIZE_URL + } + fn token_url(&self) -> &str { + ANTHROPIC_TOKEN_URL + } + fn redirect(&self) -> Option<(u16, &'static str)> { + Some((ANTHROPIC_REDIRECT_PORT, "/callback")) + } + fn scope(&self) -> &str { + ANTHROPIC_SCOPE + } + fn extra_authorize_params(&self) -> &'static [(&'static str, &'static str)] { + &[("code", "true")] + } + fn token_body(&self) -> TokenBodyFormat { + TokenBodyFormat::Json + } +} + +/// Resolve a vendor descriptor by `auth.json` namespace. `None` for non-OAuth +/// tenants (e.g. `mcp:`, whose refresh rmcp owns). +fn vendor_for(namespace: &str) -> Option> { + match namespace { + CODEX_NAMESPACE => Some(Box::new(CodexVendor)), + ANTHROPIC_NAMESPACE => Some(Box::new(AnthropicVendor)), + _ => None, + } +} + +/// Build a vendor's PKCE authorize URL. Pure (unit-testable). `state` is an +/// independent random CSRF value kept distinct from the PKCE verifier (which +/// stays back-channel-only) — the AS just echoes it back. +fn build_authorize_url(vendor: &dyn OAuthVendor, challenge: &str, state: &str) -> Result { + let redirect = vendor.redirect_uri().ok_or_else(|| { + anyhow!( + "{} has no loopback redirect (not a PKCE vendor)", + vendor.namespace() + ) + })?; + let redir = urlencoding::encode(&redirect); + let scope = urlencoding::encode(vendor.scope()); + let client_id = vendor.client_id(); + let mut url = format!( + "{}?client_id={client_id}&response_type=code&redirect_uri={redir}&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&state={state}", + vendor.authorize_url() + ); + for (k, v) in vendor.extra_authorize_params() { + url.push('&'); + url.push_str(k); + url.push('='); + url.push_str(v); + } + Ok(url) +} + +/// Exchange an authorization `code` for tokens against `vendor`, encoding the +/// body per `token_body()`. The JSON path also carries `state` (Anthropic +/// echoes it); the form path omits it (Codex). +async fn exchange_authorization_code( + vendor: &dyn OAuthVendor, + code: &str, + state: &str, + verifier: &str, +) -> Result { + let redirect = vendor + .redirect_uri() + .ok_or_else(|| anyhow!("{} has no loopback redirect", vendor.namespace()))?; + let client_id = vendor.client_id(); + let client = reqwest::Client::new(); + let req = client.post(vendor.token_url()); + let resp = match vendor.token_body() { + TokenBodyFormat::Json => { + req.json(&serde_json::json!({ + "grant_type": "authorization_code", + "client_id": client_id, + "code": code, + "state": state, + "redirect_uri": redirect, + "code_verifier": verifier, + })) + .send() + .await? + } + TokenBodyFormat::Form => { + req.form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code), + ("code_verifier", verifier), + ("redirect_uri", redirect.as_str()), + ]) + .send() + .await? + } + }; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + token_store_from_payload(&payload, vendor.token_url(), vendor.namespace()) +} + +/// Build a `TokenStore` from an OAuth token response, requiring `access_token` +/// and `refresh_token`. Shared by every login + exchange path. +fn token_store_from_payload( + payload: &serde_json::Value, + token_endpoint: &str, + provider: &str, +) -> Result { + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: token_endpoint.to_string(), + provider: provider.to_string(), + }) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -227,6 +472,15 @@ fn write_auth_file(path: &Path, map: &HashMap) -> Result<()> Ok(()) } +/// CLI subcommand that (re)authenticates a tenant `namespace`. Used in +/// credential-error messages so the user runs the right login. +fn auth_subcommand(namespace: &str) -> &'static str { + if namespace == ANTHROPIC_NAMESPACE { + "openab-agent auth anthropic-oauth" + } else { + "openab-agent auth codex-oauth" + } +} // ── auth.json cross-process locking (ADR §5.4) ────────────────────────────── // // `auth.json` is written by multiple processes (one openab-agent per Discord @@ -480,29 +734,42 @@ async fn lock_tenant_refresh_until( } } -pub fn load_tokens() -> Result { +/// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). +pub fn load_tokens_for(namespace: &str) -> Result { let path = auth_path(); - let map = read_auth_file(&path).map_err(|_| { + let cmd = auth_subcommand(namespace); + // Preserve the underlying read/parse error for debugging. + let map = read_auth_file(&path).map_err(|e| { anyhow!( - "No credentials found at {}. Run `openab-agent auth codex-oauth` first.", + "No credentials at {} ({e}). Run `{cmd}` 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 `{cmd}` first.", path.display() )), } } -fn save_tokens(store: &TokenStore) -> Result<()> { - with_auth_locked(&auth_path(), |map| { - map.insert(CODEX_NAMESPACE.to_string(), AuthEntry::Token(store.clone())); +/// Save a token under its own `provider` field as the namespace key, leaving +/// every other tenant in `auth.json` untouched. Funnels through +/// `with_auth_locked` so a concurrent codex/MCP/anthropic writer never +/// lost-updates this tenant (ADR §5.4 (a)). +fn save_tokens_for(store: &TokenStore) -> Result<()> { + let provider = store.provider.clone(); + let store = store.clone(); + with_auth_locked(&auth_path(), move |map| { + map.insert(provider, AuthEntry::Token(store)); }) } +pub fn load_tokens() -> Result { + load_tokens_for(CODEX_NAMESPACE) +} + /// rmcp [`CredentialStore`] backed by the shared `auth.json` file (ADR §6.1 /// storage-format decision A). One instance is bound to a single MCP server's /// bare-name key (e.g. `linear`); rmcp's `AuthorizationManager` owns the @@ -603,77 +870,104 @@ impl CredentialStore for McpCredentialStore { } } -pub async fn get_valid_token() -> Result { +pub async fn get_valid_token_for(namespace: &str) -> Result { // 1. Fast path: a fresh token needs no lock. - let store = load_tokens()?; + let store = load_tokens_for(namespace)?; if !store.is_expired() { return Ok(store.access_token); } - // 2. Serialise the refresh for the codex tenant — held across the network - // call so a second process does not present the same RT_old (§5.4 (b)). - // Fail closed on a contended-lock timeout: surface a retryable error rather - // than refresh unserialised (which would risk §10.4 family revocation). + // 2. Serialise the refresh per tenant — held across the network call so a + // second process does not present the same RT_old (§5.4 (b)). Fail closed + // on a contended-lock timeout: surface a retryable error rather than + // refresh unserialised (which would risk §10.4 family revocation). #[cfg(unix)] - let _refresh_guard = match lock_tenant_refresh(&auth_path(), CODEX_NAMESPACE).await { + let _refresh_guard = match lock_tenant_refresh(&auth_path(), namespace).await { RefreshLock::Held(g) => Some(g), RefreshLock::Unavailable => None, RefreshLock::TimedOut => { return Err(anyhow!( - "codex token refresh is busy (refresh lock contended); retry shortly" + "{namespace} token refresh is busy (refresh lock contended); retry shortly" )) } }; // 3. Double-check: another process may have refreshed while we waited. - let store = load_tokens()?; + let store = load_tokens_for(namespace)?; if !store.is_expired() { return Ok(store.access_token); } // 4. Exactly one network refresh per tenant per expiry (tenant lock held). let fresh = refresh_token(&store).await?; // 5. Commit under the global file lock. - save_tokens(&fresh)?; + save_tokens_for(&fresh)?; Ok(fresh.access_token) } -pub async fn force_refresh() -> Result { +pub async fn force_refresh_for(namespace: &str) -> Result { // Serialise even a forced refresh so two of them can't both rotate RT_old. - // Fail closed on timeout (see get_valid_token) rather than refresh unserialised. + // Fail closed on timeout (see get_valid_token_for) rather than refresh unserialised. #[cfg(unix)] - let _refresh_guard = match lock_tenant_refresh(&auth_path(), CODEX_NAMESPACE).await { + let _refresh_guard = match lock_tenant_refresh(&auth_path(), namespace).await { RefreshLock::Held(g) => Some(g), RefreshLock::Unavailable => None, RefreshLock::TimedOut => { return Err(anyhow!( - "codex token refresh is busy (refresh lock contended); retry shortly" + "{namespace} token refresh is busy (refresh lock contended); retry shortly" )) } }; - let store = load_tokens()?; + let store = load_tokens_for(namespace)?; let new_store = refresh_token(&store).await?; - save_tokens(&new_store)?; + save_tokens_for(&new_store)?; Ok(new_store.access_token) } +pub async fn get_valid_token() -> Result { + get_valid_token_for(CODEX_NAMESPACE).await +} + +pub async fn force_refresh() -> Result { + force_refresh_for(CODEX_NAMESPACE).await +} + async fn refresh_token(store: &TokenStore) -> Result { - let client_id = codex_client_id(); + let vendor = vendor_for(&store.provider) + .ok_or_else(|| anyhow!("No OAuth vendor for provider `{}`", store.provider))?; + let client_id = vendor.client_id(); // Bound the refresh so the per-tenant lock (held across this call) is provably // released before another process's lock deadline — see REFRESH_HTTP_TIMEOUT. let client = reqwest::Client::builder() .timeout(REFRESH_HTTP_TIMEOUT) .build()?; - let resp = client - .post(&store.token_endpoint) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", store.refresh_token.as_str()), - ("client_id", client_id.as_str()), - ]) - .send() - .await?; + // Body encoding comes from the vendor descriptor: Anthropic takes JSON (and + // rejects a `scope` field on refresh — Pi #2169); Codex takes a form body. + let req = client.post(&store.token_endpoint); + let resp = match vendor.token_body() { + TokenBodyFormat::Json => { + req.json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": store.refresh_token, + "client_id": client_id, + })) + .send() + .await? + } + TokenBodyFormat::Form => { + req.form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", store.refresh_token.as_str()), + ("client_id", client_id.as_str()), + ]) + .send() + .await? + } + }; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth codex-oauth` again.")); + return Err(anyhow!( + "Token refresh failed (HTTP {status}): {body}. Run `{}` again.", + auth_subcommand(&store.provider) + )); } let payload: serde_json::Value = resp.json().await?; let access_token = payload["access_token"] @@ -701,27 +995,29 @@ pub fn generate_pkce() -> (String, String) { (verifier, challenge) } -// Browser PKCE flow -pub async fn login_browser_flow(no_browser: bool) -> Result<()> { - let client_id = codex_client_id(); - let (code_verifier, code_challenge) = generate_pkce(); - let mut state_buf = [0u8; 16]; +/// Shared PKCE browser/paste login driver (ADR §5.1). The authorize URL, +/// loopback redirect, and token-body encoding all come from the `vendor` +/// descriptor, so every PKCE vendor reuses this one flow. Folds the codex flow +/// into the `accept_callback_code` / `code_from_redirect` helpers (the +/// long-standing TODO) and unifies the `127.0.0.1` bind across vendors. +async fn login_pkce_flow(vendor: &dyn OAuthVendor, no_browser: bool) -> Result<()> { + let (port, _path) = vendor + .redirect() + .ok_or_else(|| anyhow!("{} is not a PKCE vendor", vendor.namespace()))?; + let (verifier, challenge) = generate_pkce(); + // Independent random CSRF state, kept distinct from the PKCE verifier (which + // stays back-channel-only). 32 bytes: claude.ai's authorize rejects a short + // state ("Invalid request format") — long enough for every vendor. + let mut state_buf = [0u8; 32]; getrandom::fill(&mut state_buf).expect("getrandom failed"); let state = URL_SAFE_NO_PAD.encode(state_buf); - let redir_str = redirect_uri(); - let redir = urlencoding::encode(&redir_str); - let auth_url = format!("{CODEX_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={redir}&response_type=code&scope=openid+profile+email+offline_access&code_challenge={code_challenge}&code_challenge_method=S256&state={state}&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator=openab-agent"); - - let listener = TcpListener::bind(format!("127.0.0.1:{REDIRECT_PORT}")).map_err(|e| { - anyhow!("Failed to bind port {REDIRECT_PORT}: {e}. Is another instance running?") - })?; - - if no_browser { - println!("Open this URL in your browser:\n"); - println!(" {auth_url}\n"); - println!("After approving, your browser will redirect to a localhost URL."); - println!("Copy the full URL from the browser address bar and paste it here:\n"); + let auth_url = build_authorize_url(vendor, &challenge, &state)?; + let code = if no_browser { + println!("Open this URL in your browser:\n\n {auth_url}\n"); + println!( + "After approving, copy the full redirect URL (or just the `code#state`) and paste it here:\n" + ); let mut input = String::new(); std::io::stdin() .read_line(&mut input) @@ -730,90 +1026,49 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { if input.is_empty() { return Err(anyhow!("No URL provided")); } - let url = url::Url::parse(input).map_err(|_| anyhow!("Invalid URL: {input}"))?; - - // Skip TCP listener for paste flow - let code = url - .query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()) - .ok_or_else(|| { - let error = url - .query_pairs() - .find(|(k, _)| k == "error") - .map(|(_, v)| v.to_string()); - anyhow!( - "No code in URL. Error: {}", - error.unwrap_or_else(|| "unknown".into()) - ) + // Accept either a full redirect URL or a bare `code#state`. Require the + // `#state` form so CSRF state is always verified — a bare code can't be + // checked and is rejected rather than trusted. + if let Ok(url) = url::Url::parse(input) { + code_from_redirect(&url, &state)? + } else { + let (code, st) = input.split_once('#').ok_or_else(|| { + anyhow!("Paste the full `code#state` value (or the redirect URL) so the state can be verified") })?; - let cb_state = url - .query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()); - if cb_state.as_deref() != Some(&state) { - return Err(anyhow!("State mismatch")); - } - - // Exchange code for tokens - let client = reqwest::Client::new(); - let resp = client - .post(CODEX_TOKEN_URL) - .form(&[ - ("grant_type", "authorization_code"), - ("client_id", client_id.as_str()), - ("code", code.as_str()), - ("code_verifier", code_verifier.as_str()), - ("redirect_uri", redirect_uri().as_str()), - ]) - .send() - .await?; - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("Token exchange failed: {body}")); + if st != state { + return Err(anyhow!("State mismatch")); + } + code.to_string() } - let payload: serde_json::Value = resp.json().await?; - let access_token = payload["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token"))?; - let refresh_token_val = payload["refresh_token"] - .as_str() - .ok_or_else(|| anyhow!("No refresh_token"))?; - let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let store = TokenStore { - access_token: access_token.to_string(), - refresh_token: refresh_token_val.to_string(), - expires_at: now + expires_in, - token_endpoint: CODEX_TOKEN_URL.to_string(), - provider: "codex".to_string(), - }; - save_tokens(&store)?; - println!( - "\n\u{2705} Login successful! Token saved to {:?}", - auth_path() - ); - return Ok(()); } else { + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).map_err(|e| { + anyhow!("Failed to bind port {port}: {e}. Is another instance running?") + })?; println!("Opening browser for authentication...\n"); if open::that(&auth_url).is_err() { - println!("Could not open browser. Open this URL manually:\n"); - println!(" {auth_url}\n"); + println!("Could not open browser. Open this URL manually:\n\n {auth_url}\n"); } println!("Waiting for callback..."); - } + accept_callback_code(&listener, &state)? + }; - listener.set_nonblocking(false)?; - let (mut stream, _) = listener - .accept() - .map_err(|e| anyhow!("Failed to accept callback: {e}"))?; - let mut reader = std::io::BufReader::new(&stream); - let mut request_line = String::new(); - reader.read_line(&mut request_line)?; + let store = exchange_authorization_code(vendor, &code, &state, &verifier).await?; + save_tokens_for(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + Ok(()) +} - let path = request_line.split_whitespace().nth(1).unwrap_or(""); - let url = url::Url::parse(&format!("http://localhost{path}")) - .map_err(|_| anyhow!("Invalid callback URL"))?; +/// Codex (OpenAI) browser PKCE login. +pub async fn login_browser_flow(no_browser: bool) -> Result<()> { + login_pkce_flow(&CodexVendor, no_browser).await +} + +/// Extract the OAuth `code` from a parsed redirect URL, validating `state`. +/// Shared by every loopback-callback OAuth flow. +fn code_from_redirect(url: &url::Url, expected_state: &str) -> Result { let code = url .query_pairs() .find(|(k, _)| k == "code") @@ -824,7 +1079,7 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { .find(|(k, _)| k == "error") .map(|(_, v)| v.to_string()); anyhow!( - "No code in callback. Error: {}", + "No code in redirect. Error: {}", error.unwrap_or_else(|| "unknown".into()) ) })?; @@ -832,58 +1087,43 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { .query_pairs() .find(|(k, _)| k == "state") .map(|(_, v)| v.to_string()); - if cb_state.as_deref() != Some(&state) { - return Err(anyhow!("State mismatch in callback")); + if cb_state.as_deref() != Some(expected_state) { + return Err(anyhow!("State mismatch")); } + Ok(code) +} +/// Block on the loopback listener for the OAuth redirect, reply 200, return the +/// authorization code. Note: the Codex flow above predates this helper and still +/// inlines the same logic; fold it in if that path is ever touched again. +fn accept_callback_code(listener: &TcpListener, expected_state: &str) -> Result { + listener.set_nonblocking(false)?; + let (mut stream, _) = listener + .accept() + .map_err(|e| anyhow!("Failed to accept callback: {e}"))?; + let mut reader = std::io::BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + let path = request_line.split_whitespace().nth(1).unwrap_or(""); + let url = url::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| anyhow!("Invalid callback URL"))?; + let code = code_from_redirect(&url, expected_state)?; let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authentication successful!

You can close this tab.

"; let _ = stream.write_all(response.as_bytes()); + Ok(code) +} - let client = reqwest::Client::new(); - let resp = client - .post(CODEX_TOKEN_URL) - .form(&[ - ("grant_type", "authorization_code"), - ("client_id", client_id.as_str()), - ("code", code.as_str()), - ("code_verifier", code_verifier.as_str()), - ("redirect_uri", redirect_uri().as_str()), - ]) - .send() - .await?; - if !resp.status().is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("Token exchange failed: {body}")); - } - let payload: serde_json::Value = resp.json().await?; - let access_token = payload["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token"))?; - let refresh_token_val = payload["refresh_token"] - .as_str() - .ok_or_else(|| anyhow!("No refresh_token"))?; - let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let store = TokenStore { - access_token: access_token.to_string(), - refresh_token: refresh_token_val.to_string(), - expires_at: now + expires_in, - token_endpoint: CODEX_TOKEN_URL.to_string(), - provider: "codex".to_string(), - }; - save_tokens(&store)?; - println!( - "\n\u{2705} Login successful! Token saved to {:?}", - auth_path() - ); - Ok(()) +/// Anthropic OAuth (Claude Pro/Max) browser PKCE login. JSON token exchange +/// against `platform.claude.com`; all vendor specifics live in `AnthropicVendor`. +pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { + login_pkce_flow(&AnthropicVendor, no_browser).await } // Device code flow pub async fn login_codex_device_flow() -> Result<()> { println!("Starting OpenAI Codex device-code login...\n"); let client = reqwest::Client::new(); - let client_id = codex_client_id(); + let client_id = CodexVendor.client_id(); let resp = client .post(CODEX_DEVICE_AUTH_URL) @@ -948,22 +1188,8 @@ pub async fn login_codex_device_flow() -> Result<()> { return Err(anyhow!("Token exchange failed: {body}")); } let token_payload: serde_json::Value = token_resp.json().await?; - let access_token = token_payload["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token: {token_payload}"))?; - let refresh_token_val = token_payload["refresh_token"] - .as_str() - .ok_or_else(|| anyhow!("No refresh_token"))?; - let expires_in = token_payload["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let store = TokenStore { - access_token: access_token.to_string(), - refresh_token: refresh_token_val.to_string(), - expires_at: now + expires_in, - token_endpoint: CODEX_TOKEN_URL.to_string(), - provider: "codex".to_string(), - }; - save_tokens(&store)?; + let store = token_store_from_payload(&token_payload, CODEX_TOKEN_URL, CODEX_NAMESPACE)?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -995,31 +1221,49 @@ pub async fn login_codex_device_flow() -> Result<()> { } pub fn show_status() { - match load_tokens() { - Ok(store) => { - let expired = store.is_expired(); - let masked = if store.access_token.len() > 12 { - format!( - "{}...{}", - &store.access_token[..8], - &store.access_token[store.access_token.len() - 4..] - ) - } else { - "****".to_string() - }; - println!("Provider: {}", store.provider); - println!("Token: {}", masked); - println!( - "Expires: {} ({})", - store.expires_at, - if expired { "EXPIRED" } else { "valid" } - ); - println!("File: {:?}", auth_path()); - } - Err(e) => { - println!("Not authenticated: {e}\nRun: openab-agent auth codex-oauth"); - } + let path = auth_path(); + let tokens: Vec = read_auth_file(&path) + .map(|map| { + let mut v: Vec = map + .into_values() + .filter_map(|e| match e { + AuthEntry::Token(t) => Some(t), + _ => None, + }) + .collect(); + v.sort_by(|a, b| a.provider.cmp(&b.provider)); + v + }) + .unwrap_or_default(); + + if tokens.is_empty() { + println!( + "Not authenticated.\nRun: openab-agent auth codex-oauth | openab-agent auth anthropic-oauth" + ); + return; } + + for store in tokens { + let expired = store.is_expired(); + let masked = if store.access_token.len() > 12 { + format!( + "{}...{}", + &store.access_token[..8], + &store.access_token[store.access_token.len() - 4..] + ) + } else { + "****".to_string() + }; + println!("Provider: {}", store.provider); + println!("Token: {}", masked); + println!( + "Expires: {} ({})", + store.expires_at, + if expired { "EXPIRED" } else { "valid" } + ); + println!(); + } + println!("File: {:?}", path); } #[cfg(test)] @@ -1036,6 +1280,77 @@ mod tests { } } + // ── OAuthVendor wire-format locks (ADR §5.1) ────────────────────────── + // The login authorize URL + token-body encoding hit live OAuth servers, so + // no integration test covers them. These pure-function assertions pin the + // exact wire contract so the descriptor refactor can't silently drift it. + + #[test] + fn codex_authorize_url_pins_wire_contract() { + let url = build_authorize_url(&CodexVendor, "CH", "ST").unwrap(); + assert!(url.starts_with(CODEX_AUTHORIZE_URL), "{url}"); + for needle in [ + "response_type=code", + "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", + "scope=openid%20profile%20email%20offline_access", + "code_challenge=CH", + "code_challenge_method=S256", + "state=ST", + // codex simplified-flow hints carried as extra authorize params + "id_token_add_organizations=true", + "codex_cli_simplified_flow=true", + "originator=openab-agent", + ] { + assert!(url.contains(needle), "missing `{needle}` in {url}"); + } + } + + #[test] + fn anthropic_authorize_url_pins_wire_contract() { + let url = build_authorize_url(&AnthropicVendor, "CH", "ST").unwrap(); + assert!(url.starts_with(ANTHROPIC_AUTHORIZE_URL), "{url}"); + for needle in [ + "response_type=code", + "redirect_uri=http%3A%2F%2Flocalhost%3A53692%2Fcallback", + "code_challenge=CH", + "code_challenge_method=S256", + "state=ST", + "code=true", // Anthropic-only extra authorize param + "scope=org%3Acreate_api_key", // scope prefix, colons percent-encoded + ] { + assert!(url.contains(needle), "missing `{needle}` in {url}"); + } + } + + #[test] + fn vendor_for_resolves_oauth_tenants_only() { + assert_eq!( + vendor_for(CODEX_NAMESPACE).unwrap().namespace(), + CODEX_NAMESPACE + ); + assert_eq!( + vendor_for(ANTHROPIC_NAMESPACE).unwrap().namespace(), + ANTHROPIC_NAMESPACE + ); + // MCP and unknown tenants have no OAuthVendor (rmcp owns MCP refresh). + assert!(vendor_for("mcp:linear").is_none()); + assert!(vendor_for("nope").is_none()); + } + + #[test] + fn token_body_and_redirect_per_vendor() { + assert_eq!(CodexVendor.token_body(), TokenBodyFormat::Form); + assert_eq!(AnthropicVendor.token_body(), TokenBodyFormat::Json); + assert_eq!( + CodexVendor.redirect_uri().as_deref(), + Some("http://localhost:1455/auth/callback") + ); + assert_eq!( + AnthropicVendor.redirect_uri().as_deref(), + Some("http://localhost:53692/callback") + ); + } + #[test] fn test_is_expired_future_token() { let now = SystemTime::now() @@ -1074,14 +1389,14 @@ mod tests { #[test] fn test_codex_client_id_default() { temp_env::with_var("OPENAB_AGENT_OAUTH_CLIENT_ID", None::<&str>, || { - assert_eq!(codex_client_id(), "app_EMoamEEZ73f0CkXaXp7hrann"); + assert_eq!(CodexVendor.client_id(), "app_EMoamEEZ73f0CkXaXp7hrann"); }); } #[test] fn test_codex_client_id_override() { temp_env::with_var("OPENAB_AGENT_OAUTH_CLIENT_ID", Some("custom_id"), || { - assert_eq!(codex_client_id(), "custom_id"); + assert_eq!(CodexVendor.client_id(), "custom_id"); }); } @@ -1093,6 +1408,44 @@ mod tests { assert_eq!(challenge, expected); } + #[test] + fn test_anthropic_authorize_url_carries_required_params() { + temp_env::with_var("OPENAB_AGENT_ANTHROPIC_CLIENT_ID", None::<&str>, || { + let url = build_authorize_url(&AnthropicVendor, "CHAL", "STATE").unwrap(); + assert!(url.starts_with("https://claude.ai/oauth/authorize?")); + assert!(url.contains("client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e")); + assert!(url.contains("response_type=code")); + assert!(url.contains("code_challenge=CHAL")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("state=STATE")); + // scope is url-encoded; spot-check one encoded scope token + assert!(url.contains("user%3Ainference")); + // redirect must be the loopback callback on the Anthropic port + assert!(url.contains("localhost%3A53692%2Fcallback")); + }); + } + + #[test] + fn test_anthropic_save_uses_provider_as_key_disjoint_from_codex() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + let mut codex = make_store(1); + codex.provider = "codex".to_string(); + let mut anth = make_store(2); + anth.provider = ANTHROPIC_NAMESPACE.to_string(); + anth.access_token = "sk-ant-oat-xyz".to_string(); + let mut input = HashMap::new(); + input.insert(codex.provider.clone(), AuthEntry::Token(codex)); + input.insert(anth.provider.clone(), AuthEntry::Token(anth)); + write_auth_file(&path, &input).unwrap(); + let map = read_auth_file(&path).unwrap(); + assert_eq!(token_of(map.get("codex")).expires_at, 1); + assert_eq!( + token_of(map.get(ANTHROPIC_NAMESPACE)).access_token, + "sk-ant-oat-xyz" + ); + } + fn token_of(entry: Option<&AuthEntry>) -> &TokenStore { match entry { Some(AuthEntry::Token(t)) => t, @@ -1496,4 +1849,74 @@ mod tests { "acquire succeeds once the holder releases" ); } + + #[test] + fn with_auth_locked_merges_anthropic_tenant_no_lost_update() { + // The §5.4 lost-update guarantee must hold for the `anthropic-oauth` + // tenant too: a concurrent codex write must not clobber a just-written + // Anthropic token (proves the new tenant rides the same locked funnel). + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + + let mut anth = make_store(7); + anth.provider = ANTHROPIC_NAMESPACE.to_string(); + with_auth_locked(&path, |m| { + m.insert(ANTHROPIC_NAMESPACE.to_string(), AuthEntry::Token(anth)); + }) + .unwrap(); + with_auth_locked(&path, |m| { + m.insert("codex".to_string(), AuthEntry::Token(make_store(1))); + }) + .unwrap(); + + let map = read_auth_file(&path).unwrap(); + assert_eq!(map.len(), 2, "second write merged, did not lost-update"); + assert_eq!(token_of(map.get(ANTHROPIC_NAMESPACE)).expires_at, 7); + assert_eq!(token_of(map.get("codex")).expires_at, 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn lock_tenant_refresh_fails_closed_for_anthropic_and_is_per_tenant() { + // §5.4 (b) proven for the `anthropic-oauth` tenant: while one holder keeps + // its refresh lock, a second acquire fails closed (`TimedOut`) — single- + // flight for the new tenant, not just codex. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + + let held = lock_tenant_refresh(&path, ANTHROPIC_NAMESPACE).await; + assert!( + matches!(held, RefreshLock::Held(_)), + "anthropic acquire holds" + ); + + let contended = lock_tenant_refresh_until( + &path, + ANTHROPIC_NAMESPACE, + std::time::Duration::from_millis(200), + ) + .await; + assert!( + matches!(contended, RefreshLock::TimedOut), + "second anthropic acquire fails closed while held" + ); + + // Per-tenant isolation: the locks are keyed per tenant, so holding the + // Anthropic lock must NOT block codex — a slow Anthropic refresh never + // head-of-line-blocks another tenant's refresh (the reason §5.4 uses a + // per-tenant lock rather than the global one). + let codex = lock_tenant_refresh(&path, "codex").await; + assert!( + matches!(codex, RefreshLock::Held(_)), + "codex acquire is independent of the held anthropic lock" + ); + + drop(held); + drop(codex); + let after = lock_tenant_refresh(&path, ANTHROPIC_NAMESPACE).await; + assert!( + matches!(after, RefreshLock::Held(_)), + "anthropic acquire succeeds once released" + ); + } } diff --git a/openab-agent/src/config.rs b/openab-agent/src/config.rs new file mode 100644 index 000000000..cb578fea9 --- /dev/null +++ b/openab-agent/src/config.rs @@ -0,0 +1,105 @@ +//! Centralized agent config (ADR §5.5 — declarative defaults; secrets stay in +//! `auth.json`). A small JSON file next to `auth.json` that supplies the default +//! provider/model and a few params, so a deployment can declare them in a file +//! instead of only via env vars. +//! +//! Precedence is **env-over-config**: `OPENAB_AGENT_MODEL` / `OPENAB_AGENT_MAX_TOKENS` +//! still win, and this file is the declarative default for runs that don't set +//! them (a pod's injected env stays authoritative over a baked config). The +//! resolution chain lives in `llm.rs` (`anthropic_model` / `anthropic_max_tokens` +//! / `resolve_provider_choice`). +//! +//! Unknown keys are tolerated (forward-compat: `providers`/`small_model`/… can +//! land later without breaking older binaries). Secrets never live here. + +use serde::Deserialize; +use std::path::PathBuf; + +/// Parsed `config.json`. Every field is optional — a missing file is an empty +/// config, and any field falls back to env/built-in default downstream. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct AgentConfig { + /// Default model as a single `provider/model` string (see `ModelRef`). + pub model: Option, + /// Default max output tokens. + pub max_tokens: Option, +} + +impl AgentConfig { + /// `config.json` next to `auth.json`. `OPENAB_CONFIG_PATH` overrides the + /// whole path (ops injection / tests). + pub fn path() -> PathBuf { + if let Ok(p) = std::env::var("OPENAB_CONFIG_PATH") { + return PathBuf::from(p); + } + crate::auth::auth_path().with_file_name("config.json") + } + + /// Parse config JSON. Pure (unit-testable). + pub fn parse(data: &str) -> anyhow::Result { + serde_json::from_str(data).map_err(Into::into) + } + + /// Load + parse the config file. A missing file is an empty config (not an + /// error); a present-but-malformed file IS an error so a typo is visible + /// rather than silently dropped. + pub fn load() -> anyhow::Result { + let path = Self::path(); + match std::fs::read_to_string(&path) { + Ok(s) => { + Self::parse(&s).map_err(|e| anyhow::anyhow!("invalid {}: {e}", path.display())) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(anyhow::anyhow!("reading {}: {e}", path.display())), + } + } + + /// Load, or an empty config (logging a warning) when the file is malformed — + /// a typo'd config must not crash the agent, but it has to be visible. Used + /// by the resolution path, which then falls through to env/built-in defaults. + pub fn load_or_default() -> Self { + match Self::load() { + Ok(c) => c, + Err(e) => { + tracing::error!("ignoring config: {e}"); + Self::default() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_reads_known_fields() { + let c = AgentConfig::parse(r#"{"model":"anthropic/claude-sonnet-4-6","max_tokens":4096}"#) + .unwrap(); + assert_eq!(c.model.as_deref(), Some("anthropic/claude-sonnet-4-6")); + assert_eq!(c.max_tokens, Some(4096)); + } + + #[test] + fn empty_object_is_all_none() { + let c = AgentConfig::parse("{}").unwrap(); + assert_eq!(c.model, None); + assert_eq!(c.max_tokens, None); + } + + #[test] + fn unknown_keys_tolerated_for_forward_compat() { + // `providers` / future keys must not break an older binary. + let c = + AgentConfig::parse(r#"{"model":"anthropic/x","providers":{"anthropic":{}}}"#).unwrap(); + assert_eq!(c.model.as_deref(), Some("anthropic/x")); + } + + #[test] + fn malformed_json_is_an_error() { + assert!(AgentConfig::parse("{not json").is_err()); + // wrong type for a known field is also a hard error (fail loud) + assert!(AgentConfig::parse(r#"{"max_tokens":"lots"}"#).is_err()); + } +} diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index de5e1eb6c..baec41ae3 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -68,6 +68,13 @@ pub trait LlmProvider: Send + Sync { /// `CreateMessageResult.model` when serving MCP sampling so the requesting /// server learns which model produced the response. fn model(&self) -> &str; + + /// True if this provider authenticates via OAuth rather than an API key. + /// Lets a session rebuild (model switch) preserve its auth mode instead of + /// silently falling back to `ANTHROPIC_API_KEY`. + fn is_oauth(&self) -> bool { + false + } } /// Shared, cloneable handle to an `LlmProvider`. A newtype over @@ -90,23 +97,92 @@ impl std::ops::Deref for SharedLlmProvider { } } +/// Provider prefixes [`ModelRef::parse`] recognizes. A `prefix/rest` model +/// splits into `(provider, model)` ONLY when `prefix` is one of these. Otherwise +/// the whole string is the model id — so a HuggingFace-style `org/model` id +/// (e.g. `meta-llama/Llama-3-8B`) for a custom/OpenAI-compatible endpoint stays +/// intact instead of mis-parsing `org` as a provider. Extend as vendors land. +const KNOWN_PROVIDERS: &[&str] = &["anthropic", "anthropic-oauth", "claude", "openai", "codex"]; + +/// A model reference, optionally provider-qualified. Accepts the canonical +/// `provider/model_id` form (e.g. `anthropic/claude-sonnet-4-6`) as well as a +/// bare `model_id` (provider then inferred from credentials). Only a *known* +/// provider prefix is split off (see [`KNOWN_PROVIDERS`]), so model ids that +/// themselves contain `/` (HuggingFace `org/model`) are preserved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRef { + pub provider: Option, + pub model: String, +} + +impl ModelRef { + pub fn parse(input: &str) -> Self { + match input.split_once('/') { + Some((p, m)) if KNOWN_PROVIDERS.contains(&p) && !m.is_empty() => ModelRef { + provider: Some(p.to_string()), + model: m.to_string(), + }, + _ => ModelRef { + provider: None, + model: input.to_string(), + }, + } + } +} + +/// The provider the user asked for. Precedence: explicit `OPENAB_AGENT_PROVIDER` +/// → `provider/` prefix of `OPENAB_AGENT_MODEL` (e.g. `openai/gpt-5.4` selects +/// OpenAI even when an Anthropic key is also present) → `provider/` prefix of +/// `config.json`'s `model` → empty (auto-detect). Env-over-config (ADR §5.5). +pub fn resolve_provider_choice() -> String { + if let Ok(p) = std::env::var("OPENAB_AGENT_PROVIDER") { + if !p.is_empty() { + return p; + } + } + if let Some(p) = std::env::var("OPENAB_AGENT_MODEL") + .ok() + .and_then(|m| ModelRef::parse(&m).provider) + { + return p; + } + crate::config::AgentConfig::load_or_default() + .model + .and_then(|m| ModelRef::parse(&m).provider) + .unwrap_or_default() +} + /// Select an `LlmProvider` from an explicit `choice` (`anthropic` / -/// `openai` / `codex`) or, for any other value, auto-detect (Anthropic API -/// key first, then codex OAuth). Shared by the ACP session path and MCP -/// sampling so both honor the same `OPENAB_AGENT_PROVIDER` selection and -/// credential fallback. +/// `anthropic-oauth` / `openai` / `codex`) or, for any other value, auto-detect +/// (Anthropic API key, then Claude subscription OAuth, then codex OAuth). The +/// `anthropic` choice itself auto-falls-back from API key to OAuth. Shared by +/// the ACP session path and MCP sampling so both honor the same +/// `OPENAB_AGENT_PROVIDER` selection and credential fallback. pub fn select_provider(choice: &str) -> Result, String> { match choice { - "anthropic" => Ok(Box::new(AnthropicProvider::from_env()?)), + "anthropic" => Ok(Box::new(AnthropicProvider::auto()?)), + "anthropic-oauth" | "claude" => Ok(Box::new(AnthropicProvider::from_oauth_auto()?)), "openai" | "codex" => Ok(Box::new(OpenAiProvider::from_auth_store()?)), - _ => match AnthropicProvider::from_env() { + _ => 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}" - )), - }, + // F3 — don't let a *present-but-misconfigured* Anthropic credential + // silently fall through to Codex. If a credential exists, the failure + // is a real config error (e.g. a credential set but no model): fail + // loud with it. Only fall through to Codex when no Anthropic + // credential exists at all. + Err(anthropic_err) => { + if AnthropicProvider::credential_present() { + Err(format!( + "Anthropic credential present but unusable: {anthropic_err}" + )) + } else { + OpenAiProvider::from_auth_store() + .map(|p| Box::new(p) as Box) + .map_err(|codex_err| format!( + "No credentials: set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN, or run `openab-agent auth anthropic-oauth` / `openab-agent auth codex-oauth`. ({codex_err})" + )) + } + } }, } } @@ -116,57 +192,242 @@ pub fn select_provider(choice: &str) -> Result, String> { /// credentials are available so the caller can simply decline to advertise /// the `sampling` capability rather than fail. pub fn default_provider() -> Option { - let choice = std::env::var("OPENAB_AGENT_PROVIDER").unwrap_or_default(); + let choice = resolve_provider_choice(); select_provider(&choice) .ok() .map(|b| SharedLlmProvider(Arc::from(b))) } +/// How an `AnthropicProvider` authenticates to the Messages API +/// (credential-source precedence per ADR §5.3). +enum AnthropicAuth { + /// `ANTHROPIC_API_KEY` → `x-api-key`, plain system prompt. + ApiKey(String), + /// Claude Pro/Max subscription OAuth → `Bearer` + Claude Code identity + /// headers/system block. The live token is fetched (and refreshed) per call + /// from the `anthropic-oauth` tenant in auth.json. + OAuth, + /// Pre-provisioned long-lived subscription OAuth token via + /// `CLAUDE_CODE_OAUTH_TOKEN` (ADR §5.3 fleet route). Same `Bearer` + Claude + /// Code identity path as `OAuth`, but the token comes from the env, never + /// touches `auth.json`, and is never refreshed (ops re-mints it). + OAuthEnv(String), +} + /// Anthropic Claude provider. pub struct AnthropicProvider { - api_key: String, + auth: AnthropicAuth, model: String, - #[allow(dead_code)] max_tokens: u32, client: reqwest::Client, } +/// Resolve the Anthropic model. Precedence (ADR §5.3/§5.5): `OPENAB_AGENT_MODEL` +/// env → `model` in `config.json` → error. No hardcoded default: dateless 4.6+ +/// IDs are fixed canonical IDs (not evergreen pointers), so a pinned default is a +/// per-generation 404 timebomb. Require an explicit choice and fail loud instead. +fn anthropic_model() -> Result { + if let Ok(m) = std::env::var("OPENAB_AGENT_MODEL") { + if !m.is_empty() { + return Ok(m); + } + } + if let Some(m) = crate::config::AgentConfig::load_or_default().model { + if !m.is_empty() { + return Ok(m); + } + } + Err("no model configured; set OPENAB_AGENT_MODEL, add `model` to config.json, or select a model".to_string()) +} + +/// Max output tokens: `OPENAB_AGENT_MAX_TOKENS` env → `max_tokens` in +/// `config.json` → built-in 8192 (env-over-config, ADR §5.5). +fn anthropic_max_tokens() -> u32 { + if let Some(v) = std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + { + return v; + } + if let Some(v) = crate::config::AgentConfig::load_or_default().max_tokens { + return v; + } + 8192 +} + +/// openab-agent's built-in tools mapped to Claude Code's canonical casing. The +/// `claude-code-20250219` beta (sent with OAuth tokens) expects these names, so +/// they're rewritten on the way out and restored on the way back. Unknown names +/// (e.g. MCP tools) pass through unchanged, matching Pi's behaviour. +const CC_TOOL_NAMES: &[(&str, &str)] = &[ + ("read", "Read"), + ("write", "Write"), + ("edit", "Edit"), + ("bash", "Bash"), +]; + +fn to_claude_code_name(name: &str) -> String { + CC_TOOL_NAMES + .iter() + .find(|(lc, _)| *lc == name) + .map(|(_, cc)| (*cc).to_string()) + .unwrap_or_else(|| name.to_string()) +} + +fn from_claude_code_name(name: &str) -> String { + CC_TOOL_NAMES + .iter() + .find(|(_, cc)| *cc == name) + .map(|(lc, _)| (*lc).to_string()) + .unwrap_or_else(|| name.to_string()) +} + impl AnthropicProvider { - pub fn from_env() -> Result { + fn build(auth: AnthropicAuth, model: String) -> Self { + Self { + auth, + // Accept a provider-qualified ref (`anthropic/claude-…`); the API + // wants the bare model id. + model: ModelRef::parse(&model).model, + max_tokens: anthropic_max_tokens(), + client: reqwest::Client::new(), + } + } + + fn api_key_from_env() -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| "ANTHROPIC_API_KEY not set".to_string())?; if api_key.is_empty() { return Err("ANTHROPIC_API_KEY is empty".to_string()); } - Ok(Self { - api_key, - model: std::env::var("OPENAB_AGENT_MODEL") - .unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()), - max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8192), - client: reqwest::Client::new(), - }) + Ok(api_key) } - /// Create provider with a specific model override. - pub fn from_env_with_model(model: &str) -> Result { - let mut p = Self::from_env()?; - p.model = model.to_string(); - Ok(p) + /// Verify the `anthropic-oauth` tenant has a stored token; the live token is + /// fetched (and refreshed) at call time. + fn ensure_oauth_token() -> Result<(), String> { + crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + /// Claude Pro/Max OAuth. + pub fn from_oauth_store() -> Result { + Self::ensure_oauth_token()?; + Ok(Self::build(AnthropicAuth::OAuth, anthropic_model()?)) + } + + /// Pre-provisioned long-lived subscription OAuth token from + /// `CLAUDE_CODE_OAUTH_TOKEN` (ADR §5.3). No `auth.json`, no refresh. + fn oauth_env_token() -> Option { + std::env::var("CLAUDE_CODE_OAUTH_TOKEN") + .ok() + .filter(|t| !t.is_empty()) + } + + /// Build from the `CLAUDE_CODE_OAUTH_TOKEN` env route. + pub fn from_oauth_env() -> Result { + let token = + Self::oauth_env_token().ok_or_else(|| "CLAUDE_CODE_OAUTH_TOKEN not set".to_string())?; + Ok(Self::build( + AnthropicAuth::OAuthEnv(token), + anthropic_model()?, + )) + } + + fn from_oauth_env_with_model(model: &str) -> Result { + let token = + Self::oauth_env_token().ok_or_else(|| "CLAUDE_CODE_OAUTH_TOKEN not set".to_string())?; + Ok(Self::build( + AnthropicAuth::OAuthEnv(token), + model.to_string(), + )) + } + + /// True when *some* Anthropic credential source exists (API key, env OAuth + /// token, or stored tenant). Lets `select_provider` tell a real config error + /// ("credential present but `auto()` failed" → fail loud) from "no Anthropic + /// credentials" (legitimately fall through to Codex) — review F3. + pub fn credential_present() -> bool { + Self::api_key_from_env().is_ok() + || Self::oauth_env_token().is_some() + || crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE).is_ok() + } + + /// Apply the Claude Pro/Max OAuth `Bearer` + Claude Code identity headers. + /// Shared by the stored-tenant (`OAuth`) and env-token (`OAuthEnv`) paths. + fn oauth_headers(req: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + req.header("authorization", format!("Bearer {token}")) + .header("anthropic-beta", "claude-code-20250219,oauth-2025-04-20") + .header("user-agent", "claude-cli/1.0.0") + .header("x-app", "cli") + .header("anthropic-dangerous-direct-browser-access", "true") + } + + /// Credential-source precedence (ADR §5.3): explicit `ANTHROPIC_API_KEY` → + /// pre-provisioned `CLAUDE_CODE_OAUTH_TOKEN` env route → stored interactive + /// `anthropic-oauth` tenant. When a source is present its own errors (e.g. a + /// missing model) surface rather than falling through to an unrelated + /// lower-precedence credential error. + pub fn auto() -> Result { + if let Ok(key) = Self::api_key_from_env() { + return Ok(Self::build(AnthropicAuth::ApiKey(key), anthropic_model()?)); + } + if Self::oauth_env_token().is_some() { + return Self::from_oauth_env(); + } + Self::from_oauth_store() + } + + /// `auto()` with an explicit model override. The override replaces + /// `OPENAB_AGENT_MODEL`, so it does not require that env var to be set. + pub fn auto_with_model(model: &str) -> Result { + if let Ok(key) = Self::api_key_from_env() { + return Ok(Self::build(AnthropicAuth::ApiKey(key), model.to_string())); + } + if Self::oauth_env_token().is_some() { + return Self::from_oauth_env_with_model(model); + } + Self::from_oauth_store_with_model(model) + } + + /// `from_oauth_store()` with an explicit model override. + pub fn from_oauth_store_with_model(model: &str) -> Result { + Self::ensure_oauth_token()?; + Ok(Self::build(AnthropicAuth::OAuth, model.to_string())) + } + + /// OAuth with env-over-store precedence: `CLAUDE_CODE_OAUTH_TOKEN` → stored + /// `anthropic-oauth` tenant. Lets fleet pods that only set the env token work + /// without an `auth.json`. + pub fn from_oauth_auto() -> Result { + if Self::oauth_env_token().is_some() { + return Self::from_oauth_env(); + } + Self::from_oauth_store() + } + + /// `from_oauth_auto()` with an explicit model override. + pub fn from_oauth_auto_with_model(model: &str) -> Result { + if Self::oauth_env_token().is_some() { + return Self::from_oauth_env_with_model(model); + } + Self::from_oauth_store_with_model(model) } fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { - let msgs: Vec = messages - .iter() - .map(|m| { - let content: Vec = m + let oauth = self.is_oauth(); + let msgs: Vec = + messages + .iter() + .map(|m| { + let content: Vec = m .content .iter() .map(|b| match b { ContentBlock::Text { text } => json!({ "type": "text", "text": text }), ContentBlock::ToolUse { id, name, input } => { + let name = if oauth { to_claude_code_name(name) } else { name.clone() }; json!({ "type": "tool_use", "id": id, "name": name, "input": input }) } ContentBlock::ToolResult { @@ -186,23 +447,39 @@ impl AnthropicProvider { } }) .collect(); - json!({ "role": &m.role, "content": content }) - }) - .collect(); + json!({ "role": &m.role, "content": content }) + }) + .collect(); let mut body = json!({ "model": &self.model, "max_tokens": self.max_tokens, "messages": msgs, - "system": system, }); + // OAuth tokens MUST carry the Claude Code identity as the first system + // block, with the real prompt appended. API-key callers send a plain + // string (unchanged behaviour). + if oauth { + body["system"] = json!([ + { "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }, + { "type": "text", "text": system }, + ]); + } else { + body["system"] = json!(system); + } + if !tools.is_empty() { let tool_defs: Vec = tools .iter() .map(|t| { + let name = if oauth { + to_claude_code_name(&t.name) + } else { + t.name.clone() + }; json!({ - "name": &t.name, + "name": name, "description": &t.description, "input_schema": &t.input_schema }) @@ -220,6 +497,10 @@ impl LlmProvider for AnthropicProvider { &self.model } + fn is_oauth(&self) -> bool { + matches!(self.auth, AnthropicAuth::OAuth | AnthropicAuth::OAuthEnv(_)) + } + fn chat<'a>( &'a self, system: &'a str, @@ -228,15 +509,33 @@ impl LlmProvider for AnthropicProvider { ) -> Pin>> + Send + 'a>> { Box::pin(async move { let body = self.build_request_body(system, messages, tools); + let oauth = self.is_oauth(); + // Only the stored `anthropic-oauth` tenant can be refreshed on a 401; + // the `CLAUDE_CODE_OAUTH_TOKEN` env route has no tenant to refresh + // (a 401 there means the pre-provisioned token is bad → surface it). + let refreshable = matches!(self.auth, AnthropicAuth::OAuth); let max_retries = 3u32; + let mut oauth_refreshed = false; for attempt in 0..=max_retries { - let resp = self + let mut req = self .client .post("https://api.anthropic.com/v1/messages") - .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") - .header("content-type", "application/json") + .header("content-type", "application/json"); + req = match &self.auth { + AnthropicAuth::ApiKey(key) => req.header("x-api-key", key), + AnthropicAuth::OAuth => { + // Claude Pro/Max: live token from the stored tenant. + let token = + crate::auth::get_valid_token_for(crate::auth::ANTHROPIC_NAMESPACE) + .await?; + Self::oauth_headers(req, &token) + } + AnthropicAuth::OAuthEnv(token) => Self::oauth_headers(req, token), + }; + + let resp = req .json(&body) .send() .await @@ -251,6 +550,15 @@ impl LlmProvider for AnthropicProvider { continue; } + // 401 on OAuth: token may have expired mid-request; force a + // refresh and retry once. Surface a failed refresh instead of + // retrying with the stale token. + if refreshable && status.as_u16() == 401 && !oauth_refreshed { + oauth_refreshed = true; + crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await?; + continue; + } + if !status.is_success() { let text = resp.text().await.unwrap_or_default(); return Err(anyhow!("Anthropic API error {status}: {text}")); @@ -261,7 +569,17 @@ impl LlmProvider for AnthropicProvider { .await .map_err(|e| anyhow!("Failed to parse response: {e}"))?; - return parse_anthropic_response(&response); + let mut events = parse_anthropic_response(&response)?; + // Restore openab-agent's lowercase tool names from the Claude + // Code canonical casing the model echoes back under OAuth. + if oauth { + for ev in &mut events { + if let LlmEvent::ToolUse { name, .. } = ev { + *name = from_claude_code_name(name); + } + } + } + return Ok(events); } Err(anyhow!("Anthropic API: max retries exceeded")) @@ -332,9 +650,12 @@ impl OpenAiProvider { Ok(Self { base_url: std::env::var("OPENAB_AGENT_OPENAI_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), - model: std::env::var("OPENAB_AGENT_OPENAI_MODEL") - .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) - .unwrap_or_else(|_| "gpt-5.4-mini".to_string()), + model: ModelRef::parse( + &std::env::var("OPENAB_AGENT_OPENAI_MODEL") + .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) + .unwrap_or_else(|_| "gpt-5.4-mini".to_string()), + ) + .model, max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") .ok() .and_then(|v| v.parse().ok()) @@ -346,7 +667,7 @@ impl OpenAiProvider { /// Create provider with a specific model override. pub fn from_auth_store_with_model(model: &str) -> Result { let mut p = Self::from_auth_store()?; - p.model = model.to_string(); + p.model = ModelRef::parse(model).model; Ok(p) } } @@ -629,6 +950,46 @@ fn parse_openai_response(response: &Value) -> Result> { mod tests { use super::*; + #[test] + fn test_model_ref_parse() { + // Provider-qualified form splits on the first slash. + let r = ModelRef::parse("anthropic/claude-sonnet-4-6"); + assert_eq!(r.provider.as_deref(), Some("anthropic")); + assert_eq!(r.model, "claude-sonnet-4-6"); + + // Bare model id → no provider, model unchanged. + let r = ModelRef::parse("claude-sonnet-4-6"); + assert_eq!(r.provider, None); + assert_eq!(r.model, "claude-sonnet-4-6"); + + // Degenerate slashes fall back to bare (no empty provider/model). + assert_eq!(ModelRef::parse("/gpt-5.4").provider, None); + assert_eq!(ModelRef::parse("openai/").model, "openai/"); + + // F4: a HuggingFace-style `org/model` id is NOT a known provider, so the + // whole string stays the model id (the `/` is part of the id). + let r = ModelRef::parse("meta-llama/Llama-3-8B-Instruct"); + assert_eq!(r.provider, None); + assert_eq!(r.model, "meta-llama/Llama-3-8B-Instruct"); + + // Every known provider prefix still splits. + for prov in KNOWN_PROVIDERS { + let r = ModelRef::parse(&format!("{prov}/some-model")); + assert_eq!(r.provider.as_deref(), Some(*prov)); + assert_eq!(r.model, "some-model"); + } + } + + #[test] + fn test_provider_build_strips_prefix() { + // A qualified ref reaches the API as the bare model id. + let p = AnthropicProvider::build( + AnthropicAuth::ApiKey("k".to_string()), + "anthropic/claude-opus-4-8".to_string(), + ); + assert_eq!(p.model(), "claude-opus-4-8"); + } + #[test] fn test_parse_text_response() { let resp = json!({ @@ -664,14 +1025,130 @@ mod tests { } } - #[test] - fn test_build_request_body() { - let provider = AnthropicProvider { - api_key: "test".to_string(), + fn test_provider(auth: AnthropicAuth) -> AnthropicProvider { + AnthropicProvider { + auth, model: "claude-sonnet-4-20250514".to_string(), max_tokens: 4096, client: reqwest::Client::new(), - }; + } + } + + #[test] + fn test_is_oauth_reflects_auth_mode() { + // Guards the ACP model-switch rebuild: an OAuth session must report + // OAuth so it isn't silently rebuilt against ANTHROPIC_API_KEY. The env + // route is OAuth too — it uses the same Claude Code identity path. + assert!(test_provider(AnthropicAuth::OAuth).is_oauth()); + assert!(test_provider(AnthropicAuth::OAuthEnv("oat".to_string())).is_oauth()); + assert!(!test_provider(AnthropicAuth::ApiKey("k".to_string())).is_oauth()); + } + + #[test] + fn auto_prefers_api_key_over_env_token() { + // ADR §5.3 precedence: ANTHROPIC_API_KEY wins over CLAUDE_CODE_OAUTH_TOKEN. + temp_env::with_vars( + [ + ("ANTHROPIC_API_KEY", Some("sk-ant-test")), + ("CLAUDE_CODE_OAUTH_TOKEN", Some("oat-test")), + ("OPENAB_AGENT_MODEL", Some("anthropic/claude-sonnet-4-6")), + ], + || { + let p = AnthropicProvider::auto().unwrap(); + assert!(matches!(p.auth, AnthropicAuth::ApiKey(_))); + }, + ); + } + + #[test] + fn auto_uses_env_token_when_no_api_key() { + // No API key → the CLAUDE_CODE_OAUTH_TOKEN env route, not the stored tenant + // (this builds without reading auth.json). + temp_env::with_vars( + [ + ("ANTHROPIC_API_KEY", None), + ("CLAUDE_CODE_OAUTH_TOKEN", Some("oat-test")), + ("OPENAB_AGENT_MODEL", Some("anthropic/claude-sonnet-4-6")), + ], + || { + let p = AnthropicProvider::auto().unwrap(); + assert!(matches!(p.auth, AnthropicAuth::OAuthEnv(_))); + assert!(p.is_oauth()); + }, + ); + } + + #[test] + fn model_resolves_env_over_config_over_error() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.json"); + std::fs::write(&cfg, r#"{"model":"anthropic/from-config"}"#).unwrap(); + let cfg_path = cfg.to_str().unwrap(); + + // env wins over config.json + temp_env::with_vars( + [ + ("OPENAB_CONFIG_PATH", Some(cfg_path)), + ("OPENAB_AGENT_MODEL", Some("anthropic/from-env")), + ], + || { + assert_eq!(anthropic_model().unwrap(), "anthropic/from-env"); + assert_eq!(resolve_provider_choice(), "anthropic"); + }, + ); + + // no env → config.json supplies the model (and its provider prefix) + temp_env::with_vars( + [ + ("OPENAB_CONFIG_PATH", Some(cfg_path)), + ("OPENAB_AGENT_MODEL", None), + ("OPENAB_AGENT_PROVIDER", None), + ], + || { + assert_eq!(anthropic_model().unwrap(), "anthropic/from-config"); + assert_eq!(resolve_provider_choice(), "anthropic"); + }, + ); + + // neither env nor config → fail loud + temp_env::with_vars( + [ + ( + "OPENAB_CONFIG_PATH", + Some(dir.path().join("missing.json").to_str().unwrap()), + ), + ("OPENAB_AGENT_MODEL", None), + ], + || assert!(anthropic_model().is_err()), + ); + } + + #[test] + fn select_provider_fails_loud_on_misconfigured_anthropic() { + // F3: an Anthropic credential is present (API key) but no model is set, so + // auto() fails for a config reason. select_provider must surface that + // error, not silently fall through to Codex. + temp_env::with_vars( + [ + ("ANTHROPIC_API_KEY", Some("sk-ant-test")), + ("CLAUDE_CODE_OAUTH_TOKEN", None), + ("OPENAB_AGENT_MODEL", None), + ("OPENAB_AGENT_PROVIDER", None), + ], + || { + // `Box` isn't Debug, so match rather than unwrap_err. + let err = match select_provider("") { + Ok(_) => panic!("expected a fail-loud error, got a provider"), + Err(e) => e, + }; + assert!(err.contains("present but unusable"), "got: {err}"); + }, + ); + } + + #[test] + fn test_build_request_body() { + let provider = test_provider(AnthropicAuth::ApiKey("test".to_string())); let messages = vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { @@ -681,10 +1158,48 @@ mod tests { let body = provider.build_request_body("system prompt", &messages, &[]); assert_eq!(body["model"], "claude-sonnet-4-20250514"); assert_eq!(body["max_tokens"], 4096); + // API-key mode keeps the plain-string system prompt. assert_eq!(body["system"], "system prompt"); assert_eq!(body["messages"][0]["role"], "user"); } + #[test] + fn test_build_request_body_oauth_injects_claude_code_identity_and_caps_tools() { + let provider = test_provider(AnthropicAuth::OAuth); + let messages = vec![Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: json!({"path": "/tmp/x"}), + }], + }]; + let tools = vec![ToolDef { + name: "bash".to_string(), + description: "run".to_string(), + input_schema: json!({}), + }]; + let body = provider.build_request_body("real prompt", &messages, &tools); + // system[0] must be the Claude Code identity, real prompt appended. + assert_eq!( + body["system"][0]["text"], + "You are Claude Code, Anthropic's official CLI for Claude." + ); + assert_eq!(body["system"][1]["text"], "real prompt"); + // tool def + assistant tool_use names normalised to CC casing. + assert_eq!(body["tools"][0]["name"], "Bash"); + assert_eq!(body["messages"][0]["content"][0]["name"], "Read"); + } + + #[test] + fn test_claude_code_name_round_trip_and_passthrough() { + assert_eq!(to_claude_code_name("read"), "Read"); + assert_eq!(from_claude_code_name("Read"), "read"); + // unknown (e.g. MCP) names pass through unchanged both ways. + assert_eq!(to_claude_code_name("linear_search"), "linear_search"); + assert_eq!(from_claude_code_name("linear_search"), "linear_search"); + } + #[test] fn test_parse_openai_text_response() { let resp = json!({ diff --git a/openab-agent/src/main.rs b/openab-agent/src/main.rs index 95d059771..856045ae1 100644 --- a/openab-agent/src/main.rs +++ b/openab-agent/src/main.rs @@ -1,6 +1,7 @@ mod acp; mod agent; mod auth; +mod config; mod llm; mod mcp; mod skills; @@ -86,6 +87,12 @@ enum AuthProvider { }, /// OpenAI Codex via device code (headless servers) CodexDevice, + /// Anthropic Claude Pro/Max via browser PKCE flow + AnthropicOauth { + /// Print URL and paste the redirect instead of opening a browser + #[arg(long)] + no_browser: bool, + }, /// Show stored credentials Status, } @@ -118,6 +125,12 @@ async fn main() { std::process::exit(1); } } + AuthProvider::AnthropicOauth { no_browser } => { + if let Err(e) = auth::login_anthropic_browser_flow(no_browser).await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } AuthProvider::Status => { auth::show_status(); }