From e994eaf2941b280337465b9d114752a36f18163c Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:19:07 -0400 Subject: [PATCH 1/7] Add GitHub Copilot ACP runtime registry --- apps/desktop/native-backend/src/ai.rs | 58 +++++++++++++++---- .../scripts/smoke-electron-ai-runtime.mjs | 1 + crates/ai/src/domain.rs | 1 + 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/desktop/native-backend/src/ai.rs b/apps/desktop/native-backend/src/ai.rs index 591db07f..df1e1389 100644 --- a/apps/desktop/native-backend/src/ai.rs +++ b/apps/desktop/native-backend/src/ai.rs @@ -47,7 +47,8 @@ use neverwrite_ai::{ AI_SESSION_ERROR_EVENT, AI_SESSION_UPDATED_EVENT, AI_STATUS_EVENT, AI_THINKING_COMPLETED_EVENT, AI_THINKING_DELTA_EVENT, AI_THINKING_STARTED_EVENT, AI_TOKEN_USAGE_EVENT, AI_TOOL_ACTIVITY_EVENT, AI_URL_ELICITATION_REQUEST_EVENT, AI_USER_INPUT_REQUEST_EVENT, - CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID, GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, + CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID, COPILOT_RUNTIME_ID, GROK_RUNTIME_ID, KILO_RUNTIME_ID, + OPENCODE_RUNTIME_ID, }; use portable_pty::{ native_pty_system, Child as PtyChild, ChildKiller, CommandBuilder, MasterPty, PtySize, @@ -151,6 +152,7 @@ enum AcpProtocolFlavor { } const NO_ACP_ARGS: &[&str] = &[]; +const COPILOT_ACP_ARGS: &[&str] = &["--acp"]; const GROK_ACP_ARGS: &[&str] = &["--no-auto-update", "agent", "stdio"]; const SHELL_ACP_ARGS: &[&str] = &["acp"]; @@ -175,6 +177,16 @@ const RUNTIME_DEFINITIONS: &[RuntimeDefinition] = &[ acp_protocol: AcpProtocolFlavor::Current, supports_native_resume: false, }, + RuntimeDefinition { + id: COPILOT_RUNTIME_ID, + name: "GitHub Copilot", + description: "GitHub Copilot CLI running as a native ACP agent.", + default_executable: "copilot", + bin_env_var: "NEVERWRITE_COPILOT_ACP_BIN", + acp_args: COPILOT_ACP_ARGS, + acp_protocol: AcpProtocolFlavor::Current, + supports_native_resume: false, + }, RuntimeDefinition { id: GROK_RUNTIME_ID, name: "Grok", @@ -3784,9 +3796,7 @@ fn run_acp_auth_command(spec: AcpProcessSpec, auth_command: AcpAuthCommand) -> R } }; let result = match flavor { - AcpProtocolFlavor::Current => { - runtime.block_on(run_acp_auth_inner(spec, auth_command)) - } + AcpProtocolFlavor::Current => runtime.block_on(run_acp_auth_inner(spec, auth_command)), AcpProtocolFlavor::Legacy12 => { runtime.block_on(run_acp12_auth_inner(spec, auth_command)) } @@ -7023,7 +7033,10 @@ fn resolve_grok_official_runtime_fallback(runtime_id: &str) -> Option { #[cfg(target_os = "macos")] fn resolve_macos_homebrew_runtime_fallback(runtime_id: &str) -> Option { - if !matches!(runtime_id, GROK_RUNTIME_ID | OPENCODE_RUNTIME_ID) { + if !matches!( + runtime_id, + COPILOT_RUNTIME_ID | GROK_RUNTIME_ID | OPENCODE_RUNTIME_ID + ) { return None; } ["/opt/homebrew/bin", "/usr/local/bin"] @@ -9882,6 +9895,7 @@ mod tests { fn native_resume_is_currently_limited_to_codex() { assert!(runtime_supports_native_resume(CODEX_RUNTIME_ID)); assert!(!runtime_supports_native_resume(CLAUDE_RUNTIME_ID)); + assert!(!runtime_supports_native_resume(COPILOT_RUNTIME_ID)); assert!(!runtime_supports_native_resume(GROK_RUNTIME_ID)); assert!(!runtime_supports_native_resume(KILO_RUNTIME_ID)); assert!(!runtime_supports_native_resume(OPENCODE_RUNTIME_ID)); @@ -9972,6 +9986,29 @@ mod tests { assert!(diagnostic_executable_names().contains(&"grok")); } + #[test] + fn copilot_runtime_is_registered_with_expected_launch_contract() { + let definition = runtime_definition(COPILOT_RUNTIME_ID).unwrap(); + assert_eq!(definition.name, "GitHub Copilot"); + assert_eq!(definition.default_executable, "copilot"); + assert_eq!(definition.bin_env_var, "NEVERWRITE_COPILOT_ACP_BIN"); + assert_eq!(definition.acp_args, ["--acp"]); + assert_eq!(definition.acp_protocol, AcpProtocolFlavor::Current); + assert!(!definition.supports_native_resume); + + let descriptor = runtime_descriptors() + .into_iter() + .find(|descriptor| descriptor.runtime.id == COPILOT_RUNTIME_ID) + .unwrap(); + assert_eq!(descriptor.runtime.name, "GitHub Copilot"); + assert!(!descriptor + .runtime + .capabilities + .iter() + .any(|capability| capability == "resume_session")); + assert!(diagnostic_executable_names().contains(&"copilot")); + } + #[test] fn grok_setup_status_finds_official_user_install_path() { let _guard = ENV_TEST_LOCK.lock().unwrap(); @@ -12311,13 +12348,11 @@ mod tests { for runtime_id in [ CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID, + COPILOT_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, ] { - assert_eq!( - acp_protocol_flavor(runtime_id), - AcpProtocolFlavor::Current - ); + assert_eq!(acp_protocol_flavor(runtime_id), AcpProtocolFlavor::Current); } } @@ -12713,7 +12748,10 @@ mod tests { assert_eq!(option.label, "Grid layout"); assert_eq!(option.value, "Grid layout"); - assert_eq!(option.description.as_deref(), Some("Structured description")); + assert_eq!( + option.description.as_deref(), + Some("Structured description") + ); } #[test] diff --git a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs index 78077d39..c34dde6e 100644 --- a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs +++ b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs @@ -471,6 +471,7 @@ async function main() { for (const runtimeId of [ "codex-acp", "claude-acp", + "copilot-acp", "grok-acp", "kilo-acp", "opencode-acp", diff --git a/crates/ai/src/domain.rs b/crates/ai/src/domain.rs index 2d9fd8e7..0f13e021 100644 --- a/crates/ai/src/domain.rs +++ b/crates/ai/src/domain.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; pub const CODEX_RUNTIME_ID: &str = "codex-acp"; pub const CLAUDE_RUNTIME_ID: &str = "claude-acp"; +pub const COPILOT_RUNTIME_ID: &str = "copilot-acp"; pub const GROK_RUNTIME_ID: &str = "grok-acp"; pub const KILO_RUNTIME_ID: &str = "kilo-acp"; pub const OPENCODE_RUNTIME_ID: &str = "opencode-acp"; From 70bc57ef2a39ecaf70deca91d7194d28e3a39c38 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:21:45 -0400 Subject: [PATCH 2/7] Persist verified external Copilot login safely --- apps/desktop/native-backend/src/ai.rs | 206 ++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 14 deletions(-) diff --git a/apps/desktop/native-backend/src/ai.rs b/apps/desktop/native-backend/src/ai.rs index df1e1389..f9e75a71 100644 --- a/apps/desktop/native-backend/src/ai.rs +++ b/apps/desktop/native-backend/src/ai.rs @@ -125,7 +125,9 @@ const AUTH_TERMINAL_DEFAULT_ROWS: u16 = 28; const AUTH_TERMINAL_MONITOR_INTERVAL: Duration = Duration::from_millis(120); const AUTH_TERMINAL_OUTPUT_CHUNK_SIZE: usize = 4096; const ACP_SESSION_START_TIMEOUT: Duration = Duration::from_secs(15); -const RUNTIME_SETUP_STORE_VERSION: u32 = 2; +const RUNTIME_SETUP_STORE_VERSION: u32 = 3; +const COPILOT_LOGIN_INVALIDATED_MESSAGE: &str = + "GitHub Copilot login looks invalid or expired. Run Copilot login again to reconnect."; const RUNTIME_SECRET_SERVICE: &str = "NeverWrite AI Provider Secrets"; const RUNTIME_SECRET_STORE_MODE_ENV: &str = "NEVERWRITE_AI_SECRET_STORE"; const LEGACY_GEMINI_RUNTIME_ID: &str = "gemini-acp"; @@ -378,6 +380,7 @@ struct RuntimeSetupState { auth_method: Option, suppress_persisted_auth: bool, auth_invalidated_at_ms: Option, + external_auth_verified_at_ms: Option, has_gateway_config: bool, has_gateway_url: bool, message: Option, @@ -397,6 +400,8 @@ struct PersistedRuntimeSetupState { #[serde(default)] auth_invalidated_at_ms: Option, #[serde(default)] + external_auth_verified_at_ms: Option, + #[serde(default)] env: HashMap, #[serde(default)] secret_env_keys: Vec, @@ -528,7 +533,7 @@ impl RuntimeSetupStore { }; let persisted: PersistedRuntimeSetupFile = serde_json::from_str(&raw) .map_err(|error| format!("Failed to parse AI runtime setup store: {error}"))?; - if !matches!(persisted.version, 1 | RUNTIME_SETUP_STORE_VERSION) { + if !matches!(persisted.version, 1..=RUNTIME_SETUP_STORE_VERSION) { return Ok(HashMap::new()); } @@ -590,6 +595,7 @@ impl RuntimeSetupStore { .auth_method .and_then(normalize_optional_string), auth_invalidated_at_ms: persisted_setup.auth_invalidated_at_ms, + external_auth_verified_at_ms: persisted_setup.external_auth_verified_at_ms, env, ..RuntimeSetupState::default() }; @@ -600,7 +606,8 @@ impl RuntimeSetupStore { runtime_setup.auth_method = local_auth_method_for_runtime(&runtime_id, &runtime_setup); } - runtime_setup.auth_ready = has_local_auth_config(&runtime_id, &runtime_setup); + runtime_setup.auth_ready = has_local_auth_config(&runtime_id, &runtime_setup) + || has_verified_external_auth(&runtime_id, &runtime_setup); setup.insert(runtime_id, runtime_setup); } if should_rewrite_store { @@ -730,10 +737,12 @@ impl PersistedRuntimeSetupState { .and_then(normalize_optional_string) .filter(|method| should_persist_auth_method(runtime_id, setup, method)); let auth_invalidated_at_ms = setup.auth_invalidated_at_ms; + let external_auth_verified_at_ms = setup.external_auth_verified_at_ms; if custom_binary_path.is_none() && auth_method.is_none() && auth_invalidated_at_ms.is_none() + && external_auth_verified_at_ms.is_none() && env.is_empty() && secret_env_keys.is_empty() { @@ -744,6 +753,7 @@ impl PersistedRuntimeSetupState { custom_binary_path, auth_method, auth_invalidated_at_ms, + external_auth_verified_at_ms, env, secret_env_keys, })) @@ -767,6 +777,9 @@ fn is_secret_runtime_env_key(key: &str) -> bool { | "XAI_API_KEY" | "OPENCODE_API_KEY" | "KILO_API_KEY" + | "COPILOT_GITHUB_TOKEN" + | "GH_TOKEN" + | "GITHUB_TOKEN" ) } @@ -1425,13 +1438,13 @@ impl NativeAi { ) { Ok(created) => created, Err(error) => { - if let Err(update_error) = self.invalidate_grok_auth_after_session_start_error( + if let Err(update_error) = self.invalidate_auth_after_session_start_error( &input.runtime_id, &setup, &error, ) { return Err(format!( - "{error}\n\nFailed to update Grok auth state: {update_error}" + "{error}\n\nFailed to update AI auth state: {update_error}" )); } return Err(error); @@ -1532,13 +1545,13 @@ impl NativeAi { ) { Ok(created) => created, Err(error) => { - if let Err(update_error) = self.invalidate_grok_auth_after_session_start_error( + if let Err(update_error) = self.invalidate_auth_after_session_start_error( &input.runtime_id, &setup, &error, ) { return Err(format!( - "{error}\n\nFailed to update Grok auth state: {update_error}" + "{error}\n\nFailed to update AI auth state: {update_error}" )); } return Err(error); @@ -1953,6 +1966,9 @@ impl NativeAi { pending.auth_method = Some(method_id.to_string()); pending.auth_ready = false; pending.suppress_persisted_auth = false; + if runtime_id == COPILOT_RUNTIME_ID { + pending.external_auth_verified_at_ms = None; + } if !is_invalidation_tracked_external_auth_runtime(runtime_id) { pending.auth_invalidated_at_ms = None; } @@ -1968,12 +1984,15 @@ impl NativeAi { Ok(()) } - fn invalidate_grok_auth_after_session_start_error( + fn invalidate_auth_after_session_start_error( &self, runtime_id: &str, setup_at_start: &RuntimeSetupState, error: &str, ) -> Result<(), String> { + if runtime_id == COPILOT_RUNTIME_ID && is_copilot_auth_error(error) { + return self.invalidate_copilot_auth(); + } if runtime_id != GROK_RUNTIME_ID || !is_grok_auth_error(error) { return Ok(()); } @@ -2006,6 +2025,36 @@ impl NativeAi { Ok(()) } + fn invalidate_copilot_auth(&self) -> Result<(), String> { + let (mut pending_setup, setup_load_error) = { + let state = self + .inner + .lock() + .map_err(|error| format!("Internal AI state error: {error}"))?; + (state.setup.clone(), state.setup_load_error.clone()) + }; + if setup_load_error.is_some() { + pending_setup = self.setup_store.load().map_err(runtime_setup_load_error)?; + } + let setup = pending_setup + .entry(COPILOT_RUNTIME_ID.to_string()) + .or_default(); + setup.auth_method = Some("copilot-login".to_string()); + setup.auth_ready = false; + setup.suppress_persisted_auth = false; + setup.external_auth_verified_at_ms = None; + setup.auth_invalidated_at_ms = Some(current_epoch_ms()); + setup.message = Some(COPILOT_LOGIN_INVALIDATED_MESSAGE.to_string()); + self.setup_store.save(&pending_setup)?; + let mut state = self + .inner + .lock() + .map_err(|error| format!("Internal AI state error: {error}"))?; + state.setup = pending_setup; + state.setup_load_error = None; + Ok(()) + } + pub(crate) fn write_auth_terminal_session(&self, args: &Value) -> Result { let input: AiAuthTerminalWriteInput = input_from_args(args)?; let (writer, snapshot) = { @@ -6742,6 +6791,13 @@ fn setup_load_error_status_for( } fn runtime_auth_diagnostics(runtime_id: &str) -> Value { + if runtime_id == COPILOT_RUNTIME_ID { + let environment = copilot_env_auth_keys() + .iter() + .map(|key| ((*key).to_string(), json!(env_secret_present(key)))) + .collect::>(); + return json!({ "environment": environment }); + } if runtime_id != OPENCODE_RUNTIME_ID { return Value::Null; } @@ -7056,6 +7112,7 @@ fn default_terminal_auth_method(runtime_id: &str) -> &'static str { GROK_RUNTIME_ID => "grok-login", KILO_RUNTIME_ID => "kilo-login", OPENCODE_RUNTIME_ID => "opencode-login", + COPILOT_RUNTIME_ID => "copilot-login", _ => "terminal-login", } } @@ -7111,6 +7168,10 @@ fn auth_terminal_launch_config( args.extend(["auth".to_string(), "login".to_string()]); "OpenCode Login".to_string() } + (COPILOT_RUNTIME_ID, "copilot-login") => { + args.push("login".to_string()); + "GitHub Copilot Login".to_string() + } _ => { return Err(format!( "Unsupported terminal auth method for {}: {}", @@ -7342,6 +7403,7 @@ fn inherited_auth_method( auth_invalidated_at_ms, ) }), + COPILOT_RUNTIME_ID => copilot_env_auth_present().then(|| "copilot-login".to_string()), _ => None, } } @@ -7450,6 +7512,16 @@ fn opencode_env_auth_keys() -> &'static [&'static str] { ] } +fn copilot_env_auth_keys() -> &'static [&'static str] { + &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] +} + +fn copilot_env_auth_present() -> bool { + copilot_env_auth_keys() + .iter() + .any(|key| env_secret_present(key)) +} + fn opencode_env_auth_present() -> bool { opencode_env_auth_keys() .iter() @@ -7610,12 +7682,23 @@ fn should_persist_auth_method( fn is_persistable_external_auth_method(runtime_id: &str, method_id: &str) -> bool { matches!( (runtime_id, method_id), - (GROK_RUNTIME_ID, "grok-login") | (OPENCODE_RUNTIME_ID, "opencode-login") + (GROK_RUNTIME_ID, "grok-login") + | (OPENCODE_RUNTIME_ID, "opencode-login") + | (COPILOT_RUNTIME_ID, "copilot-login") ) } fn is_invalidation_tracked_external_auth_runtime(runtime_id: &str) -> bool { - matches!(runtime_id, GROK_RUNTIME_ID | OPENCODE_RUNTIME_ID) + matches!( + runtime_id, + COPILOT_RUNTIME_ID | GROK_RUNTIME_ID | OPENCODE_RUNTIME_ID + ) +} + +fn has_verified_external_auth(runtime_id: &str, setup: &RuntimeSetupState) -> bool { + runtime_id == COPILOT_RUNTIME_ID + && setup.external_auth_verified_at_ms.is_some() + && setup.auth_invalidated_at_ms.is_none() } fn is_local_auth_method(method_id: &str) -> bool { @@ -7704,6 +7787,7 @@ fn clear_runtime_auth_state(runtime_id: &str, setup: &mut RuntimeSetupState) { setup.suppress_persisted_auth = true; setup.auth_invalidated_at_ms = is_invalidation_tracked_external_auth_runtime(runtime_id).then(current_epoch_ms); + setup.external_auth_verified_at_ms = None; setup.has_gateway_config = false; setup.has_gateway_url = false; setup.message = None; @@ -7720,6 +7804,9 @@ fn clear_runtime_auth_state(runtime_id: &str, setup: &mut RuntimeSetupState) { "XAI_API_KEY", "OPENCODE_API_KEY", "KILO_API_KEY", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", ] { setup.env.remove(key); } @@ -7760,6 +7847,21 @@ fn is_grok_auth_error(error: &str) -> bool { .any(|needle| normalized.contains(needle)) } +fn is_copilot_auth_error(error: &str) -> bool { + let normalized = error.to_lowercase(); + [ + "run copilot login", + "copilot login", + "login required", + "not authenticated", + "authentication required", + "unauthorized", + "401", + ] + .into_iter() + .any(|needle| normalized.contains(needle)) +} + fn grok_auth_failure_source(setup: &RuntimeSetupState) -> Option { match effective_auth_method_for_acp_process_spec(GROK_RUNTIME_ID, setup).as_deref() { Some("grok-login") => Some(GrokAuthFailureSource::Login), @@ -7903,6 +8005,11 @@ fn auth_methods(runtime_id: &str) -> Vec { description: "Open the OpenCode CLI sign-in flow in an integrated terminal." .to_string(), }], + COPILOT_RUNTIME_ID => vec![AiAuthMethod { + id: "copilot-login".to_string(), + name: "Copilot login".to_string(), + description: "Open GitHub Copilot sign-in in an integrated terminal.".to_string(), + }], _ => vec![], } } @@ -7914,6 +8021,7 @@ fn auth_method_ids(runtime_id: &str) -> Vec<&'static str> { GROK_RUNTIME_ID => vec!["grok-login", "xai-api-key"], KILO_RUNTIME_ID => vec!["kilo-login", "kilo-api-key"], OPENCODE_RUNTIME_ID => vec!["opencode-login"], + COPILOT_RUNTIME_ID => vec!["copilot-login"], _ => vec![], } } @@ -9251,6 +9359,9 @@ fn mark_runtime_auth_verified( setup.auth_ready = true; setup.suppress_persisted_auth = false; setup.auth_invalidated_at_ms = None; + if runtime_id == COPILOT_RUNTIME_ID && method_id == "copilot-login" { + setup.external_auth_verified_at_ms = Some(current_epoch_ms()); + } setup.message = None; state.setup.clone() }); @@ -15422,6 +15533,73 @@ mod tests { assert!(!is_grok_auth_error("model does not support that option")); } + #[test] + fn copilot_auth_error_detector_matches_cli_auth_failures() { + assert!(is_copilot_auth_error("Run copilot login to continue")); + assert!(is_copilot_auth_error("not authenticated")); + assert!(!is_copilot_auth_error("model does not support that option")); + } + + #[test] + fn copilot_verified_login_persists_without_credentials() { + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("runtime-setup.json"); + let store = RuntimeSetupStore::with_secret_store( + store_path.clone(), + Arc::new(InMemoryRuntimeSecretStore::default()), + ); + let mut setup = HashMap::new(); + setup.insert( + COPILOT_RUNTIME_ID.to_string(), + RuntimeSetupState { + auth_method: Some("copilot-login".to_string()), + auth_ready: true, + external_auth_verified_at_ms: Some(123), + ..RuntimeSetupState::default() + }, + ); + store.save(&setup).unwrap(); + let encoded = fs::read_to_string(&store_path).unwrap(); + assert!(encoded.contains("external_auth_verified_at_ms")); + assert!(!encoded.contains("COPILOT_GITHUB_TOKEN")); + let loaded = store.load().unwrap(); + let copilot = loaded.get(COPILOT_RUNTIME_ID).unwrap(); + assert_eq!(copilot.external_auth_verified_at_ms, Some(123)); + assert!(copilot.auth_ready); + } + + #[test] + fn copilot_auth_error_invalidates_verified_login() { + let temp = tempfile::tempdir().unwrap(); + let native_ai = test_native_ai_with_secret_store( + temp.path().join("runtime-setup.json"), + Arc::new(InMemoryRuntimeSecretStore::default()), + ); + native_ai.inner.lock().unwrap().setup.insert( + COPILOT_RUNTIME_ID.to_string(), + RuntimeSetupState { + auth_method: Some("copilot-login".to_string()), + auth_ready: true, + external_auth_verified_at_ms: Some(123), + ..RuntimeSetupState::default() + }, + ); + native_ai + .invalidate_auth_after_session_start_error( + COPILOT_RUNTIME_ID, + &RuntimeSetupState::default(), + "Run copilot login to continue", + ) + .unwrap(); + let setup = native_ai.inner.lock().unwrap().setup[COPILOT_RUNTIME_ID].clone(); + assert!(!setup.auth_ready); + assert_eq!(setup.external_auth_verified_at_ms, None); + assert_eq!( + setup.message.as_deref(), + Some(COPILOT_LOGIN_INVALIDATED_MESSAGE) + ); + } + #[test] fn grok_login_auth_error_marks_external_auth_invalidated() { let _guard = ENV_TEST_LOCK.lock().unwrap(); @@ -15447,7 +15625,7 @@ mod tests { .insert(GROK_RUNTIME_ID.to_string(), setup_at_start.clone()); native_ai - .invalidate_grok_auth_after_session_start_error( + .invalidate_auth_after_session_start_error( GROK_RUNTIME_ID, &setup_at_start, "cached_token unauthorized", @@ -15523,7 +15701,7 @@ mod tests { .cloned() .expect("Grok setup should exist"); native_ai - .invalidate_grok_auth_after_session_start_error( + .invalidate_auth_after_session_start_error( GROK_RUNTIME_ID, &setup_at_start, "401 invalid api key", @@ -15586,7 +15764,7 @@ mod tests { .cloned() .expect("Grok setup should exist"); native_ai - .invalidate_grok_auth_after_session_start_error( + .invalidate_auth_after_session_start_error( GROK_RUNTIME_ID, &setup_at_start, "unauthorized", @@ -15974,7 +16152,7 @@ mod tests { Some("legacy-kilo-secret".to_string()) ); let migrated = fs::read_to_string(&store_path).unwrap(); - assert!(migrated.contains("\"version\": 2")); + assert!(migrated.contains("\"version\": 3")); assert!(!migrated.contains("legacy-kilo-secret")); } From c59eabe5a21fa54f29bd69f9f6020ca92a52bec9 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:23:11 -0400 Subject: [PATCH 3/7] Add GitHub Copilot provider setup UI --- .../src/features/ai/utils/authMethods.test.ts | 4 ++++ .../src/features/ai/utils/authMethods.ts | 7 +++++- .../features/ai/utils/runtimeMetadata.test.ts | 12 ++++++++++ .../src/features/ai/utils/runtimeMetadata.ts | 7 ++++++ .../features/settings/AIProvidersSettings.tsx | 24 ++++++++++++++++--- 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/ai/utils/authMethods.test.ts b/apps/desktop/src/features/ai/utils/authMethods.test.ts index bd6a36d8..48dec3b9 100644 --- a/apps/desktop/src/features/ai/utils/authMethods.test.ts +++ b/apps/desktop/src/features/ai/utils/authMethods.test.ts @@ -18,6 +18,9 @@ describe("authMethods", () => { expect( isIntegratedTerminalAuthMethod("opencode-acp", "opencode-login"), ).toBe(true); + expect( + isIntegratedTerminalAuthMethod("copilot-acp", "copilot-login"), + ).toBe(true); }); it("rejects terminal auth methods for the wrong runtime", () => { @@ -40,6 +43,7 @@ describe("authMethods", () => { expect(isIntegratedTerminalAuthMethodId("grok-login")).toBe(true); expect(isIntegratedTerminalAuthMethodId("kilo-login")).toBe(true); expect(isIntegratedTerminalAuthMethodId("opencode-login")).toBe(true); + expect(isIntegratedTerminalAuthMethodId("copilot-login")).toBe(true); expect(isIntegratedTerminalAuthMethodId("openai-api-key")).toBe(false); }); }); diff --git a/apps/desktop/src/features/ai/utils/authMethods.ts b/apps/desktop/src/features/ai/utils/authMethods.ts index a7e38ecb..cb6f1204 100644 --- a/apps/desktop/src/features/ai/utils/authMethods.ts +++ b/apps/desktop/src/features/ai/utils/authMethods.ts @@ -32,6 +32,10 @@ export function isIntegratedTerminalAuthMethod( return methodId === "opencode-login"; } + if (runtimeId === "copilot-acp") { + return methodId === "copilot-login"; + } + return false; } @@ -40,6 +44,7 @@ export function isIntegratedTerminalAuthMethodId(methodId?: string) { isClaudeTerminalAuthMethodId(methodId) || methodId === "grok-login" || methodId === "kilo-login" || - methodId === "opencode-login" + methodId === "opencode-login" || + methodId === "copilot-login" ); } diff --git a/apps/desktop/src/features/ai/utils/runtimeMetadata.test.ts b/apps/desktop/src/features/ai/utils/runtimeMetadata.test.ts index f92a842b..935e397c 100644 --- a/apps/desktop/src/features/ai/utils/runtimeMetadata.test.ts +++ b/apps/desktop/src/features/ai/utils/runtimeMetadata.test.ts @@ -24,6 +24,11 @@ describe("runtimeMetadata", () => { name: "Grok", company: "xAI", }), + expect.objectContaining({ + id: "copilot-acp", + name: "GitHub Copilot", + company: "GitHub", + }), ]), ); }); @@ -60,6 +65,12 @@ describe("runtimeMetadata", () => { ]), }), }), + expect.objectContaining({ + runtime: expect.objectContaining({ + id: "copilot-acp", + name: "GitHub Copilot ACP", + }), + }), ]), ); }); @@ -80,6 +91,7 @@ describe("runtimeMetadata", () => { expect(getRuntimeDisplayName("kilo-acp")).toBe("Kilo"); expect(getRuntimeDisplayName("grok-acp")).toBe("Grok"); expect(getRuntimeDisplayName("opencode-acp")).toBe("OpenCode"); + expect(getRuntimeDisplayName("copilot-acp")).toBe("GitHub Copilot"); expect(getRuntimeDisplayName(undefined, undefined)).toBe("Assistant"); }); }); diff --git a/apps/desktop/src/features/ai/utils/runtimeMetadata.ts b/apps/desktop/src/features/ai/utils/runtimeMetadata.ts index beeead9e..b33cc148 100644 --- a/apps/desktop/src/features/ai/utils/runtimeMetadata.ts +++ b/apps/desktop/src/features/ai/utils/runtimeMetadata.ts @@ -44,6 +44,13 @@ const RUNTIME_METADATA: RuntimeMetadata[] = [ "prompt_queueing", ], }, + { + id: "copilot-acp", + name: "GitHub Copilot", + company: "GitHub", + description: "GitHub Copilot CLI running as a native ACP agent.", + capabilities: ["attachments", "permissions", "plans", "terminal_output", "create_session", "prompt_queueing", "user_input"], + }, { id: "grok-acp", name: "Grok", diff --git a/apps/desktop/src/features/settings/AIProvidersSettings.tsx b/apps/desktop/src/features/settings/AIProvidersSettings.tsx index 5b22c2f5..c93a003f 100644 --- a/apps/desktop/src/features/settings/AIProvidersSettings.tsx +++ b/apps/desktop/src/features/settings/AIProvidersSettings.tsx @@ -44,6 +44,8 @@ import type { const OPENCODE_RUNTIME_ID = "opencode-acp"; const OPENCODE_AUTH_METHOD_ID = "opencode-login"; +const COPILOT_RUNTIME_ID = "copilot-acp"; +const COPILOT_AUTH_METHOD_ID = "copilot-login"; const GROK_RUNTIME_ID = "grok-acp"; function getErrorMessage(error: unknown, fallback: string): string { @@ -89,6 +91,7 @@ function getShortMethodDesc(id: string): string { case "grok-login": case "kilo-login": case OPENCODE_AUTH_METHOD_ID: + case COPILOT_AUTH_METHOD_ID: return "Terminal sign-in"; case "openai-api-key": return "OpenAI API key"; @@ -125,6 +128,8 @@ function getAuthHelpText(id: string): string { return "Opens a Kilo sign-in terminal inside the app."; case OPENCODE_AUTH_METHOD_ID: return "Use providers and credentials configured by the OpenCode CLI."; + case COPILOT_AUTH_METHOD_ID: + return "Open GitHub Copilot sign-in in an integrated terminal."; case "openai-api-key": return `Store an OpenAI API key locally for ${APP_BRAND_NAME} only.`; case "codex-api-key": @@ -163,6 +168,7 @@ function getActionLabel( if (methodId === "grok-login") return "Open sign-in terminal"; if (methodId === "kilo-login") return "Open sign-in terminal"; if (methodId === OPENCODE_AUTH_METHOD_ID) return "Open sign-in terminal"; + if (methodId === COPILOT_AUTH_METHOD_ID) return "Open sign-in terminal"; if (isApiKeyMethod(methodId)) { return status.authReady && status.authMethod === methodId ? "Replace key" @@ -173,11 +179,13 @@ function getActionLabel( } function getSecondaryAuthActionLabel(status: AIRuntimeSetupStatus): string { - return status.runtimeId === OPENCODE_RUNTIME_ID ? "Disconnect" : "Log Out"; + return [OPENCODE_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(status.runtimeId) + ? "Disconnect" + : "Log Out"; } function getLogoutErrorFallback(runtimeId: string): string { - return runtimeId === OPENCODE_RUNTIME_ID + return [OPENCODE_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(runtimeId) ? "Failed to disconnect." : "Failed to log out."; } @@ -233,7 +241,7 @@ function setSecretPatch(value: string): AISecretPatch { } function supportsRuntimeBinaryOverride(runtimeId: string): boolean { - return runtimeId === OPENCODE_RUNTIME_ID || runtimeId === GROK_RUNTIME_ID; + return [OPENCODE_RUNTIME_ID, GROK_RUNTIME_ID, COPILOT_RUNTIME_ID].includes(runtimeId); } function getRuntimeBinaryPlaceholder(runtimeId: string): string { @@ -243,6 +251,9 @@ function getRuntimeBinaryPlaceholder(runtimeId: string): string { if (runtimeId === GROK_RUNTIME_ID) { return "Custom Grok runtime path, for example grok"; } + if (runtimeId === COPILOT_RUNTIME_ID) { + return "Custom GitHub Copilot runtime path, for example copilot"; + } return "Custom runtime path"; } @@ -253,6 +264,9 @@ function getRuntimeBinaryHelpText(runtimeId: string): string { if (runtimeId === GROK_RUNTIME_ID) { return "Leave empty to use grok from PATH."; } + if (runtimeId === COPILOT_RUNTIME_ID) { + return "Leave empty to use copilot from PATH."; + } return "Leave empty to use the bundled runtime or PATH."; } @@ -338,6 +352,10 @@ function getProviderSearchValues( provider.id === GROK_RUNTIME_ID ? "NEVERWRITE_GROK_ACP_BIN" : undefined, + provider.id === COPILOT_RUNTIME_ID ? "copilot acp --acp" : undefined, + provider.id === COPILOT_RUNTIME_ID + ? "NEVERWRITE_COPILOT_ACP_BIN" + : undefined, getMethodDisplayName(setupStatus), error, ...(setupStatus?.authMethods.flatMap((method) => [ From e0b646e4cad3fad5fd2f90005f4a5cb7380a314c Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:23:37 -0400 Subject: [PATCH 4/7] Preserve Copilot ACP controls and auth recovery --- apps/desktop/src/features/ai/store/chatStore.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index e3a67fff..a9519dc0 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -1692,6 +1692,7 @@ function isAuthenticationErrorMessage( ) { const normalized = message.trim().toLowerCase(); const isOpenCodeRuntime = runtimeId === "opencode-acp"; + const isCopilotRuntime = runtimeId === "copilot-acp"; const isOpenCodeAuthGuidance = normalized.includes("run opencode auth login") || normalized.includes("use /connect") || @@ -1700,12 +1701,21 @@ function isAuthenticationErrorMessage( normalized.includes("no provider configured") || normalized.includes("unauthorized") || normalized.includes("401"))); + const isCopilotAuthGuidance = + normalized.includes("run copilot login") || + normalized.includes("copilot login") || + (isCopilotRuntime && + (normalized.includes("login required") || + normalized.includes("not authenticated") || + normalized.includes("unauthorized") || + normalized.includes("401"))); return ( normalized.includes("auth_required") || normalized.includes("authentication required") || normalized.includes("auth required") || isOpenCodeAuthGuidance || + isCopilotAuthGuidance || normalized.includes("you were signed out") || normalized.includes("reconnect in ai setup") || normalized.includes("reconnect codex") || From 619112824e6b99a52da6cdc5b6432f926d335a29 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:24:32 -0400 Subject: [PATCH 5/7] Cover GitHub Copilot changes through review --- .../scripts/smoke-electron-ai-runtime.mjs | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs index c34dde6e..751ed5c6 100644 --- a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs +++ b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs @@ -203,6 +203,7 @@ async function writeFakeAcpRuntime(runtimeDir) { import { createInterface } from "node:readline"; const sessionId = "fake-electron-acp-session"; +const isCopilot = process.argv.includes("--acp"); function send(message) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); } @@ -213,6 +214,20 @@ function option(id, name) { return { value: id, name }; } function configOptions(mode = "default") { + if (isCopilot) { + return [ + { + id: "mode", name: "Mode", category: "mode", type: "select", + currentValue: "https://agentclientprotocol.com/protocol/session-modes#agent", + options: [ + option("https://agentclientprotocol.com/protocol/session-modes#agent", "Agent"), + option("https://agentclientprotocol.com/protocol/session-modes#plan", "Plan"), + option("https://agentclientprotocol.com/protocol/session-modes#autopilot", "Autopilot") + ] + }, + { id: "allow_all", name: "Allow All", category: "other", type: "select", currentValue: "off", options: [option("off", "Off"), option("on", "On")] } + ]; + } return [ { id: "mode", @@ -251,8 +266,12 @@ createInterface({ input: process.stdin }).on("line", (line) => { availableModels: [{ modelId: "auto", name: "Auto" }] }, modes: { - currentModeId: "default", - availableModes: [ + currentModeId: isCopilot ? "https://agentclientprotocol.com/protocol/session-modes#agent" : "default", + availableModes: isCopilot ? [ + { id: "https://agentclientprotocol.com/protocol/session-modes#agent", name: "Agent" }, + { id: "https://agentclientprotocol.com/protocol/session-modes#plan", name: "Plan" }, + { id: "https://agentclientprotocol.com/protocol/session-modes#autopilot", name: "Autopilot" } + ] : [ { id: "default", name: "Default" }, { id: "review", name: "Review" } ] @@ -596,6 +615,27 @@ async function main() { "real ACP stream completion", ); + const copilotSetup = await client.invoke("ai_update_setup", { + runtimeId: "copilot-acp", + input: { custom_binary_path: fakeAcpPath }, + }); + assert(copilotSetup.binary_ready === true, "Copilot should resolve the fake ACP binary"); + const copilotSession = await client.invoke("ai_create_session", { + input: { runtime_id: "copilot-acp", additional_roots: null }, + vaultPath, + }); + assert( + copilotSession.modes.some((mode) => mode.name === "Agent") && + copilotSession.modes.some((mode) => mode.name === "Plan") && + copilotSession.modes.some((mode) => mode.name === "Autopilot"), + "Copilot ACP modes should preserve their negotiated IDs", + ); + const allowAll = copilotSession.config_options.find((option) => option.id === "allow_all"); + assert(allowAll?.value === "off", "Copilot Allow All must remain disabled by default"); + await client.invoke("ai_set_config_option", { + input: { session_id: copilotSession.session_id, option_id: "allow_all", value: "on" }, + }); + const grokSetup = await client.invoke("ai_update_setup", { runtimeId: "grok-acp", input: { From e47dae5c03d03cd7c8ec51604054abc70c568bff Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:25:08 -0400 Subject: [PATCH 6/7] Document GitHub Copilot ACP setup --- README.md | 9 ++++++--- docs/ai-runtime-setup.md | 32 +++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bcfd31e6..24397eac 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Today the repository combines: - An Electron desktop app with a Rust sidecar that opens a local vault and keeps working state on disk. - A Markdown, CSV, Mermaid, and text/code editing workflow with wikilinks, live preview, frontmatter editing, OKF document status hints, spellcheck, and grammar checking. - Knowledge navigation tools such as backlinks, tags, advanced search, bookmarks, concept maps, and a 2D/3D graph view. -- An ACP-based AI layer with Codex, Claude, Grok, Kilo, and OpenCode runtimes. +- An ACP-based AI layer with Codex, Claude, GitHub Copilot, Grok, Kilo, and OpenCode runtimes. - An explicit AI change-review system with inline review inside the editor and a dedicated surface in chat and a tab with changes pending approval. - A separate browser web clipper that can save directly into the desktop app through a local API, with deep-link fallback. Compatible with both Firefox and Chromium. @@ -89,7 +89,7 @@ NeverWrite has partial Open Knowledge Format support for `status`, `type`, and v ### AI and change control -- ACP runtime integration for Codex, Claude, Grok, Kilo, and OpenCode +- ACP runtime integration for Codex, Claude, GitHub Copilot, Grok, Kilo, and OpenCode - Attachment flows for notes, folders, files, PDFs, audio, images, and screenshots - Session history, transcript viewing, session export, fork, resume, and rename flows - Crash recovery for saved chats through `Chat History` and local `.neverwrite/sessions/` transcripts @@ -212,10 +212,11 @@ The repository already contains broad Vitest coverage in the desktop app and web ## AI Runtime Notes -NeverWrite currently wires five ACP runtimes: +NeverWrite currently wires six ACP runtimes: - `codex-acp` - `claude-acp` +- `copilot-acp` - `grok-acp` - `kilo-acp` - `opencode-acp` @@ -224,6 +225,7 @@ Current packaging status: - Codex is intended to be bundled as a sidecar binary in desktop release builds. - Claude is intended to be bundled through an embedded Node runtime plus vendored runtime files. +- GitHub Copilot is integrated in the app, but not bundled by default today. - Grok is integrated in the app, but not bundled by default today. - Kilo is integrated in the app, but not bundled by default today. - OpenCode is integrated in the app, but not bundled by default today. @@ -232,6 +234,7 @@ Useful runtime overrides during development: - `NEVERWRITE_CODEX_ACP_BIN` - `NEVERWRITE_CLAUDE_ACP_BIN` +- `NEVERWRITE_COPILOT_ACP_BIN` - `NEVERWRITE_GROK_ACP_BIN` - `NEVERWRITE_KILO_ACP_BIN` - `NEVERWRITE_OPENCODE_ACP_BIN` diff --git a/docs/ai-runtime-setup.md b/docs/ai-runtime-setup.md index f7f4f21d..b3c0867c 100644 --- a/docs/ai-runtime-setup.md +++ b/docs/ai-runtime-setup.md @@ -21,13 +21,14 @@ and terminal-auth routing helpers are in | --- | --- | --- | --- | | `codex-acp` | `codex-acp` | Yes. Staged as a sidecar binary. | ChatGPT account, OpenAI API key, Codex API key | | `claude-acp` | Claude ACP adapter | Yes. Staged as vendored JS plus embedded Node. | Claude subscription terminal login, Anthropic Console terminal login, Anthropic API key, custom Anthropic-compatible gateway | +| `copilot-acp` | `copilot --acp` | No. Must be installed separately and available from PATH or a configured binary override. | Copilot terminal login | | `grok-acp` | `grok --no-auto-update agent stdio` | No. Must be available from PATH or a configured binary override. | Grok terminal login, xAI API key | | `kilo-acp` | `kilo acp` | No. Must be available from PATH or a configured binary override. | Kilo terminal login | | `opencode-acp` | `opencode acp` | No. Must be available from PATH or a configured binary override. | OpenCode terminal login | NeverWrite currently supports two ACP compatibility paths: -- `Current14`: Claude, Codex, Kilo, and OpenCode use the current ACP session +- `Current14`: Claude, Codex, GitHub Copilot, Kilo, and OpenCode use the current ACP session config path. - `Legacy12`: Grok uses the legacy ACP model/mode path. @@ -53,7 +54,7 @@ For every provider, the backend resolves the runtime command in this order: 3. Packaged release resources, when available. 4. Development vendor fallback for Codex and Claude. 5. A command found on the app process `PATH`. -6. macOS Homebrew fallback paths for Grok and OpenCode. +6. macOS Homebrew fallback paths for GitHub Copilot, Grok, and OpenCode. The provider-specific runtime binary overrides are: @@ -61,13 +62,14 @@ The provider-specific runtime binary overrides are: | --- | --- | | `NEVERWRITE_CODEX_ACP_BIN` | Codex | | `NEVERWRITE_CLAUDE_ACP_BIN` | Claude | +| `NEVERWRITE_COPILOT_ACP_BIN` | GitHub Copilot | | `NEVERWRITE_GROK_ACP_BIN` | Grok | | `NEVERWRITE_KILO_ACP_BIN` | Kilo | | `NEVERWRITE_OPENCODE_ACP_BIN` | OpenCode | The values may be absolute paths or command names resolvable on `PATH`. For -Grok, Kilo, and OpenCode, NeverWrite appends the ACP arguments automatically: -`grok --no-auto-update agent stdio`, `kilo acp`, and `opencode acp`. +For GitHub Copilot, Grok, Kilo, and OpenCode, NeverWrite appends the ACP arguments automatically: +`copilot --acp`, `grok --no-auto-update agent stdio`, `kilo acp`, and `opencode acp`. Packaged builds use `NEVERWRITE_ELECTRON_ACP_RESOURCE_DIR` internally to point the native backend at staged Electron resources. In normal app usage this is set @@ -91,12 +93,13 @@ The backend also detects existing CLI auth files and environment secrets: | Grok | `XAI_API_KEY` or active non-empty Grok CLI auth under `~/.grok/`, currently `~/.grok/auth.json` | | Kilo | Non-empty Kilo auth file, including `~/.local/share/kilo/auth.json` on Unix-like systems | | OpenCode | `OPENCODE_API_KEY`, provider keys inherited by OpenCode, or active `opencode/auth.json` in the platform data directory | +| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN`; otherwise a successful integrated `copilot login` is recorded locally without reading Copilot's credential store | Codex ChatGPT auth is implemented through the ACP `authenticate` request and requires a resolved Codex runtime binary before NeverWrite marks it connected. Codex does not use the integrated auth terminal. -Claude, Grok, Kilo, and OpenCode expose integrated terminal auth methods. +Claude, GitHub Copilot, Grok, Kilo, and OpenCode expose integrated terminal auth methods. NeverWrite starts the provider CLI in a PTY and marks auth pending before launch. A zero exit code marks the provider verified; Grok and OpenCode can also be marked verified when terminal output contains success strings recognized @@ -212,6 +215,24 @@ Use Kilo terminal login from the setup UI, a Kilo API key saved in the setup UI, or pre-existing Kilo CLI auth. Because Kilo is not bundled by default, install the CLI separately or configure `NEVERWRITE_KILO_ACP_BIN`. +### GitHub Copilot + +Install the official GitHub Copilot CLI, then use **Open sign-in terminal** in +AI Providers to run `copilot login`. NeverWrite launches chat sessions with +`copilot --acp`; it neither downloads the CLI with `npx` nor stores GitHub +tokens, PATs, or OAuth credentials. The tested baseline is Copilot CLI 1.0.70. + +Copilot manages its credential store itself. A successful terminal exit records +only a local verification timestamp. If ACP reports an authentication failure, +NeverWrite clears that marker and asks for login again. **Disconnect** only +forgets NeverWrite's local state: it does not run a provider logout, remove +`COPILOT_HOME`, or touch the Copilot keychain entry. + +ACP-provided Agent, Plan, Autopilot, and `allow_all` controls are preserved as +runtime controls. Autopilot and Allow All are never selected automatically. +NeverWrite does not import, list, resume, or fork external Copilot sessions; +all Copilot edits remain subject to the normal Edits and Review flows. + ### OpenCode Use OpenCode terminal login from the setup UI, pre-existing OpenCode CLI auth, a @@ -233,6 +254,7 @@ These `NEVERWRITE_*` variables are relevant to AI runtime setup and packaging: | --- | --- | | `NEVERWRITE_CODEX_ACP_BIN` | Runtime launch override for Codex in dev or local troubleshooting. | | `NEVERWRITE_CLAUDE_ACP_BIN` | Runtime launch override for Claude in dev or local troubleshooting. | +| `NEVERWRITE_COPILOT_ACP_BIN` | Runtime launch override for GitHub Copilot in dev or local troubleshooting. | | `NEVERWRITE_GROK_ACP_BIN` | Runtime launch override for Grok in dev or local troubleshooting. | | `NEVERWRITE_KILO_ACP_BIN` | Runtime launch override for Kilo in dev or local troubleshooting. | | `NEVERWRITE_OPENCODE_ACP_BIN` | Runtime launch override for OpenCode in dev or local troubleshooting. | From 96d8c6763360f26e8778bc434aa674ced8fe47d1 Mon Sep 17 00:00:00 2001 From: jsgerrchg Date: Mon, 13 Jul 2026 01:28:23 -0400 Subject: [PATCH 7/7] Invalidate Copilot auth after prompt failures --- apps/desktop/native-backend/src/ai.rs | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/desktop/native-backend/src/ai.rs b/apps/desktop/native-backend/src/ai.rs index f9e75a71..25c7eceb 100644 --- a/apps/desktop/native-backend/src/ai.rs +++ b/apps/desktop/native-backend/src/ai.rs @@ -908,6 +908,7 @@ struct AcpPromptCapabilities { struct AcpActorSharedState { event_tx: Sender, session_state: Arc>, + setup_store: Option, tool_diffs: ToolDiffState, agent_writes: AgentWriteTracker, } @@ -1134,6 +1135,7 @@ impl NativeAi { shared: AcpActorSharedState { event_tx: self.event_tx.clone(), session_state: Arc::clone(&self.inner), + setup_store: Some(self.setup_store.clone()), tool_diffs: self.tool_diffs.clone(), agent_writes: self.agent_writes.clone(), }, @@ -2482,6 +2484,7 @@ impl AcpSessionHandle { struct NativeAcpClient { event_tx: Sender, session_state: Arc>, + setup_store: Option, message_ids: Arc>>, thinking_ids: Arc>>, permission_waiters: Arc>>>, @@ -2530,6 +2533,29 @@ impl NativeAcpClient { } } + fn invalidate_copilot_auth_for_session(&self, session_id: &str, error: &str) { + if !is_copilot_auth_error(error) { + return; + } + let setup_to_save = self.session_state.lock().ok().and_then(|mut state| { + let runtime_id = state.sessions.get(session_id)?.session.runtime_id.clone(); + if runtime_id != COPILOT_RUNTIME_ID { + return None; + } + let setup = state.setup.entry(runtime_id).or_default(); + setup.auth_method = Some("copilot-login".to_string()); + setup.auth_ready = false; + setup.suppress_persisted_auth = false; + setup.external_auth_verified_at_ms = None; + setup.auth_invalidated_at_ms = Some(current_epoch_ms()); + setup.message = Some(COPILOT_LOGIN_INVALIDATED_MESSAGE.to_string()); + Some(state.setup.clone()) + }); + if let (Some(store), Some(setup)) = (&self.setup_store, setup_to_save) { + let _ = store.save(&setup); + } + } + fn emit_session_update_from_result(&self, result: Result, String>) { match result { Ok(Some(session)) => self.emit(AI_SESSION_UPDATED_EVENT, session), @@ -4211,6 +4237,7 @@ async fn run_acp12_actor_inner( let client = NativeAcpClient { event_tx: event_tx.clone(), session_state: Arc::clone(&context.shared.session_state), + setup_store: context.shared.setup_store.clone(), message_ids: Arc::new(Mutex::new(HashMap::new())), thinking_ids: Arc::new(Mutex::new(HashMap::new())), permission_waiters: Arc::new(Mutex::new(HashMap::new())), @@ -4408,6 +4435,7 @@ async fn run_acp_actor_inner( let client = NativeAcpClient { event_tx: event_tx.clone(), session_state: Arc::clone(&context.shared.session_state), + setup_store: context.shared.setup_store.clone(), message_ids: Arc::new(Mutex::new(HashMap::new())), thinking_ids: Arc::new(Mutex::new(HashMap::new())), permission_waiters: Arc::new(Mutex::new(HashMap::new())), @@ -4815,6 +4843,7 @@ async fn handle_acp_command( client.end_user_message(&session_id); client.complete_assistant_turn(&session_id, &message_id); if let Err(error) = &result { + client.invalidate_copilot_auth_for_session(&session_id, error); client.emit( AI_SESSION_ERROR_EVENT, AiSessionErrorPayload { @@ -9464,6 +9493,7 @@ mod tests { NativeAcpClient { event_tx, session_state, + setup_store: None, message_ids: Arc::new(Mutex::new(HashMap::new())), thinking_ids: Arc::new(Mutex::new(HashMap::new())), permission_waiters: Arc::new(Mutex::new(HashMap::new())), @@ -15600,6 +15630,37 @@ mod tests { ); } + #[test] + fn copilot_prompt_auth_error_invalidates_and_persists_verified_login() { + let temp = tempfile::tempdir().unwrap(); + let store = RuntimeSetupStore::with_secret_store( + temp.path().join("runtime-setup.json"), + Arc::new(InMemoryRuntimeSecretStore::default()), + ); + let session_state = Arc::new(Mutex::new(NativeAiInner::default())); + insert_test_managed_session(&session_state, COPILOT_RUNTIME_ID, "copilot-session"); + session_state.lock().unwrap().setup.insert( + COPILOT_RUNTIME_ID.to_string(), + RuntimeSetupState { + auth_method: Some("copilot-login".to_string()), + auth_ready: true, + external_auth_verified_at_ms: Some(123), + ..RuntimeSetupState::default() + }, + ); + let (event_tx, _) = mpsc::channel(); + let mut client = test_client_with_state(event_tx, Arc::clone(&session_state)); + client.setup_store = Some(store.clone()); + + client.invalidate_copilot_auth_for_session("copilot-session", "Run copilot login"); + + let persisted = store.load().unwrap(); + let setup = persisted.get(COPILOT_RUNTIME_ID).unwrap(); + assert!(!setup.auth_ready); + assert_eq!(setup.external_auth_verified_at_ms, None); + assert!(setup.auth_invalidated_at_ms.is_some()); + } + #[test] fn grok_login_auth_error_marks_external_auth_invalidated() { let _guard = ENV_TEST_LOCK.lock().unwrap();