copilot_migration.rs

  1use credentials_provider::CredentialsProvider;
  2use gpui::App;
  3use std::path::PathBuf;
  4
  5const COPILOT_CHAT_EXTENSION_ID: &str = "copilot-chat";
  6const COPILOT_CHAT_PROVIDER_ID: &str = "copilot-chat";
  7
  8/// Migrates Copilot OAuth credentials from the GitHub Copilot config files
  9/// to the new extension-based credential location.
 10///
 11/// This should only be called during auto-install of the extension.
 12pub fn migrate_copilot_credentials_if_needed(extension_id: &str, cx: &mut App) {
 13    if extension_id != COPILOT_CHAT_EXTENSION_ID {
 14        return;
 15    }
 16
 17    let credential_key = format!(
 18        "extension-llm-{}:{}",
 19        COPILOT_CHAT_EXTENSION_ID, COPILOT_CHAT_PROVIDER_ID
 20    );
 21
 22    let credentials_provider = <dyn CredentialsProvider>::global(cx);
 23
 24    cx.spawn(async move |_cx| {
 25        // Read from copilot config files
 26        let oauth_token = match read_copilot_oauth_token().await {
 27            Some(token) if !token.is_empty() => token,
 28            _ => {
 29                log::debug!("No existing Copilot OAuth token found to migrate");
 30                return;
 31            }
 32        };
 33
 34        log::info!("Migrating existing Copilot OAuth token to Copilot Chat extension");
 35
 36        match credentials_provider
 37            .write_credentials(&credential_key, "api_key", oauth_token.as_bytes(), &_cx)
 38            .await
 39        {
 40            Ok(()) => {
 41                log::info!("Successfully migrated Copilot OAuth token to Copilot Chat extension");
 42            }
 43            Err(err) => {
 44                log::error!("Failed to migrate Copilot OAuth token: {}", err);
 45            }
 46        }
 47    })
 48    .detach();
 49}
 50
 51async fn read_copilot_oauth_token() -> Option<String> {
 52    let config_paths = copilot_config_paths();
 53
 54    for path in config_paths {
 55        if let Some(token) = read_oauth_token_from_file(&path).await {
 56            return Some(token);
 57        }
 58    }
 59
 60    None
 61}
 62
 63fn copilot_config_paths() -> Vec<PathBuf> {
 64    let config_dir = if cfg!(target_os = "windows") {
 65        dirs::data_local_dir()
 66    } else {
 67        std::env::var("XDG_CONFIG_HOME")
 68            .map(PathBuf::from)
 69            .ok()
 70            .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
 71    };
 72
 73    let Some(config_dir) = config_dir else {
 74        return Vec::new();
 75    };
 76
 77    let copilot_dir = config_dir.join("github-copilot");
 78
 79    vec![
 80        copilot_dir.join("hosts.json"),
 81        copilot_dir.join("apps.json"),
 82    ]
 83}
 84
 85async fn read_oauth_token_from_file(path: &PathBuf) -> Option<String> {
 86    let contents = match smol::fs::read_to_string(path).await {
 87        Ok(contents) => contents,
 88        Err(_) => return None,
 89    };
 90
 91    extract_oauth_token(&contents, "github.com")
 92}
 93
 94fn extract_oauth_token(contents: &str, domain: &str) -> Option<String> {
 95    let value: serde_json::Value = serde_json::from_str(contents).ok()?;
 96    let obj = value.as_object()?;
 97
 98    for (key, value) in obj.iter() {
 99        if key.starts_with(domain) {
100            if let Some(token) = value.get("oauth_token").and_then(|v| v.as_str()) {
101                return Some(token.to_string());
102            }
103        }
104    }
105
106    None
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use gpui::TestAppContext;
113
114    #[test]
115    fn test_extract_oauth_token_from_hosts_json() {
116        let contents = r#"{
117            "github.com": {
118                "oauth_token": "ghu_test_token_12345"
119            }
120        }"#;
121
122        let token = extract_oauth_token(contents, "github.com");
123        assert_eq!(token, Some("ghu_test_token_12345".to_string()));
124    }
125
126    #[test]
127    fn test_extract_oauth_token_with_user_suffix() {
128        let contents = r#"{
129            "github.com:user": {
130                "oauth_token": "ghu_another_token"
131            }
132        }"#;
133
134        let token = extract_oauth_token(contents, "github.com");
135        assert_eq!(token, Some("ghu_another_token".to_string()));
136    }
137
138    #[test]
139    fn test_extract_oauth_token_wrong_domain() {
140        let contents = r#"{
141            "gitlab.com": {
142                "oauth_token": "some_token"
143            }
144        }"#;
145
146        let token = extract_oauth_token(contents, "github.com");
147        assert_eq!(token, None);
148    }
149
150    #[test]
151    fn test_extract_oauth_token_invalid_json() {
152        let contents = "not valid json";
153        let token = extract_oauth_token(contents, "github.com");
154        assert_eq!(token, None);
155    }
156
157    #[test]
158    fn test_extract_oauth_token_missing_oauth_token_field() {
159        let contents = r#"{
160            "github.com": {
161                "user": "testuser"
162            }
163        }"#;
164
165        let token = extract_oauth_token(contents, "github.com");
166        assert_eq!(token, None);
167    }
168
169    #[test]
170    fn test_extract_oauth_token_multiple_entries_picks_first_match() {
171        let contents = r#"{
172            "gitlab.com": {
173                "oauth_token": "gitlab_token"
174            },
175            "github.com": {
176                "oauth_token": "github_token"
177            }
178        }"#;
179
180        let token = extract_oauth_token(contents, "github.com");
181        assert_eq!(token, Some("github_token".to_string()));
182    }
183
184    #[gpui::test]
185    async fn test_skips_migration_for_other_extensions(cx: &mut TestAppContext) {
186        cx.update(|cx| {
187            migrate_copilot_credentials_if_needed("some-other-extension", cx);
188        });
189
190        cx.run_until_parked();
191
192        let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat");
193        assert!(
194            credentials.is_none(),
195            "Should not create credentials for other extensions"
196        );
197    }
198
199    // Note: Unlike the other migrations, copilot migration reads from the filesystem
200    // (copilot config files), not from the credentials provider. In tests, these files
201    // don't exist, so no migration occurs.
202    #[gpui::test]
203    async fn test_no_credentials_when_no_copilot_config_exists(cx: &mut TestAppContext) {
204        cx.update(|cx| {
205            migrate_copilot_credentials_if_needed(COPILOT_CHAT_EXTENSION_ID, cx);
206        });
207
208        cx.run_until_parked();
209
210        let credentials = cx.read_credentials("extension-llm-copilot-chat:copilot-chat");
211        assert!(
212            credentials.is_none(),
213            "No credentials should be written when copilot config doesn't exist"
214        );
215    }
216}