diff --git a/Cargo.lock b/Cargo.lock index 4ede35f2c..5e6cc5e24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10978,6 +10978,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2 0.11.0", "smol", "tar", "tcode-core", diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1baf9858a..94ba8bee4 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -384,7 +384,10 @@ impl LocalKernel { } fn main() { - env_logger::init(); + // ureq debug traces include full URLs; GitHub logs only sanitized paths. + env_logger::Builder::from_default_env() + .filter_module("ureq", log::LevelFilter::Off) + .init(); if std::env::args().any(|arg| arg == "--cu-smoke") { use std::io::Write as _; diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index a647090e6..2d4c75172 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -258,7 +258,12 @@ impl HostLink { /// Restore before starting the pump or admitting new work. pub fn restore_outbox(&self, storage: Arc) -> Result<(), ProtocolError> { - let entries = storage.load()?; + let mut entries = storage.load()?; + let original_count = entries.len(); + entries.retain(|entry| !entry.command.contains_secret()); + if entries.len() != original_count { + storage.save(&entries)?; + } if entries.len() > outbox::MAX_ITEMS || serde_json::to_vec(&entries) .map_err(outbox::storage_error)? @@ -357,6 +362,20 @@ impl HostLink { } fn enqueue(&self, id: u64, command: Command) -> Result<(), ProtocolError> { + let state = command + .contains_secret() + .then(|| self.inner.connection_state.lock().unwrap()); + if state.as_ref().is_some_and(|state| { + !matches!( + **state, + ConnectionState::Connected { .. } | ConnectionState::Syncing { .. } + ) + }) { + return Err(error( + "disconnected", + "Saving a secret requires a connection", + )); + } let entry = outbox::Entry { key: uuid::Uuid::new_v4().to_string(), command, @@ -411,6 +430,7 @@ impl HostLink { delivery.failed.pop_front(); } drop(delivery); + drop(state); let _ = self.inner.delivery_changes.0.try_send(()); let mut rejected = false; for write in evicted { @@ -906,6 +926,7 @@ fn snapshot(delivery: &Delivery) -> Vec { delivery .writes .iter() + .filter(|write| !write.entry.command.contains_secret()) .map(|write| write.entry.clone()) .collect() } @@ -1023,6 +1044,92 @@ mod tests { } } + #[test] + fn secrets_keep_reconnect_delivery_keys_without_entering_the_persisted_outbox() { + let storage = Arc::new(MemoryStorage::default()); + let (to_host, outgoing) = async_channel::unbounded(); + let (incoming, from_host) = async_channel::unbounded(); + let link = HostLink::new(to_host, from_host); + link.restore_outbox(storage.clone()).unwrap(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + let mut pump = std::pin::pin!(link.pump_with_timer(std::future::pending::<()>)); + for command in [ + Command::SetGitHubToken { + host: "github.com".into(), + token: Some("fixture-private-token".into()), + }, + Command::SetProfileSecret { + profile_id: "claude".into(), + name: "KEY".into(), + value: Some("fixture-private-token".into()), + }, + Command::CreateThirdPartyProfile { + name: "sample".into(), + base_url: "https://example.com".into(), + model: None, + api_key: "fixture-private-token".into(), + }, + ] { + link.dispatch(command.clone()).unwrap(); + let request = + tcode_protocol::decode_client_line(&outgoing.try_recv().unwrap()).unwrap(); + assert!(request.key.is_some()); + assert_eq!(request.payload, ClientPayload::Command(command)); + assert!(storage.0.lock().unwrap().is_empty()); + link.set_connection_state(ConnectionState::Reconnecting { + attempt: 1, + reason: None, + }); + link.set_connection_state(ConnectionState::Syncing { path: None }); + let replay = tcode_protocol::decode_client_line(&outgoing.try_recv().unwrap()).unwrap(); + assert_eq!(replay.key, request.key); + assert_eq!(replay.payload, request.payload); + incoming + .try_send( + encode_line(&HostMessage::Ack { + id: replay.id, + result: Ok(CommandResponse::Unit), + }) + .unwrap(), + ) + .unwrap(); + assert!(pump.as_mut().poll(&mut cx).is_pending()); + assert!(link.pending_commands().is_empty()); + assert!(storage.0.lock().unwrap().is_empty()); + } + link.set_connection_state(ConnectionState::Reconnecting { + attempt: 1, + reason: None, + }); + assert!( + link.dispatch(Command::SetGitHubToken { + host: "github.com".into(), + token: Some("fixture-private-token".into()) + }) + .is_err() + ); + assert!(storage.0.lock().unwrap().is_empty()); + // Older clients persisted provider secrets. Restore removes those copies + // rather than replaying them after a process restart. + storage.0.lock().unwrap().push(outbox::Entry { + key: "old-secret".into(), + command: Command::SetProfileSecret { + profile_id: "claude".into(), + name: "KEY".into(), + value: Some("fixture-private-token".into()), + }, + }); + let (send, receive) = async_channel::unbounded(); + let (_send, receive_host) = async_channel::unbounded(); + let restored = HostLink::new(send, receive_host); + restored.restore_outbox(storage.clone()).unwrap(); + restored.set_connection_state(ConnectionState::Syncing { path: None }); + assert!(receive.try_recv().is_err()); + assert!(storage.0.lock().unwrap().is_empty()); + link.close(); + assert!(pump.as_mut().poll(&mut cx).is_ready()); + } + #[test] fn durable_writes_recreate_in_order_and_oldest_surplus_fails() { let storage = Arc::new(MemoryStorage::default()); diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index cff54061e..9a17d9305 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -14,6 +14,48 @@ use orchestrate_legacy::LegacyOrchestrateModel; mod orchestrate_fleet; mod orchestrate_legacy; +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitHubSettings { + #[serde(default)] + pub hosts: BTreeMap, + /// Host-authored discovery; never persisted in settings.json. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub status: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitHubHostSettings { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub account: Option, +} + +impl Default for GitHubHostSettings { + fn default() -> Self { + Self { + enabled: true, + account: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitHubCredentialSource { + Saved, + Env, + Gh, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitHubCredentialStatus { + pub token_set: bool, + pub source: Option, + pub accounts: Vec, + pub env_overrides_account: bool, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThemeMode { @@ -732,6 +774,11 @@ impl PluginManagementSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", content = "content", rename_all = "snake_case")] pub enum SettingsPatch { + GitHubHost { + host: String, + enabled: Option, + account: Option>, + }, Language(Option), ThemeMode(ThemeMode), WordWrapDiffs(bool), @@ -820,6 +867,8 @@ impl BrowserSettings { // agent crate derives only `PartialEq` for. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Settings { + #[serde(default)] + pub github: GitHubSettings, /// None follows the operating-system language. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -985,6 +1034,7 @@ const fn default_auto_archive_keep_count() -> usize { impl Default for Settings { fn default() -> Self { Self { + github: GitHubSettings::default(), language: None, providers: BTreeMap::new(), profiles: BTreeMap::new(), @@ -1032,6 +1082,23 @@ impl Settings { /// Apply one field-scoped mutation without replacing sibling fields. pub fn apply(&mut self, patch: SettingsPatch) { match patch { + SettingsPatch::GitHubHost { + host, + enabled, + account, + } => { + let entry = self + .github + .hosts + .entry(host.trim().to_ascii_lowercase()) + .or_default(); + if let Some(enabled) = enabled { + entry.enabled = enabled; + } + if let Some(account) = account { + entry.account = account.filter(|value| !value.trim().is_empty()); + } + } SettingsPatch::Language(value) => self.language = value, SettingsPatch::ThemeMode(value) => self.theme_mode = value, SettingsPatch::WordWrapDiffs(value) => self.word_wrap_diffs = value, diff --git a/crates/headless/src/main.rs b/crates/headless/src/main.rs index f60ce7cb2..164d22e87 100644 --- a/crates/headless/src/main.rs +++ b/crates/headless/src/main.rs @@ -43,7 +43,10 @@ const MIGRATION_REPORT_INTERVAL: Duration = Duration::from_secs(1); static INTERRUPTED: AtomicBool = AtomicBool::new(false); fn main() { - env_logger::init(); + // ureq debug traces include full URLs; GitHub logs only sanitized paths. + env_logger::Builder::from_default_env() + .parse_filters("ureq=off") + .init(); if let Err(error) = run(std::env::args().skip(1).collect()) { eprintln!("tcode-headless: {error}"); std::process::exit(1); diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs index 61d2bb059..49e1ca347 100644 --- a/crates/protocol/src/command.rs +++ b/crates/protocol/src/command.rs @@ -87,6 +87,11 @@ pub enum Command { attachment_paths: Vec, }, ReloadProvider, + SetGitHubToken { + host: String, + token: Option, + }, + RefreshGitHubCredentials, SetProfileSecret { profile_id: String, name: String, @@ -482,6 +487,16 @@ impl Command { } } + /// Secret-bearing payloads may be retained in memory, never on disk. + pub fn contains_secret(&self) -> bool { + matches!( + self, + Self::SetGitHubToken { .. } + | Self::SetProfileSecret { .. } + | Self::CreateThirdPartyProfile { .. } + ) + } + /// Idempotent controls and reads do not need retained delivery. /// All other variants are retained writes, including settings assignments: /// repeating an old assignment after a newer one would undo user intent. @@ -492,6 +507,7 @@ impl Command { | Self::PreviewReply { .. } | Self::ShutdownAllAndFlush | Self::OpenLatestSession + | Self::RefreshGitHubCredentials | Self::RefreshProviderStatus | Self::RefreshProviderUsage | Self::CheckProviderVersions diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 9d41334cf..2aa96f819 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -72,7 +72,8 @@ pub use wire::{ // // Unreleased: replaces SessionActivity.background_only with waiting, which // also covers unfinished child threads; adds the CreateNewProject and -// StartScratchDraft commands. +// StartScratchDraft commands; adds per-host GitHub settings, credential source +// discovery, SetGitHubToken and RefreshGitHubCredentials. pub const PROTOCOL_VERSION: u32 = 10; #[cfg(test)] diff --git a/crates/runtime/src/app/authorization.rs b/crates/runtime/src/app/authorization.rs index bb2254d7d..c2ecdfa2b 100644 --- a/crates/runtime/src/app/authorization.rs +++ b/crates/runtime/src/app/authorization.rs @@ -290,6 +290,8 @@ impl AppState { | Command::AutoArchiveSweep { .. } | Command::PreviewReply { .. } | Command::ReloadProvider + | Command::SetGitHubToken { .. } + | Command::RefreshGitHubCredentials | Command::SetProfileSecret { .. } | Command::UpdateProfileSettings { .. } | Command::CreateThirdPartyProfile { .. } diff --git a/crates/runtime/src/app/command_validation.rs b/crates/runtime/src/app/command_validation.rs index 7c73d4774..cf6a1ae71 100644 --- a/crates/runtime/src/app/command_validation.rs +++ b/crates/runtime/src/app/command_validation.rs @@ -37,6 +37,16 @@ impl AppState { }; self.validate_plugin_command(command)?; match command { + Command::SetGitHubToken { host, .. } + | Command::PatchSettings { + patch: tcode_core::settings::SettingsPatch::GitHubHost { host, .. }, + } if tcode_services::github::normalize_host(host).is_err() => { + return Err(error( + "invalid_github_host", + "Use a GitHub hostname without a URL or path.", + )); + } + Command::DeleteProfile { profile_id } if Settings::is_builtin_profile_id(profile_id) => { diff --git a/crates/runtime/src/app/github.rs b/crates/runtime/src/app/github.rs new file mode 100644 index 000000000..3f044eb1d --- /dev/null +++ b/crates/runtime/src/app/github.rs @@ -0,0 +1,55 @@ +use super::*; +use tcode_protocol::{CommandResponse, ProtocolError}; + +impl AppState { + pub fn refresh_github_credentials(&mut self, cx: &HostCx) { + self.github_generation += 1; + let generation = self.github_generation; + let credentials = self.github.credentials().clone(); + let host_cx = cx.clone(); + HostCx::spawn_detached(cx, async move { + let status = host_cx.unblock(move || credentials.discover()).await; + host_cx.enqueue(move |state, _| { + if generation == state.github_generation { + state.settings.github.status = status; + } + }); + }); + } + + pub fn set_github_token( + &mut self, + host: String, + token: Option, + cx: &mut HostCx, + ) -> HostTask> { + let (completion, receiver) = smol::channel::bounded(1); + self.enqueue_store_write( + StoreWrite::SetGitHubToken { + host: host.clone(), + token, + completion, + }, + cx, + ); + let host_cx = cx.clone(); + cx.spawn_background(async move { + receiver + .recv() + .await + .map_err(|_| ProtocolError { + code: "store_closed".into(), + message: "The settings writer stopped.".into(), + })? + .map_err(|error| ProtocolError { + code: "settings_write_failed".into(), + message: error, + })?; + host_cx.enqueue(move |state, cx| { + state.github.credentials().invalidate(&host); + state.refresh_github_credentials(cx); + }); + Ok(CommandResponse::Unit) + }) + } +} diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index 2cec1035c..d012fc0b8 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -248,6 +248,7 @@ mod events; mod git; mod history; use history::{Hydration, Joined, SessionLog}; +mod github; mod lifecycle; mod options; mod orchestrate; @@ -327,6 +328,8 @@ pub struct AppState { store: SessionStore, user_directories: user_files::UserDirectories, settings_store: SettingsStore, + github: Arc, + github_generation: u64, store_writes: smol::channel::Sender, store_write_receiver: Option>, store_write_failures: smol::channel::Sender, @@ -493,6 +496,9 @@ impl AppState { let settings_store = SettingsStore::new(store.root().clone()); let mut settings = settings_store.load(); settings.collapsed_threads = startup_collapsed_threads(&sessions); + let github = tcode_services::github::GitHubApi::host( + tcode_services::github::Credentials::new(settings_store.clone(), std::env::vars()), + ); let provider_secret_names = provider_secret_names(&settings, &settings_store); // Push the loaded computer-use config to the (already-running) MCP layer // so the tools honor the persisted image-mode / allow-input choices from @@ -533,6 +539,8 @@ impl AppState { store, user_directories, settings_store, + github, + github_generation: 0, store_writes, store_write_receiver: Some(store_write_receiver), store_write_failures, @@ -673,7 +681,9 @@ impl AppState { } fn enqueue_settings(&mut self, settings: &Settings, cx: &mut HostCx) { - match serde_json::to_vec_pretty(settings) { + let mut settings = settings.clone(); + settings.github.status.clear(); + match serde_json::to_vec_pretty(&settings) { Ok(bytes) => self.enqueue_store_write(StoreWrite::WriteSettings(bytes), cx), Err(err) => self.report_error( RuntimeError::PersistSettings { diff --git a/crates/runtime/src/app/providers.rs b/crates/runtime/src/app/providers.rs index d5b95c5cf..ae7f48b3e 100644 --- a/crates/runtime/src/app/providers.rs +++ b/crates/runtime/src/app/providers.rs @@ -664,9 +664,12 @@ impl AppState { } self.providers.tcode_update.checking = true; let current = self.providers.tcode_update.current.clone(); + let github = self.github.clone(); let host_cx = cx.clone(); HostCx::spawn_detached(cx, async move { - let fetched = host_cx.unblock(fetch_latest_tcode_release_json).await; + let fetched = host_cx + .unblock(move || fetch_latest_tcode_release_json(&github)) + .await; let fetched = match &fetched { Ok(bytes) => Ok(bytes.as_slice()), Err(error) => Err(*error), diff --git a/crates/runtime/src/app/sessions.rs b/crates/runtime/src/app/sessions.rs index 8fd21e059..b65de653c 100644 --- a/crates/runtime/src/app/sessions.rs +++ b/crates/runtime/src/app/sessions.rs @@ -690,7 +690,14 @@ impl AppState { for id in changed { self.providers.invalidate_usage(&id); } + let github_changed = self.settings.github.hosts != settings.github.hosts; self.settings = settings; + self.github + .credentials() + .configure(self.settings.github.hosts.clone()); + if github_changed { + self.refresh_github_credentials(cx); + } self.forget_disabled_plugin_catalogs(cx); self.providers.provider_secret_names = provider_secret_names(&self.settings, &self.settings_store); diff --git a/crates/runtime/src/app/store_write.rs b/crates/runtime/src/app/store_write.rs index 2068d0c66..125eb9b2c 100644 --- a/crates/runtime/src/app/store_write.rs +++ b/crates/runtime/src/app/store_write.rs @@ -54,6 +54,11 @@ pub(super) enum StoreWrite { key: String, value: Option, }, + SetGitHubToken { + host: String, + token: Option, + completion: smol::channel::Sender>, + }, ClearProfileSecrets(String), /// [`SessionStore::drop_superseded_diffs`] for one thread, answered with /// what it did and how long it held the writer. @@ -185,6 +190,10 @@ impl StoreWrite { |error| RuntimeError::PersistSession { error }, Some(completion), ), + StoreWrite::SetGitHubToken { completion, .. } => ( + |error| RuntimeError::PersistSettings { error }, + Some(completion), + ), StoreWrite::Flush(completion) => ( |error| RuntimeError::PersistEvent { error }, Some(completion), @@ -390,6 +399,18 @@ impl StoreWriter { .set_profile_secret(&profile_id, &key, value.as_deref()) .err() .map(settings_failure), + StoreWrite::SetGitHubToken { + host, + token, + completion, + } => { + let result = self + .settings_store + .set_github_token(&host, token.as_deref()) + .map_err(|error| error.to_string()); + let _ = completion.try_send(result); + None + } StoreWrite::ClearProfileSecrets(profile_id) => self .settings_store .clear_profile_secrets(&profile_id) diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index d5ddfeece..757e707dd 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -10423,3 +10423,175 @@ fn a_long_thread_opens_from_its_tail_with_the_whole_logs_window() { assert!(!early, "an append nobody folded forgot the index"); assert_eq!(window, whole); } + +#[test] +fn github_secret_command_persists_separately_and_settings_never_replicate_it() { + let cx = &mut TestAppContext::default(); + let store = TestStore::new("tcode-github-secret-command"); + let settings_store = SettingsStore::new(store.root().clone()); + let persisted = || -> serde_json::Value { + serde_json::from_slice(&std::fs::read(store.root().join("secrets.json")).unwrap()).unwrap() + }; + let mut legacy_settings = Settings::default(); + for id in ["github", "profiles", "version"] { + legacy_settings.profiles.insert( + id.into(), + ProviderProfile { + kind: ProviderKind::ClaudeCode, + settings: ProviderSettings::default(), + }, + ); + } + settings_store.save(&legacy_settings).unwrap(); + std::fs::write(store.root().join("secrets.json"), + r#"{"github":{"ANTHROPIC_API_KEY":"legacy-provider-secret"},"profiles":{"KEY":"legacy-profiles-secret"},"version":{"KEY":"legacy-version-secret"}}"#, + ).unwrap(); + let state = cx.new_entity(TestClientState::new((*store).clone())); + state.update(cx, |state, _| { + state.github = tcode_services::github::GitHubApi::host( + tcode_services::github::Credentials::new(SettingsStore::new(store.root().clone()), []), + ); + }); + state.dispatch_command( + cx, + 638, + Command::SetGitHubToken { + host: "GITHUB.COM".into(), + token: Some("github-writer-secret".into()), + }, + ); + cx.run_until(|_| persisted()["github"]["github.com"] == "github-writer-secret"); + assert_eq!( + settings_store.profile_secrets("github")["ANTHROPIC_API_KEY"], + "legacy-provider-secret" + ); + assert_eq!( + settings_store.profile_secrets("profiles")["KEY"], + "legacy-profiles-secret" + ); + assert_eq!( + settings_store.profile_secrets("version")["KEY"], + "legacy-version-secret" + ); + state.dispatch_command( + cx, + 642, + Command::SetProfileSecret { + profile_id: "github".into(), + name: "ANTHROPIC_API_KEY".into(), + value: Some("provider-replacement-secret".into()), + }, + ); + cx.run_until(|_| { + settings_store + .profile_secrets("github") + .get("ANTHROPIC_API_KEY") + .map(String::as_str) + == Some("provider-replacement-secret") + }); + assert_eq!(persisted()["github"]["github.com"], "github-writer-secret"); + state.dispatch_command( + cx, + 639, + Command::PatchSettings { + patch: SettingsPatch::GitHubHost { + host: "github.com".into(), + enabled: Some(false), + account: Some(Some("sample".into())), + }, + }, + ); + state.dispatch_command( + cx, + 640, + Command::PatchSettings { + patch: SettingsPatch::GitHubHost { + host: "git.example.com".into(), + enabled: Some(true), + account: None, + }, + }, + ); + cx.run_until_parked(); + state.update(cx, |state, _| { + let snapshot = state.settings_snapshot(); + assert!(!snapshot.github.hosts["github.com"].enabled); + assert_eq!( + snapshot.github.hosts["github.com"].account.as_deref(), + Some("sample") + ); + assert!(snapshot.github.hosts["git.example.com"].enabled); + assert!( + !serde_json::to_string(&snapshot) + .unwrap() + .contains("github-writer-secret") + ); + }); + let secret_file: serde_json::Value = + serde_json::from_slice(&std::fs::read(store.root().join("secrets.json")).unwrap()).unwrap(); + assert_eq!(secret_file["github"]["github.com"], "github-writer-secret"); + assert_eq!(secret_file["version"], 1); + assert_eq!( + secret_file["profiles"]["github"]["ANTHROPIC_API_KEY"], + "provider-replacement-secret" + ); + state.dispatch_command( + cx, + 641, + Command::SetGitHubToken { + host: "github.com".into(), + token: None, + }, + ); + cx.run_until(|_| persisted()["github"]["github.com"].is_null()); + assert_eq!( + settings_store.profile_secrets("github")["ANTHROPIC_API_KEY"], + "provider-replacement-secret" + ); + state.dispatch_command( + cx, + 643, + Command::SetProfileSecret { + profile_id: "github".into(), + name: "ANTHROPIC_API_KEY".into(), + value: None, + }, + ); + cx.run_until(|_| settings_store.profile_secrets("github").is_empty()); + let outgoing = cx.drain_outgoing(); + assert!( + !serde_json::to_string(&outgoing) + .unwrap() + .contains("github-writer-secret") + ); + assert!( + !std::fs::read_to_string(store.root().join("settings.json")) + .unwrap() + .contains("github-writer-secret") + ); + let unsupported = r#"{"version":2,"github":{"github.com":"future-secret"},"profiles":{}}"#; + std::fs::write(store.root().join("secrets.json"), unsupported).unwrap(); + state.dispatch_command( + cx, + 644, + Command::SetGitHubToken { + host: "github.com".into(), + token: Some("replacement".into()), + }, + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + cx.run_until_parked(); + if cx.drain_outgoing().iter().any(|message| matches!(message, HostMessage::Ack { id: 644, result: Err(error) } if error.code == "settings_write_failed")) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "secret write failure acknowledged" + ); + } + assert_eq!( + std::fs::read_to_string(store.root().join("secrets.json")).unwrap(), + unsupported + ); +} diff --git a/crates/runtime/src/pipe.rs b/crates/runtime/src/pipe.rs index 224fc132e..8f0611bf6 100644 --- a/crates/runtime/src/pipe.rs +++ b/crates/runtime/src/pipe.rs @@ -422,6 +422,10 @@ fn dispatch_command(app: &mut AppState, cx: &mut HostCx, command: Command) -> Co attachment_paths, } => app.orchestrate_turn(&session_id, text, attachment_paths, cx), Command::ReloadProvider => app.reload_provider(cx), + Command::RefreshGitHubCredentials => app.refresh_github_credentials(cx), + Command::SetGitHubToken { host, token } => { + return CommandOutcome::Pending(app.set_github_token(host, token, cx)); + } Command::SetProfileSecret { profile_id, name, diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index c5a04bc33..c5db6f0d1 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -19,6 +19,7 @@ log = "0.4" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.11" smol = "2" tar = "0.4" # Default features add a global allocator (mimalloc) and full-text search diff --git a/crates/services/examples/github_identity.rs b/crates/services/examples/github_identity.rs new file mode 100644 index 000000000..f22b8bef8 --- /dev/null +++ b/crates/services/examples/github_identity.rs @@ -0,0 +1,30 @@ +//! Live keyring/transport check. Prints only the verified login and id. +use tcode_services::{ + github::{Credentials, GitHubApi}, + settings::SettingsStore, +}; +fn main() { + let root = std::env::temp_dir().join(format!("tcode-github-live-{}", std::process::id())); + let credentials = Credentials::new( + SettingsStore::new(root), + std::env::vars().filter(|(key, _)| { + !matches!( + key.as_str(), + "GH_TOKEN" | "GITHUB_TOKEN" | "GH_ENTERPRISE_TOKEN" | "GITHUB_ENTERPRISE_TOKEN" + ) + }), + ); + let api = GitHubApi::host(credentials); + match api.verified_credential("github.com") { + Ok((credential, identity)) => println!( + "GET /user succeeded: login={}, id={}, source={:?}", + identity.login, + identity.id, + credential.source() + ), + Err(error) => { + eprintln!("GET /user failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/crates/services/src/github/api.rs b/crates/services/src/github/api.rs new file mode 100644 index 000000000..472191f62 --- /dev/null +++ b/crates/services/src/github/api.rs @@ -0,0 +1,562 @@ +use super::{ + Credential, CredentialError, Credentials, Identity, + graphql::Document, + normalize_host, + quota::{Ledger, number}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + io::Read as _, + sync::{Arc, Condvar, Mutex}, + time::{Duration, Instant, SystemTime}, +}; + +pub type Headers = BTreeMap; +const BODY_LIMIT: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitHubError { + Credential(CredentialError), + Paused { retry_at: SystemTime }, + RateLimited { status: u16, retry_at: SystemTime }, + Unauthorized, + NotFound, + Response { status: u16, messages: Vec }, + Request, + Deadline, + BodyTooLarge, + InvalidResponse, + InvalidPath, +} +impl std::fmt::Display for GitHubError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Server messages may echo request values; keep the display safe for generic logging. + match self { + Self::Response { status, .. } => write!(f, "GitHub response failed ({status})"), + Self::Credential(error) => error.fmt(f), + _ => write!( + f, + "GitHub request failed ({})", + match self { + Self::Paused { .. } => "paused", + Self::RateLimited { .. } => "rate limited", + Self::Unauthorized => "unauthorized", + Self::NotFound => "not found", + Self::Request => "network", + Self::Deadline => "deadline", + Self::BodyTooLarge => "body too large", + Self::InvalidResponse => "invalid response", + Self::InvalidPath => "invalid path", + _ => unreachable!(), + } + ), + } + } +} +impl std::error::Error for GitHubError {} +impl From for GitHubError { + fn from(value: CredentialError) -> Self { + Self::Credential(value) + } +} + +#[derive(Debug, Clone, Default)] +pub enum Authentication { + #[default] + Automatic, + Pinned(Credential), + Anonymous { + namespace: String, + }, +} + +#[derive(Debug, Clone)] +pub struct RequestOptions { + pub authentication: Authentication, + pub interactive: bool, + pub operation: &'static str, + pub timeout: Duration, + pub body_limit: usize, +} +impl Default for RequestOptions { + fn default() -> Self { + Self { + authentication: Authentication::Automatic, + interactive: false, + operation: "GitHub", + timeout: Duration::from_secs(30), + body_limit: BODY_LIMIT, + } + } +} + +pub struct RestRequest<'a> { + pub method: &'a str, + pub path: &'a str, + pub body: Option<&'a serde_json::Value>, + pub if_none_match: Option<&'a str>, +} +impl<'a> RestRequest<'a> { + pub fn get(path: &'a str) -> Self { + Self { + method: "GET", + path, + body: None, + if_none_match: None, + } + } +} + +#[derive(Debug)] +pub struct Response { + pub status: u16, + pub headers: Headers, + pub body: Vec, + /// REST exposes bounded bytes; structured consumers must reject a cut response. + pub truncated: bool, +} +impl Response { + pub fn json(&self) -> Result { + if self.truncated { + return Err(GitHubError::BodyTooLarge); + } + serde_json::from_slice(&self.body).map_err(|_| GitHubError::InvalidResponse) + } +} + +#[derive(Default)] +struct Gate { + active: Mutex, + ready: Condvar, +} +struct Permit(Arc); +impl Gate { + fn acquire(self: &Arc) -> Permit { + let mut active = self.active.lock().unwrap(); + while *active >= 8 { + active = self.ready.wait(active).unwrap(); + } + *active += 1; + Permit(self.clone()) + } +} +impl Drop for Permit { + fn drop(&mut self) { + *self.0.active.lock().unwrap() -= 1; + self.0.ready.notify_one(); + } +} + +struct CachedIdentity { + identity: Identity, + expires: Instant, +} + +pub struct GitHubApi { + credentials: Arc, + agent: ureq::Agent, + gate: Arc, + ledger: Mutex, + identities: Mutex>, + identity_gates: Mutex>>>, +} + +impl GitHubApi { + pub fn host(credentials: Arc) -> Arc { + Self::new(credentials, ureq::AgentBuilder::new()) + } + + /// The builder allows host-specific TLS trust and proxy/DNS configuration. Pooling is + /// disabled because ureq retries stale pooled sockets; GitHub writes must never retry here. + pub fn new(credentials: Arc, builder: ureq::AgentBuilder) -> Arc { + Arc::new(Self { + credentials, + agent: builder.redirects(0).max_idle_connections(0).build(), + gate: Arc::new(Gate::default()), + ledger: Mutex::new(Ledger::default()), + identities: Mutex::new(HashMap::new()), + identity_gates: Mutex::new(HashMap::new()), + }) + } + + pub fn credentials(&self) -> &Arc { + &self.credentials + } + + pub fn rest( + self: &Arc, + host: &str, + request: RestRequest<'_>, + options: &RequestOptions, + ) -> Result { + self.execute(host, request, None, options) + } + + pub fn graphql( + self: &Arc, + host: &str, + document: &Document, + options: &RequestOptions, + ) -> Result { + let body = serde_json::json!({"query": document.query, "variables": document.variables}); + self.execute( + host, + RestRequest { + method: "POST", + path: "/graphql", + body: Some(&body), + if_none_match: None, + }, + Some(&document.query), + options, + ) + } + + /// Capture and verify once before a multi-request operation to prevent account changes mid-page. + pub fn verified_credential( + self: &Arc, + host: &str, + ) -> Result<(Credential, Identity), GitHubError> { + let credential = self.credentials.get(host)?; + { + let mut cache = self.identities.lock().unwrap(); + cache.retain(|_, value| value.expires > Instant::now()); + if let Some(cached) = cache.get(&credential.fingerprint) { + return Ok((credential, cached.identity.clone())); + } + } + let gate = { + let mut gates = self.identity_gates.lock().unwrap(); + gates.retain(|_, gate| gate.strong_count() > 0); + let weak = gates.entry(credential.fingerprint.clone()).or_default(); + match weak.upgrade() { + Some(gate) => gate, + None => { + let gate = Arc::new(Mutex::new(())); + *weak = Arc::downgrade(&gate); + gate + } + } + }; + let _verification = gate.lock().unwrap(); + if let Some(cached) = self.identities.lock().unwrap().get(&credential.fingerprint) { + return Ok((credential, cached.identity.clone())); + } + let response = self.rest( + host, + RestRequest::get("/user"), + &RequestOptions { + authentication: Authentication::Pinned(credential.clone()), + interactive: true, + operation: "Identity", + ..Default::default() + }, + )?; + let identity: Identity = response.json()?; + let mut cache = self.identities.lock().unwrap(); + if cache.len() >= 128 + && let Some(oldest) = cache + .iter() + .min_by_key(|(_, value)| value.expires) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + cache.insert( + credential.fingerprint.clone(), + CachedIdentity { + identity: identity.clone(), + expires: Instant::now() + Duration::from_secs(600), + }, + ); + Ok((credential, identity)) + } + + fn execute( + self: &Arc, + raw_host: &str, + input: RestRequest<'_>, + graphql: Option<&str>, + options: &RequestOptions, + ) -> Result { + let host = normalize_host(raw_host)?; + if !input.path.starts_with('/') + || input.path.starts_with("//") + || input.path.contains(['\r', '\n', '#', '\\']) + { + return Err(GitHubError::InvalidPath); + } + let credential = match &options.authentication { + Authentication::Automatic => Some(self.credentials.get(&host)?), + Authentication::Pinned(credential) => { + if credential.host != host { + return Err(CredentialError::HostMismatch.into()); + } + self.credentials.check_enabled(&host)?; + Some(credential.clone()) + } + Authentication::Anonymous { .. } => None, + }; + let scope = match (&credential, &options.authentication) { + (Some(credential), _) => credential.fingerprint.clone(), + (_, Authentication::Anonymous { namespace }) => format!("anonymous:{namespace}"), + _ => unreachable!(), + }; + let permit = self.gate.acquire(); + let lease = self.ledger.lock().unwrap().admit( + &host, + &scope, + if graphql.is_some() { "graphql" } else { "core" }, + options.interactive, + )?; + let timeout = options.timeout.min(Duration::from_secs(30)); + let api = self.clone(); + let method = input.method.to_owned(); + let path = input.path.to_owned(); + let body = input.body.cloned(); + let validator = input.if_none_match.map(str::to_owned); + let graphql = graphql.map(str::to_owned); + let options = options.clone(); + let (send, receive) = std::sync::mpsc::sync_channel(1); + // ureq 2 cannot apply its deadline to DNS or every TLS handshake phase. The + // result deadline includes them; this I/O job retains the permit until it exits, + // even after timeout or when the HostCx::unblock waiter has been dropped. + std::thread::Builder::new() + .name("github-io".into()) + .spawn(move || { + let _permit = permit; + let answer = api.execute_io( + &host, + RestRequest { + method: &method, + path: &path, + body: body.as_ref(), + if_none_match: validator.as_deref(), + }, + graphql.as_deref(), + &options, + credential, + &scope, + lease, + ); + let _ = send.send(answer); + }) + .map_err(|_| GitHubError::Request)?; + receive.recv_timeout(timeout).map_err(|error| match error { + std::sync::mpsc::RecvTimeoutError::Timeout => GitHubError::Deadline, + _ => GitHubError::Request, + })? + } + + #[allow(clippy::too_many_arguments)] + fn execute_io( + &self, + host: &str, + input: RestRequest<'_>, + graphql: Option<&str>, + options: &RequestOptions, + credential: Option, + scope: &str, + lease: u64, + ) -> Result { + let public = host == "github.com"; + let residency = host.ends_with(".ghe.com"); + let root = if public { + "https://api.github.com".to_owned() + } else if residency { + format!("https://api.{host}") + } else { + format!("https://{host}/api/v3") + }; + let url = if graphql.is_some() && !public && !residency { + format!("https://{host}/api/graphql") + } else { + format!("{root}{}", input.path) + }; + let timeout = options.timeout.min(Duration::from_secs(30)); + let started = Instant::now(); + let mut request = self + .agent + .request(input.method, &url) + .timeout(timeout) + .set( + "Accept", + if graphql.is_some() { + "application/json" + } else { + "application/vnd.github+json" + }, + ) + .set("X-GitHub-Api-Version", "2022-11-28") + .set("User-Agent", "tcode"); + if let Some(credential) = &credential { + request = request.set("Authorization", &format!("Bearer {}", credential.token)); + } + if let Some(etag) = input.if_none_match { + request = request.set("If-None-Match", etag); + } + let answer = if let Some(body) = input.body { + request + .set("Content-Type", "application/json") + .send_bytes(&serde_json::to_vec(body).map_err(|_| GitHubError::InvalidResponse)?) + } else { + request.call() + }; + let response = match answer { + Ok(response) | Err(ureq::Error::Status(_, response)) => response, + Err(ureq::Error::Transport(error)) => { + use std::error::Error as _; + let timed_out = error + .source() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| error.kind() == std::io::ErrorKind::TimedOut); + return Err(if timed_out || started.elapsed() >= timeout { + GitHubError::Deadline + } else { + GitHubError::Request + }); + } + }; + let status = response.status(); + let headers: Headers = response + .headers_names() + .into_iter() + .filter_map(|name| { + response + .header(&name) + .map(|value| (name.to_ascii_lowercase(), value.to_owned())) + }) + .collect(); + self.ledger.lock().unwrap().observe(host, scope, &headers); + log::debug!( + "github host={} path={} operation={} status={} limit={:?} remaining={:?} reset={:?} document={}", + host, + input.path.split('?').next().unwrap_or("/"), + options.operation, + status, + number(&headers, "x-ratelimit-limit"), + number(&headers, "x-ratelimit-remaining"), + number(&headers, "x-ratelimit-reset"), + graphql.map(super::digest).unwrap_or_default() + ); + let cap = options.body_limit.min(BODY_LIMIT); + let mut body = Vec::new(); + response + .into_reader() + .take(cap as u64 + 1) + .read_to_end(&mut body) + .map_err(|error| { + if error.kind() == std::io::ErrorKind::TimedOut || started.elapsed() >= timeout { + GitHubError::Deadline + } else { + GitHubError::Request + } + })?; + if started.elapsed() >= timeout { + return Err(GitHubError::Deadline); + } + let truncated = body.len() > cap; + body.truncate(cap); + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let errors = graphql + .and_then(|_| parsed.get("errors").and_then(|errors| errors.as_array())) + .filter(|errors| !errors.is_empty()); + let types: Vec<_> = errors + .into_iter() + .flatten() + .filter_map(|error| error.get("type").and_then(|kind| kind.as_str())) + .collect(); + let messages: Vec = errors + .into_iter() + .flatten() + .filter_map(|error| { + error + .get("message") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + .collect(); + let limited = status == 429 + || types.contains(&"RATE_LIMITED") + || (errors.is_some() + && headers + .get("x-ratelimit-remaining") + .is_some_and(|value| value == "0")) + || messages.iter().any(|message| { + let message = message.to_ascii_lowercase(); + message.contains("rate limit exceeded") + || message.contains("rate limit already exceeded") + }) + || (status == 403 + && (headers + .get("x-ratelimit-remaining") + .is_some_and(|value| value == "0") + || headers.contains_key("retry-after") + || String::from_utf8_lossy(&body) + .to_ascii_lowercase() + .contains("rate limit"))); + if limited { + let retry_at = self + .ledger + .lock() + .unwrap() + .refused(host, scope, lease, &headers); + return Err(GitHubError::RateLimited { status, retry_at }); + } + if status == 401 { + self.credentials.invalidate(host); + return Err(GitHubError::Unauthorized); + } + if status == 404 + || errors.is_some_and(|errors| { + errors.iter().all(|error| { + error.get("type").and_then(|value| value.as_str()) == Some("NOT_FOUND") + }) + }) + { + return Err(GitHubError::NotFound); + } + if errors.is_some() { + return Err(GitHubError::Response { status, messages }); + } + if (200..300).contains(&status) || (status == 304 && input.if_none_match.is_some()) { + self.ledger.lock().unwrap().succeeded(host, scope, lease); + if graphql.is_some() && truncated { + return Err(GitHubError::BodyTooLarge); + } + if graphql.is_some() && !parsed.is_object() { + return Err(GitHubError::InvalidResponse); + } + return Ok(Response { + status, + headers, + body, + truncated, + }); + } + let mut messages = parsed + .get("message") + .and_then(|value| value.as_str()) + .map(|message| vec![message.to_owned()]) + .unwrap_or_default(); + for error in parsed + .get("errors") + .and_then(|errors| errors.as_array()) + .into_iter() + .flatten() + { + if let Some(message) = error + .as_str() + .or_else(|| error.get("message").and_then(|value| value.as_str())) + { + messages.push(message.to_owned()); + } else if let (Some(field), Some(code)) = ( + error.get("field").and_then(|value| value.as_str()), + error.get("code").and_then(|value| value.as_str()), + ) { + messages.push(format!("{field} {code}")); + } + } + Err(GitHubError::Response { status, messages }) + } +} diff --git a/crates/services/src/github/credentials.rs b/crates/services/src/github/credentials.rs new file mode 100644 index 000000000..7de62d4db --- /dev/null +++ b/crates/services/src/github/credentials.rs @@ -0,0 +1,323 @@ +use super::normalize_host; +use crate::settings::SettingsStore; +use std::{ + collections::{BTreeMap, HashMap}, + path::PathBuf, + sync::{Arc, Mutex, RwLock}, + time::{Duration, Instant}, +}; +use tcode_core::settings::{GitHubCredentialSource, GitHubCredentialStatus, GitHubHostSettings}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialError { + InvalidHost, + Disabled, + CliMissing, + NotSignedIn, + CliFailed, + HostMismatch, +} + +impl std::fmt::Display for CredentialError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GitHub credential unavailable: {self:?}") + } +} +impl std::error::Error for CredentialError {} + +#[derive(Clone)] +pub struct Credential { + pub(super) host: String, + pub(super) token: String, + pub(super) fingerprint: String, + pub(super) source: GitHubCredentialSource, +} +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credential") + .field("host", &self.host) + .field("source", &self.source) + .finish_non_exhaustive() + } +} +impl Credential { + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + pub fn source(&self) -> GitHubCredentialSource { + self.source + } + fn new(host: String, token: String, source: GitHubCredentialSource) -> Self { + let fingerprint = format!("{host}:{}", super::digest(&token)); + Self { + host, + token, + fingerprint, + source, + } + } +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +pub struct Identity { + pub id: u64, + pub login: String, +} + +struct Cached { + value: Result, + expires: Instant, +} + +pub struct Credentials { + store: SettingsStore, + environment: BTreeMap, + hosts: RwLock>, + cache: Mutex), Cached>>, +} + +impl Credentials { + /// The host's launch environment also supplies PATH for gh (including GUI shell repair). + pub fn new( + store: SettingsStore, + environment: impl IntoIterator, + ) -> Arc { + let hosts = store.load().github.hosts; + Arc::new(Self { + store, + environment: environment.into_iter().collect(), + hosts: RwLock::new(hosts), + cache: Mutex::new(HashMap::new()), + }) + } + + pub fn configure(&self, hosts: BTreeMap) { + *self.hosts.write().unwrap() = hosts; + } + + pub(super) fn check_enabled(&self, host: &str) -> Result<(), CredentialError> { + if self + .hosts + .read() + .unwrap() + .get(host) + .is_some_and(|choice| !choice.enabled) + { + Err(CredentialError::Disabled) + } else { + Ok(()) + } + } + + pub fn get(&self, raw_host: &str) -> Result { + let host = normalize_host(raw_host)?; + let choice = self + .hosts + .read() + .unwrap() + .get(&host) + .cloned() + .unwrap_or_default(); + if !choice.enabled { + return Err(CredentialError::Disabled); + } + if let Some(token) = self.store.github_token(&host).and_then(nonempty) { + return Ok(Credential::new(host, token, GitHubCredentialSource::Saved)); + } + if let Some(token) = self.environment_token(&host) { + return Ok(Credential::new(host, token, GitHubCredentialSource::Env)); + } + let key = (host.clone(), choice.account.clone()); + // Holding the lock coalesces keyring lookups; no HTTP runs under this lock. + let mut cache = self.cache.lock().unwrap(); + cache.retain(|_, value| value.expires > Instant::now()); + if let Some(cached) = cache.get(&key) { + return cached.value.clone(); + } + let mut result = self.gh_token(&host, choice.account.as_deref()); + if result == Err(CredentialError::NotSignedIn) && choice.account.is_some() { + result = self.gh_token(&host, None); + } + let value = result.map(|token| Credential::new(host, token, GitHubCredentialSource::Gh)); + let ttl = match &value { + Ok(_) => 300, + Err(CredentialError::CliFailed) => 0, + Err(_) => 10, + }; + if ttl > 0 { + if cache.len() >= 32 + && let Some(oldest) = cache + .iter() + .min_by_key(|(_, value)| value.expires) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + cache.insert( + key, + Cached { + value: value.clone(), + expires: Instant::now() + Duration::from_secs(ttl), + }, + ); + } + value + } + + pub fn invalidate(&self, host: &str) { + if let Ok(host) = normalize_host(host) { + self.cache + .lock() + .unwrap() + .retain(|(cached_host, _), _| cached_host != &host); + } + } + + fn environment_token(&self, host: &str) -> Option { + let keys = if host == "github.com" || host.ends_with(".ghe.com") { + ["GH_TOKEN", "GITHUB_TOKEN"] + } else if self + .environment + .get("GH_HOST") + .and_then(|value| normalize_host(value).ok()) + .as_deref() + == Some(host) + { + ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] + } else { + return None; + }; + keys.iter() + .find_map(|key| self.environment.get(*key).cloned().and_then(nonempty)) + } + + fn gh_program(&self) -> Result { + if let Some(path) = self.environment.get("PATH") { + for dir in std::env::split_paths(path) { + let program = dir.join(if cfg!(windows) { "gh.exe" } else { "gh" }); + if program.is_file() { + return Ok(program); + } + } + } + Err(CredentialError::CliMissing) + } + + fn gh_token(&self, host: &str, account: Option<&str>) -> Result { + let mut args = vec!["auth", "token", "--hostname", host]; + if let Some(account) = account { + args.extend(["--user", account]); + } + self.run_gh(&args) + .and_then(|output| nonempty(output).ok_or(CredentialError::NotSignedIn)) + } + + fn run_gh(&self, args: &[&str]) -> Result { + use smol::io::AsyncReadExt as _; + let mut command = crate::process::async_command(self.gh_program()?); + command + .args(args) + .env_clear() + .envs(&self.environment) + .env("GH_DEBUG", "") + .env("GH_PROMPT_DISABLED", "1") + // Ambient tokens must not let gh bypass the explicit GH_HOST binding. + .env_remove("GH_TOKEN") + .env_remove("GITHUB_TOKEN") + .env_remove("GH_ENTERPRISE_TOKEN") + .env_remove("GITHUB_ENTERPRISE_TOKEN") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + CredentialError::CliMissing + } else { + CredentialError::CliFailed + } + })?; + let stdout = child.stdout.take().ok_or(CredentialError::CliFailed)?; + futures_lite::future::block_on(smol::future::race( + async { + let mut bytes = Vec::new(); + stdout + .take(1024 * 1024 + 1) + .read_to_end(&mut bytes) + .await + .map_err(|_| CredentialError::CliFailed)?; + if bytes.len() > 1024 * 1024 { + return Err(CredentialError::CliFailed); + } + if !child + .status() + .await + .map_err(|_| CredentialError::CliFailed)? + .success() + { + return Err(CredentialError::NotSignedIn); + } + String::from_utf8(bytes).map_err(|_| CredentialError::CliFailed) + }, + async { + smol::Timer::after(Duration::from_secs(10)).await; + Err(CredentialError::CliFailed) + }, + )) + } + + /// Discovery is safe to replicate: login names and source only, never a token or fingerprint. + pub fn discover(&self) -> BTreeMap { + let accounts: serde_json::Value = self + .run_gh(&["auth", "status", "--json", "hosts"]) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default(); + let mut hosts = self.hosts.read().unwrap().clone(); + hosts.entry("github.com".into()).or_default(); + if let Some(logins) = accounts.get("hosts").and_then(|hosts| hosts.as_object()) { + for host in logins.keys() { + if let Ok(host) = normalize_host(host) { + hosts.entry(host).or_default(); + } + } + } + hosts + .into_iter() + .map(|(host, choice)| { + let token_set = self.store.github_token(&host).and_then(nonempty).is_some(); + let logins = accounts + .get("hosts") + .and_then(|hosts| hosts.get(&host)) + .and_then(|logins| logins.as_array()); + let accounts = logins + .into_iter() + .flatten() + .filter_map(|login| { + login + .get("login") + .and_then(|value| value.as_str()) + .map(str::to_owned) + }) + .collect(); + let source = self.get(&host).ok().map(|credential| credential.source); + let env_overrides_account = + source == Some(GitHubCredentialSource::Env) && choice.account.is_some(); + ( + host, + GitHubCredentialStatus { + token_set, + source, + accounts, + env_overrides_account, + }, + ) + }) + .collect() + } +} + +fn nonempty(value: String) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} diff --git a/crates/services/src/github/graphql.rs b/crates/services/src/github/graphql.rs new file mode 100644 index 000000000..ab3639a36 --- /dev/null +++ b/crates/services/src/github/graphql.rs @@ -0,0 +1,100 @@ +use serde_json::Value; +use std::collections::{BTreeMap, HashSet}; + +#[derive(Debug, Clone)] +pub struct Document { + pub query: String, + pub variables: BTreeMap, +} +pub type Variables = BTreeMap; + +pub struct AliasItem { + pub key: usize, + pub variables: Variables, +} + +/// Callers supply trusted schema text; values are delivered only in the variables map. +pub fn aliases( + operation: &str, + name: &str, + prefix: &str, + items: &[AliasItem], + shared: &Variables, + field: impl Fn(&BTreeMap) -> String, + within: impl Fn(String) -> String, +) -> Option { + if items.is_empty() { + return None; + } + let mut declarations = Vec::new(); + let mut variables = BTreeMap::new(); + for (name, (kind, value)) in shared { + declarations.push(format!("${name}: {kind}")); + variables.insert(name.clone(), value.clone()); + } + let fields: Vec<_> = items + .iter() + .map(|item| { + let alias = format!("{prefix}{}", item.key); + let mut placeholders = BTreeMap::new(); + for (name, (kind, value)) in &item.variables { + let variable = format!("{alias}_{name}"); + declarations.push(format!("${variable}: {kind}")); + variables.insert(variable.clone(), value.clone()); + placeholders.insert(name.clone(), format!("${variable}")); + } + format!("{alias}: {}", field(&placeholders)) + }) + .collect(); + let parameters = if declarations.is_empty() { + String::new() + } else { + format!("({})", declarations.join(", ")) + }; + Some(Document { + query: format!( + "{operation} {name}{parameters} {{\n{}\n}}", + within(fields.join("\n")) + ), + variables, + }) +} + +#[derive(Debug)] +pub struct Pages { + pub pages: Vec, + pub truncated: bool, +} + +pub fn pages( + from: Option, + max_pages: Option, + mut read: impl FnMut(Option<&str>, &[T]) -> Result, + next_cursor: impl Fn(&T) -> Option, + until: impl Fn(&[T]) -> bool, +) -> Result, E> { + let mut pages = Vec::new(); + let mut seen = HashSet::new(); + let mut after = from; + loop { + let page = read(after.as_deref(), &pages)?; + let next = next_cursor(&page); + pages.push(page); + let Some(next) = next else { + return Ok(Pages { + pages, + truncated: false, + }); + }; + if !seen.insert(next.clone()) + || max_pages.is_some_and(|max| pages.len() >= max) + || until(&pages) + { + return Ok(Pages { + pages, + truncated: true, + }); + } + after = Some(next); + } +} diff --git a/crates/services/src/github/mod.rs b/crates/services/src/github/mod.rs new file mode 100644 index 000000000..a5368a47c --- /dev/null +++ b/crates/services/src/github/mod.rs @@ -0,0 +1,36 @@ +//! Host-owned GitHub HTTP, credentials and admission policy. Call blocking entries via HostCx::unblock. + +pub mod api; +pub mod credentials; +pub mod graphql; +mod quota; + +pub use api::{GitHubApi, GitHubError, RequestOptions, Response, RestRequest}; +pub use credentials::{Credential, CredentialError, Credentials, Identity}; + +pub fn normalize_host(host: &str) -> Result { + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() + || host.len() > 253 + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + { + return Err(CredentialError::InvalidHost); + } + Ok(host) +} + +fn digest(value: &str) -> String { + use sha2::{Digest as _, Sha256}; + Sha256::digest(value.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/services/src/github/quota.rs b/crates/services/src/github/quota.rs new file mode 100644 index 000000000..68afd2ca5 --- /dev/null +++ b/crates/services/src/github/quota.rs @@ -0,0 +1,158 @@ +use super::api::{GitHubError, Headers}; +use std::{ + collections::HashMap, + time::{Duration, SystemTime}, +}; + +#[derive(Clone)] +struct Snapshot { + limit: u64, + remaining: u64, + reset: SystemTime, +} +#[derive(Default)] +struct Pause { + attempt: u32, + generation: u64, + retry_at: Option, +} +#[derive(Default)] +pub(super) struct Ledger { + quotas: HashMap<(String, String, String), Snapshot>, + pauses: HashMap<(String, String), Pause>, +} + +impl Ledger { + pub fn admit( + &self, + host: &str, + scope: &str, + resource: &str, + interactive: bool, + ) -> Result { + let now = SystemTime::now(); + let pause = self.pauses.get(&(host.to_owned(), scope.to_owned())); + if !interactive + && let Some(retry_at) = pause + .and_then(|pause| pause.retry_at) + .filter(|retry_at| *retry_at > now) + { + return Err(GitHubError::Paused { retry_at }); + } + if let Some(snapshot) = + self.quotas + .get(&(host.to_owned(), resource.to_owned(), scope.to_owned())) + && snapshot.reset > now + && (snapshot.remaining == 0 + || (!interactive && (snapshot.remaining as f64) < (snapshot.limit as f64) * 0.1)) + { + return Err(GitHubError::Paused { + retry_at: snapshot.reset, + }); + } + Ok(pause.map_or(0, |pause| pause.generation)) + } + + pub fn observe(&mut self, host: &str, scope: &str, headers: &Headers) { + let Some(resource) = headers + .get("x-ratelimit-resource") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return; + }; + let (Some(limit), Some(remaining), Some(reset)) = ( + number(headers, "x-ratelimit-limit"), + number(headers, "x-ratelimit-remaining"), + reset_time(headers), + ) else { + return; + }; + if limit == 0 { + return; + } + let key = (host.to_owned(), resource.to_owned(), scope.to_owned()); + if self + .quotas + .get(&key) + .is_some_and(|snapshot| snapshot.reset > reset) + { + return; + } + self.quotas.insert( + key, + Snapshot { + limit, + remaining, + reset, + }, + ); + } + + pub fn refused( + &mut self, + host: &str, + scope: &str, + lease: u64, + headers: &Headers, + ) -> SystemTime { + let now = SystemTime::now(); + let explicit = headers + .get("retry-after") + .and_then(|value| { + if let Ok(seconds) = value.trim().parse::() { + now.checked_add(Duration::from_secs(seconds)) + } else { + chrono::DateTime::parse_from_rfc2822(value) + .ok() + .map(SystemTime::from) + .filter(|time| *time > now) + } + }) + .or_else(|| reset_time(headers).filter(|time| *time > now)); + let pause = self + .pauses + .entry((host.to_owned(), scope.to_owned())) + .or_default(); + if pause.generation > lease { + if let Some(explicit) = explicit + && pause.retry_at.is_none_or(|old| explicit > old) + { + pause.retry_at = Some(explicit); + } + return pause.retry_at.unwrap_or(now); + } + pause.attempt = pause.attempt.saturating_add(1); + let cooldown = 30_u64 + .saturating_mul(2_u64.saturating_pow(pause.attempt - 1)) + .min(900); + let proposed = explicit + .filter(|time| *time > now) + .unwrap_or(now + Duration::from_secs(cooldown)); + pause.retry_at = Some( + pause + .retry_at + .filter(|time| *time > now) + .map_or(proposed, |old| old.max(proposed)), + ); + pause.generation = pause.generation.max(lease) + 1; + pause.retry_at.unwrap() + } + + pub fn succeeded(&mut self, host: &str, scope: &str, lease: u64) { + if let Some(pause) = self.pauses.get_mut(&(host.to_owned(), scope.to_owned())) + && pause.generation == lease + && pause.retry_at.is_none_or(|time| time <= SystemTime::now()) + { + pause.attempt = 0; + pause.retry_at = None; + } + } +} + +pub(super) fn number(headers: &Headers, name: &str) -> Option { + headers.get(name)?.trim().parse().ok() +} +fn reset_time(headers: &Headers) -> Option { + SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(number(headers, "x-ratelimit-reset")?)) +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index e111d03b5..588a675bf 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -4,6 +4,7 @@ pub mod acp_registry; pub mod desktop; pub mod export; pub mod git; +pub mod github; pub mod import; pub mod process; pub mod project_config; diff --git a/crates/services/src/settings.rs b/crates/services/src/settings.rs index 6246889ed..139639eff 100644 --- a/crates/services/src/settings.rs +++ b/crates/services/src/settings.rs @@ -10,6 +10,24 @@ use tcode_core::settings::Settings; #[cfg(test)] use tcode_core::settings::{EnvVar, ThemeMode, TraverseSetting}; +#[derive(serde::Serialize, serde::Deserialize)] +struct Secrets { + version: u32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + github: BTreeMap, + #[serde(default)] + profiles: BTreeMap>, +} +impl Default for Secrets { + fn default() -> Self { + Self { + version: 1, + github: BTreeMap::new(), + profiles: BTreeMap::new(), + } + } +} + #[derive(Debug, Clone)] pub struct SettingsStore { path: PathBuf, @@ -30,6 +48,7 @@ impl SettingsStore { }; match serde_json::from_slice::(&bytes) { Ok(mut settings) => { + settings.github.status.clear(); settings.migrate_legacy(); settings } @@ -40,11 +59,39 @@ impl SettingsStore { } } - /// Every stored secret, keyed by provider key then variable name. + fn read_secrets(&self) -> std::io::Result { + let bytes = match fs::read(&self.secrets_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Secrets::default()); + } + Err(error) => return Err(error), + }; + let invalid = + || std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid secrets file"); + let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| invalid())?; + // Legacy root values are profile maps, so a numeric version cannot collide + // with a provider id (including profiles named github, profiles or version). + if let Some(version) = value.get("version").and_then(serde_json::Value::as_u64) { + if version != 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Unsupported secrets version", + )); + } + serde_json::from_value(value).map_err(|_| invalid()) + } else { + Ok(Secrets { + profiles: serde_json::from_value(value).map_err(|_| invalid())?, + ..Default::default() + }) + } + } + + /// Every stored provider secret, keyed by profile id then variable name. pub fn load_secrets(&self) -> BTreeMap> { - fs::read(&self.secrets_path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + self.read_secrets() + .map(|all| all.profiles) .unwrap_or_default() } @@ -54,6 +101,24 @@ impl SettingsStore { self.load_secrets().remove(profile_id).unwrap_or_default() } + pub(crate) fn github_token(&self, host: &str) -> Option { + self.read_secrets().ok()?.github.get(host).cloned() + } + + pub fn set_github_token(&self, host: &str, token: Option<&str>) -> std::io::Result<()> { + let host = crate::github::normalize_host(host).map_err(std::io::Error::other)?; + let mut all = self.read_secrets()?; + match token.map(str::trim).filter(|token| !token.is_empty()) { + Some(token) => { + all.github.insert(host, token.to_owned()); + } + None => { + all.github.remove(&host); + } + } + self.write_secrets(&all) + } + /// Store (`Some`) or clear (`None`) one profile secret, by profile id. pub fn set_profile_secret( &self, @@ -61,8 +126,8 @@ impl SettingsStore { name: &str, value: Option<&str>, ) -> std::io::Result<()> { - let mut all = self.load_secrets(); - let entry = all.entry(profile_id.to_string()).or_default(); + let mut all = self.read_secrets()?; + let entry = all.profiles.entry(profile_id.to_string()).or_default(); match value { Some(value) => { entry.insert(name.to_string(), value.to_string()); @@ -72,36 +137,44 @@ impl SettingsStore { } } if entry.is_empty() { - all.remove(profile_id); + all.profiles.remove(profile_id); } self.write_secrets(&all) } /// Drop every secret stored for a profile id (used when deleting a profile). pub fn clear_profile_secrets(&self, profile_id: &str) -> std::io::Result<()> { - let mut all = self.load_secrets(); - if all.remove(profile_id).is_some() { + let mut all = self.read_secrets()?; + if all.profiles.remove(profile_id).is_some() { return self.write_secrets(&all); } Ok(()) } - fn write_secrets( - &self, - all: &BTreeMap>, - ) -> std::io::Result<()> { + fn write_secrets(&self, all: &Secrets) -> std::io::Result<()> { let data = serde_json::to_vec_pretty(all) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; let tmp = self.secrets_path.with_extension("json.tmp"); - fs::write(&tmp, data)?; + use std::io::Write as _; + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(&tmp)?; restrict_permissions(&tmp)?; + file.write_all(&data)?; fs::rename(&tmp, &self.secrets_path)?; restrict_permissions(&self.secrets_path) } pub fn save(&self, settings: &Settings) -> std::io::Result<()> { + let mut settings = settings.clone(); + settings.github.status.clear(); let tmp = self.path.with_extension("json.tmp"); - let data = serde_json::to_vec_pretty(settings) + let data = serde_json::to_vec_pretty(&settings) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; fs::write(&tmp, data)?; fs::rename(tmp, &self.path) diff --git a/crates/services/src/version_check.rs b/crates/services/src/version_check.rs index 5f37aa51e..fc72cd410 100644 --- a/crates/services/src/version_check.rs +++ b/crates/services/src/version_check.rs @@ -1,43 +1,44 @@ //! Update assessments and their host-facing adapters. -use std::io::Read as _; use std::time::Duration; +use crate::github::{GitHubApi, GitHubError, RequestOptions, RestRequest, api::Authentication}; use serde::Deserialize; pub mod provider_updates; -const TCODE_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Tryanks/tcode/releases/latest"; -const MAX_RELEASE_RESPONSE_BYTES: u64 = 1024 * 1024; - -/// Fetch the GitHub release payload consumed by [`check`]. -/// -/// This is an adapter, separate from the assessment function, so callers and -/// tests can supply already-fetched bytes to the same check interface. -pub fn fetch_latest_tcode_release_json() -> Result, FetchError> { - let response = ureq::get(TCODE_LATEST_RELEASE_URL) - .set("Accept", "application/vnd.github+json") - .set("X-GitHub-Api-Version", "2022-11-28") - .set("User-Agent", "tcode-update-check") - .timeout(Duration::from_secs(10)) - .call() - .map_err(|error| match error { - ureq::Error::Status(status, _) if matches!(status, 403 | 429) => { - FetchError::RateLimited { status } - } - ureq::Error::Status(status, _) => FetchError::Http { status }, - ureq::Error::Transport(_) => FetchError::Network, - })?; - let mut body = Vec::new(); - response - .into_reader() - .take(MAX_RELEASE_RESPONSE_BYTES + 1) - .read_to_end(&mut body) - .map_err(|_| FetchError::Read)?; - if body.len() as u64 > MAX_RELEASE_RESPONSE_BYTES { - return Err(FetchError::ResponseTooLarge); - } - Ok(body) +pub fn fetch_latest_tcode_release_json( + api: &std::sync::Arc, +) -> Result, FetchError> { + api.rest( + "github.com", + RestRequest::get("/repos/Tryanks/tcode/releases/latest"), + &RequestOptions { + authentication: Authentication::Anonymous { + namespace: "version-check".into(), + }, + operation: "LatestRelease", + timeout: Duration::from_secs(10), + body_limit: 1024 * 1024, + ..Default::default() + }, + ) + .and_then(|response| { + if response.truncated { + Err(GitHubError::BodyTooLarge) + } else { + Ok(response.body) + } + }) + .map_err(|error| match error { + GitHubError::RateLimited { status, .. } => FetchError::RateLimited { status }, + GitHubError::Paused { .. } => FetchError::RateLimited { status: 429 }, + GitHubError::Response { status, .. } => FetchError::Http { status }, + GitHubError::Unauthorized => FetchError::Http { status: 401 }, + GitHubError::NotFound => FetchError::Http { status: 404 }, + GitHubError::BodyTooLarge => FetchError::ResponseTooLarge, + _ => FetchError::Network, + }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/services/tests/github.rs b/crates/services/tests/github.rs new file mode 100644 index 000000000..a746b7687 --- /dev/null +++ b/crates/services/tests/github.rs @@ -0,0 +1,1232 @@ +use std::{ + collections::BTreeMap, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, SystemTime}, +}; +use tcode_core::settings::GitHubHostSettings; +use tcode_services::{ + github::{ + CredentialError, Credentials, GitHubApi, GitHubError, RequestOptions, RestRequest, + api::Authentication, + graphql::{self, AliasItem, Document}, + }, + settings::SettingsStore, +}; + +// The agent's documented TLS/DNS interfaces route real HTTPS requests into a loopback +// HTTP fixture without a certificate dependency or an endpoint override in production. +struct LoopbackTls; +impl ureq::TlsConnector for LoopbackTls { + fn connect( + &self, + _: &str, + io: Box, + ) -> Result, ureq::Error> { + Ok(io) + } +} +struct Exchange { + request: String, + body: Vec, + stream: TcpStream, +} +impl Exchange { + fn reply(mut self, status: u16, headers: &str, body: &[u8]) { + write!( + self.stream, + "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", + body.len() + ) + .unwrap(); + let _ = self.stream.write_all(body); + } +} +struct Fixture { + address: std::net::SocketAddr, + incoming: mpsc::Receiver, + stop: Arc, + thread: Option>, +} +impl Fixture { + fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, incoming) = mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = stop.clone(); + let thread = thread::spawn(move || { + 'connections: for stream in listener.incoming() { + if stopping.load(Ordering::SeqCst) { + break; + } + let mut stream = stream.unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + if stream.read_exact(&mut byte).is_err() { + continue 'connections; + } + request.push(byte[0]); + } + let request = String::from_utf8(request).unwrap(); + let length = request + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + if tx + .send(Exchange { + request, + body, + stream, + }) + .is_err() + { + break; + } + } + }); + Self { + address, + incoming, + stop, + thread: Some(thread), + } + } + fn builder(&self) -> ureq::AgentBuilder { + let address = self.address; + ureq::AgentBuilder::new() + .resolver(move |_: &str| Ok(vec![address])) + .tls_connector(Arc::new(LoopbackTls)) + } + fn next(&self) -> Exchange { + self.incoming + .recv_timeout(Duration::from_secs(5)) + .expect("client sent request to fixture") + } + fn call( + &self, + run: impl FnOnce() -> T + Send, + status: u16, + headers: &str, + body: &[u8], + ) -> (T, String, Vec) { + thread::scope(|scope| { + let job = scope.spawn(run); + let exchange = self.next(); + let request = exchange.request.clone(); + let sent = exchange.body.clone(); + exchange.reply(status, headers, body); + (job.join().unwrap(), request, sent) + }) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.address); + self.thread.take().unwrap().join().unwrap(); + } +} +struct Store { + store: SettingsStore, + root: std::path::PathBuf, +} +impl Store { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("tcode-github-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + Self { + store: SettingsStore::new(root.clone()), + root, + } + } + fn credentials(&self, environment: &[(&str, &str)]) -> Arc { + Credentials::new( + self.store.clone(), + environment + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())), + ) + } +} +impl Drop for Store { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.root).unwrap(); + } +} +fn quota(resource: &str, remaining: u64, reset: u64) -> String { + format!( + "x-ratelimit-resource: {resource}\r\nx-ratelimit-limit: 100\r\nx-ratelimit-remaining: {remaining}\r\nx-ratelimit-reset: {reset}\r\n" + ) +} +fn reset() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600 +} +fn document() -> Document { + Document { + query: "query Fixture { viewer { login } }".into(), + variables: BTreeMap::new(), + } +} + +#[test] +fn maps_hosts_and_keeps_saved_environment_and_enterprise_credentials_in_their_boundaries() { + let fixture = Fixture::new(); + let store = Store::new(); + let credentials = store.credentials(&[ + ("GH_TOKEN", "public-first"), + ("GITHUB_TOKEN", "public-second"), + ("GH_ENTERPRISE_TOKEN", "enterprise-first"), + ("GITHUB_ENTERPRISE_TOKEN", "enterprise-second"), + ("GH_HOST", " GIT.EXAMPLE.COM "), + ]); + let api = GitHubApi::new(credentials.clone(), fixture.builder()); + for (host, domain, path, token) in [ + (" GITHUB.COM ", "api.github.com", "/user", "public-first"), + ( + "tenant.ghe.com", + "api.tenant.ghe.com", + "/user", + "public-first", + ), + ( + "git.example.com", + "git.example.com", + "/api/v3/user", + "enterprise-first", + ), + ] { + let (answer, request, _) = fixture.call( + || api.rest(host, RestRequest::get("/user"), &RequestOptions::default()), + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.starts_with(&format!("GET {path} "))); + assert!( + request + .to_ascii_lowercase() + .contains(&format!("host: {domain}\r\n")) + ); + assert!(request.contains(&format!("Bearer {token}"))); + assert!( + request + .to_ascii_lowercase() + .contains("user-agent: tcode\r\n") + ); + } + let (answer, request, _) = fixture.call( + || api.graphql("git.example.com", &document(), &RequestOptions::default()), + 200, + "", + b"{\"data\":{}}", + ); + answer.unwrap(); + assert!(request.starts_with("POST /api/graphql ")); + store + .store + .set_github_token("GITHUB.COM", Some("saved-first")) + .unwrap(); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer saved-first")); + store + .store + .set_github_token("github.com", Some("replacement")) + .unwrap(); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer replacement")); + store.store.set_github_token("github.com", None).unwrap(); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer public-first")); + credentials.configure(BTreeMap::from([( + "github.com".into(), + GitHubHostSettings { + enabled: false, + account: None, + }, + )])); + assert_eq!( + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default() + ) + .unwrap_err(), + GitHubError::Credential(CredentialError::Disabled) + ); + // Anonymous release checks never read a saved token or the disabled switch. + let (answer, request, _) = fixture.call( + || tcode_services::version_check::fetch_latest_tcode_release_json(&api), + 200, + "", + b"{\"tag_name\":\"v99.0.0\"}", + ); + answer.unwrap(); + assert!(request.starts_with("GET /repos/Tryanks/tcode/releases/latest ")); + assert!(!request.to_ascii_lowercase().contains("authorization")); +} + +#[test] +fn classifies_graphql_partial_errors_rate_limits_and_rest_errors_before_http_success() { + for (status, headers, body, expected) in [ + ( + 200, + "", + r#"{"data":{"viewer":{}},"errors":[{"message":"bad"}]}"#, + "response", + ), + ( + 200, + "", + r#"{"errors":[{"type":"RATE_LIMITED"}]}"#, + "limited", + ), + ( + 200, + "x-ratelimit-remaining: 0\r\n", + r#"{"errors":[{"message":"quota exhausted"}]}"#, + "limited", + ), + ( + 200, + "", + r#"{"errors":[{"message":"API rate limit already exceeded"}]}"#, + "limited", + ), + ( + 200, + "", + r#"{"errors":[{"message":"API rate limit exceeded"}]}"#, + "limited", + ), + ( + 401, + "", + r#"{"errors":[{"type":"RATE_LIMITED"}]}"#, + "limited", + ), + (200, "", r#"{"errors":[{"type":"NOT_FOUND"}]}"#, "missing"), + ( + 200, + "", + r#"{"errors":[{"type":"NOT_FOUND"},{"type":"FORBIDDEN"}]}"#, + "response", + ), + ] { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let (answer, _, _) = fixture.call( + || api.graphql("github.com", &document(), &RequestOptions::default()), + status, + headers, + body.as_bytes(), + ); + match expected { + "response" => assert!(matches!(answer, Err(GitHubError::Response { .. }))), + "limited" => { + assert!(matches!(answer, Err(GitHubError::RateLimited { .. }))); + assert!(matches!( + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default() + ), + Err(GitHubError::Paused { .. }) + )); + } + "missing" => assert!(matches!(answer, Err(GitHubError::NotFound))), + _ => unreachable!(), + } + } + for (status, headers, body, limited) in [ + (429, "", "", true), + (403, "x-ratelimit-remaining: 0\r\n", "", true), + (403, "retry-after: 60\r\n", "", true), + (403, "", "rate limit", true), + ( + 403, + "", + r#"{"message":"forbidden","errors":["detail",{"field":"ref","code":"invalid"}]}"#, + false, + ), + ] { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + status, + headers, + body.as_bytes(), + ); + if limited { + assert!(matches!(answer, Err(GitHubError::RateLimited { .. }))); + } else { + assert_eq!( + answer.unwrap_err(), + GitHubError::Response { + status: 403, + messages: vec!["forbidden".into(), "detail".into(), "ref invalid".into()] + } + ); + } + } +} + +#[test] +fn conditional_reads_reuse_the_validator_without_debiting_and_reserve_stays_interactive() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let window = reset(); + let headers = quota("core", 10, window) + "etag: \"same\"\r\n"; + let (first, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/checks"), + &RequestOptions::default(), + ) + }, + 200, + &headers, + b"cached checks", + ); + let first = first.unwrap(); + for _ in 0..12 { + let mut request = RestRequest::get("/checks"); + request.if_none_match = first.headers.get("etag").map(String::as_str); + let (answer, sent, _) = fixture.call( + || api.rest("github.com", request, &RequestOptions::default()), + 304, + "", + b"", + ); + assert_eq!(answer.unwrap().status, 304); + assert!( + sent.to_ascii_lowercase() + .contains("if-none-match: \"same\"") + ); + } + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/checks"), + &RequestOptions::default(), + ) + }, + 304, + "", + b"", + ); + assert!(matches!( + answer, + Err(GitHubError::Response { status: 304, .. }) + )); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/checks"), + &RequestOptions::default(), + ) + }, + 200, + "a("core", 9, window), + b"{}", + ); + answer.unwrap(); + assert!(matches!( + api.rest( + "github.com", + RestRequest::get("/checks"), + &RequestOptions::default() + ), + Err(GitHubError::Paused { .. }) + )); + let interactive = RequestOptions { + interactive: true, + ..Default::default() + }; + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/checks"), &interactive), + 200, + "a("core", 1, window), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/checks"), &interactive), + 200, + "a("core", 0, window), + b"{}", + ); + answer.unwrap(); + assert!(matches!( + api.rest("github.com", RestRequest::get("/checks"), &interactive), + Err(GitHubError::Paused { .. }) + )); + // A different resource and anonymous credential have independent quota snapshots. + let (answer, _, _) = fixture.call( + || api.graphql("github.com", &document(), &RequestOptions::default()), + 200, + "", + b"{\"data\":{}}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || tcode_services::version_check::fetch_latest_tcode_release_json(&api), + 200, + "", + b"{}", + ); + answer.unwrap(); +} + +#[test] +fn latest_arrival_can_restore_quota_but_an_older_success_cannot_clear_a_new_refusal() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let window = reset(); + thread::scope(|scope| { + let old = scope.spawn(|| { + api.rest( + "github.com", + RestRequest::get("/old"), + &RequestOptions::default(), + ) + }); + let old_exchange = fixture.next(); + let (answer, _, _) = fixture.call( + || api.graphql("github.com", &document(), &RequestOptions::default()), + 200, + "retry-after: 600\r\n", + b"{\"errors\":[{\"type\":\"RATE_LIMITED\"}]}", + ); + assert!(matches!(answer, Err(GitHubError::RateLimited { .. }))); + old_exchange.reply(200, "a("core", 90, window), b"{}"); + old.join().unwrap().unwrap(); + }); + assert!(matches!( + api.rest( + "github.com", + RestRequest::get("/next"), + &RequestOptions::default() + ), + Err(GitHubError::Paused { .. }) + )); + let interactive = RequestOptions { + interactive: true, + ..Default::default() + }; + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/next"), &interactive), + 200, + "a("core", 1, window), + b"{}", + ); + answer.unwrap(); + assert!(matches!( + api.rest( + "github.com", + RestRequest::get("/next"), + &RequestOptions::default() + ), + Err(GitHubError::Paused { .. }) + )); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/next"), &interactive), + 200, + "a("core", 70, window), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/next"), &interactive), + 200, + "a("core", 0, window - 1), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/next"), &interactive), + 200, + "", + b"{}", + ); + answer.unwrap(); + store + .store + .set_github_token("github.com", Some("fresh-quota-scope")) + .unwrap(); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/new"), &interactive), + 200, + "a("core", 1, window), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || api.rest("github.com", RestRequest::get("/new"), &interactive), + 200, + "a("core", 70, window), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/new"), + &RequestOptions::default(), + ) + }, + 200, + "a("core", 0, window - 1), + b"{}", + ); + answer.unwrap(); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/new"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); +} + +#[test] +fn pinned_verified_identity_is_cached_per_fingerprint_and_refuses_another_host() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let (verified, sent, _) = fixture.call( + || api.verified_credential("github.com"), + 200, + "", + b"{\"id\":42,\"login\":\"fixture-user\"}", + ); + assert!(sent.starts_with("GET /user ")); + let (credential, identity) = verified.unwrap(); + assert_eq!(identity.login, "fixture-user"); + assert_eq!(api.verified_credential("github.com").unwrap().1, identity); + let options = RequestOptions { + authentication: Authentication::Pinned(credential), + ..Default::default() + }; + assert_eq!( + api.rest("other.example.com", RestRequest::get("/user"), &options) + .unwrap_err(), + GitHubError::Credential(CredentialError::HostMismatch) + ); + store + .store + .set_github_token("github.com", Some("replacement")) + .unwrap(); + let (verified, _, _) = fixture.call( + || api.verified_credential("github.com"), + 200, + "", + b"{\"id\":43,\"login\":\"other-user\"}", + ); + assert_eq!(verified.unwrap().1.id, 43); +} + +#[test] +fn body_cap_and_deadline_cover_streaming_after_headers() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/large"), + &RequestOptions::default(), + ) + }, + 200, + "", + &vec![b'a'; 8 * 1024 * 1024 + 1], + ); + let response = answer.unwrap(); + assert!(response.truncated); + assert_eq!(response.body.len(), 8 * 1024 * 1024); + assert_eq!( + response.json::().unwrap_err(), + GitHubError::BodyTooLarge + ); + let bounded = RequestOptions { + body_limit: 16, + ..Default::default() + }; + let (answer, _, _) = fixture.call( + || api.graphql("github.com", &document(), &bounded), + 200, + "", + b"{\"data\":{}} {\"errors\":[{}]}", + ); + assert_eq!(answer.unwrap_err(), GitHubError::BodyTooLarge); + let (answer, _, _) = fixture.call( + || api.graphql("github.com", &document(), &bounded), + 429, + "", + b"a refusal whose body exceeds the cap", + ); + assert!(matches!( + answer, + Err(GitHubError::RateLimited { status: 429, .. }) + )); + let (answer, _, _) = fixture.call( + || tcode_services::version_check::fetch_latest_tcode_release_json(&api), + 200, + "", + &vec![b'a'; 1024 * 1024 + 1], + ); + assert_eq!( + answer.unwrap_err(), + tcode_services::version_check::FetchError::ResponseTooLarge + ); + let timeout = RequestOptions { + timeout: Duration::from_millis(200), + interactive: true, + ..Default::default() + }; + thread::scope(|scope| { + let job = scope.spawn(|| api.rest("github.com", RestRequest::get("/slow"), &timeout)); + let mut exchange = fixture.next(); + exchange + .stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\na") + .unwrap(); + // Keep the body open until the client reports its whole-response deadline. + assert_eq!(job.join().unwrap().unwrap_err(), GitHubError::Deadline); + }); +} + +#[test] +fn aliases_and_pager_send_values_as_variables_and_report_unread_tail() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let hostile = "owner\") { secret } #"; + let doc = graphql::aliases( + "query", + "Batch", + "r", + &[ + AliasItem { + key: 0, + variables: BTreeMap::from([( + "owner".into(), + ("String!".into(), serde_json::json!(hostile)), + )]), + }, + AliasItem { + key: 1, + variables: BTreeMap::from([( + "owner".into(), + ("String!".into(), serde_json::json!("other")), + )]), + }, + ], + &BTreeMap::from([( + "name".into(), + ("String!".into(), serde_json::json!("shared-repo")), + )]), + |vars| format!("repository(owner: {}, name: $name) {{ id }}", vars["owner"]), + |fields| fields, + ) + .unwrap(); + let (answer, _, body) = fixture.call( + || api.graphql("github.com", &doc, &RequestOptions::default()), + 200, + "", + b"{\"data\":{}}", + ); + answer.unwrap(); + let sent: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let query = sent["query"].as_str().unwrap(); + assert!(!query.contains(hostile)); + assert!(!query.contains("shared-repo")); + assert_eq!(sent["variables"]["r0_owner"], hostile); + assert_eq!(query.matches("$name:").count(), 1); + for (cursors, max_pages, truncated) in [ + (vec![Some("a"), None], None, false), + (vec![Some("a"), Some("a")], None, true), + (vec![Some("a")], Some(1), true), + ] { + thread::scope(|scope| { + let job = scope.spawn(|| graphql::pages(None, max_pages, |after, _| { + let doc = Document { query: "query Page($after: String) { viewer { repositories(first: 1, after: $after) { pageInfo { endCursor } } } }".into(), variables: BTreeMap::from([("after".into(), serde_json::json!(after))]) }; + api.graphql("github.com", &doc, &RequestOptions::default())?.json::() + }, |page| page.get("next").and_then(|value| value.as_str()).map(str::to_owned), |_| false)); + let mut expected = None; + for cursor in cursors { + let exchange = fixture.next(); + let sent: serde_json::Value = serde_json::from_slice(&exchange.body).unwrap(); + assert_eq!(sent["variables"]["after"], serde_json::json!(expected)); + exchange.reply( + 200, + "", + serde_json::json!({"data":{}, "next":cursor}) + .to_string() + .as_bytes(), + ); + expected = cursor; + } + assert_eq!(job.join().unwrap().unwrap().truncated, truncated); + }); + } +} + +#[cfg(unix)] +fn gh_fixture(store: &Store) -> Vec<(String, String)> { + use std::os::unix::fs::PermissionsExt as _; + let program = store.root.join("gh"); + std::fs::write( + &program, + r#"#!/bin/sh +[ "$GH_PROMPT_DISABLED" = 1 ] || exit 9 +[ -z "$GH_DEBUG$GH_TOKEN$GITHUB_TOKEN$GH_ENTERPRISE_TOKEN$GITHUB_ENTERPRISE_TOKEN" ] || exit 9 +printf '%s\n' "$*" >> "$FIXTURE_ROOT/calls" +if [ "$2" = status ]; then + printf '%s' '{"hosts":{"github.com":[{"login":"first"},{"login":"second"}]}}' + exit 0 +fi +if [ "$6" = missing ]; then exit 1; fi +if [ -f "$FIXTURE_ROOT/signed-out" ]; then exit 1; fi +if [ -n "$6" ]; then printf '%s' "account-$6"; else /bin/cat "$FIXTURE_ROOT/token"; fi +"#, + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::write(store.root.join("token"), "cli-first").unwrap(); + vec![ + ("PATH".into(), store.root.to_string_lossy().into_owned()), + ( + "FIXTURE_ROOT".into(), + store.root.to_string_lossy().into_owned(), + ), + ] +} + +#[cfg(unix)] +#[test] +fn gh_process_boundary_honors_binding_pins_cache_invalidation_and_failure_ttls() { + use std::os::unix::fs::PermissionsExt as _; + use tcode_core::settings::GitHubCredentialSource; + let fixture = Fixture::new(); + let store = Store::new(); + let mut env = gh_fixture(&store); + env.extend([ + ("GH_ENTERPRISE_TOKEN".into(), "enterprise-secret".into()), + ("GH_HOST".into(), "allowed.example.com".into()), + ]); + let credentials = Credentials::new(store.store.clone(), env); + let api = GitHubApi::new(credentials.clone(), fixture.builder()); + let (answer, request, _) = fixture.call( + || { + api.rest( + "untrusted.example.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer cli-first")); + assert!(!request.contains("enterprise-secret")); + let (answer, request, _) = fixture.call( + || { + api.rest( + "allowed.example.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer enterprise-secret")); + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + std::fs::write(store.root.join("token"), "cli-next").unwrap(); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 401, + "", + b"{}", + ); + assert_eq!(answer.unwrap_err(), GitHubError::Unauthorized); + assert!(request.contains("Bearer cli-first")); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer cli-next")); + for (account, token) in [ + ("first", "account-first"), + ("second", "account-second"), + ("missing", "cli-next"), + ] { + credentials.configure(BTreeMap::from([( + "github.com".into(), + GitHubHostSettings { + enabled: true, + account: Some(account.into()), + }, + )])); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains(&format!("Bearer {token}"))); + } + let mut env = gh_fixture(&store); + env.extend([ + ("GH_TOKEN".into(), " ".into()), + ("GITHUB_TOKEN".into(), "environment-fallback".into()), + ]); + let env_credentials = Credentials::new(store.store.clone(), env); + env_credentials.configure(BTreeMap::from([( + "github.com".into(), + GitHubHostSettings { + enabled: true, + account: Some("second".into()), + }, + )])); + let env_api = GitHubApi::new(env_credentials.clone(), fixture.builder()); + let (answer, request, _) = fixture.call( + || { + env_api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.contains("Bearer environment-fallback")); + let status = env_credentials.discover(); + assert!(status["github.com"].env_overrides_account); + assert_eq!(status["github.com"].accounts, ["first", "second"]); + assert_eq!( + status["github.com"].source, + Some(GitHubCredentialSource::Env) + ); + // A negative CLI lookup is cached; a repaired executable failure is retried immediately. + let retry_credentials = Credentials::new(store.store.clone(), gh_fixture(&store)); + let retry_api = GitHubApi::new(retry_credentials.clone(), fixture.builder()); + std::fs::set_permissions( + store.root.join("gh"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + assert_eq!( + retry_api + .rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default() + ) + .unwrap_err(), + GitHubError::Credential(CredentialError::CliFailed) + ); + std::fs::set_permissions( + store.root.join("gh"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let (answer, _, _) = fixture.call( + || { + retry_api.rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + retry_credentials.invalidate("github.com"); + std::fs::write(store.root.join("signed-out"), "").unwrap(); + assert_eq!( + retry_api + .rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default() + ) + .unwrap_err(), + GitHubError::Credential(CredentialError::NotSignedIn) + ); + std::fs::remove_file(store.root.join("signed-out")).unwrap(); + assert_eq!( + retry_api + .rest( + "github.com", + RestRequest::get("/user"), + &RequestOptions::default() + ) + .unwrap_err(), + GitHubError::Credential(CredentialError::NotSignedIn) + ); + assert!( + std::fs::read_to_string(store.root.join("calls")) + .unwrap() + .contains("auth token --hostname github.com --user second") + ); +} + +#[test] +#[allow(clippy::disallowed_methods)] // Drive the same blocking-job cancellation used by HostCx::unblock. +fn dropped_waiters_keep_all_eight_socket_permits_until_their_jobs_finish() { + let fixture = Fixture::new(); + let store = Store::new(); + let api = GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture")]), + fixture.builder(), + ); + let mut tasks = Vec::new(); + let mut exchanges = Vec::new(); + for _ in 0..8 { + let api = api.clone(); + tasks.push(smol::spawn(async move { + smol::unblock(move || { + api.rest( + "github.com", + RestRequest::get("/held"), + &RequestOptions::default(), + ) + }) + .await + })); + exchanges.push(fixture.next()); + } + // Dropping every async owner leaves eight real sockets held by blocking jobs. + drop(tasks); + let (started_tx, started_rx) = mpsc::channel(); + let next_api = api.clone(); + let next = thread::spawn(move || { + started_tx.send(()).unwrap(); + next_api.rest( + "github.com", + RestRequest::get("/ninth"), + &RequestOptions::default(), + ) + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(matches!( + fixture.incoming.recv_timeout(Duration::from_millis(200)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + exchanges.pop().unwrap().reply(200, "", b"{}"); + let ninth = fixture.next(); + assert!(ninth.request.starts_with("GET /ninth ")); + ninth.reply(200, "", b"{}"); + next.join().unwrap().unwrap(); + for exchange in exchanges { + exchange.reply(200, "", b"{}"); + } + // A round-trip after releasing all sockets ensures no abandoned I/O or fixture threads. + let (answer, _, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/finished"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); +} + +#[test] +fn whole_response_result_deadline_includes_a_stalled_dns_resolver() { + let fixture = Fixture::new(); + let store = Store::new(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = std::sync::Mutex::new(release_rx); + let first = AtomicBool::new(true); + let address = fixture.address; + let builder = fixture.builder().resolver(move |_: &str| { + if first.swap(false, Ordering::SeqCst) { + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } + Ok(vec![address]) + }); + let api = GitHubApi::new(store.credentials(&[("GH_TOKEN", "fixture")]), builder); + thread::scope(|scope| { + let job = scope.spawn(|| { + api.rest( + "github.com", + RestRequest::get("/dns"), + &RequestOptions { + timeout: Duration::from_millis(200), + ..Default::default() + }, + ) + }); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(job.join().unwrap().unwrap_err(), GitHubError::Deadline); + release_tx.send(()).unwrap(); + }); + let (answer, request, _) = fixture.call( + || { + api.rest( + "github.com", + RestRequest::get("/after-dns"), + &RequestOptions::default(), + ) + }, + 200, + "", + b"{}", + ); + answer.unwrap(); + assert!(request.starts_with("GET /after-dns ")); +} diff --git a/crates/ui/examples/phone.rs b/crates/ui/examples/phone.rs index 4620a4566..bb400d93d 100644 --- a/crates/ui/examples/phone.rs +++ b/crates/ui/examples/phone.rs @@ -9,6 +9,7 @@ //! ```sh //! cargo run -p tcode-ui --example phone # 393×852 //! cargo run -p tcode-ui --example phone -- --android # 412×915 +//! TCODE_DATA_DIR=$(mktemp -d) cargo run -p tcode-ui --example phone -- --local //! ``` use std::borrow::Cow; @@ -18,7 +19,10 @@ use gpui::{Bounds, WindowBackgroundAppearance, WindowBounds, WindowOptions, poin use tcode_client::host::ClientHost; use tcode_ui::{ShellOptions, ShellSetup}; +type LocalTransport = Rc tcode_client::host::Transport>; + fn main() { + let local = std::env::args().any(|arg| arg == "--local"); let android = std::env::args().any(|arg| arg == "--android"); gpui_platform::application() .with_assets(tcode_ui::assets::Assets) @@ -34,6 +38,25 @@ fn main() { data_dir, tcode_traverse::native_host::default_device_name(), )); + let local_transport: Option = local.then(|| { + let store = tcode_services::store::SessionStore::open_at( + tcode_services::store::data_dir().expect("data directory"), + ) + .expect("open local host"); + let host = tcode_runtime::pipe::spawn_host(store, Default::default()) + .expect("start local host"); + let mux = tcode_traverse::HostMux::new(host.to_host, host.from_host); + Rc::new(move || { + let connection = mux.attach(tcode_protocol::Principal::Full); + let (_, state) = async_channel::unbounded(); + tcode_client::host::Transport { + to_host: connection.to_host.into(), + from_host: connection.from_host, + state, + current_host: None, + } + }) as LocalTransport + }); tcode_ui::run_shell( cx, host.clone(), @@ -51,11 +74,15 @@ fn main() { theme_json: Cow::Owned(tcode_ui::flattened_theme_json()), activate: true, setup: ShellSetup { - initial: tcode_ui::last_host_target(host.as_ref()), + initial: if local { + Some(tcode_ui::remote::AttachmentTarget::Local) + } else { + tcode_ui::last_host_target(host.as_ref()) + }, initial_pairing_error: None, client_host: Some(host), - local: None, - seed_blocking: false, + local: local_transport, + seed_blocking: local, restore_navigation: true, }, ..Default::default() diff --git a/crates/ui/src/github_settings.rs b/crates/ui/src/github_settings.rs new file mode 100644 index 000000000..3f540e569 --- /dev/null +++ b/crates/ui/src/github_settings.rs @@ -0,0 +1,290 @@ +use crate::{ + sizing::Sizable as _, + store::{StoreChange, TopicKind, WorkspaceStore}, + theme::ActiveTheme as _, + widgets::{ + button::{Button, ButtonVariants as _}, + input::{Input, InputState}, + menu::DropdownMenu as _, + switch::Switch, + }, +}; +use gpui::{ + Action, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, Styled as _, Subscription, Window, div, px, +}; +use gpui_base::{h_flex, v_flex}; +use std::collections::{BTreeMap, BTreeSet}; +use tcode_core::settings::GitHubCredentialSource; + +#[derive(Action, Clone, PartialEq, serde::Deserialize)] +#[action(namespace = github_settings, no_json)] +struct ChooseAccount { + host: String, + account: Option, +} + +pub struct GitHubSettingsPanel { + store: Entity, + tokens: BTreeMap>, + host_input: Entity, + visible: bool, + _subscription: Subscription, +} +impl GitHubSettingsPanel { + pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> Self { + let subscription = cx.subscribe(&store, |_, _, change: &StoreChange, cx| { + if change.topic == TopicKind::Settings { + cx.notify(); + } + }); + let host_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(crate::tr!("source_control.host_placeholder")) + }); + Self { + store, + tokens: BTreeMap::new(), + host_input, + visible: false, + _subscription: subscription, + } + } + pub fn show(&mut self, cx: &mut Context) { + if !self.visible { + self.visible = true; + self.store + .update(cx, |store, _| store.refresh_github_credentials()); + } + } + pub fn hide(&mut self) { + self.visible = false; + self.tokens.clear(); + } +} +impl Render for GitHubSettingsPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings = self.store.read(cx).settings().github.clone(); + let hosts: BTreeSet<_> = settings + .hosts + .keys() + .chain(settings.status.keys()) + .cloned() + .chain(std::iter::once("github.com".into())) + .collect(); + let mut rows = Vec::new(); + for host in hosts { + let choice = settings.hosts.get(&host).cloned().unwrap_or_default(); + let status = settings.status.get(&host).cloned().unwrap_or_default(); + let token = self + .tokens + .entry(host.clone()) + .or_insert_with(|| { + cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(crate::tr!("source_control.token_placeholder")) + }) + }) + .clone(); + let store = self.store.clone(); + let toggle_host = host.clone(); + let switch = Switch::new(SharedString::from(format!("github-enabled-{host}"))) + .checked(choice.enabled) + .label(crate::tr!("source_control.enabled").into_owned()) + .on_click(move |enabled, _, cx| { + store.update(cx, |store, cx| { + store.patch_github_host(toggle_host.clone(), Some(*enabled), None); + cx.notify(); + }); + }); + let source = match status.source { + Some(GitHubCredentialSource::Saved) => crate::tr!("source_control.source_saved"), + Some(GitHubCredentialSource::Env) => crate::tr!("source_control.source_env"), + Some(GitHubCredentialSource::Gh) => crate::tr!("source_control.source_gh"), + None => crate::tr!("source_control.source_none"), + }; + let mut row = v_flex() + .gap_3() + .p_4() + .child( + h_flex() + .w_full() + .justify_between() + .gap_3() + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(host.clone()), + ) + .child(switch), + ) + .child( + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(source.into_owned()), + ); + if status.accounts.len() > 1 { + let selected = choice + .account + .clone() + .unwrap_or_else(|| crate::tr!("source_control.active_account").into_owned()); + let selected_account = choice.account.clone(); + let menu_host = host.clone(); + let accounts = status.accounts.clone(); + row = row.child( + Button::new(SharedString::from(format!("github-account-{host}"))) + .ghost() + .outline() + .compact() + .label(selected) + .dropdown_menu(move |mut menu, _, _| { + menu = menu.menu_with_check( + crate::tr!("source_control.active_account").into_owned(), + selected_account.is_none(), + Box::new(ChooseAccount { + host: menu_host.clone(), + account: None, + }), + ); + for account in &accounts { + menu = menu.menu_with_check( + account.clone(), + selected_account.as_ref() == Some(account), + Box::new(ChooseAccount { + host: menu_host.clone(), + account: Some(account.clone()), + }), + ); + } + menu + }), + ); + } + if status.env_overrides_account { + row = row.child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("source_control.env_override").into_owned()), + ); + } + row = row.child( + div() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(if status.token_set { + crate::tr!("source_control.token_set").into_owned() + } else { + crate::tr!("source_control.token_unset").into_owned() + }), + ); + let set_host = host.clone(); + let clear_host = host.clone(); + let input = token.clone(); + let store = self.store.clone(); + let clear_store = self.store.clone(); + row = row.child( + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(120.)) + .child(Input::new(&token).small()), + ) + .child( + Button::new(SharedString::from(format!("github-token-set-{host}"))) + .ghost() + .outline() + .compact() + .label(crate::tr!("source_control.set").into_owned()) + .on_click(move |_, window, cx| { + let value = input.read(cx).value().to_string(); + if !value.trim().is_empty() { + store.update(cx, |store, _| { + store.set_github_token(set_host.clone(), Some(value)) + }); + input.update(cx, |input, cx| input.set_value("", window, cx)); + } + }), + ) + .child( + Button::new(SharedString::from(format!("github-token-clear-{host}"))) + .ghost() + .compact() + .disabled(!status.token_set) + .label(crate::tr!("source_control.clear").into_owned()) + .on_click(move |_, _, cx| { + clear_store.update(cx, |store, _| { + store.set_github_token(clear_host.clone(), None) + }) + }), + ), + ); + rows.push(row); + } + v_flex() + .gap_4() + .on_action(cx.listener(|this, action: &ChooseAccount, _, cx| { + this.store.update(cx, |store, _| { + store.patch_github_host(action.host.clone(), None, Some(action.account.clone())) + }); + })) + .child( + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("source_control.description").into_owned()), + ) + .child( + v_flex() + .border_1() + .border_color(cx.theme().border) + .rounded(crate::material::radius_card(cx)) + .children(rows), + ) + .child( + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .child( + div() + .flex_1() + .min_w(px(120.)) + .child(Input::new(&self.host_input).small()), + ) + .child( + Button::new("github-add-host") + .ghost() + .outline() + .compact() + .label(crate::tr!("source_control.add_host").into_owned()) + .on_click(cx.listener(|this, _, window, cx| { + let host = + this.host_input.read(cx).value().trim().to_ascii_lowercase(); + if !host.is_empty() { + this.store.update(cx, |store, _| { + store.patch_github_host(host, Some(true), None) + }); + this.host_input + .update(cx, |input, cx| input.set_value("", window, cx)); + } + })), + ), + ) + .child( + Button::new("github-refresh") + .ghost() + .compact() + .label(crate::tr!("source_control.refresh").into_owned()) + .on_click(cx.listener(|this, _, _, cx| { + this.store + .update(cx, |store, _| store.refresh_github_credentials()); + })), + ) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 832ba9b08..00a32102d 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -25,6 +25,7 @@ mod image_viewer; mod local_permissions; pub mod markdown; // Shared material helpers are also used by the phone shell. +mod github_settings; #[cfg(target_os = "macos")] mod macos_backdrop; pub mod material; diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 81e92e400..f80dc4e82 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -65,6 +65,7 @@ const CONTENT_MAX_WIDTH: f32 = 768.; enum Section { General, Providers, + SourceControl, Usage, Browser, ComputerUse, @@ -81,10 +82,11 @@ enum Section { /// headless listener in a browser. Choosing a machine, and the invitation other /// devices pair with, live in `crate::remote`. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] -const SECTIONS: [Section; 9] = [ +const SECTIONS: [Section; 10] = [ Section::General, Section::Remote, Section::Providers, + Section::SourceControl, Section::Usage, Section::Orchestrate, Section::Plugins, @@ -93,9 +95,10 @@ const SECTIONS: [Section; 9] = [ Section::Archived, ]; #[cfg(not(any(feature = "remote-hosting", target_family = "wasm")))] -const SECTIONS: [Section; 8] = [ +const SECTIONS: [Section; 9] = [ Section::General, Section::Providers, + Section::SourceControl, Section::Usage, Section::Orchestrate, Section::Plugins, @@ -156,7 +159,8 @@ impl Section { SectionGroup::Device } } - Self::Providers + Self::SourceControl + | Self::Providers | Self::Usage | Self::Browser | Self::ComputerUse @@ -181,6 +185,7 @@ impl Section { #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] Self::Remote => cx.hosting, Self::General + | Self::SourceControl | Self::Providers | Self::Usage | Self::ComputerUse @@ -193,6 +198,7 @@ impl Section { fn id(self) -> &'static str { match self { Self::General => "settings-nav-general", + Self::SourceControl => "settings-nav-source-control", Self::Providers => "settings-nav-providers", Self::Usage => "settings-nav-usage", Self::Browser => "settings-nav-browser", @@ -208,6 +214,7 @@ impl Section { fn icon(self) -> IconName { match self { Self::General => IconName::Settings, + Self::SourceControl => IconName::Github, Self::Providers => IconName::Bot, Self::Usage => IconName::ChartPie, Self::Browser => IconName::Globe, @@ -223,6 +230,7 @@ impl Section { fn label(self) -> SharedString { match self { Self::General => crate::tr!("settings.general"), + Self::SourceControl => crate::tr!("settings.source_control"), Self::Providers => crate::tr!("settings.providers"), Self::Usage => crate::tr!("settings.usage"), Self::Browser => crate::tr!("settings.browser"), @@ -311,6 +319,7 @@ pub struct SettingsPage { orchestrate_panel: Entity, /// Each enabled profile's native plugin catalog. plugins_panel: Entity, + github_panel: Entity, /// Hosting this machine. Absent where the client cannot listen at all. #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] #[cfg(not(target_family = "wasm"))] @@ -375,6 +384,7 @@ impl SettingsPage { .map(|section| match section.as_str() { "providers" => Section::Providers, "usage" => Section::Usage, + "source_control" => Section::SourceControl, "browser" => Section::Browser, "computer_use" => Section::ComputerUse, "orchestrate" => Section::Orchestrate, @@ -544,6 +554,8 @@ impl SettingsPage { let store = store.clone(); cx.new(|cx| crate::local_permissions::LocalPermissions::new(store, window, cx)) }); + let github_panel = cx + .new(|cx| crate::github_settings::GitHubSettingsPanel::new(store.clone(), window, cx)); let mut page = Self { store, window_state, @@ -552,6 +564,7 @@ impl SettingsPage { acp_panel, orchestrate_panel, plugins_panel, + github_panel, #[cfg(any(feature = "remote-hosting", target_family = "wasm"))] hosting_panel, title_model_picker, @@ -1314,8 +1327,14 @@ impl SettingsPage { if self.section != Section::Plugins { self.plugins_panel.update(cx, |panel, _| panel.hide()); } + if self.section == Section::SourceControl { + self.github_panel.update(cx, |panel, cx| panel.show(cx)); + } else { + self.github_panel.update(cx, |panel, _| panel.hide()); + } let column = match self.section { Section::General => self.render_general(cx), + Section::SourceControl => v_flex().child(self.github_panel.clone()), Section::Providers => self.render_providers(window, cx), Section::Usage => self.render_usage(cx), Section::Browser => self.render_browser(cx), diff --git a/crates/ui/src/store/intents.rs b/crates/ui/src/store/intents.rs index a7080e1bd..788a98ae5 100644 --- a/crates/ui/src/store/intents.rs +++ b/crates/ui/src/store/intents.rs @@ -857,6 +857,25 @@ impl WorkspaceStore { pub fn reload_provider(&mut self) { self.dispatch(Command::ReloadProvider); } + pub fn refresh_github_credentials(&mut self) { + self.dispatch(Command::RefreshGitHubCredentials); + } + pub fn set_github_token(&mut self, host: String, token: Option) { + self.dispatch(Command::SetGitHubToken { host, token }); + } + pub fn patch_github_host( + &mut self, + host: String, + enabled: Option, + account: Option>, + ) { + self.patch_settings(SettingsPatch::GitHubHost { + host, + enabled, + account, + }); + } + pub fn set_profile_secret(&mut self, profile_id: String, name: String, value: Option) { self.dispatch(Command::SetProfileSecret { profile_id, diff --git a/locales/en.yml b/locales/en.yml index 1e39a6fc6..dc87158e7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -243,6 +243,7 @@ migration: quitting: "Stopping. Nothing is lost; the upgrade continues the next time Tcode starts." failed: "The upgrade could not finish: %{reason}" settings: + source_control: "Source Control" title: "Settings" sections: "All settings" this_device: "This device" @@ -1420,3 +1421,21 @@ member: empty_description: "Ask the owner to add a project to this space." threads_empty: "No threads in this space yet." unavailable: "This action is unavailable in a shared space." + +source_control: + description: "Connect GitHub hosts for Source Control. Tokens stay on this machine and are never shown again." + enabled: "Enabled" + host_placeholder: "github.example.com" + add_host: "Add host" + refresh: "Refresh credentials" + token_placeholder: "Paste a token" + token_set: "Saved token is set" + token_unset: "No saved token" + set: "Set" + clear: "Clear" + source_saved: "Credential source: saved token" + source_env: "Credential source: environment" + source_gh: "Credential source: gh login" + source_none: "No credential in use" + active_account: "Active gh account" + env_override: "An environment token overrides the selected gh account." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index ae6f2d1e6..918e10ec2 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -243,6 +243,7 @@ migration: quitting: "正在停止。数据不会丢失,下次启动 Tcode 时会继续升级。" failed: "升级未能完成:%{reason}" settings: + source_control: "源代码管理" title: "设置" sections: "全部设置" this_device: "本设备" @@ -1413,3 +1414,21 @@ member: empty_description: "请让所有者为此共享空间添加项目。" threads_empty: "此共享空间还没有对话。" unavailable: "共享空间中无法执行此操作。" + +source_control: + description: "连接 GitHub 主机以使用源代码管理。令牌仅保存在此主机,不会再次显示。" + enabled: "启用" + host_placeholder: "github.example.com" + add_host: "添加主机" + refresh: "刷新凭据" + token_placeholder: "粘贴令牌" + token_set: "已设置保存的令牌" + token_unset: "未保存令牌" + set: "设置" + clear: "清除" + source_saved: "凭据来源:保存的令牌" + source_env: "凭据来源:环境变量" + source_gh: "凭据来源:gh 登录" + source_none: "未使用凭据" + active_account: "当前 gh 账户" + env_override: "环境变量令牌会覆盖所选的 gh 账户。"