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}