diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 3a8e792ee5e2..ffd1e4cd2f0e 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -64,106 +64,141 @@ pub async fn handle_configure() -> Result<(), Box> { ); println!(); cliclack::intro(style(" goose-configure ").on_cyan().black())?; - match configure_provider_dialog().await { - Ok(true) => { - println!( - "\n {}: Run '{}' again to adjust your config or add extensions", - style("Tip").green().italic(), - style("goose configure").cyan() - ); - // Since we are setting up for the first time, we'll also enable the developer system - // This operation is best-effort and errors are ignored - ExtensionConfigManager::set(ExtensionEntry { - enabled: true, - config: ExtensionConfig::Builtin { - name: "developer".to_string(), - display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), - timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), - bundled: Some(true), - description: None, - }, - })?; - } - Ok(false) => { - let _ = config.clear(); - println!( - "\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect", - style("Warning").yellow().italic(), - style("goose configure").cyan() - ); - } - Err(e) => { - let _ = config.clear(); - match e.downcast_ref::() { - Some(ConfigError::NotFound(key)) => { - println!( - "\n {} Required configuration key '{}' not found \n Please provide this value and run '{}' again", - style("Error").red().italic(), - key, - style("goose configure").cyan() - ); - } - Some(ConfigError::KeyringError(msg)) => { - #[cfg(target_os = "macos")] - println!( - "\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.", - style("Error").red().italic(), - msg, - style("goose configure").cyan() - ); - - #[cfg(target_os = "windows")] - println!( - "\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.", - style("Error").red().italic(), - msg, - style("goose configure").cyan() - ); + // Check if user wants to use OpenRouter login or manual configuration + let setup_method = cliclack::select("How would you like to set up your provider?") + .item( + "openrouter", + "OpenRouter Login (Recommended)", + "Sign in with OpenRouter to automatically configure models", + ) + .item( + "manual", + "Manual Configuration", + "Choose a provider and enter credentials manually", + ) + .interact()?; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - println!( - "\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.", - style("Error").red().italic(), - msg, - style("goose configure").cyan() - ); - } - Some(ConfigError::DeserializeError(msg)) => { - println!( - "\n {} Invalid configuration value: {} \n Please check your input and run '{}' again", - style("Error").red().italic(), - msg, - style("goose configure").cyan() - ); + match setup_method { + "openrouter" => { + match handle_openrouter_auth().await { + Ok(_) => { + // OpenRouter auth already handles everything including enabling developer extension } - Some(ConfigError::FileError(e)) => { + Err(e) => { + let _ = config.clear(); println!( - "\n {} Failed to access config file: {} \n Please check file permissions and run '{}' again", + "\n {} OpenRouter authentication failed: {} \n Please try again or use manual configuration", style("Error").red().italic(), e, - style("goose configure").cyan() ); } - Some(ConfigError::DirectoryError(msg)) => { + } + } + "manual" => { + match configure_provider_dialog().await { + Ok(true) => { println!( - "\n {} Failed to access config directory: {} \n Please check directory permissions and run '{}' again", - style("Error").red().italic(), - msg, + "\n {}: Run '{}' again to adjust your config or add extensions", + style("Tip").green().italic(), style("goose configure").cyan() ); + // Since we are setting up for the first time, we'll also enable the developer system + // This operation is best-effort and errors are ignored + ExtensionConfigManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Builtin { + name: "developer".to_string(), + display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), + timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + bundled: Some(true), + description: None, + }, + })?; } - // handle all other nonspecific errors - _ => { + Ok(false) => { + let _ = config.clear(); println!( - "\n {} {} \n We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect", - style("Error").red().italic(), - e, + "\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect", + style("Warning").yellow().italic(), style("goose configure").cyan() ); } + Err(e) => { + let _ = config.clear(); + + match e.downcast_ref::() { + Some(ConfigError::NotFound(key)) => { + println!( + "\n {} Required configuration key '{}' not found \n Please provide this value and run '{}' again", + style("Error").red().italic(), + key, + style("goose configure").cyan() + ); + } + Some(ConfigError::KeyringError(msg)) => { + #[cfg(target_os = "macos")] + println!( + "\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + + #[cfg(target_os = "windows")] + println!( + "\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + println!( + "\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + } + Some(ConfigError::DeserializeError(msg)) => { + println!( + "\n {} Invalid configuration value: {} \n Please check your input and run '{}' again", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + } + Some(ConfigError::FileError(e)) => { + println!( + "\n {} Failed to access config file: {} \n Please check file permissions and run '{}' again", + style("Error").red().italic(), + e, + style("goose configure").cyan() + ); + } + Some(ConfigError::DirectoryError(msg)) => { + println!( + "\n {} Failed to access config directory: {} \n Please check directory permissions and run '{}' again", + style("Error").red().italic(), + msg, + style("goose configure").cyan() + ); + } + // handle all other nonspecific errors + _ => { + println!( + "\n {} {} \n We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect", + style("Error").red().italic(), + e, + style("goose configure").cyan() + ); + } + } + } } } + _ => unreachable!(), } Ok(()) } else { @@ -1465,3 +1500,96 @@ pub fn configure_max_turns_dialog() -> Result<(), Box> { Ok(()) } + +/// Handle OpenRouter authentication +pub async fn handle_openrouter_auth() -> Result<(), Box> { + use goose::config::{configure_openrouter, signup_openrouter::OpenRouterAuth}; + use goose::message::Message; + use goose::providers::create; + + // Use the OpenRouter authentication flow + let mut auth_flow = OpenRouterAuth::new()?; + match auth_flow.complete_flow().await { + Ok(api_key) => { + println!("\nAuthentication complete!"); + + // Get config instance + let config = Config::global(); + + // Use the existing configure_openrouter function to set everything up + println!("\nConfiguring OpenRouter..."); + if let Err(e) = configure_openrouter(config, api_key) { + eprintln!("Failed to configure OpenRouter: {}", e); + return Err(e.into()); + } + + println!("✓ OpenRouter configuration complete"); + println!("✓ Models configured successfully"); + + // Test configuration - get the model that was configured + println!("\nTesting configuration..."); + let configured_model: String = config.get_param("GOOSE_MODEL")?; + let model_config = goose::model::ModelConfig::new(configured_model); + match create("openrouter", model_config) { + Ok(provider) => { + // Simple test request + let test_result = provider + .complete( + "You are Goose, an AI assistant.", + &[Message::user().with_text("Say 'Configuration test successful!'")], + &[], + ) + .await; + + match test_result { + Ok(_) => { + println!("✓ Configuration test passed!"); + + // Enable the developer extension by default if not already enabled + let entries = ExtensionConfigManager::get_all()?; + let has_developer = entries + .iter() + .any(|e| e.config.name() == "developer" && e.enabled); + + if !has_developer { + match ExtensionConfigManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Builtin { + name: "developer".to_string(), + display_name: Some( + goose::config::DEFAULT_DISPLAY_NAME.to_string(), + ), + timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), + bundled: Some(true), + description: None, + }, + }) { + Ok(_) => println!("✓ Developer extension enabled"), + Err(e) => { + eprintln!("⚠️ Failed to enable developer extension: {}", e) + } + } + } + + cliclack::outro("OpenRouter setup complete! You can now use Goose.")?; + } + Err(e) => { + eprintln!("⚠️ Configuration test failed: {}", e); + eprintln!("Your settings have been saved, but there may be an issue with the connection."); + } + } + } + Err(e) => { + eprintln!("⚠️ Failed to create provider for testing: {}", e); + eprintln!("Your settings have been saved. Please check your configuration."); + } + } + } + Err(e) => { + eprintln!("Authentication failed: {}", e); + return Err(e.into()); + } + } + + Ok(()) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index b757baa0306d..0c14880bde0b 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -10,6 +10,7 @@ pub mod recipe; pub mod reply; pub mod schedule; pub mod session; +pub mod setup; pub mod utils; use std::sync::Arc; @@ -29,4 +30,5 @@ pub fn configure(state: Arc) -> Router { .merge(session::routes(state.clone())) .merge(schedule::routes(state.clone())) .merge(project::routes(state.clone())) + .merge(setup::routes(state.clone())) } diff --git a/crates/goose-server/src/routes/setup.rs b/crates/goose-server/src/routes/setup.rs new file mode 100644 index 000000000000..017315e57da6 --- /dev/null +++ b/crates/goose-server/src/routes/setup.rs @@ -0,0 +1,60 @@ +use crate::state::AppState; +use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; +use goose::config::signup_openrouter::OpenRouterAuth; +use goose::config::{configure_openrouter, Config}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Serialize)] +pub struct SetupResponse { + pub success: bool, + pub message: String, +} + +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/handle_openrouter", post(start_openrouter_setup)) + .with_state(state) +} + +async fn start_openrouter_setup( + State(_state): State>, +) -> Result, StatusCode> { + tracing::info!("Starting OpenRouter setup flow"); + + let mut auth_flow = OpenRouterAuth::new().map_err(|e| { + tracing::error!("Failed to initialize auth flow: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + tracing::info!("Auth flow initialized, starting complete_flow"); + + match auth_flow.complete_flow().await { + Ok(api_key) => { + tracing::info!("Got API key, configuring OpenRouter..."); + + let config = Config::global(); + + if let Err(e) = configure_openrouter(config, api_key) { + tracing::error!("Failed to configure OpenRouter: {}", e); + return Ok(Json(SetupResponse { + success: false, + message: format!("Failed to configure OpenRouter: {}", e), + })); + } + + tracing::info!("OpenRouter setup completed successfully"); + Ok(Json(SetupResponse { + success: true, + message: "OpenRouter setup completed successfully".to_string(), + })) + } + Err(e) => { + tracing::error!("OpenRouter setup failed: {}", e); + Ok(Json(SetupResponse { + success: false, + message: format!("Setup failed: {}", e), + })) + } + } +} diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index bf5c045ea02d..ceb05db371f6 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -53,7 +53,6 @@ nanoid = "0.4" sha2 = "0.10" base64 = "0.21" url = "2.5" -urlencoding = "2.1" axum = "0.8.1" webbrowser = "0.8" lazy_static = "1.5.0" @@ -66,6 +65,7 @@ etcetera = "0.8.0" rand = "0.8.5" utoipa = { version = "4.1", features = ["chrono"] } tokio-cron-scheduler = "0.14.0" +urlencoding = "2.1" # For Bedrock provider aws-config = { version = "1.5.16", features = ["behavior-version-latest"] } diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index ca9abb01e7ae..eaa40072ea5e 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -2,12 +2,14 @@ pub mod base; mod experiments; pub mod extensions; pub mod permission; +pub mod signup_openrouter; pub use crate::agents::ExtensionConfig; pub use base::{Config, ConfigError, APP_STRATEGY}; pub use experiments::ExperimentManager; pub use extensions::{ExtensionConfigManager, ExtensionEntry}; pub use permission::PermissionManager; +pub use signup_openrouter::configure_openrouter; pub use extensions::DEFAULT_DISPLAY_NAME; pub use extensions::DEFAULT_EXTENSION; diff --git a/crates/goose/src/config/signup_openrouter/mod.rs b/crates/goose/src/config/signup_openrouter/mod.rs new file mode 100644 index 000000000000..47eb5dd9b38b --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/mod.rs @@ -0,0 +1,175 @@ +pub mod server; + +#[cfg(test)] +mod tests; + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::{distributions::Alphanumeric, Rng}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use tokio::sync::oneshot; +use tokio::time::timeout; + +/// Default models for openrouter config configuration +const OPENROUTER_DEFAULT_MODEL: &str = "qwen/qwen3-coder"; + +const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth"; +const OPENROUTER_TOKEN_URL: &str = "https://openrouter.ai/api/v1/auth/keys"; +const CALLBACK_URL: &str = "http://localhost:3000"; +const AUTH_TIMEOUT: Duration = Duration::from_secs(180); // 3 minutes + +#[derive(Debug)] +pub struct PkceAuthFlow { + code_verifier: String, + code_challenge: String, + server_shutdown_tx: Option>, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + key: String, +} + +#[derive(Debug, Serialize)] +struct TokenRequest { + code: String, + code_verifier: String, + code_challenge_method: String, +} + +impl PkceAuthFlow { + pub fn new() -> Result { + let code_verifier: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(128) + .map(char::from) + .collect(); + + let mut hasher = Sha256::new(); + hasher.update(&code_verifier); + let hash = hasher.finalize(); + + let code_challenge = URL_SAFE_NO_PAD.encode(hash); + + Ok(Self { + code_verifier, + code_challenge, + server_shutdown_tx: None, + }) + } + + pub fn get_auth_url(&self) -> String { + format!( + "{}?callback_url={}&code_challenge={}&code_challenge_method=S256", + OPENROUTER_AUTH_URL, + urlencoding::encode(CALLBACK_URL), + urlencoding::encode(&self.code_challenge) + ) + } + + /// Start local server and wait for callback + pub async fn start_server(&mut self) -> Result { + let (code_tx, code_rx) = oneshot::channel::(); + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + // Store shutdown sender so we can stop the server later + self.server_shutdown_tx = Some(shutdown_tx); + + // Start the server in a background task + tokio::spawn(async move { + if let Err(e) = server::run_callback_server(code_tx, shutdown_rx).await { + eprintln!("Server error: {}", e); + } + }); + + // Wait for the authorization code with timeout + match timeout(AUTH_TIMEOUT, code_rx).await { + Ok(Ok(code)) => Ok(code), + Ok(Err(_)) => Err(anyhow!("Failed to receive authorization code")), + Err(_) => Err(anyhow!("Authentication timeout - please try again")), + } + } + + pub async fn exchange_code(&self, code: String) -> Result { + let client = Client::new(); + + let request_body = TokenRequest { + code: code.clone(), + code_verifier: self.code_verifier.clone(), + code_challenge_method: "S256".to_string(), + }; + + eprintln!("Exchanging code for API key..."); + eprintln!("Code: {}", code); + eprintln!("Code verifier length: {}", self.code_verifier.len()); + eprintln!("Code challenge: {}", self.code_challenge); + + let response = client + .post(OPENROUTER_TOKEN_URL) + .json(&request_body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + eprintln!("Token exchange failed!"); + eprintln!("Status: {}", status); + eprintln!("Error response: {}", error_text); + return Err(anyhow!( + "Failed to exchange code: {} - {}", + status, + error_text + )); + } + + let token_response: TokenResponse = response.json().await?; + Ok(token_response.key) + } + + /// Complete flow: open browser, wait for callback, exchange code + pub async fn complete_flow(&mut self) -> Result { + let auth_url = self.get_auth_url(); + + println!("Opening browser for authentication..."); + eprintln!("Auth URL: {}", auth_url); + + if let Err(e) = webbrowser::open(&auth_url) { + eprintln!("Failed to open browser automatically: {}", e); + println!("Please open this URL manually: {}", auth_url); + } + + println!("Waiting for authentication callback..."); + let code = self.start_server().await?; + + println!("Authorization code received. Exchanging for API key..."); + eprintln!("Received code: {}", code); + + let api_key = self.exchange_code(code).await?; + + // Shutdown the server if it's still running + if let Some(tx) = self.server_shutdown_tx.take() { + let _ = tx.send(()); + } + + Ok(api_key) + } +} + +pub use self::PkceAuthFlow as OpenRouterAuth; + +use crate::config::Config; +use serde_json::Value; + +pub fn configure_openrouter(config: &Config, api_key: String) -> Result<()> { + config.set_secret("OPENROUTER_API_KEY", Value::String(api_key))?; + config.set_param("GOOSE_PROVIDER", Value::String("openrouter".to_string()))?; + config.set_param( + "GOOSE_MODEL", + Value::String(OPENROUTER_DEFAULT_MODEL.to_string()), + )?; + Ok(()) +} diff --git a/crates/goose/src/config/signup_openrouter/server.rs b/crates/goose/src/config/signup_openrouter/server.rs new file mode 100644 index 000000000000..d809382f6f84 --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/server.rs @@ -0,0 +1,86 @@ +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_openrouter/templates"); + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: Option, + error: Option, +} + +/// Run the callback server on localhost:3000 +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], 3000)); + 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", template_content).unwrap(); + let tmpl = env.get_template("error").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(code); + } + + 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())) +} diff --git a/crates/goose/src/config/signup_openrouter/templates/error.html b/crates/goose/src/config/signup_openrouter/templates/error.html new file mode 100644 index 000000000000..b9effc9fba79 --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/templates/error.html @@ -0,0 +1,50 @@ + + + + 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_openrouter/templates/invalid.html b/crates/goose/src/config/signup_openrouter/templates/invalid.html new file mode 100644 index 000000000000..6bc9bbee8d5f --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/templates/invalid.html @@ -0,0 +1,39 @@ + + + + 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_openrouter/templates/success.html b/crates/goose/src/config/signup_openrouter/templates/success.html new file mode 100644 index 000000000000..c6c97a4d0d7c --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/templates/success.html @@ -0,0 +1,45 @@ + + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

You have successfully authenticated with OpenRouter.

+

You can now close this tab and return to Goose.

+
+ + diff --git a/crates/goose/src/config/signup_openrouter/tests.rs b/crates/goose/src/config/signup_openrouter/tests.rs new file mode 100644 index 000000000000..3ea87b0e035d --- /dev/null +++ b/crates/goose/src/config/signup_openrouter/tests.rs @@ -0,0 +1,68 @@ +#[cfg(test)] +mod tests { + use crate::config::signup_openrouter::PkceAuthFlow; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use sha2::{Digest, Sha256}; + + #[test] + fn test_pkce_flow_creation() { + let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); + + // Verify code_verifier is 128 characters + assert_eq!(flow.code_verifier.len(), 128); + + // Verify code_challenge is base64url encoded (no padding) + assert!(!flow.code_challenge.contains('=')); + assert!(!flow.code_challenge.contains('+')); + assert!(!flow.code_challenge.contains('/')); + + // Verify auth URL is properly formatted + let auth_url = flow.get_auth_url(); + assert!(auth_url.starts_with("https://openrouter.ai/auth")); + assert!(auth_url.contains("callback_url=http%3A%2F%2Flocalhost%3A3000")); + assert!(auth_url.contains(&format!("code_challenge={}", flow.code_challenge))); + assert!(auth_url.contains("code_challenge_method=S256")); + } + + #[test] + fn test_different_flows_have_different_verifiers() { + let flow1 = PkceAuthFlow::new().expect("Failed to create PKCE flow 1"); + let flow2 = PkceAuthFlow::new().expect("Failed to create PKCE flow 2"); + + // Verify that different flows have different verifiers and challenges + assert_ne!(flow1.code_verifier, flow2.code_verifier); + assert_ne!(flow1.code_challenge, flow2.code_challenge); + } + + #[test] + fn test_code_verifier_is_alphanumeric() { + let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); + + // Verify all characters in code_verifier are alphanumeric + assert!(flow.code_verifier.chars().all(|c| c.is_alphanumeric())); + } + + #[test] + fn test_code_challenge_matches_verifier() { + let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); + + // Manually compute the expected challenge + let mut hasher = Sha256::new(); + hasher.update(&flow.code_verifier); + let hash = hasher.finalize(); + let expected_challenge = URL_SAFE_NO_PAD.encode(hash); + + // Verify the challenge matches + assert_eq!(flow.code_challenge, expected_challenge); + } + + #[test] + fn test_pkce_verifier_length_bounds() { + // PKCE spec requires verifier to be 43-128 characters + // Our implementation uses 128 characters + let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow"); + + assert!(flow.code_verifier.len() >= 43); + assert!(flow.code_verifier.len() <= 128); + } +} diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 578add95dd2c..a0acf9f803ef 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -26,6 +26,8 @@ pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[ "anthropic/claude-sonnet-4", "google/gemini-2.5-pro", "deepseek/deepseek-r1-0528", + "qwen/qwen3-coder", + "moonshotai/kimi-k2", ]; pub const OPENROUTER_DOC_URL: &str = "https://openrouter.ai/models"; diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 76aff0ec9c97..3c88e806e833 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -1,33 +1,116 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useConfig } from './ConfigContext'; +import { SetupModal } from './SetupModal'; +import { startOpenRouterSetup } from '../utils/openRouterSetup'; +import WelcomeGooseLogo from './WelcomeGooseLogo'; +import { initializeSystem } from '../utils/providerUtils'; +import { toastService } from '../toasts'; interface ProviderGuardProps { children: React.ReactNode; } export default function ProviderGuard({ children }: ProviderGuardProps) { - const { read } = useConfig(); + const { read, getExtensions, addExtension } = useConfig(); const navigate = useNavigate(); const [isChecking, setIsChecking] = useState(true); const [hasProvider, setHasProvider] = useState(false); + const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false); + const [openRouterSetupState, setOpenRouterSetupState] = useState<{ + show: boolean; + title: string; + message: string; + showProgress: boolean; + showRetry: boolean; + autoClose?: number; + } | null>(null); + + const handleOpenRouterSetup = async () => { + setOpenRouterSetupState({ + show: true, + title: 'Setting up OpenRouter', + message: 'A browser window will open for authentication...', + showProgress: true, + showRetry: false, + }); + + const result = await startOpenRouterSetup(); + if (result.success) { + setOpenRouterSetupState({ + show: true, + title: 'Setup Complete!', + message: 'OpenRouter has been configured successfully. Initializing Goose...', + showProgress: true, + showRetry: false, + }); + + // After successful OpenRouter setup, force reload config and initialize system + try { + // Get the latest config from disk + const config = window.electron.getConfig(); + const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; + const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; + + if (provider && model) { + // Initialize the system with the new provider/model + await initializeSystem(provider as string, model as string, { + getExtensions, + addExtension, + }); + + toastService.configure({ silent: false }); + toastService.success({ + title: 'Success!', + msg: `Started goose with ${model} by OpenRouter. You can change the model via the lower right corner.`, + }); + + // Close the modal and mark as having provider + setOpenRouterSetupState(null); + setShowFirstTimeSetup(false); + setHasProvider(true); + } else { + throw new Error('Provider or model not found after OpenRouter setup'); + } + } catch (error) { + console.error('Failed to initialize after OpenRouter setup:', error); + toastService.configure({ silent: false }); + toastService.error({ + title: 'Initialization Failed', + msg: `Failed to initialize with OpenRouter: ${error instanceof Error ? error.message : String(error)}`, + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + } else { + setOpenRouterSetupState({ + show: true, + title: 'Openrouter setup pending', + message: result.message, + showProgress: false, + showRetry: true, + }); + } + }; useEffect(() => { const checkProvider = async () => { try { const config = window.electron.getConfig(); + console.log('ProviderGuard - Full config:', config); + const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; if (provider && model) { + console.log('ProviderGuard - Provider and model found, continuing normally'); setHasProvider(true); } else { - console.log('No provider/model configured, redirecting to welcome'); - navigate('/welcome', { replace: true }); + console.log('ProviderGuard - No provider/model configured, showing first time setup'); + setShowFirstTimeSetup(true); } } catch (error) { - console.error('Error checking provider configuration:', error); // On error, assume no provider and redirect to welcome + console.error('Error checking provider configuration:', error); navigate('/welcome', { replace: true }); } finally { setIsChecking(false); @@ -35,9 +118,10 @@ export default function ProviderGuard({ children }: ProviderGuardProps) { }; checkProvider(); - }, [read, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [read]); - if (isChecking) { + if (isChecking && !openRouterSetupState?.show && !showFirstTimeSetup) { return (
@@ -45,8 +129,57 @@ export default function ProviderGuard({ children }: ProviderGuardProps) { ); } + if (openRouterSetupState?.show) { + return ( + setOpenRouterSetupState(null)} + /> + ); + } + + if (showFirstTimeSetup) { + return ( +
+
+ +

Welcome to Goose!

+

+ Let's get you set up with an AI provider to start using Goose. +

+ +
+ + + +
+ +

+ OpenRouter provides access to multiple AI models. To use this it will need to create an + account with OpenRouter. +

+
+
+ ); + } + if (!hasProvider) { - // This will be handled by the navigation above, but we return null to be safe + // This shouldn't happen, but just in case return null; } diff --git a/ui/desktop/src/components/SetupModal.tsx b/ui/desktop/src/components/SetupModal.tsx new file mode 100644 index 000000000000..cefb2c502053 --- /dev/null +++ b/ui/desktop/src/components/SetupModal.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { Button } from './ui/button'; + +interface SetupModalProps { + title: string; + message: string; + showProgress?: boolean; + showRetry?: boolean; + onRetry?: () => void; + autoClose?: number; + onClose?: () => void; +} + +export function SetupModal({ + title, + message, + showProgress, + showRetry, + onRetry, + autoClose, + onClose, +}: SetupModalProps) { + useEffect(() => { + if (autoClose && onClose) { + const timer = window.setTimeout(() => { + onClose(); + }, autoClose); + return () => window.clearTimeout(timer); + } + return undefined; + }, [autoClose, onClose]); + + return ( +
+
+

{title}

+

{message}

+ + {showProgress && ( +
+
+
+ )} + + {onClose && ( +
+ +
+
+ )} + + {showRetry && onRetry && ( + + )} +
+
+ ); +} diff --git a/ui/desktop/src/utils/openRouterSetup.ts b/ui/desktop/src/utils/openRouterSetup.ts new file mode 100644 index 000000000000..87b9a61df4c0 --- /dev/null +++ b/ui/desktop/src/utils/openRouterSetup.ts @@ -0,0 +1,25 @@ +export interface OpenRouterSetupStatus { + isRunning: boolean; + error: string | null; +} + +export async function startOpenRouterSetup(): Promise<{ success: boolean; message: string }> { + const baseUrl = `${window.appConfig.get('GOOSE_API_HOST')}:${window.appConfig.get('GOOSE_PORT')}`; + const response = await fetch(`${baseUrl}/handle_openrouter`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error('Failed to start Openrouter setup:', response.statusText); + return { + success: false, + message: `Failed to start Openrouter setup ['${response.status}]`, + }; + } + + const result = await response.json(); + return result; +}