diff --git a/README.md b/README.md index 77a30e0a..ad7cd5d8 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ NeverWrite is an agentic markdown workspace for people who work with a local vau - **Write in the format that fits.** Edit Markdown, Mermaid, CSV, text/code files, PDFs, images, and Excalidraw concept maps in the same workspace. - **Navigate connected knowledge.** Follow wikilinks, backlinks, tags, advanced search, bookmarks, and 2D or 3D graph views. -- **Work with your preferred agent.** Run Codex, Claude, Grok, Kilo, or OpenCode sessions with attachments, saved transcripts, and local history. +- **Work with your preferred agent.** Run Codex, Claude, GitHub Copilot, Grok, Kilo, or OpenCode sessions with attachments, saved transcripts, and local history. - **Review AI changes deliberately.** Inspect tracked edits inline, in chat, or in a dedicated review tab, then keep or reject complete files and individual hunks. - **Capture the web into your vault.** Use the companion browser extension to clip pages, selections, or URLs directly to the desktop app. diff --git a/apps/desktop/native-backend/src/ai.rs b/apps/desktop/native-backend/src/ai.rs index 70cc8505..217e7441 100644 --- a/apps/desktop/native-backend/src/ai.rs +++ b/apps/desktop/native-backend/src/ai.rs @@ -49,8 +49,8 @@ use neverwrite_ai::{ AI_SESSION_CREATED_EVENT, 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, + AI_USER_INPUT_REQUEST_EVENT, 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, @@ -150,7 +150,9 @@ const ACP_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const ACP_RUNTIME_CONFIGURATION_ERROR_MARKER: &[u8] = b"error loading config:"; const ACP_RUNTIME_CONFIGURATION_INVALID_DIAGNOSTIC: &str = "The AI runtime configuration is invalid."; -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_SERVICE_ENV: &str = "NEVERWRITE_AI_SECRET_SERVICE"; const RUNTIME_SECRET_STORE_MODE_ENV: &str = "NEVERWRITE_AI_SECRET_STORE"; @@ -438,6 +440,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, @@ -459,6 +462,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, @@ -600,7 +605,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()); } @@ -667,6 +672,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() }; @@ -677,7 +683,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 { @@ -817,6 +824,7 @@ 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; let claude_provider_routing = if runtime_id == CLAUDE_RUNTIME_ID { setup.claude_provider_routing.clone() } else { @@ -827,6 +835,7 @@ impl PersistedRuntimeSetupState { && claude_provider_routing.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() { @@ -838,6 +847,7 @@ impl PersistedRuntimeSetupState { claude_provider_routing, auth_method, auth_invalidated_at_ms, + external_auth_verified_at_ms, env, secret_env_keys, })) @@ -861,6 +871,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" ) } @@ -1017,6 +1030,7 @@ struct AcpPromptCapabilities { struct AcpActorSharedState { event_tx: Sender, session_state: Arc>, + setup_store: Option, tool_diffs: ToolDiffState, agent_writes: AgentWriteTracker, } @@ -1249,6 +1263,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(), }, @@ -2466,6 +2481,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; } @@ -2487,6 +2505,9 @@ impl NativeAi { setup_at_start: &RuntimeSetupState, error: &str, ) -> Result<(), String> { + if runtime_id == COPILOT_RUNTIME_ID && is_copilot_auth_error(error) { + return self.invalidate_copilot_auth(); + } let claude_method = (runtime_id == CLAUDE_RUNTIME_ID && is_claude_auth_error(error)) .then(|| effective_auth_method_for_acp_process_spec(runtime_id, setup_at_start)) .flatten() @@ -2526,6 +2547,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) = { @@ -3120,6 +3171,7 @@ impl AcpSessionHandle { struct NativeAcpClient { event_tx: Sender, session_state: Arc>, + setup_store: Option, message_ids: Arc>>, thinking_ids: Arc>>, permission_waiters: Arc>>>, @@ -3174,6 +3226,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), @@ -5074,6 +5149,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())), @@ -5317,6 +5393,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())), @@ -6061,6 +6138,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 { @@ -8122,6 +8200,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; } @@ -8446,7 +8531,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"] @@ -8466,6 +8554,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", } } @@ -8521,6 +8610,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 {}: {}", @@ -8760,6 +8853,7 @@ fn inherited_auth_method( setup, ) }), + COPILOT_RUNTIME_ID => copilot_env_auth_present().then(|| "copilot-login".to_string()), _ => None, } } @@ -9050,6 +9144,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() @@ -9213,16 +9317,23 @@ fn is_persistable_external_auth_method(runtime_id: &str, method_id: &str) -> boo (CLAUDE_RUNTIME_ID, "claude-ai-login" | "claude-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, - CLAUDE_RUNTIME_ID | GROK_RUNTIME_ID | OPENCODE_RUNTIME_ID + CLAUDE_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 { matches!( method_id, @@ -9316,6 +9427,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; @@ -9332,6 +9444,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); } @@ -9395,6 +9510,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), @@ -9575,6 +9705,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![], } } @@ -9586,6 +9721,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![], } } @@ -11147,6 +11283,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() }); @@ -11278,6 +11417,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())), @@ -12174,6 +12314,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)); @@ -12756,6 +12897,29 @@ mod tests { assert!(diagnostic_executable_names().contains(&"grok")); } + #[test] + fn copilot_runtime_is_registered_with_expected_launch_contract() { + let definition = RUNTIME_CATALOG.definition(COPILOT_RUNTIME_ID).unwrap(); + assert_eq!(definition.name(), "GitHub Copilot"); + assert_eq!(definition.default_executable(), "copilot"); + assert_eq!(definition.bin_env_var(), Some("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(); @@ -15741,6 +15905,7 @@ mod tests { for runtime_id in [ CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID, + COPILOT_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, ] { @@ -19178,6 +19343,104 @@ 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 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 claude_auth_status_parser_uses_the_cli_logged_in_flag() { assert_eq!( @@ -20051,7 +20314,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")); } diff --git a/apps/desktop/native-backend/src/runtime_catalog.rs b/apps/desktop/native-backend/src/runtime_catalog.rs index 6a0b49f6..f68da7f3 100644 --- a/apps/desktop/native-backend/src/runtime_catalog.rs +++ b/apps/desktop/native-backend/src/runtime_catalog.rs @@ -1,6 +1,6 @@ use neverwrite_ai::{ custom_runtimes::CustomAcpRuntimeDefinition, CLAUDE_RUNTIME_ID, CODEX_RUNTIME_ID, - GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, + COPILOT_RUNTIME_ID, GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -180,6 +180,7 @@ impl<'a> RuntimeCatalogView<'a> { } 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"]; @@ -204,6 +205,16 @@ const BUILT_IN_RUNTIME_DEFINITIONS: &[BuiltInRuntimeDefinition] = &[ acp_protocol: AcpProtocolFlavor::Current, supports_native_resume: false, }, + BuiltInRuntimeDefinition { + 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, + }, BuiltInRuntimeDefinition { id: GROK_RUNTIME_ID, name: "Grok", @@ -275,6 +286,7 @@ mod tests { [ CODEX_RUNTIME_ID, CLAUDE_RUNTIME_ID, + COPILOT_RUNTIME_ID, GROK_RUNTIME_ID, KILO_RUNTIME_ID, OPENCODE_RUNTIME_ID, @@ -321,6 +333,12 @@ mod tests { Some("NEVERWRITE_CLAUDE_ACP_BIN"), Vec::new(), ), + ( + COPILOT_RUNTIME_ID, + "copilot", + Some("NEVERWRITE_COPILOT_ACP_BIN"), + vec!["--acp".to_string()], + ), ( GROK_RUNTIME_ID, "grok", @@ -353,7 +371,7 @@ mod tests { let catalog = RUNTIME_CATALOG.with_custom(&custom); let definitions = catalog.definitions().collect::>(); - assert_eq!(definitions.len(), 6); + assert_eq!(definitions.len(), 7); let custom = catalog.definition(&custom[0].id).unwrap(); assert!(custom.is_custom()); assert_eq!(custom.name(), "Local agent"); diff --git a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs index 6819d2d1..2acb187e 100644 --- a/apps/desktop/scripts/smoke-electron-ai-runtime.mjs +++ b/apps/desktop/scripts/smoke-electron-ai-runtime.mjs @@ -224,6 +224,7 @@ if (customCapturePath) { pid: process.pid })); } +const isCopilot = process.argv.includes("--acp"); function send(message) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); } @@ -234,6 +235,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", @@ -314,8 +329,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" } ] @@ -644,6 +663,7 @@ async function main() { for (const runtimeId of [ "codex-acp", "claude-acp", + "copilot-acp", "grok-acp", "kilo-acp", "opencode-acp", @@ -1295,6 +1315,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" }, + }); + await fs.writeFile(fakeAcpRequestLogPath, ""); await client.invoke("ai_update_setup", { runtimeId: "claude-acp", diff --git a/apps/desktop/src/features/ai/store/chatStore.ts b/apps/desktop/src/features/ai/store/chatStore.ts index 02de59d8..3966359f 100644 --- a/apps/desktop/src/features/ai/store/chatStore.ts +++ b/apps/desktop/src/features/ai/store/chatStore.ts @@ -2038,6 +2038,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") || @@ -2046,12 +2047,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") || 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 543260fd..6a1f75c9 100644 --- a/apps/desktop/src/features/settings/AIProvidersSettings.tsx +++ b/apps/desktop/src/features/settings/AIProvidersSettings.tsx @@ -47,6 +47,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"; const CLAUDE_ACP_RUNTIME_ID = "claude-acp"; const GOOGLE_VERTEX_METHOD_ID = "google-vertex"; @@ -161,6 +163,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"; @@ -199,6 +202,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": @@ -241,6 +246,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" @@ -251,11 +257,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."; } @@ -313,7 +321,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 { @@ -323,6 +331,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"; } @@ -333,6 +344,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."; } @@ -420,6 +434,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 diff --git a/crates/ai/src/domain.rs b/crates/ai/src/domain.rs index 72617ddc..a29e13e7 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"; diff --git a/crates/index/tests/integration.rs b/crates/index/tests/integration.rs index dbbf4f80..a2be79b9 100644 --- a/crates/index/tests/integration.rs +++ b/crates/index/tests/integration.rs @@ -883,7 +883,10 @@ fn reindex_updates_status() { "Note A", serde_json::json!({ "status": "draft", "type": "article" }), )]); - assert_eq!(index.metadata[&NoteId("a".into())].status.as_deref(), Some("draft")); + assert_eq!( + index.metadata[&NoteId("a".into())].status.as_deref(), + Some("draft") + ); index.reindex_note(make_note_with_frontmatter( "a", @@ -903,7 +906,10 @@ fn reindex_clears_removed_status() { "Note A", serde_json::json!({ "status": "draft" }), )]); - assert_eq!(index.metadata[&NoteId("a".into())].status.as_deref(), Some("draft")); + assert_eq!( + index.metadata[&NoteId("a".into())].status.as_deref(), + Some("draft") + ); index.reindex_note(make_note_with_frontmatter( "a", diff --git a/crates/vault/tests/integration.rs b/crates/vault/tests/integration.rs index 6682bd08..939a0e8d 100644 --- a/crates/vault/tests/integration.rs +++ b/crates/vault/tests/integration.rs @@ -822,7 +822,11 @@ fn detect_okf_version_non_string() { #[test] fn detect_okf_version_no_frontmatter() { let dir = TempDir::new().unwrap(); - fs::write(dir.path().join("index.md"), "# Plain index, no frontmatter\n").unwrap(); + fs::write( + dir.path().join("index.md"), + "# Plain index, no frontmatter\n", + ) + .unwrap(); let vault = Vault::open(dir.path().to_path_buf()).unwrap(); assert_eq!(vault.detect_okf_version(), None); } diff --git a/docs/ai-runtime-setup.md b/docs/ai-runtime-setup.md index ecde015a..8a912550 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 a prepared npm runtime 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. @@ -61,7 +62,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 or the prepared host-target cache for 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: @@ -69,13 +70,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 @@ -99,12 +101,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 @@ -222,6 +225,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 @@ -243,6 +264,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. |