Skip to content

Commit ed4c004

Browse files
committed
copilot_chat: support SDK auth.db and reload via LSP didChangeStatus
The GitHub Copilot SDK migrated its OAuth token storage from JSON files (apps.json / hosts.json) to a SQLite database (auth.db) under the same github-copilot config directory. Zed only knew about the JSON files, so users on a fresh SDK install saw an empty Copilot Chat model dropdown even though they were signed in via the LSP. Changes: * Add extract_oauth_token_from_db() which reads token_ciphertext from the oauth_tokens table via the existing sqlez dependency. The column stores the raw 'ghu_...' OAuth token as text on current SDK builds; format is validated (prefix + length + charset) so we fail closed if the SDK changes the schema. * Replace the previous fs.watch() loop on the config directory with an LSP-driven reload. The copilot crate already receives didChangeStatus notifications from the Copilot language server; we now route those through a small public CopilotChat::reload_auth() method. SQLite's WAL writes interact poorly with directory watchers (we observed both spurious early fires and missed commits on Windows), and the LSP gives us a clean, semantic, cross-platform signal that fires after the token is durable. * Sign-out clears api_endpoint and the model catalogue so a subsequent sign-in re-discovers them rather than reusing stale state. Tested on Windows 11 with the official Copilot SDK build that ships auth.db; cold sign-in now populates the model list within a couple of seconds of the LSP reporting Authorized status, with no manual restart.
1 parent 1dac14c commit ed4c004

4 files changed

