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}