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-core/src/pre_seed.rs b/crates/openab-core/src/pre_seed.rs index c5695391e..8eee27874 100644 --- a/crates/openab-core/src/pre_seed.rs +++ b/crates/openab-core/src/pre_seed.rs @@ -222,7 +222,7 @@ fn extract_zip_budgeted( for i in 0..file_count { // Cooperative deadline check per file - if i % 100 == 0 && Instant::now() >= deadline { + if i.is_multiple_of(100) && Instant::now() >= deadline { anyhow::bail!("hooks.pre_seed: timed out during extraction at entry {i}"); } @@ -286,7 +286,7 @@ fn extract_tarball_with_limits(data: &[u8], dest: &Path, deadline: Instant) -> a } // Cooperative deadline check every 10 files - if file_count % 10 == 0 && Instant::now() >= deadline { + if file_count.is_multiple_of(10) && Instant::now() >= deadline { anyhow::bail!("hooks.pre_seed: timed out during tarball extraction at entry {file_count}"); } 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..11e98bceb 100644 --- a/docs/native-agent.md +++ b/docs/native-agent.md @@ -31,6 +31,7 @@ env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" } | Variable | Default | Description | |----------|---------|-------------| +| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id (e.g. `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. | | `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`) | diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index 1113c8ad4..615602697 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_store_with_model(m), + (_, None) => AnthropicProvider::from_oauth_store(), }; 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_store_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 d9a701237..a30292947 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,15 +24,46 @@ 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; +// 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"; + fn codex_client_id() -> String { std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) } +fn anthropic_client_id() -> String { + std::env::var("OPENAB_AGENT_ANTHROPIC_CLIENT_ID") + .unwrap_or_else(|_| "9d1c250a-e61b-44d9-88ed-5944d1962f5e".to_string()) +} + fn redirect_uri() -> String { format!("http://localhost:{REDIRECT_PORT}/auth/callback") } +fn anthropic_redirect_uri() -> String { + format!("http://localhost:{ANTHROPIC_REDIRECT_PORT}/callback") +} + +/// Build the Anthropic authorize URL. Pure so it can be unit-tested. `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 anthropic_authorize_url(challenge: &str, state: &str) -> String { + let client_id = anthropic_client_id(); + let redirect = anthropic_redirect_uri(); + let redir = urlencoding::encode(&redirect); + let scope = urlencoding::encode(ANTHROPIC_SCOPE); + format!( + "{ANTHROPIC_AUTHORIZE_URL}?code=true&client_id={client_id}&response_type=code&redirect_uri={redir}&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&state={state}" + ) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenStore { pub access_token: String, @@ -214,30 +247,49 @@ fn write_auth_file(path: &Path, map: &HashMap) -> Result<()> Ok(()) } -pub fn load_tokens() -> Result { +/// 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" + } +} + +/// 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<()> { +/// Save a token under its own `provider` field as the namespace key, leaving +/// every other tenant in `auth.json` untouched. +fn save_tokens_for(store: &TokenStore) -> Result<()> { let path = auth_path(); let mut map = read_auth_file(&path).unwrap_or_default(); - map.insert(CODEX_NAMESPACE.to_string(), AuthEntry::Token(store.clone())); + map.insert(store.provider.clone(), AuthEntry::Token(store.clone())); write_auth_file(&path, &map) } +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 @@ -328,38 +380,63 @@ impl CredentialStore for McpCredentialStore { } } -pub async fn get_valid_token() -> Result { - let mut store = load_tokens()?; +pub async fn get_valid_token_for(namespace: &str) -> Result { + let mut store = load_tokens_for(namespace)?; if store.is_expired() { store = refresh_token(&store).await?; - save_tokens(&store)?; + save_tokens_for(&store)?; } Ok(store.access_token) } -pub async fn force_refresh() -> Result { - let store = load_tokens()?; +pub async fn force_refresh_for(namespace: &str) -> Result { + 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 client = reqwest::Client::new(); - 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?; + // Anthropic's token endpoint takes a JSON body and rejects a `scope` field + // on refresh (Pi #2169); Codex takes a form body. Branch on the stored + // provider so each tenant refreshes the way its AS expects. + let resp = if store.provider == ANTHROPIC_NAMESPACE { + client + .post(&store.token_endpoint) + .json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": store.refresh_token, + "client_id": anthropic_client_id(), + })) + .send() + .await? + } else { + client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", store.refresh_token.as_str()), + ("client_id", codex_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"] @@ -474,7 +551,7 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -557,7 +634,118 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + Ok(()) +} + +/// 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") + .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 redirect. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + 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) +} + +/// Anthropic OAuth (Claude Pro/Max). PKCE with an independent random CSRF +/// `state` (verifier stays back-channel-only) and a JSON token exchange against +/// `platform.claude.com`. +pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { + let (verifier, challenge) = generate_pkce(); + // Independent random CSRF state — keep the PKCE verifier back-channel-only. + // 32 bytes: claude.ai's authorize rejects a short state ("Invalid request + // format"); matching the verifier's length keeps it happy while the value + // stays independent (full PKCE strength). + let mut state_buf = [0u8; 32]; + getrandom::fill(&mut state_buf).expect("getrandom failed"); + let state = URL_SAFE_NO_PAD.encode(state_buf); + let auth_url = anthropic_authorize_url(&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) and paste it here:\n" + ); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| anyhow!("Failed to read input: {e}"))?; + let input = input.trim(); + if input.is_empty() { + return Err(anyhow!("No URL provided")); + } + // 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") + })?; + if st != state { + return Err(anyhow!("State mismatch")); + } + code.to_string() + } + } else { + let listener = TcpListener::bind(format!("127.0.0.1:{ANTHROPIC_REDIRECT_PORT}")).map_err( + |e| { + anyhow!("Failed to bind port {ANTHROPIC_REDIRECT_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\n {auth_url}\n"); + } + println!("Waiting for callback..."); + accept_callback_code(&listener, &state)? + }; + + let store = exchange_anthropic_code(&code, &state, &verifier).await?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -565,6 +753,42 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { Ok(()) } +async fn exchange_anthropic_code(code: &str, state: &str, verifier: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(ANTHROPIC_TOKEN_URL) + .json(&serde_json::json!({ + "grant_type": "authorization_code", + "client_id": anthropic_client_id(), + "code": code, + "state": state, + "redirect_uri": anthropic_redirect_uri(), + "code_verifier": verifier, + })) + .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(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: ANTHROPIC_TOKEN_URL.to_string(), + provider: ANTHROPIC_NAMESPACE.to_string(), + }) +} + // Device code flow pub async fn login_codex_device_flow() -> Result<()> { println!("Starting OpenAI Codex device-code login...\n"); @@ -649,7 +873,7 @@ pub async fn login_codex_device_flow() -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -681,31 +905,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)] @@ -779,6 +1021,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 = anthropic_authorize_url("CHAL", "STATE"); + 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, diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index de5e1eb6c..a854fd21e 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,21 +97,61 @@ impl std::ops::Deref for SharedLlmProvider { } } +/// 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). Model IDs never +/// contain `/`, so the first `/` cleanly separates the two. +#[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 !p.is_empty() && !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: explicit `OPENAB_AGENT_PROVIDER`, else the +/// `provider/` prefix of `OPENAB_AGENT_MODEL` (e.g. `openai/gpt-5.4` selects +/// OpenAI even when an Anthropic key is also present), else empty (auto-detect). +pub fn resolve_provider_choice() -> String { + match std::env::var("OPENAB_AGENT_PROVIDER") { + Ok(p) if !p.is_empty() => p, + _ => std::env::var("OPENAB_AGENT_MODEL") + .ok() + .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_store()?)), "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}" + "No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `openab-agent auth codex-oauth`. {e}" )), }, }, @@ -116,57 +163,145 @@ 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. +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, +} + /// 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 from `OPENAB_AGENT_MODEL`. 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 { + std::env::var("OPENAB_AGENT_MODEL") + .map_err(|_| "no model configured; set OPENAB_AGENT_MODEL or select a model".to_string()) +} + +fn anthropic_max_tokens() -> u32 { + std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(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()?)) + } + + /// Prefer an explicit API key, else a stored Claude subscription OAuth token. + /// When a key is present its own errors (e.g. missing model) surface rather + /// than falling through to an unrelated OAuth-token error. + pub fn auto() -> Result { + match Self::api_key_from_env() { + Ok(key) => Ok(Self::build(AnthropicAuth::ApiKey(key), anthropic_model()?)), + Err(_) => 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 { + Self::api_key_from_env() + .map(|key| Self::build(AnthropicAuth::ApiKey(key), model.to_string())) + .or_else(|_| 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())) } 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 +321,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 +371,10 @@ impl LlmProvider for AnthropicProvider { &self.model } + fn is_oauth(&self) -> bool { + matches!(self.auth, AnthropicAuth::OAuth) + } + fn chat<'a>( &'a self, system: &'a str, @@ -228,15 +383,31 @@ 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(); let max_retries = 3u32; 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: Bearer + Claude Code identity headers. + let token = + crate::auth::get_valid_token_for(crate::auth::ANTHROPIC_NAMESPACE) + .await?; + 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") + } + }; + + let resp = req .json(&body) .send() .await @@ -251,6 +422,14 @@ 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 oauth && status.as_u16() == 401 && attempt < max_retries { + 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 +440,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 +521,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 +538,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 +821,33 @@ 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/"); + } + + #[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 +883,26 @@ 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. + assert!(test_provider(AnthropicAuth::OAuth).is_oauth()); + assert!(!test_provider(AnthropicAuth::ApiKey("k".to_string())).is_oauth()); + } + + #[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 +912,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..068a74fd4 100644 --- a/openab-agent/src/main.rs +++ b/openab-agent/src/main.rs @@ -86,6 +86,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 +124,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(); }