Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7fd3dea
fix: open browser and copy code for github copilot device oauth flow
sheikhlimon Feb 27, 2026
d4209eb
Merge remote-tracking branch 'upstream/main' into fix/githubcopilot-b…
sheikhlimon Feb 27, 2026
96f4489
add oauth response types for device code flow support
sheikhlimon Mar 12, 2026
15b62e7
add oauth response data enum and update provider trait
sheikhlimon Mar 12, 2026
398a072
update chatgpt codex to return oauth completed response
sheikhlimon Mar 12, 2026
f0c7f9a
update github copilot to return device code and remove clipboard
sheikhlimon Mar 13, 2026
fd7ecb4
remove unused arboard dependency
sheikhlimon Mar 13, 2026
e03f890
update cargo lock after removing arboard
sheikhlimon Mar 13, 2026
cf0e6fb
fix duplicate serde import in base.rs
sheikhlimon Mar 13, 2026
b6697b8
add response body to oauth endpoint utoipa spec
sheikhlimon Mar 13, 2026
81aa8b6
add oauth response types to openapi schema
sheikhlimon Mar 13, 2026
dc5fcf3
update oauth setup to handle device code response
sheikhlimon Mar 13, 2026
7f3cd29
add device code modal component for github copilot
sheikhlimon Mar 13, 2026
7db3e10
merge upstream main
sheikhlimon Mar 13, 2026
be4e6ff
extract github copilot setup to separate file
sheikhlimon Mar 13, 2026
64d498c
fmt
sheikhlimon Mar 13, 2026
2f2588a
add oauth response data serialization tests
sheikhlimon Mar 13, 2026
32fb2f1
fix oauth response data to use struct variants
sheikhlimon Mar 13, 2026
b2b5873
handle device code response in cli oauth configuration
sheikhlimon Mar 14, 2026
3aa286e
handle device code response in settings provider modal
sheikhlimon Mar 14, 2026
2b07c72
avoid calling refresh_api_info in configure_oauth to prevent double b…
sheikhlimon Mar 14, 2026
363016a
prevent double browser open in device code modal
sheikhlimon Mar 14, 2026
f1f61a1
use project dialog component and remove redundant browser button
sheikhlimon Mar 14, 2026
95c2837
fmt
sheikhlimon Mar 14, 2026
3ffde1b
fix: use dedicated completion check endpoint for OAuth polling
sheikhlimon Mar 14, 2026
94507ad
fmt
sheikhlimon Mar 14, 2026
7c9f107
use setInterval pattern for OAuth polling
sheikhlimon Mar 14, 2026
b8ad58f
fix: add polling logging and tracing for OAuth completion debugging
sheikhlimon Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 15 additions & 28 deletions crates/goose-cli/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,36 +323,23 @@ async fn handle_existing_config() -> anyhow::Result<()> {

/// Helper function to handle OAuth configuration for a provider
async fn handle_oauth_configuration(provider_name: &str, key_name: &str) -> anyhow::Result<()> {
let _ = cliclack::log::info(format!(
"Configuring {} using OAuth device code flow...",
key_name
));

// Create a temporary provider instance to handle OAuth
let temp_model = ModelConfig::new("temp")?.with_canonical_limits(provider_name);
match create(provider_name, temp_model, Vec::new()).await {
Ok(provider) => match provider.configure_oauth().await {
Ok(_) => {
let _ = cliclack::log::success("OAuth authentication completed successfully!");
Ok(())
}
Err(e) => {
let _ = cliclack::log::error(format!("Failed to authenticate: {}", e));
Err(anyhow::anyhow!(
"OAuth authentication failed for {}: {}",
key_name,
e
))
}
},
Err(e) => {
let provider = create(provider_name, temp_model, Vec::new())
.await
.map_err(|e| {
let _ = cliclack::log::error(format!("Failed to create provider for OAuth: {}", e));
Err(anyhow::anyhow!(
"Failed to create provider for OAuth: {}",
e
))
}
}
anyhow::anyhow!("Failed to create provider for OAuth: {}", e)
})?;

let _ = cliclack::log::info(format!("Configuring {} using OAuth...", key_name));

provider.configure_oauth().await.map_err(|e| {
let _ = cliclack::log::error(format!("Failed to authenticate: {}", e));
anyhow::anyhow!("OAuth authentication failed for {}: {}", key_name, e)
})?;

let _ = cliclack::log::success("OAuth configuration completed");
Ok(())
}

fn interactive_model_search(models: &[String]) -> anyhow::Result<String> {
Expand Down
5 changes: 5 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::check_provider,
super::routes::config_management::set_config_provider,
super::routes::config_management::configure_provider_oauth,
super::routes::config_management::check_oauth_completion,
super::routes::config_management::get_canonical_model_info,
super::routes::prompts::get_prompts,
super::routes::prompts::get_prompt,
Expand Down Expand Up @@ -514,6 +515,10 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::ModelInfoQuery,
super::routes::config_management::ModelInfoResponse,
super::routes::config_management::ModelInfoData,
super::routes::config_management::OauthCompletedResponse,
super::routes::config_management::DeviceCodeResponse,
super::routes::config_management::OauthResponse,
super::routes::config_management::OauthCompletionResponse,
super::routes::prompts::PromptsListResponse,
super::routes::prompts::PromptContentResponse,
super::routes::prompts::SavePromptRequest,
Expand Down
116 changes: 107 additions & 9 deletions crates/goose-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,20 +830,102 @@ pub async fn get_provider_catalog_template(
Ok(Json(template))
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct OauthCompletedResponse {
pub message: String,
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCodeResponse {
pub user_code: String,
pub verification_uri: String,
}

#[derive(Serialize, ToSchema)]
#[serde(untagged)]
pub enum OauthResponse {
Completed(OauthCompletedResponse),
DeviceCode(DeviceCodeResponse),
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct OauthCompletionResponse {
pub completed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub debug: Option<String>,
}

#[utoipa::path(
get,
path = "/config/providers/{name}/oauth/completion",
params(
("name" = String, Path, description = "Provider name")
),
responses(
(status = 200, description = "OAuth completion status", body = OauthCompletionResponse),
(status = 400, description = "Failed to check OAuth completion")
)
)]
pub async fn check_oauth_completion(
Path(provider_name): Path<String>,
) -> Result<Json<OauthCompletionResponse>, ErrorResponse> {
use goose::model::ModelConfig;
use goose::providers::create;

if !is_valid_provider_name(&provider_name) {
return Err(ErrorResponse::bad_request(format!(
"Invalid provider name: '{}'",
provider_name
)));
}

let temp_model = ModelConfig::new("temp")
.map_err(|e| {
ErrorResponse::bad_request(format!("Failed to create temporary model config: {}", e))
})?
.with_canonical_limits(&provider_name);

let provider = create(&provider_name, temp_model, Vec::new())
.await
.map_err(|e| {
ErrorResponse::bad_request(format!(
"Failed to create provider '{}': {}",
provider_name, e
))
})?;

let has_token = goose::config::Config::global()
.get_secret::<String>("GITHUB_COPILOT_TOKEN")
.is_ok();

let (completed, debug_msg) = match provider.check_oauth_completion().await {
Ok(val) => (val, format!("ok({}) has_token_before={}", val, has_token)),
Err(e) => {
let msg = format!("err: {} has_token_before={}", e, has_token);
return Ok(Json(OauthCompletionResponse { completed: false, debug: Some(msg) }));
}
};

Ok(Json(OauthCompletionResponse { completed, debug: Some(debug_msg) }))
}

#[utoipa::path(
post,
path = "/config/providers/{name}/oauth",
params(
("name" = String, Path, description = "Provider name")
),
responses(
(status = 200, description = "OAuth configuration completed"),
(status = 200, description = "OAuth configuration completed or device code returned", body = OauthResponse),
(status = 400, description = "OAuth configuration failed")
)
)]
pub async fn configure_provider_oauth(
Path(provider_name): Path<String>,
) -> Result<Json<String>, ErrorResponse> {
) -> Result<Json<OauthResponse>, ErrorResponse> {
use goose::model::ModelConfig;
use goose::providers::create;

Expand All @@ -860,7 +942,6 @@ pub async fn configure_provider_oauth(
})?
.with_canonical_limits(&provider_name);

// OAuth configuration does not use extensions.
let provider = create(&provider_name, temp_model, Vec::new())
.await
.map_err(|e| {
Expand All @@ -870,19 +951,32 @@ pub async fn configure_provider_oauth(
))
})?;