Lines changed: 130 additions & 22 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/copilot/src/copilot.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,7 @@ impl Copilot {
12591259
| request::SignInStatus::AlreadySignedIn { .. } => {
12601260
server.sign_in_status = SignInStatus::Authorized;
12611261
cx.emit(Event::CopilotAuthSignedIn);
1262+
notify_copilot_chat_auth_changed(cx);
12621263
for buffer in self.buffers.iter().cloned().collect::<Vec<_>>() {
12631264
if let Some(buffer) = buffer.upgrade() {
12641265
self.register_buffer(&buffer, cx);
@@ -1278,6 +1279,7 @@ impl Copilot {
12781279
};
12791280
}
12801281
cx.emit(Event::CopilotAuthSignedOut);
1282+
notify_copilot_chat_auth_changed(cx);
12811283
for buffer in self.buffers.iter().cloned().collect::<Vec<_>>() {
12821284
self.unregister_buffer(&buffer);
12831285
}
@@ -1381,6 +1383,17 @@ fn notify_did_change_config_to_server(
13811383
Ok(())
13821384
}
13831385

1386+
/// Notify the `copilot_chat` global that the Copilot auth state may have
1387+
/// changed (sign-in, sign-out, account switch). This is the LSP's
1388+
/// `didChangeStatus` notification turned into a cross-crate poke: it
1389+
/// replaces a previous filesystem watcher on the Copilot SDK's config
1390+
/// directory, which was unreliable for SQLite writes on Windows.
1391+
fn notify_copilot_chat_auth_changed(cx: &mut Context<Copilot>) {
1392+
if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
1393+
copilot_chat.update(cx, |chat, cx| chat.reload_auth(cx));
1394+
}
1395+
}
1396+
13841397
async fn clear_copilot_dir() {
13851398
remove_matching(paths::copilot_dir(), |_| true).await
13861399
}

crates/copilot_chat/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ paths.workspace = true
3434
serde.workspace = true
3535
serde_json.workspace = true
3636
settings.workspace = true
37+
sqlez.workspace = true
3738

3839
[dev-dependencies]
3940
gpui = { workspace = true, features = ["test-support"] }

crates/copilot_chat/src/copilot_chat.rs

Lines changed: 115 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
1717
use paths::home_dir;
1818
use serde::{Deserialize, Serialize};
1919

20-
use settings::watch_config_dir;
21-
2220
pub const COPILOT_OAUTH_ENV_VAR: &str = "GH_COPILOT_TOKEN";
2321
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
2422

@@ -501,6 +499,7 @@ pub struct CopilotChat {
501499
configuration: CopilotChatConfiguration,
502500
models: Option<Vec<Model>>,
503501
client: Arc<dyn HttpClient>,
502+
fs: Arc<dyn Fs>,
504503
}
505504

506505
pub fn init(
@@ -546,40 +545,51 @@ impl CopilotChat {
546545
configuration: CopilotChatConfiguration,
547546
cx: &mut Context<Self>,
548547
) -> Self {
549-
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
550-
let dir_path = copilot_chat_config_dir();
551-
548+
// Initial async scan of token sources (JSON files + auth.db).
549+
// Live reload is driven by `reload_auth()`, called from the
550+
// `copilot` crate when its LSP fires `didChangeStatus`. We do
551+
// not watch the filesystem directly: SQLite writes its WAL
552+
// alongside the main file and finalises with rename/replace
553+
// operations whose ordering and visibility differ across
554+
// platforms, so a `fs.watch` loop over the config dir is racy
555+
// (we observed it firing before the token row is committed,
556+
// and missing later commits on Windows). The LSP already knows
557+
// when auth state changes and gives us a clean, semantic
558+
// signal that works the same way on every platform.
559+
let fs_for_scan = fs.clone();
552560
cx.spawn(async move |this, cx| {
553-
let mut parent_watch_rx = watch_config_dir(
554-
cx.background_executor(),
555-
fs.clone(),
556-
dir_path.clone(),
557-
config_paths,
558-
);
559-
while let Some(contents) = parent_watch_rx.next().await {
560-
let oauth_domain =
561-
this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
562-
let oauth_token = extract_oauth_token(contents, &oauth_domain);
563-
561+
let oauth_domain =
562+
this.read_with(cx, |this, _| this.configuration.oauth_domain())?;
563+
let config_paths: HashSet<PathBuf> =
564+
copilot_chat_config_paths().into_iter().collect();
565+
let auth_db_path = copilot_chat_config_dir().join("auth.db");
566+
567+
let oauth_token =
568+
read_oauth_token(&fs_for_scan, &config_paths, &oauth_domain, &auth_db_path).await;
569+
if oauth_token.is_some() {
564570
this.update(cx, |this, cx| {
565-
this.oauth_token = oauth_token.clone();
571+
this.oauth_token = oauth_token;
566572
cx.notify();
567573
})?;
568-
569-
if oauth_token.is_some() {
570-
Self::update_models(&this, cx).await?;
571-
}
574+
Self::update_models(&this, cx).await?;
572575
}
573576
anyhow::Ok(())
574577
})
575578
.detach_and_log_err(cx);
576579

577580
let this = Self {
578-
oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR).ok(),
581+
oauth_token: std::env::var(COPILOT_OAUTH_ENV_VAR)
582+
.ok()
583+
.or_else(|| {
584+
// Fallback: newer Copilot SDK stores tokens in auth.db
585+
let db_path = copilot_chat_config_dir().join("auth.db");
586+
extract_oauth_token_from_db(&db_path)
587+
}),
579588
api_endpoint: None,
580589
models: None,
581590
configuration,
582591
client,
592+
fs,
583593
};
584594

585595
if this.oauth_token.is_some() {
@@ -764,6 +774,43 @@ impl CopilotChat {
764774
.detach();
765775
}
766776
}
777+
778+
/// Re-read the OAuth token from disk and refresh the model catalog
779+
/// if the token changed. Called from the `copilot` crate when its
780+
/// LSP fires `didChangeStatus` (sign-in, sign-out, account switch).
781+
pub fn reload_auth(&mut self, cx: &mut Context<Self>) {
782+
let fs = self.fs.clone();
783+
let oauth_domain = self.configuration.oauth_domain();
784+
cx.spawn(async move |this, cx| {
785+
let config_paths: HashSet<PathBuf> =
786+
copilot_chat_config_paths().into_iter().collect();
787+
let auth_db_path = copilot_chat_config_dir().join("auth.db");
788+
789+
let new_token =
790+
read_oauth_token(&fs, &config_paths, &oauth_domain, &auth_db_path).await;
791+
792+
let token_present = this.update(cx, |this, cx| {
793+
let changed = this.oauth_token != new_token;
794+
if changed {
795+
this.oauth_token = new_token.clone();
796+
if new_token.is_none() {
797+
// Sign-out: drop derived state so a future sign-in
798+
// re-discovers the endpoint and re-fetches models.
799+
this.api_endpoint = None;
800+
this.models = None;
801+
}
802+
cx.notify();
803+
}
804+
new_token.is_some()
805+
})?;
806+
807+
if token_present {
808+
Self::update_models(&this, cx).await?;
809+
}
810+
anyhow::Ok(())
811+
})
812+
.detach_and_log_err(cx);
813+
}
767814
}
768815

769816
async fn get_models(
@@ -917,6 +964,25 @@ async fn request_models(
917964
Ok(models)
918965
}
919966

967+
/// Read the OAuth token from any available source, in order:
968+
/// legacy JSON files (`hosts.json`, `apps.json`) first, then the
969+
/// newer `auth.db` SQLite database written by the Copilot SDK.
970+
async fn read_oauth_token(
971+
fs: &Arc<dyn Fs>,
972+
config_paths: &HashSet<PathBuf>,
973+
oauth_domain: &str,
974+
auth_db_path: &std::path::Path,
975+
) -> Option<String> {
976+
for file_path in config_paths {
977+
if let Ok(contents) = fs.load(file_path).await {
978+
if let Some(token) = extract_oauth_token(contents, oauth_domain) {
979+
return Some(token);
980+
}
981+
}
982+
}
983+
extract_oauth_token_from_db(auth_db_path)
984+
}
985+
920986
fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
921987
serde_json::from_str::<serde_json::Value>(&contents)
922988
.map(|v| {
@@ -934,6 +1000,33 @@ fn extract_oauth_token(contents: String, domain: &str) -> Option<String> {
9341000
.flatten()
9351001
}
9361002

1003+
/// Extract OAuth token from the newer Copilot SDK's SQLite auth.db.
1004+
///
1005+
/// The Copilot SDK migrated token storage from `apps.json` to a SQLite
1006+
/// database (`auth.db`). The schema has an `oauth_tokens` table with a
1007+
/// `token_ciphertext` column containing the raw `ghu_...` token as text.
1008+
fn extract_oauth_token_from_db(db_path: &std::path::Path) -> Option<String> {
1009+
if !db_path.exists() {
1010+
return None;
1011+
}
1012+
1013+
let db = sqlez::connection::Connection::open_file(db_path.to_str()?);
1014+
1015+
let mut select = db
1016+
.select_row::<String>("SELECT token_ciphertext FROM oauth_tokens LIMIT 1")
1017+
.ok()?;
1018+
1019+
let token = select().ok().flatten()?;
1020+
1021+
if token.starts_with("ghu_") && token.len() >= 36 && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1022+
log::debug!("Copilot OAuth token loaded from auth.db");
1023+
Some(token)
1024+
} else {
1025+
log::warn!("Copilot auth.db: token does not match expected GitHub OAuth format (ghu_<alphanumeric>)");
1026+
None
1027+
}
1028+
}
1029+
9371030
async fn stream_completion(
9381031
client: Arc<dyn HttpClient>,
9391032
oauth_token: String,

0 commit comments

Comments
 (0)