copilot_migration.rs

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