diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 6cf9ec2dda66..a5895fd2b358 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -238,11 +238,6 @@ async fn handle_first_time_setup(config: &Config) -> anyhow::Result<()> { cliclack::intro(style(" goose-configure ").on_cyan().black())?; let setup_method = cliclack::select("How would you like to set up your provider?") - .item( - "aimlapi", - "AI/ML API Login (Recommended)", - "Sign in with AI/ML API to automatically configure models", - ) .item( "openrouter", "OpenRouter Login (Recommended)", @@ -281,18 +276,6 @@ async fn handle_first_time_setup(config: &Config) -> anyhow::Result<()> { ); } } - "aimlapi" => { - if let Err(e) = handle_aimlapi_auth().await { - let _ = config.clear(); - println!( - " - {} AI/ML API sign-in failed: {} - Please try again or use manual configuration", - style("Error").red().italic(), - e, - ); - } - } "manual" => handle_manual_provider_setup(config).await, _ => unreachable!(), } @@ -1966,30 +1949,6 @@ pub fn configure_max_turns_dialog() -> anyhow::Result<()> { Ok(()) } -/// Handle AI/ML API authentication (authorization code + PKCE) -pub async fn handle_aimlapi_auth() -> anyhow::Result<()> { - use goose::config::{configure_aimlapi, signup_aimlapi::AimlapiAuth}; - - let mut auth_flow = AimlapiAuth::new()?; - let api_key = auth_flow.complete_flow().await?; - println!( - " -Sign-in complete!" - ); - - let config = Config::global(); - - println!( - " -Configuring AI/ML API..." - ); - configure_aimlapi(config, api_key)?; - - println!("AI/ML API configuration complete"); - - Ok(()) -} - /// Handle OpenRouter authentication pub async fn handle_openrouter_auth() -> anyhow::Result<()> { use goose::config::{configure_openrouter, signup_openrouter::OpenRouterAuth}; diff --git a/crates/goose/examples/aimlapi_auth.rs b/crates/goose/examples/aimlapi_auth.rs deleted file mode 100644 index d119738c3d43..000000000000 --- a/crates/goose/examples/aimlapi_auth.rs +++ /dev/null @@ -1,39 +0,0 @@ -// Example of AI/ML API authorization-code + PKCE authentication. -// -// Run with: cargo run --example aimlapi_auth -// -// Requires AIMLAPI_PARTNER_ID. To exercise a non-production environment, also -// set AIMLAPI_APP_URL (the API host) and AIMLAPI_WEB_URL (the consent screen). - -use goose::config::signup_aimlapi::AimlapiAuth; - -#[tokio::main] -async fn main() -> Result<(), Box> { - println!("Testing AI/ML API PKCE flow...\n"); - - let mut auth_flow = AimlapiAuth::new()?; - - println!("Starting authentication flow..."); - println!("This will:"); - println!("1. Register an authorization request carrying the PKCE challenge"); - println!("2. Open your browser to the consent screen"); - println!("3. Wait for the redirect back to the loopback listener"); - println!("4. Exchange the one-time code plus the verifier for an api-key\n"); - - match auth_flow.complete_flow().await { - Ok(api_key) => { - println!("\nAuthentication successful."); - println!( - "API key received: {}...", - &api_key.chars().take(10).collect::() - ); - println!("\nYou can now use this key with the aimlapi provider."); - } - Err(e) => { - eprintln!("\nAuthentication failed: {}", e); - eprintln!("Error details: {:?}", e); - } - } - - Ok(()) -} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index 94e63ac45df1..fcc331e03494 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -7,7 +7,6 @@ pub mod paths; pub mod permission; pub mod providers; pub mod search_path; -pub mod signup_aimlapi; pub mod signup_openrouter; pub mod signup_tetrate; pub mod tls; @@ -23,7 +22,6 @@ pub use extensions::{ }; pub use goose_providers::goose_mode::GooseMode; pub use permission::PermissionManager; -pub use signup_aimlapi::configure_aimlapi; pub use signup_openrouter::configure_openrouter; pub use signup_tetrate::configure_tetrate; diff --git a/crates/goose/src/config/signup_aimlapi/mod.rs b/crates/goose/src/config/signup_aimlapi/mod.rs deleted file mode 100644 index c84e44487726..000000000000 --- a/crates/goose/src/config/signup_aimlapi/mod.rs +++ /dev/null @@ -1,276 +0,0 @@ -pub mod server; - -#[cfg(test)] -mod tests; - -use anyhow::{anyhow, Result}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use rand::{distr::Alphanumeric, RngExt}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::time::Duration; -use tokio::sync::oneshot; -use tokio::time::timeout; - -use self::server::CallbackResult; - -/// Model the provider is set to after a successful sign-in. Any catalog id -/// works; this one is a broadly capable default the user can change with -/// `/model`. -const AIMLAPI_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5"; - -/// Account/consent host. Overridable so one build can be pointed at a -/// non-production environment for testing; the default is production and no -/// user ever needs to set it. -const AIMLAPI_APP_URL_ENV: &str = "AIMLAPI_APP_URL"; -pub(crate) const AIMLAPI_APP_URL_DEFAULT: &str = "https://app.aimlapi.com"; - -/// Where the browser consent screen lives (the page the user actually sees). -/// -/// This is sent as `verificationBaseUrl`, and the server hands it straight back -/// with `/agent/authorize` appended. The web app is served under an `/app/` -/// base path, so the base has to carry it: dropping the `/app` yields -/// `https://aimlapi.com/agent/authorize`, which is a 404 and strands the user -/// on the very first step of the flow. -const AIMLAPI_WEB_URL_ENV: &str = "AIMLAPI_WEB_URL"; -pub(crate) const AIMLAPI_WEB_URL_DEFAULT: &str = "https://aimlapi.com/app"; - -/// Partner attribution. AI/ML API expects a registered partner id on the -/// authorization request; it identifies goose as the integration that brought -/// the user, and carries no user data — no account, no prompt, no usage. -/// -/// goose's own registered id ships compiled in, so a normal install needs no -/// configuration. The environment variable exists to point a build at another -/// AI/ML API environment during testing, alongside the two URLs above. -const AIMLAPI_PARTNER_ID_ENV: &str = "AIMLAPI_PARTNER_ID"; -pub(crate) const AIMLAPI_PARTNER_ID_DEFAULT: &str = "part_R2KG8QMDBjtWAubVMgG0GF9L"; - -/// Loopback port the consent screen redirects back to. Chosen from the -/// ephemeral range and fixed, because it has to be registered with the -/// authorization request before the browser opens. -const CALLBACK_PORT: u16 = 53682; - -const AUTH_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes - -fn env_or(name: &str, default: &str) -> String { - std::env::var(name) - .ok() - .map(|v| v.trim().trim_end_matches('/').to_string()) - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| default.to_string()) -} - -/// Authorization-code + PKCE sign-in for AI/ML API (RFC 7636). -/// -/// Unlike a provider that takes the challenge as a browser query parameter, -/// AI/ML API starts the request server-side: the CLI POSTs the challenge and -/// its loopback redirect, gets back a consent URL, and the browser is sent -/// there. The code that comes back to the loopback listener is exchanged with -/// the verifier for an api-key — the key is minted only at that exchange, and -/// only once. -#[derive(Debug)] -pub struct PkceAuthFlow { - code_verifier: String, - code_challenge: String, - state: String, - request_id: Option, - server_shutdown_tx: Option>, -} - -#[derive(Debug, Serialize)] -struct CreateAuthorizationRequest { - #[serde(rename = "partnerId")] - partner_id: String, - #[serde(rename = "agentName")] - agent_name: String, - #[serde(rename = "codeChallenge")] - code_challenge: String, - #[serde(rename = "codeChallengeMethod")] - code_challenge_method: String, - #[serde(rename = "redirectUri")] - redirect_uri: String, - state: String, - #[serde(rename = "verificationBaseUrl")] - verification_base_url: String, -} - -#[derive(Debug, Deserialize)] -struct CreateAuthorizationResponse { - #[serde(rename = "requestId")] - request_id: String, - #[serde(rename = "verificationUriComplete")] - verification_uri_complete: String, -} - -#[derive(Debug, Serialize)] -struct ExchangeRequest { - code: String, - #[serde(rename = "codeVerifier")] - code_verifier: String, -} - -#[derive(Debug, Deserialize)] -struct ExchangeResponse { - status: String, - #[serde(rename = "apiKey")] - api_key: Option, -} - -impl PkceAuthFlow { - pub fn new() -> Result { - // RFC 7636 §4.1 allows 43..128 unreserved characters. - let code_verifier: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(96) - .map(char::from) - .collect(); - - let mut hasher = Sha256::new(); - hasher.update(&code_verifier); - let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - let state: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(24) - .map(char::from) - .collect(); - - Ok(Self { - code_verifier, - code_challenge, - state, - request_id: None, - server_shutdown_tx: None, - }) - } - - fn redirect_uri() -> String { - format!("http://127.0.0.1:{}/", CALLBACK_PORT) - } - - /// Registers the authorization request and returns the consent URL to open. - async fn start_authorization(&mut self) -> Result { - let app_url = env_or(AIMLAPI_APP_URL_ENV, AIMLAPI_APP_URL_DEFAULT); - let partner_id = env_or(AIMLAPI_PARTNER_ID_ENV, AIMLAPI_PARTNER_ID_DEFAULT); - - let body = CreateAuthorizationRequest { - partner_id, - agent_name: "goose".to_string(), - code_challenge: self.code_challenge.clone(), - code_challenge_method: "S256".to_string(), - redirect_uri: Self::redirect_uri(), - state: self.state.clone(), - verification_base_url: env_or(AIMLAPI_WEB_URL_ENV, AIMLAPI_WEB_URL_DEFAULT), - }; - - let response = Client::new() - .post(format!("{}/v1/agent-auth/authorizations", app_url)) - .json(&body) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let detail = response.text().await.unwrap_or_default(); - return Err(anyhow!( - "Could not start AI/ML API sign-in: {} - {}", - status, - detail - )); - } - - let created: CreateAuthorizationResponse = response.json().await?; - self.request_id = Some(created.request_id); - Ok(created.verification_uri_complete) - } - - /// Starts the loopback listener and waits for the browser to come back. - async fn wait_for_callback(&mut self) -> Result { - let (code_tx, code_rx) = oneshot::channel::(); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - self.server_shutdown_tx = Some(shutdown_tx); - - tokio::spawn(async move { - if let Err(e) = server::run_callback_server(code_tx, shutdown_rx).await { - eprintln!("Callback server error: {}", e); - } - }); - - match timeout(AUTH_TIMEOUT, code_rx).await { - Ok(Ok(result)) => Ok(result), - Ok(Err(_)) => Err(anyhow!("Did not receive an authorization code")), - Err(_) => Err(anyhow!("Sign-in timed out - please try again")), - } - } - - /// Exchanges the one-time code plus the verifier for the api-key. - async fn exchange_code(&self, code: String) -> Result { - let app_url = env_or(AIMLAPI_APP_URL_ENV, AIMLAPI_APP_URL_DEFAULT); - let response = Client::new() - .post(format!("{}/v1/agent-auth/token/code", app_url)) - .json(&ExchangeRequest { - code, - code_verifier: self.code_verifier.clone(), - }) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let detail = response.text().await.unwrap_or_default(); - return Err(anyhow!("Key exchange failed: {} - {}", status, detail)); - } - - let exchanged: ExchangeResponse = response.json().await?; - match (exchanged.status.as_str(), exchanged.api_key) { - ("approved", Some(key)) => Ok(key), - // The server deliberately does not distinguish an unknown code from - // a bad verifier from a real expiry, so neither does this message. - (status, _) => Err(anyhow!( - "Sign-in did not complete ({}). Please run the sign-in again.", - status - )), - } - } - - /// Full flow: register, open the browser, catch the redirect, exchange. - pub async fn complete_flow(&mut self) -> Result { - let consent_url = self.start_authorization().await?; - - println!("Opening your browser to authorize goose..."); - if let Err(e) = webbrowser::open(&consent_url) { - eprintln!("Could not open the browser automatically: {}", e); - println!("Open this URL manually: {}", consent_url); - } - - println!("Waiting for you to approve in the browser..."); - let callback = self.wait_for_callback().await?; - - // RFC 6749 §10.12: a redirect whose state is not the one we sent did not - // come from the request we started, so its code is not ours to redeem. - if callback.state.as_deref() != Some(self.state.as_str()) { - return Err(anyhow!( - "The sign-in response did not match this request - please try again" - )); - } - - let api_key = self.exchange_code(callback.code).await?; - - if let Some(tx) = self.server_shutdown_tx.take() { - let _ = tx.send(()); - } - - Ok(api_key) - } -} - -pub use self::PkceAuthFlow as AimlapiAuth; - -use crate::config::Config; - -pub fn configure_aimlapi(config: &Config, api_key: String) -> Result<()> { - config.set_secret("AIMLAPI_API_KEY", &api_key)?; - crate::config::set_active_provider(config, "aimlapi", AIMLAPI_DEFAULT_MODEL)?; - Ok(()) -} diff --git a/crates/goose/src/config/signup_aimlapi/server.rs b/crates/goose/src/config/signup_aimlapi/server.rs deleted file mode 100644 index 559f8492be58..000000000000 --- a/crates/goose/src/config/signup_aimlapi/server.rs +++ /dev/null @@ -1,139 +0,0 @@ -use anyhow::Result; -use axum::{ - extract::Query, - http::StatusCode, - response::{Html, IntoResponse}, - routing::get, - Router, -}; -use include_dir::{include_dir, Dir}; -use minijinja::{context, Environment}; -use serde::Deserialize; -use std::net::SocketAddr; -use tokio::sync::oneshot; - -static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/config/signup_aimlapi/templates"); - -#[derive(Debug, Deserialize)] -struct CallbackQuery { - code: Option, - state: Option, - error: Option, -} - -/// What the loopback listener hands back to the flow: the one-time code plus -/// the `state` the server echoed, so the caller can prove the redirect belongs -/// to the request it started (RFC 6749 §10.12). -#[derive(Debug)] -pub struct CallbackResult { - pub code: String, - pub state: Option, -} - -/// Run the callback server on 127.0.0.1:53682 -pub async fn run_callback_server( - code_tx: oneshot::Sender, - shutdown_rx: oneshot::Receiver<()>, -) -> Result<()> { - let app = Router::new().route("/", get(handle_callback)); - let addr = SocketAddr::from(([127, 0, 0, 1], 53682)); - let listener = tokio::net::TcpListener::bind(addr).await?; - let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(code_tx))); - - axum::serve(listener, app.with_state(state.clone()).into_make_service()) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await?; - - Ok(()) -} - -async fn handle_callback( - Query(params): Query, - state: axum::extract::State< - std::sync::Arc>>>, - >, -) -> impl IntoResponse { - if let Some(error) = params.error { - let mut env = Environment::new(); - let template_content = TEMPLATES_DIR - .get_file("error.html") - .expect("error.html template not found") - .contents_utf8() - .expect("error.html is not valid UTF-8"); - - env.add_template("error.html", template_content).unwrap(); - let tmpl = env.get_template("error.html").unwrap(); - let rendered = tmpl.render(context! { error => error }).unwrap(); - - return (StatusCode::BAD_REQUEST, Html(rendered)); - } - - if let Some(code) = params.code { - let mut tx_guard = state.lock().await; - if let Some(tx) = tx_guard.take() { - let _ = tx.send(CallbackResult { - code, - state: params.state, - }); - } - - let success_html = TEMPLATES_DIR - .get_file("success.html") - .expect("success.html template not found") - .contents_utf8() - .expect("success.html is not valid UTF-8"); - - return (StatusCode::OK, Html(success_html.to_string())); - } - - let invalid_html = TEMPLATES_DIR - .get_file("invalid.html") - .expect("invalid.html template not found") - .contents_utf8() - .expect("invalid.html is not valid UTF-8"); - - (StatusCode::BAD_REQUEST, Html(invalid_html.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::to_bytes; - - async fn error_response(error: &str) -> (StatusCode, String) { - let state = std::sync::Arc::new(tokio::sync::Mutex::new(None)); - let response = handle_callback( - Query(CallbackQuery { - code: None, - state: None, - error: Some(error.to_string()), - }), - axum::extract::State(state), - ) - .await - .into_response(); - let status = response.status(); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - (status, String::from_utf8(body.to_vec()).unwrap()) - } - - #[tokio::test] - async fn error_response_escapes_html() { - let payload = r#"&"#; - let (status, body) = error_response(payload).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert!(!body.contains(payload)); - assert!(body.contains("<script>")); - assert!(body.contains("&")); - } - - #[tokio::test] - async fn error_response_preserves_plain_text() { - let (_, body) = error_response("authorization denied").await; - - assert!(body.contains("authorization denied")); - } -} diff --git a/crates/goose/src/config/signup_aimlapi/templates/error.html b/crates/goose/src/config/signup_aimlapi/templates/error.html deleted file mode 100644 index b9effc9fba79..000000000000 --- a/crates/goose/src/config/signup_aimlapi/templates/error.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - Authentication Failed - - - -
-