if let Some(device_code_data) = provider.get_oauth_device_code_info().await.map_err(|e| {
ErrorResponse::bad_request(format!(
"Failed to get OAuth device code for provider '{}': {}",
provider_name, e
))
})? {
return Ok(Json(OauthResponse::DeviceCode(DeviceCodeResponse {
user_code: device_code_data.user_code,
verification_uri: device_code_data.verification_uri,
})));
}

provider.configure_oauth().await.map_err(|e| {
ErrorResponse::bad_request(format!(
"OAuth configuration failed for provider '{}': {}",
provider_name, e
))
})?;

// Mark the provider as configured after successful OAuth
let configured_marker = format!("{}_configured", provider_name);
let config = goose::config::Config::global();
config.set_param(&configured_marker, true)?;

Ok(Json("OAuth configuration completed".to_string()))
Ok(Json(OauthResponse::Completed(OauthCompletedResponse {
message: "OAuth configuration completed".to_string(),
})))
}

pub fn routes(state: Arc<AppState>) -> Router {
Expand All @@ -896,6 +990,14 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/extensions/{name}", delete(remove_extension))
.route("/config/providers", get(providers))
.route("/config/providers/{name}/models", get(get_provider_models))
.route(
"/config/providers/{name}/oauth",
post(configure_provider_oauth),
)
.route(
"/config/providers/{name}/oauth/completion",
get(check_oauth_completion),
)
.route("/config/provider-catalog", get(get_provider_catalog))
.route(
"/config/provider-catalog/{id}",
Expand All @@ -921,10 +1023,6 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/custom-providers/{id}", get(get_custom_provider))
.route("/config/check_provider", post(check_provider))
.route("/config/set_provider", post(set_config_provider))
.route(
"/config/providers/{name}/oauth",
post(configure_provider_oauth),
)
.with_state(state)
}

Expand Down
71 changes: 71 additions & 0 deletions crates/goose/src/providers/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,15 @@ pub trait LeadWorkerProviderTrait {
fn get_settings(&self) -> (usize, usize, usize);
}

/// OAuth flow completed successfully (callback-based providers)
/// Device code info for OAuth device code flow (UI only)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCodeData {
pub user_code: String,
pub verification_uri: String,
}

