diff --git a/assets/settings/default.json b/assets/settings/default.json index f687778d7bd7fc0f6d66404199c34fac8d77e7a8..2c76049a5b5163153d33b01d1d4f959f9d3ebc2f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1721,6 +1721,8 @@ // If you don't want any of these extensions, add this field to your settings // and change the value to `false`. "auto_install_extensions": { + "anthropic": true, + "copilot-chat": true, "html": true }, // The capabilities granted to extensions. diff --git a/crates/extension_host/src/anthropic_migration.rs b/crates/extension_host/src/anthropic_migration.rs new file mode 100644 index 0000000000000000000000000000000000000000..5dd404d36edf84a311022a3cb5559bcab92e3f5c --- /dev/null +++ b/crates/extension_host/src/anthropic_migration.rs @@ -0,0 +1,153 @@ +use credentials_provider::CredentialsProvider; +use gpui::App; + +const ANTHROPIC_EXTENSION_ID: &str = "anthropic"; +const ANTHROPIC_PROVIDER_ID: &str = "anthropic"; +const ANTHROPIC_DEFAULT_API_URL: &str = "https://api.anthropic.com"; + +pub fn migrate_anthropic_credentials_if_needed(extension_id: &str, cx: &mut App) { + if extension_id != ANTHROPIC_EXTENSION_ID { + return; + } + + let extension_credential_key = format!( + "extension-llm-{}:{}", + ANTHROPIC_EXTENSION_ID, ANTHROPIC_PROVIDER_ID + ); + + let credentials_provider = ::global(cx); + + cx.spawn(async move |cx| { + let existing_credential = credentials_provider + .read_credentials(&extension_credential_key, &cx) + .await + .ok() + .flatten(); + + if existing_credential.is_some() { + log::debug!("Anthropic extension already has credentials, skipping migration"); + return; + } + + let old_credential = credentials_provider + .read_credentials(ANTHROPIC_DEFAULT_API_URL, &cx) + .await + .ok() + .flatten(); + + let api_key = match old_credential { + Some((_, key_bytes)) => match String::from_utf8(key_bytes) { + Ok(key) => key, + Err(_) => { + log::error!("Failed to decode Anthropic API key as UTF-8"); + return; + } + }, + None => { + log::debug!("No existing Anthropic API key found to migrate"); + return; + } + }; + + log::info!("Migrating existing Anthropic API key to Anthropic extension"); + + match credentials_provider + .write_credentials(&extension_credential_key, "Bearer", api_key.as_bytes(), &cx) + .await + { + Ok(()) => { + log::info!("Successfully migrated Anthropic API key to extension"); + } + Err(err) => { + log::error!("Failed to migrate Anthropic API key: {}", err); + } + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + async fn test_migrates_credentials_from_old_location(cx: &mut TestAppContext) { + let api_key = "sk-ant-test-key-12345"; + + cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", api_key.as_bytes()); + + cx.update(|cx| { + migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx); + }); + + cx.run_until_parked(); + + let migrated = cx.read_credentials("extension-llm-anthropic:anthropic"); + assert!(migrated.is_some(), "Credentials should have been migrated"); + let (username, password) = migrated.unwrap(); + assert_eq!(username, "Bearer"); + assert_eq!(String::from_utf8(password).unwrap(), api_key); + } + + #[gpui::test] + async fn test_skips_migration_if_extension_already_has_credentials(cx: &mut TestAppContext) { + let old_api_key = "sk-ant-old-key"; + let existing_key = "sk-ant-existing-key"; + + cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", old_api_key.as_bytes()); + cx.write_credentials( + "extension-llm-anthropic:anthropic", + "Bearer", + existing_key.as_bytes(), + ); + + cx.update(|cx| { + migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-anthropic:anthropic"); + let (_, password) = credentials.unwrap(); + assert_eq!( + String::from_utf8(password).unwrap(), + existing_key, + "Should not overwrite existing credentials" + ); + } + + #[gpui::test] + async fn test_skips_migration_if_no_old_credentials(cx: &mut TestAppContext) { + cx.update(|cx| { + migrate_anthropic_credentials_if_needed(ANTHROPIC_EXTENSION_ID, cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-anthropic:anthropic"); + assert!( + credentials.is_none(), + "Should not create credentials if none existed" + ); + } + + #[gpui::test] + async fn test_skips_migration_for_other_extensions(cx: &mut TestAppContext) { + let api_key = "sk-ant-test-key"; + + cx.write_credentials(ANTHROPIC_DEFAULT_API_URL, "Bearer", api_key.as_bytes()); + + cx.update(|cx| { + migrate_anthropic_credentials_if_needed("some-other-extension", cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-anthropic:anthropic"); + assert!( + credentials.is_none(), + "Should not migrate for other extensions" + ); + } +} diff --git a/crates/extension_host/src/copilot_migration.rs b/crates/extension_host/src/copilot_migration.rs index 90fdf48c0de69c14a560d4600fd4d24986891d2d..cd6c4167bb1d600e32c47f378e21227dcfa4769c 100644 --- a/crates/extension_host/src/copilot_migration.rs +++ b/crates/extension_host/src/copilot_migration.rs @@ -2,8 +2,8 @@ use credentials_provider::CredentialsProvider; use gpui::App; use std::path::PathBuf; -const COPILOT_CHAT_EXTENSION_ID: &str = "copilot_chat"; -const COPILOT_CHAT_PROVIDER_ID: &str = "copilot_chat"; +const COPILOT_CHAT_EXTENSION_ID: &str = "copilot-chat"; +const COPILOT_CHAT_PROVIDER_ID: &str = "copilot-chat"; pub fn migrate_copilot_credentials_if_needed(extension_id: &str, cx: &mut App) { if extension_id != COPILOT_CHAT_EXTENSION_ID { @@ -115,9 +115,10 @@ fn extract_oauth_token(contents: &str, domain: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use gpui::TestAppContext; #[test] - fn test_extract_oauth_token() { + fn test_extract_oauth_token_from_hosts_json() { let contents = r#"{ "github.com": { "oauth_token": "ghu_test_token_12345" @@ -129,7 +130,7 @@ mod tests { } #[test] - fn test_extract_oauth_token_with_prefix() { + fn test_extract_oauth_token_with_user_suffix() { let contents = r#"{ "github.com:user": { "oauth_token": "ghu_another_token" @@ -141,7 +142,7 @@ mod tests { } #[test] - fn test_extract_oauth_token_missing() { + fn test_extract_oauth_token_wrong_domain() { let contents = r#"{ "gitlab.com": { "oauth_token": "some_token" @@ -158,4 +159,86 @@ mod tests { let token = extract_oauth_token(contents, "github.com"); assert_eq!(token, None); } + + #[test] + fn test_extract_oauth_token_missing_oauth_token_field() { + let contents = r#"{ + "github.com": { + "user": "testuser" + } + }"#; + + let token = extract_oauth_token(contents, "github.com"); + assert_eq!(token, None); + } + + #[test] + fn test_extract_oauth_token_multiple_entries_picks_first_match() { + let contents = r#"{ + "gitlab.com": { + "oauth_token": "gitlab_token" + }, + "github.com": { + "oauth_token": "github_token" + } + }"#; + + let token = extract_oauth_token(contents, "github.com"); + assert_eq!(token, Some("github_token".to_string())); + } + + #[gpui::test] + async fn test_skips_migration_if_extension_already_has_credentials(cx: &mut TestAppContext) { + let existing_token = "existing_oauth_token"; + + cx.write_credentials( + "extension-llm-copilot-chat:copilot-chat", + "api_key", + existing_token.as_bytes(), + ); + + cx.update(|cx| { + migrate_copilot_credentials_if_needed(COPILOT_CHAT_EXTENSION_ID, cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat"); + let (_, password) = credentials.unwrap(); + assert_eq!( + String::from_utf8(password).unwrap(), + existing_token, + "Should not overwrite existing credentials" + ); + } + + #[gpui::test] + async fn test_skips_migration_for_other_extensions(cx: &mut TestAppContext) { + cx.update(|cx| { + migrate_copilot_credentials_if_needed("some-other-extension", cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat"); + assert!( + credentials.is_none(), + "Should not create credentials for other extensions" + ); + } + + #[gpui::test] + async fn test_no_migration_when_no_copilot_config_exists(cx: &mut TestAppContext) { + cx.update(|cx| { + migrate_copilot_credentials_if_needed(COPILOT_CHAT_EXTENSION_ID, cx); + }); + + cx.run_until_parked(); + + let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat"); + assert!( + credentials.is_none(), + "Should not create credentials when no copilot config exists" + ); + } } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index ea6d52418fe693572107445272f7ea7b658e132d..591e1347e1518acafbd8d11ede6a3eef98dc1d87 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1,3 +1,4 @@ +mod anthropic_migration; mod capability_granter; mod copilot_migration; pub mod extension_settings; @@ -85,9 +86,9 @@ const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); /// we automatically enable env var reading for these extensions on first install. const LEGACY_LLM_EXTENSION_IDS: &[&str] = &[ "anthropic", - "copilot_chat", + "copilot-chat", "google-ai", - "open_router", + "open-router", "openai", ]; @@ -128,9 +129,9 @@ fn migrate_legacy_llm_provider_env_var(manifest: &ExtensionManifest, cx: &mut Ap .unwrap_or(false); // Mark as migrated regardless of whether we enable env var reading + let should_enable_env_var = env_var_is_set; settings::update_settings_file(::global(cx), cx, { let full_provider_id = full_provider_id.clone(); - let env_var_is_set = env_var_is_set; move |settings, _| { // Always mark as migrated let migrated = settings @@ -146,7 +147,7 @@ fn migrate_legacy_llm_provider_env_var(manifest: &ExtensionManifest, cx: &mut Ap } // Only enable env var reading if the env var is set - if env_var_is_set { + if should_enable_env_var { let providers = settings .extension .allowed_env_var_providers @@ -889,6 +890,7 @@ impl ExtensionStore { // Run extension-specific migrations copilot_migration::migrate_copilot_credentials_if_needed(&extension_id, cx); + anthropic_migration::migrate_anthropic_credentials_if_needed(&extension_id, cx); }) .ok(); } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 5be2e394e8edfd26a25c70c79c321a7fb8fdc8ba..71be5d8e8e6526379f99dfd9a83de88683c6fac6 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -296,6 +296,20 @@ impl TestAppContext { &self.text_system } + /// Simulates writing credentials to the platform keychain. + pub fn write_credentials(&self, url: &str, username: &str, password: &[u8]) { + let _ = self + .test_platform + .write_credentials(url, username, password); + } + + /// Simulates reading credentials from the platform keychain. + pub fn read_credentials(&self, url: &str) -> Option<(String, Vec)> { + smol::block_on(self.test_platform.read_credentials(url)) + .ok() + .flatten() + } + /// Simulates writing to the platform clipboard pub fn write_to_clipboard(&self, item: ClipboardItem) { self.test_platform.write_to_clipboard(item) diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index dfada364667989792325e02f8530e6c91bdf4716..9677747bac943e6bb8ca370d479f7de7c9b00bee 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -6,7 +6,7 @@ use crate::{ TestDisplay, TestWindow, WindowAppearance, WindowParams, size, }; use anyhow::Result; -use collections::VecDeque; +use collections::{HashMap, VecDeque}; use futures::channel::oneshot; use parking_lot::Mutex; use std::{ @@ -32,6 +32,7 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + credentials: Mutex)>>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -117,6 +118,7 @@ impl TestPlatform { current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), + credentials: Mutex::new(HashMap::default()), weak: weak.clone(), opened_url: Default::default(), #[cfg(target_os = "windows")] @@ -416,15 +418,20 @@ impl Platform for TestPlatform { self.current_clipboard_item.lock().clone() } - fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { + fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + self.credentials + .lock() + .insert(url.to_string(), (username.to_string(), password.to_vec())); Task::ready(Ok(())) } - fn read_credentials(&self, _url: &str) -> Task)>>> { - Task::ready(Ok(None)) + fn read_credentials(&self, url: &str) -> Task)>>> { + let result = self.credentials.lock().get(url).cloned(); + Task::ready(Ok(result)) } - fn delete_credentials(&self, _url: &str) -> Task> { + fn delete_credentials(&self, url: &str) -> Task> { + self.credentials.lock().remove(url); Task::ready(Ok(())) }