❌ Authentication Failed

-

There was an error during the authentication process.

-
{{ error }}
-

Please close this tab and try again.

-
- - diff --git a/crates/goose/src/config/signup_aimlapi/templates/invalid.html b/crates/goose/src/config/signup_aimlapi/templates/invalid.html deleted file mode 100644 index 6bc9bbee8d5f..000000000000 --- a/crates/goose/src/config/signup_aimlapi/templates/invalid.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - Invalid Request - - - -
-

⚠️ Invalid Request

-

This doesn't appear to be a valid authentication callback.

-

Please close this tab and try the authentication process again.

-
- - diff --git a/crates/goose/src/config/signup_aimlapi/templates/success.html b/crates/goose/src/config/signup_aimlapi/templates/success.html deleted file mode 100644 index 6219cbcd5e42..000000000000 --- a/crates/goose/src/config/signup_aimlapi/templates/success.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - Authentication Successful - - - -
-
-

Authentication Successful!

-

You have successfully authenticated with AI/ML API.

-

You can now close this tab and return to goose.

-
- - diff --git a/crates/goose/src/config/signup_aimlapi/tests.rs b/crates/goose/src/config/signup_aimlapi/tests.rs deleted file mode 100644 index 9974677e5c67..000000000000 --- a/crates/goose/src/config/signup_aimlapi/tests.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::config::signup_aimlapi::{ - PkceAuthFlow, AIMLAPI_APP_URL_DEFAULT, AIMLAPI_PARTNER_ID_DEFAULT, AIMLAPI_WEB_URL_DEFAULT, -}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use sha2::{Digest, Sha256}; - -#[test] -fn challenge_is_the_s256_hash_of_the_verifier() { - let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); - - let mut hasher = Sha256::new(); - hasher.update(flow.code_verifier.as_bytes()); - let expected = URL_SAFE_NO_PAD.encode(hasher.finalize()); - - assert_eq!(flow.code_challenge, expected); -} - -#[test] -fn challenge_and_state_are_url_safe_and_unpadded() { - let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); - - for value in [&flow.code_challenge, &flow.state] { - assert!(!value.contains('='), "{value} is padded"); - assert!(!value.contains('+'), "{value} is not url-safe"); - assert!(!value.contains('/'), "{value} is not url-safe"); - } -} - -#[test] -fn each_flow_gets_its_own_verifier_state_and_challenge() { - let a = PkceAuthFlow::new().expect("Failed to create PKCE flow 1"); - let b = PkceAuthFlow::new().expect("Failed to create PKCE flow 2"); - - assert_ne!(a.code_verifier, b.code_verifier); - assert_ne!(a.code_challenge, b.code_challenge); - assert_ne!(a.state, b.state); -} - -#[test] -fn consent_base_keeps_the_app_path() { - // The server appends "/agent/authorize" to whatever base it is handed. The - // web app is served under an "/app/" base path, so dropping it produces - // https://aimlapi.com/agent/authorize — a 404 that strands the user on the - // first step of the flow. This has to survive future tidying of the URL. - assert!( - AIMLAPI_WEB_URL_DEFAULT.ends_with("/app"), - "consent base must carry the /app path, got {AIMLAPI_WEB_URL_DEFAULT}" - ); -} - -#[test] -fn api_and_consent_hosts_are_distinct() { - // The registration/exchange calls go to the API host; only the browser is - // sent to the consent host. Collapsing the two would send API calls to the - // marketing site. - assert_ne!(AIMLAPI_APP_URL_DEFAULT, AIMLAPI_WEB_URL_DEFAULT); - assert!(AIMLAPI_APP_URL_DEFAULT.starts_with("https://")); - assert!(AIMLAPI_WEB_URL_DEFAULT.starts_with("https://")); -} - -#[test] -fn partner_id_matches_the_gateway_pattern() { - // The gateway only attributes ids shaped part_; anything else is - // treated as untagged usage and earns nothing. - let id = AIMLAPI_PARTNER_ID_DEFAULT; - - assert!(id.starts_with("part_"), "{id} lacks the part_ prefix"); - let rest = &id["part_".len()..]; - assert!(!rest.is_empty(), "{id} has an empty body"); - assert!( - rest.chars().all(|c| c.is_ascii_alphanumeric()), - "{id} has non-alphanumeric characters after the prefix" - ); -} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index a879eb33c387..46cbb0d7f071 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -344,27 +344,6 @@ mod tests { .config_keys .iter() .any(|key| key.name == "AIMLAPI_API_KEY" && key.secret)); - - // The whole product claim, empirically: sorted with everything else - // registered, "AI/ML API" lands before "Amazon Bedrock" ("AI" < "Am") - // with no special-casing anywhere - same sort ProviderGrid.tsx uses. - let mut names: Vec = providers() - .await - .into_iter() - .map(|(m, _)| m.display_name) - .collect(); - names.sort(); - let aimlapi_pos = names - .iter() - .position(|n| n == "AI/ML API") - .expect("AI/ML API should be in the full provider list"); - let bedrock_pos = names.iter().position(|n| n == "Amazon Bedrock"); - if let Some(bedrock_pos) = bedrock_pos { - assert!( - aimlapi_pos < bedrock_pos, - "AI/ML API should sort before Amazon Bedrock" - ); - } } #[tokio::test]