/// Base trait for AI providers (OpenAI, Anthropic, etc)
#[async_trait]
pub trait Provider: Send + Sync {
Expand Down Expand Up @@ -687,6 +696,9 @@ pub trait Provider: Send + Sync {
/// This method is called when a provider has configuration keys marked with oauth_flow = true.
/// Providers that support OAuth should override this method to implement their specific OAuth flow.
///
/// This method blocks until OAuth flow completes (callback flow for browser-based auth,
/// device code flow for terminal-based auth where code is displayed and completion is awaited).
///
/// # Returns
/// * `Ok(())` if OAuth configuration succeeds and credentials are saved
/// * `Err(ProviderError)` if OAuth fails or is not supported by this provider
Expand All @@ -699,6 +711,30 @@ pub trait Provider: Send + Sync {
))
}

/// Get device code info for OAuth device code flow (UI only)
///
/// For providers that use device code flow (like GitHub Copilot), this returns the
/// device code and verification URI without completing the OAuth flow. This allows
/// the UI to display the code and let the user complete authorization in a browser.
///
/// The OAuth flow is then completed by calling configure_oauth() after the user
/// has authorized on the verification page.
///
/// # Returns
/// * `Ok(Some(DeviceCodeData))` if this provider uses device code flow
/// * `Ok(None)` if this provider doesn't use device code flow
/// * `Err(ProviderError)` if an error occurs
///
/// # Default Implementation
/// Returns Ok(None) for providers that don't use device code flow.
async fn get_oauth_device_code_info(&self) -> Result<Option<DeviceCodeData>, ProviderError> {
Ok(None)
}

async fn check_oauth_completion(&self) -> Result<bool, ProviderError> {
Ok(false)
}

fn permission_routing(&self) -> PermissionRouting {
PermissionRouting::Noop
}
Expand Down Expand Up @@ -1070,4 +1106,39 @@ mod tests {
assert_eq!(info.output_token_cost, Some(0.00001));
assert_eq!(info.currency, Some("$".to_string()));
}

#[test]
fn test_device_code_data_serializes() {
let data = DeviceCodeData {
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/verify".to_string(),
};
let json = serde_json::to_string(&data).unwrap();

assert!(json.contains("ABCD-1234"));
assert!(json.contains("verificationUri"));
assert!(json.contains("userCode"));
}

#[test]
fn test_device_code_data_deserialize() {
let json = r#"{"userCode":"ABCD-EFGH","verificationUri":"https://github.com/verify"}"#;
let data: DeviceCodeData = serde_json::from_str(json).unwrap();

assert_eq!(data.user_code, "ABCD-EFGH");
assert_eq!(data.verification_uri, "https://github.com/verify");
}

#[test]
fn test_device_code_data_roundtrip() {
let original = DeviceCodeData {
user_code: "WXYZ-5678".to_string(),
verification_uri: "https://example.com/auth".to_string(),
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: DeviceCodeData = serde_json::from_str(&json).unwrap();

assert_eq!(original.user_code, deserialized.user_code);
assert_eq!(original.verification_uri, deserialized.verification_uri);
}
}
Loading