Detailed changes
@@ -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.
@@ -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"
+ );
+ }
+}
@@ -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"
+ );
+ }
}
@@ -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();
}
@@ -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)
@@ -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(()))
}