Migrate Copilot and Anthropic to extensions

Richard Feldman created

Change summary

assets/settings/default.json                     |   2 
crates/extension_host/src/anthropic_migration.rs | 153 ++++++++++++++++++
crates/extension_host/src/copilot_migration.rs   |  93 ++++++++++
crates/extension_host/src/extension_host.rs      |  10 
crates/gpui/src/app/test_context.rs              |  14 +
crates/gpui/src/platform/test/platform.rs        |  17 +
6 files changed, 275 insertions(+), 14 deletions(-)

Detailed changes

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.

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 = <dyn CredentialsProvider>::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"
+        );
+    }
+}

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<String> {
 #[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"
+        );
+    }
 }

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(<dyn fs::Fs>::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();
             }

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<u8>)> {
+        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)

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<Option<ClipboardItem>>,
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     current_primary_item: Mutex<Option<ClipboardItem>>,
+    credentials: Mutex<HashMap<String, (String, Vec<u8>)>>,
     pub(crate) prompts: RefCell<TestPrompts>,
     screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
     pub opened_url: RefCell<Option<String>>,
@@ -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<Result<()>> {
+    fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
+        self.credentials
+            .lock()
+            .insert(url.to_string(), (username.to_string(), password.to_vec()));
         Task::ready(Ok(()))
     }
 
-    fn read_credentials(&self, _url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
-        Task::ready(Ok(None))
+    fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>> {
+        let result = self.credentials.lock().get(url).cloned();
+        Task::ready(Ok(result))
     }
 
-    fn delete_credentials(&self, _url: &str) -> Task<Result<()>> {
+    fn delete_credentials(&self, url: &str) -> Task<Result<()>> {
+        self.credentials.lock().remove(url);
         Task::ready(Ok(()))
     }