diff --git a/Cargo.lock b/Cargo.lock index 47fc6def4beb..486b65092235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image 0.25.10", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.8.2" @@ -4141,6 +4161,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getopts" version = "0.2.24" @@ -4243,6 +4273,7 @@ dependencies = [ "agent-client-protocol-schema", "ahash", "anyhow", + "arboard", "async-stream", "async-trait", "aws-config", @@ -6354,6 +6385,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -6365,6 +6408,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -6384,6 +6440,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -12100,6 +12167,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index a50cc28528cc..addf85b18519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ string_slice = "warn" [workspace.dependencies] rmcp = { version = "1.2.0", features = ["schemars", "auth"] } sacp = "10.1.0" +arboard = "3" anyhow = "1.0" async-stream = "0.3" async-trait = "0.1" diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index ca94fe8d8b04..d2ab5842c548 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -25,6 +25,7 @@ rmcp = { workspace = true, features = [ "transport-streamable-http-client-reqwest", ] } oauth2 = "5.0" +arboard = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } futures = { workspace = true } diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index fae0dd8b7ae1..d434982a30db 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -266,9 +266,13 @@ pub struct ConfigKey { pub secret: bool, /// Optional default value for the key pub default: Option, - /// Whether this key should be configured using OAuth device code flow + /// Whether this key should be configured using an OAuth flow /// When true, the provider's configure_oauth() method will be called instead of prompting for manual input pub oauth_flow: bool, + /// Whether this OAuth flow uses the device code grant (RFC 8628) + /// When true, the user must enter a verification code in the browser + #[serde(default)] + pub device_code_flow: bool, /// Whether this key should be shown prominently during provider setup /// (onboarding, settings modal, CLI configure) #[serde(default)] @@ -290,6 +294,7 @@ impl ConfigKey { secret, default: default.map(|s| s.to_string()), oauth_flow: false, + device_code_flow: false, primary, } } @@ -301,11 +306,12 @@ impl ConfigKey { secret, default: Some(T::DEFAULT.to_string()), oauth_flow: false, + device_code_flow: false, primary, } } - /// Create a new ConfigKey that uses OAuth device code flow for configuration + /// Create a new ConfigKey that uses an OAuth flow for configuration /// /// This is used for providers that support OAuth authentication instead of manual API key entry. /// When oauth_flow is true, the configuration system will call the provider's configure_oauth() method. @@ -322,6 +328,29 @@ impl ConfigKey { secret, default: default.map(|s| s.to_string()), oauth_flow: true, + device_code_flow: false, + primary, + } + } + + /// Create a new ConfigKey that uses OAuth device code flow (RFC 8628) for configuration + /// + /// Similar to new_oauth, but indicates the provider uses the device code grant where the user + /// must enter a verification code in the browser. + pub fn new_oauth_device_code( + name: &str, + required: bool, + secret: bool, + default: Option<&str>, + primary: bool, + ) -> Self { + Self { + name: name.to_string(), + required, + secret, + default: default.map(|s| s.to_string()), + oauth_flow: true, + device_code_flow: true, primary, } } diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 3c7c3965783a..fed31af79b51 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -285,6 +285,16 @@ impl GithubCopilotProvider { async fn login(&self) -> Result { let device_code_info = self.get_device_code().await?; + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Err(e) = clipboard.set_text(&device_code_info.user_code) { + tracing::warn!("Failed to copy verification code to clipboard: {}", e); + } + } + + if let Err(e) = webbrowser::open(&device_code_info.verification_uri) { + tracing::warn!("Failed to open browser: {}", e); + } + println!( "Please visit {} and enter code {}", device_code_info.verification_uri, device_code_info.user_code @@ -402,7 +412,7 @@ impl ProviderDef for GithubCopilotProvider { GITHUB_COPILOT_DEFAULT_MODEL, GITHUB_COPILOT_KNOWN_MODELS.to_vec(), GITHUB_COPILOT_DOC_URL, - vec![ConfigKey::new_oauth( + vec![ConfigKey::new_oauth_device_code( "GITHUB_COPILOT_TOKEN", true, true, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 2e3671b272bb..ab8d4458319a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4170,13 +4170,17 @@ "description": "Optional default value for the key", "nullable": true }, + "device_code_flow": { + "type": "boolean", + "description": "Whether this OAuth flow uses the device code grant (RFC 8628)\nWhen true, the user must enter a verification code in the browser" + }, "name": { "type": "string", "description": "The name of the configuration key (e.g., \"API_KEY\")" }, "oauth_flow": { "type": "boolean", - "description": "Whether this key should be configured using OAuth device code flow\nWhen true, the provider's configure_oauth() method will be called instead of prompting for manual input" + "description": "Whether this key should be configured using an OAuth flow\nWhen true, the provider's configure_oauth() method will be called instead of prompting for manual input" }, "primary": { "type": "boolean", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6add7592e84e..f5e23e6f6ae1 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -90,12 +90,17 @@ export type ConfigKey = { * Optional default value for the key */ default?: string | null; + /** + * Whether this OAuth flow uses the device code grant (RFC 8628) + * When true, the user must enter a verification code in the browser + */ + device_code_flow?: boolean; /** * The name of the configuration key (e.g., "API_KEY") */ name: string; /** - * Whether this key should be configured using OAuth device code flow + * Whether this key should be configured using an OAuth flow * When true, the provider's configure_oauth() method will be called instead of prompting for manual input */ oauth_flow: boolean; diff --git a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx index b01b382d3519..16c901838a2a 100644 --- a/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx +++ b/ui/desktop/src/components/onboarding/ProviderConfigForm.tsx @@ -56,6 +56,8 @@ function OAuthForm({ } }; + const isDeviceCodeFlow = provider.metadata.config_keys.some((key) => key.device_code_flow); + return (
); diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index b46b91600e60..abd59388d217 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -238,7 +238,9 @@ export default function ProviderConfigurationModal({ : `Sign in with ${provider.metadata.display_name}`}

- A browser window will open for you to complete the login. + {provider.metadata.config_keys.some((key) => key.device_code_flow) + ? 'A browser window will open and the verification code will be copied to your clipboard. Paste it in the browser to complete sign-in.' + : 'A browser window will open for you to complete the login.'}

) : (