1use remote::Interactive;
2use std::{
3 any::Any,
4 borrow::Borrow,
5 path::{Path, PathBuf},
6 str::FromStr as _,
7 sync::Arc,
8 time::Duration,
9};
10
11use anyhow::{Context as _, Result, bail};
12use collections::HashMap;
13use fs::{Fs, RemoveOptions, RenameOptions};
14use futures::StreamExt as _;
15use gpui::{
16 AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
17};
18use http_client::{HttpClient, github::AssetKind};
19use node_runtime::NodeRuntime;
20use remote::RemoteClient;
21use rpc::{
22 AnyProtoClient, TypedEnvelope,
23 proto::{self, ExternalExtensionAgent},
24};
25use schemars::JsonSchema;
26use semver::Version;
27use serde::{Deserialize, Serialize};
28use settings::{RegisterSetting, SettingsStore};
29use task::{Shell, SpawnInTerminal};
30use util::{ResultExt as _, debug_panic};
31
32use crate::ProjectEnvironment;
33use crate::agent_registry_store::{AgentRegistryStore, RegistryTargetConfig};
34
35#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
36pub struct AgentServerCommand {
37 #[serde(rename = "command")]
38 pub path: PathBuf,
39 #[serde(default)]
40 pub args: Vec<String>,
41 pub env: Option<HashMap<String, String>>,
42}
43
44impl std::fmt::Debug for AgentServerCommand {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 let filtered_env = self.env.as_ref().map(|env| {
47 env.iter()
48 .map(|(k, v)| {
49 (
50 k,
51 if util::redact::should_redact(k) {
52 "[REDACTED]"
53 } else {
54 v
55 },
56 )
57 })
58 .collect::<Vec<_>>()
59 });
60
61 f.debug_struct("AgentServerCommand")
62 .field("path", &self.path)
63 .field("args", &self.args)
64 .field("env", &filtered_env)
65 .finish()
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Hash)]
70pub struct ExternalAgentServerName(pub SharedString);
71
72impl std::fmt::Display for ExternalAgentServerName {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 write!(f, "{}", self.0)
75 }
76}
77
78impl From<&'static str> for ExternalAgentServerName {
79 fn from(value: &'static str) -> Self {
80 ExternalAgentServerName(value.into())
81 }
82}
83
84impl From<ExternalAgentServerName> for SharedString {
85 fn from(value: ExternalAgentServerName) -> Self {
86 value.0
87 }
88}
89
90impl Borrow<str> for ExternalAgentServerName {
91 fn borrow(&self) -> &str {
92 &self.0
93 }
94}
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub enum ExternalAgentSource {
98 Builtin,
99 #[default]
100 Custom,
101 Extension,
102 Registry,
103}
104
105pub trait ExternalAgentServer {
106 fn get_command(
107 &mut self,
108 root_dir: Option<&str>,
109 extra_env: HashMap<String, String>,
110 status_tx: Option<watch::Sender<SharedString>>,
111 new_version_available_tx: Option<watch::Sender<Option<String>>>,
112 cx: &mut AsyncApp,
113 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
114
115 fn as_any_mut(&mut self) -> &mut dyn Any;
116}
117
118impl dyn ExternalAgentServer {
119 fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
120 self.as_any_mut().downcast_mut()
121 }
122}
123
124enum AgentServerStoreState {
125 Local {
126 node_runtime: NodeRuntime,
127 fs: Arc<dyn Fs>,
128 project_environment: Entity<ProjectEnvironment>,
129 downstream_client: Option<(u64, AnyProtoClient)>,
130 settings: Option<AllAgentServersSettings>,
131 http_client: Arc<dyn HttpClient>,
132 extension_agents: Vec<(
133 Arc<str>,
134 String,
135 HashMap<String, extension::TargetConfig>,
136 HashMap<String, String>,
137 Option<String>,
138 Option<SharedString>,
139 )>,
140 _subscriptions: Vec<Subscription>,
141 },
142 Remote {
143 project_id: u64,
144 upstream_client: Entity<RemoteClient>,
145 },
146 Collab,
147}
148
149struct ExternalAgentEntry {
150 server: Box<dyn ExternalAgentServer>,
151 icon: Option<SharedString>,
152 display_name: Option<SharedString>,
153 source: ExternalAgentSource,
154}
155
156impl ExternalAgentEntry {
157 fn new(
158 server: Box<dyn ExternalAgentServer>,
159 source: ExternalAgentSource,
160 icon: Option<SharedString>,
161 display_name: Option<SharedString>,
162 ) -> Self {
163 Self {
164 server,
165 icon,
166 display_name,
167 source,
168 }
169 }
170}
171
172pub struct AgentServerStore {
173 state: AgentServerStoreState,
174 external_agents: HashMap<ExternalAgentServerName, ExternalAgentEntry>,
175}
176
177pub struct AgentServersUpdated;
178
179impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
180
181#[cfg(test)]
182mod ext_agent_tests {
183 use super::*;
184 use std::{collections::HashSet, fmt::Write as _};
185
186 // Helper to build a store in Collab mode so we can mutate internal maps without
187 // needing to spin up a full project environment.
188 fn collab_store() -> AgentServerStore {
189 AgentServerStore {
190 state: AgentServerStoreState::Collab,
191 external_agents: HashMap::default(),
192 }
193 }
194
195 // A simple fake that implements ExternalAgentServer without needing async plumbing.
196 struct NoopExternalAgent;
197
198 impl ExternalAgentServer for NoopExternalAgent {
199 fn get_command(
200 &mut self,
201 _root_dir: Option<&str>,
202 _extra_env: HashMap<String, String>,
203 _status_tx: Option<watch::Sender<SharedString>>,
204 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
205 _cx: &mut AsyncApp,
206 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
207 Task::ready(Ok((
208 AgentServerCommand {
209 path: PathBuf::from("noop"),
210 args: Vec::new(),
211 env: None,
212 },
213 "".to_string(),
214 None,
215 )))
216 }
217
218 fn as_any_mut(&mut self) -> &mut dyn Any {
219 self
220 }
221 }
222
223 #[test]
224 fn external_agent_server_name_display() {
225 let name = ExternalAgentServerName(SharedString::from("Ext: Tool"));
226 let mut s = String::new();
227 write!(&mut s, "{name}").unwrap();
228 assert_eq!(s, "Ext: Tool");
229 }
230
231 #[test]
232 fn sync_extension_agents_removes_previous_extension_entries() {
233 let mut store = collab_store();
234
235 // Seed with a couple of agents that will be replaced by extensions
236 store.external_agents.insert(
237 ExternalAgentServerName(SharedString::from("foo-agent")),
238 ExternalAgentEntry::new(
239 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
240 ExternalAgentSource::Custom,
241 None,
242 None,
243 ),
244 );
245 store.external_agents.insert(
246 ExternalAgentServerName(SharedString::from("bar-agent")),
247 ExternalAgentEntry::new(
248 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
249 ExternalAgentSource::Custom,
250 None,
251 None,
252 ),
253 );
254 store.external_agents.insert(
255 ExternalAgentServerName(SharedString::from("custom")),
256 ExternalAgentEntry::new(
257 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
258 ExternalAgentSource::Custom,
259 None,
260 None,
261 ),
262 );
263
264 // Simulate the removal phase: if we're syncing extensions that provide
265 // "foo-agent" and "bar-agent", those should be removed first
266 let extension_agent_names: HashSet<String> =
267 ["foo-agent".to_string(), "bar-agent".to_string()]
268 .into_iter()
269 .collect();
270
271 let keys_to_remove: Vec<_> = store
272 .external_agents
273 .keys()
274 .filter(|name| extension_agent_names.contains(name.0.as_ref()))
275 .cloned()
276 .collect();
277
278 for key in keys_to_remove {
279 store.external_agents.remove(&key);
280 }
281
282 // Only the custom entry should remain.
283 let remaining: Vec<_> = store
284 .external_agents
285 .keys()
286 .map(|k| k.0.to_string())
287 .collect();
288 assert_eq!(remaining, vec!["custom".to_string()]);
289 }
290
291 #[test]
292 fn resolve_extension_icon_path_allows_valid_paths() {
293 // Create a temporary directory structure for testing
294 let temp_dir = tempfile::tempdir().unwrap();
295 let extensions_dir = temp_dir.path();
296 let ext_dir = extensions_dir.join("my-extension");
297 std::fs::create_dir_all(&ext_dir).unwrap();
298
299 // Create a valid icon file
300 let icon_path = ext_dir.join("icon.svg");
301 std::fs::write(&icon_path, "<svg></svg>").unwrap();
302
303 // Test that a valid relative path works
304 let result = super::resolve_extension_icon_path(extensions_dir, "my-extension", "icon.svg");
305 assert!(result.is_some());
306 assert!(result.unwrap().ends_with("icon.svg"));
307 }
308
309 #[test]
310 fn resolve_extension_icon_path_allows_nested_paths() {
311 let temp_dir = tempfile::tempdir().unwrap();
312 let extensions_dir = temp_dir.path();
313 let ext_dir = extensions_dir.join("my-extension");
314 let icons_dir = ext_dir.join("assets").join("icons");
315 std::fs::create_dir_all(&icons_dir).unwrap();
316
317 let icon_path = icons_dir.join("logo.svg");
318 std::fs::write(&icon_path, "<svg></svg>").unwrap();
319
320 let result = super::resolve_extension_icon_path(
321 extensions_dir,
322 "my-extension",
323 "assets/icons/logo.svg",
324 );
325 assert!(result.is_some());
326 assert!(result.unwrap().ends_with("logo.svg"));
327 }
328
329 #[test]
330 fn resolve_extension_icon_path_blocks_path_traversal() {
331 let temp_dir = tempfile::tempdir().unwrap();
332 let extensions_dir = temp_dir.path();
333
334 // Create two extension directories
335 let ext1_dir = extensions_dir.join("extension1");
336 let ext2_dir = extensions_dir.join("extension2");
337 std::fs::create_dir_all(&ext1_dir).unwrap();
338 std::fs::create_dir_all(&ext2_dir).unwrap();
339
340 // Create a file in extension2
341 let secret_file = ext2_dir.join("secret.svg");
342 std::fs::write(&secret_file, "<svg>secret</svg>").unwrap();
343
344 // Try to access extension2's file from extension1 using path traversal
345 let result = super::resolve_extension_icon_path(
346 extensions_dir,
347 "extension1",
348 "../extension2/secret.svg",
349 );
350 assert!(
351 result.is_none(),
352 "Path traversal to sibling extension should be blocked"
353 );
354 }
355
356 #[test]
357 fn resolve_extension_icon_path_blocks_absolute_escape() {
358 let temp_dir = tempfile::tempdir().unwrap();
359 let extensions_dir = temp_dir.path();
360 let ext_dir = extensions_dir.join("my-extension");
361 std::fs::create_dir_all(&ext_dir).unwrap();
362
363 // Create a file outside the extensions directory
364 let outside_file = temp_dir.path().join("outside.svg");
365 std::fs::write(&outside_file, "<svg>outside</svg>").unwrap();
366
367 // Try to escape to parent directory
368 let result =
369 super::resolve_extension_icon_path(extensions_dir, "my-extension", "../outside.svg");
370 assert!(
371 result.is_none(),
372 "Path traversal to parent directory should be blocked"
373 );
374 }
375
376 #[test]
377 fn resolve_extension_icon_path_blocks_deep_traversal() {
378 let temp_dir = tempfile::tempdir().unwrap();
379 let extensions_dir = temp_dir.path();
380 let ext_dir = extensions_dir.join("my-extension");
381 std::fs::create_dir_all(&ext_dir).unwrap();
382
383 // Try deep path traversal
384 let result = super::resolve_extension_icon_path(
385 extensions_dir,
386 "my-extension",
387 "../../../../../../etc/passwd",
388 );
389 assert!(
390 result.is_none(),
391 "Deep path traversal should be blocked (file doesn't exist)"
392 );
393 }
394
395 #[test]
396 fn resolve_extension_icon_path_returns_none_for_nonexistent() {
397 let temp_dir = tempfile::tempdir().unwrap();
398 let extensions_dir = temp_dir.path();
399 let ext_dir = extensions_dir.join("my-extension");
400 std::fs::create_dir_all(&ext_dir).unwrap();
401
402 // Try to access a file that doesn't exist
403 let result =
404 super::resolve_extension_icon_path(extensions_dir, "my-extension", "nonexistent.svg");
405 assert!(result.is_none(), "Nonexistent file should return None");
406 }
407}
408
409impl AgentServerStore {
410 /// Synchronizes extension-provided agent servers with the store.
411 pub fn sync_extension_agents<'a, I>(
412 &mut self,
413 manifests: I,
414 extensions_dir: PathBuf,
415 cx: &mut Context<Self>,
416 ) where
417 I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
418 {
419 // Collect manifests first so we can iterate twice
420 let manifests: Vec<_> = manifests.into_iter().collect();
421
422 // Remove all extension-provided agents
423 // (They will be re-added below if they're in the currently installed extensions)
424 self.external_agents
425 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
426
427 // Insert agent servers from extension manifests
428 match &mut self.state {
429 AgentServerStoreState::Local {
430 extension_agents, ..
431 } => {
432 extension_agents.clear();
433 for (ext_id, manifest) in manifests {
434 for (agent_name, agent_entry) in &manifest.agent_servers {
435 let display_name = SharedString::from(agent_entry.name.clone());
436 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
437 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
438 });
439
440 extension_agents.push((
441 agent_name.clone(),
442 ext_id.to_owned(),
443 agent_entry.targets.clone(),
444 agent_entry.env.clone(),
445 icon_path,
446 Some(display_name),
447 ));
448 }
449 }
450 self.reregister_agents(cx);
451 }
452 AgentServerStoreState::Remote {
453 project_id,
454 upstream_client,
455 } => {
456 let mut agents = vec![];
457 for (ext_id, manifest) in manifests {
458 for (agent_name, agent_entry) in &manifest.agent_servers {
459 let display_name = SharedString::from(agent_entry.name.clone());
460 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
461 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
462 });
463 let icon_shared = icon_path
464 .as_ref()
465 .map(|path| SharedString::from(path.clone()));
466 let icon = icon_path;
467 let agent_server_name = ExternalAgentServerName(agent_name.clone().into());
468 self.external_agents
469 .entry(agent_server_name)
470 .and_modify(|entry| {
471 entry.icon = icon_shared.clone();
472 entry.display_name = Some(display_name.clone());
473 entry.source = ExternalAgentSource::Extension;
474 });
475
476 agents.push(ExternalExtensionAgent {
477 name: agent_name.to_string(),
478 icon_path: icon,
479 extension_id: ext_id.to_string(),
480 targets: agent_entry
481 .targets
482 .iter()
483 .map(|(k, v)| (k.clone(), v.to_proto()))
484 .collect(),
485 env: agent_entry
486 .env
487 .iter()
488 .map(|(k, v)| (k.clone(), v.clone()))
489 .collect(),
490 });
491 }
492 }
493 upstream_client
494 .read(cx)
495 .proto_client()
496 .send(proto::ExternalExtensionAgentsUpdated {
497 project_id: *project_id,
498 agents,
499 })
500 .log_err();
501 }
502 AgentServerStoreState::Collab => {
503 // Do nothing
504 }
505 }
506
507 cx.emit(AgentServersUpdated);
508 }
509
510 pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
511 self.external_agents
512 .get(name)
513 .and_then(|entry| entry.icon.clone())
514 }
515
516 pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option<ExternalAgentSource> {
517 self.external_agents.get(name).map(|entry| entry.source)
518 }
519}
520
521/// Safely resolves an extension icon path, ensuring it stays within the extension directory.
522/// Returns `None` if the path would escape the extension directory (path traversal attack).
523fn resolve_extension_icon_path(
524 extensions_dir: &Path,
525 extension_id: &str,
526 icon_relative_path: &str,
527) -> Option<String> {
528 let extension_root = extensions_dir.join(extension_id);
529 let icon_path = extension_root.join(icon_relative_path);
530
531 // Canonicalize both paths to resolve symlinks and normalize the paths.
532 // For the extension root, we need to handle the case where it might be a symlink
533 // (common for dev extensions).
534 let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root);
535 let canonical_icon_path = match icon_path.canonicalize() {
536 Ok(path) => path,
537 Err(err) => {
538 log::warn!(
539 "Failed to canonicalize icon path for extension '{}': {} (path: {})",
540 extension_id,
541 err,
542 icon_relative_path
543 );
544 return None;
545 }
546 };
547
548 // Verify the resolved icon path is within the extension directory
549 if canonical_icon_path.starts_with(&canonical_extension_root) {
550 Some(canonical_icon_path.to_string_lossy().to_string())
551 } else {
552 log::warn!(
553 "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security",
554 icon_relative_path,
555 extension_id
556 );
557 None
558 }
559}
560
561impl AgentServerStore {
562 pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
563 self.external_agents
564 .get(name)
565 .and_then(|entry| entry.display_name.clone())
566 }
567
568 pub fn init_remote(session: &AnyProtoClient) {
569 session.add_entity_message_handler(Self::handle_external_agents_updated);
570 session.add_entity_message_handler(Self::handle_loading_status_updated);
571 session.add_entity_message_handler(Self::handle_new_version_available);
572 }
573
574 pub fn init_headless(session: &AnyProtoClient) {
575 session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
576 session.add_entity_request_handler(Self::handle_get_agent_server_command);
577 }
578
579 fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
580 let AgentServerStoreState::Local {
581 settings: old_settings,
582 ..
583 } = &mut self.state
584 else {
585 debug_panic!(
586 "should not be subscribed to agent server settings changes in non-local project"
587 );
588 return;
589 };
590
591 let new_settings = cx
592 .global::<SettingsStore>()
593 .get::<AllAgentServersSettings>(None)
594 .clone();
595 if Some(&new_settings) == old_settings.as_ref() {
596 return;
597 }
598
599 self.reregister_agents(cx);
600 }
601
602 fn reregister_agents(&mut self, cx: &mut Context<Self>) {
603 let AgentServerStoreState::Local {
604 node_runtime,
605 fs,
606 project_environment,
607 downstream_client,
608 settings: old_settings,
609 http_client,
610 extension_agents,
611 ..
612 } = &mut self.state
613 else {
614 debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
615
616 return;
617 };
618
619 let new_settings = cx
620 .global::<SettingsStore>()
621 .get::<AllAgentServersSettings>(None)
622 .clone();
623
624 self.external_agents.clear();
625 self.external_agents.insert(
626 GEMINI_NAME.into(),
627 ExternalAgentEntry::new(
628 Box::new(LocalGemini {
629 fs: fs.clone(),
630 node_runtime: node_runtime.clone(),
631 project_environment: project_environment.clone(),
632 custom_command: new_settings
633 .gemini
634 .clone()
635 .and_then(|settings| settings.custom_command()),
636 settings_env: new_settings
637 .gemini
638 .as_ref()
639 .and_then(|settings| settings.env.clone()),
640 ignore_system_version: new_settings
641 .gemini
642 .as_ref()
643 .and_then(|settings| settings.ignore_system_version)
644 .unwrap_or(true),
645 }),
646 ExternalAgentSource::Builtin,
647 None,
648 None,
649 ),
650 );
651 self.external_agents.insert(
652 CODEX_NAME.into(),
653 ExternalAgentEntry::new(
654 Box::new(LocalCodex {
655 fs: fs.clone(),
656 project_environment: project_environment.clone(),
657 custom_command: new_settings
658 .codex
659 .clone()
660 .and_then(|settings| settings.custom_command()),
661 settings_env: new_settings
662 .codex
663 .as_ref()
664 .and_then(|settings| settings.env.clone()),
665 http_client: http_client.clone(),
666 no_browser: downstream_client
667 .as_ref()
668 .is_some_and(|(_, client)| !client.has_wsl_interop()),
669 }),
670 ExternalAgentSource::Builtin,
671 None,
672 None,
673 ),
674 );
675 self.external_agents.insert(
676 CLAUDE_CODE_NAME.into(),
677 ExternalAgentEntry::new(
678 Box::new(LocalClaudeCode {
679 fs: fs.clone(),
680 node_runtime: node_runtime.clone(),
681 project_environment: project_environment.clone(),
682 custom_command: new_settings
683 .claude
684 .clone()
685 .and_then(|settings| settings.custom_command()),
686 settings_env: new_settings
687 .claude
688 .as_ref()
689 .and_then(|settings| settings.env.clone()),
690 }),
691 ExternalAgentSource::Builtin,
692 None,
693 None,
694 ),
695 );
696
697 let registry_store = AgentRegistryStore::try_global(cx);
698 let registry_agents_by_id = registry_store
699 .as_ref()
700 .map(|store| {
701 store
702 .read(cx)
703 .agents()
704 .iter()
705 .cloned()
706 .map(|agent| (agent.id.to_string(), agent))
707 .collect::<HashMap<_, _>>()
708 })
709 .unwrap_or_default();
710
711 for (name, settings) in &new_settings.custom {
712 match settings {
713 CustomAgentServerSettings::Custom { command, .. } => {
714 let agent_name = ExternalAgentServerName(name.clone().into());
715 self.external_agents.insert(
716 agent_name.clone(),
717 ExternalAgentEntry::new(
718 Box::new(LocalCustomAgent {
719 command: command.clone(),
720 project_environment: project_environment.clone(),
721 }) as Box<dyn ExternalAgentServer>,
722 ExternalAgentSource::Custom,
723 None,
724 None,
725 ),
726 );
727 }
728 CustomAgentServerSettings::Registry { .. } => {
729 let Some(agent) = registry_agents_by_id.get(name) else {
730 if registry_store.is_some() {
731 log::warn!("Registry agent '{}' not found in ACP registry", name);
732 }
733 continue;
734 };
735 if !agent.supports_current_platform {
736 log::warn!(
737 "Registry agent '{}' has no compatible binary for this platform",
738 name
739 );
740 continue;
741 }
742
743 let agent_name = ExternalAgentServerName(name.clone().into());
744 self.external_agents.insert(
745 agent_name.clone(),
746 ExternalAgentEntry::new(
747 Box::new(LocalRegistryArchiveAgent {
748 fs: fs.clone(),
749 http_client: http_client.clone(),
750 node_runtime: node_runtime.clone(),
751 project_environment: project_environment.clone(),
752 registry_id: Arc::from(name.as_str()),
753 targets: agent.targets.clone(),
754 }) as Box<dyn ExternalAgentServer>,
755 ExternalAgentSource::Registry,
756 agent.icon_path.clone(),
757 Some(agent.name.clone()),
758 ),
759 );
760 }
761 CustomAgentServerSettings::Extension { .. } => {}
762 }
763 }
764
765 for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
766 let name = ExternalAgentServerName(agent_name.clone().into());
767 let icon = icon_path
768 .as_ref()
769 .map(|path| SharedString::from(path.clone()));
770
771 self.external_agents.insert(
772 name.clone(),
773 ExternalAgentEntry::new(
774 Box::new(LocalExtensionArchiveAgent {
775 fs: fs.clone(),
776 http_client: http_client.clone(),
777 node_runtime: node_runtime.clone(),
778 project_environment: project_environment.clone(),
779 extension_id: Arc::from(&**ext_id),
780 targets: targets.clone(),
781 env: env.clone(),
782 agent_id: agent_name.clone(),
783 }) as Box<dyn ExternalAgentServer>,
784 ExternalAgentSource::Extension,
785 icon,
786 display_name.clone(),
787 ),
788 );
789 }
790
791 *old_settings = Some(new_settings);
792
793 if let Some((project_id, downstream_client)) = downstream_client {
794 downstream_client
795 .send(proto::ExternalAgentsUpdated {
796 project_id: *project_id,
797 names: self
798 .external_agents
799 .keys()
800 .map(|name| name.to_string())
801 .collect(),
802 })
803 .log_err();
804 }
805 cx.emit(AgentServersUpdated);
806 }
807
808 pub fn node_runtime(&self) -> Option<NodeRuntime> {
809 match &self.state {
810 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
811 _ => None,
812 }
813 }
814
815 pub fn local(
816 node_runtime: NodeRuntime,
817 fs: Arc<dyn Fs>,
818 project_environment: Entity<ProjectEnvironment>,
819 http_client: Arc<dyn HttpClient>,
820 cx: &mut Context<Self>,
821 ) -> Self {
822 let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
823 this.agent_servers_settings_changed(cx);
824 })];
825 if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
826 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
827 this.reregister_agents(cx);
828 }));
829 }
830 let mut this = Self {
831 state: AgentServerStoreState::Local {
832 node_runtime,
833 fs,
834 project_environment,
835 http_client,
836 downstream_client: None,
837 settings: None,
838 extension_agents: vec![],
839 _subscriptions: subscriptions,
840 },
841 external_agents: Default::default(),
842 };
843 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
844 this.agent_servers_settings_changed(cx);
845 this
846 }
847
848 pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
849 // Set up the builtin agents here so they're immediately available in
850 // remote projects--we know that the HeadlessProject on the other end
851 // will have them.
852 let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [
853 (
854 CLAUDE_CODE_NAME.into(),
855 ExternalAgentEntry::new(
856 Box::new(RemoteExternalAgentServer {
857 project_id,
858 upstream_client: upstream_client.clone(),
859 name: CLAUDE_CODE_NAME.into(),
860 status_tx: None,
861 new_version_available_tx: None,
862 }) as Box<dyn ExternalAgentServer>,
863 ExternalAgentSource::Builtin,
864 None,
865 None,
866 ),
867 ),
868 (
869 CODEX_NAME.into(),
870 ExternalAgentEntry::new(
871 Box::new(RemoteExternalAgentServer {
872 project_id,
873 upstream_client: upstream_client.clone(),
874 name: CODEX_NAME.into(),
875 status_tx: None,
876 new_version_available_tx: None,
877 }) as Box<dyn ExternalAgentServer>,
878 ExternalAgentSource::Builtin,
879 None,
880 None,
881 ),
882 ),
883 (
884 GEMINI_NAME.into(),
885 ExternalAgentEntry::new(
886 Box::new(RemoteExternalAgentServer {
887 project_id,
888 upstream_client: upstream_client.clone(),
889 name: GEMINI_NAME.into(),
890 status_tx: None,
891 new_version_available_tx: None,
892 }) as Box<dyn ExternalAgentServer>,
893 ExternalAgentSource::Builtin,
894 None,
895 None,
896 ),
897 ),
898 ];
899
900 Self {
901 state: AgentServerStoreState::Remote {
902 project_id,
903 upstream_client,
904 },
905 external_agents: external_agents.into_iter().collect(),
906 }
907 }
908
909 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
910 Self {
911 state: AgentServerStoreState::Collab,
912 external_agents: Default::default(),
913 }
914 }
915
916 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
917 match &mut self.state {
918 AgentServerStoreState::Local {
919 downstream_client, ..
920 } => {
921 *downstream_client = Some((project_id, client.clone()));
922 // Send the current list of external agents downstream, but only after a delay,
923 // to avoid having the message arrive before the downstream project's agent server store
924 // sets up its handlers.
925 cx.spawn(async move |this, cx| {
926 cx.background_executor().timer(Duration::from_secs(1)).await;
927 let names = this.update(cx, |this, _| {
928 this.external_agents()
929 .map(|name| name.to_string())
930 .collect()
931 })?;
932 client
933 .send(proto::ExternalAgentsUpdated { project_id, names })
934 .log_err();
935 anyhow::Ok(())
936 })
937 .detach();
938 }
939 AgentServerStoreState::Remote { .. } => {
940 debug_panic!(
941 "external agents over collab not implemented, remote project should not be shared"
942 );
943 }
944 AgentServerStoreState::Collab => {
945 debug_panic!("external agents over collab not implemented, should not be shared");
946 }
947 }
948 }
949
950 pub fn get_external_agent(
951 &mut self,
952 name: &ExternalAgentServerName,
953 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
954 self.external_agents
955 .get_mut(name)
956 .map(|entry| entry.server.as_mut())
957 }
958
959 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
960 self.external_agents.keys()
961 }
962
963 async fn handle_get_agent_server_command(
964 this: Entity<Self>,
965 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
966 mut cx: AsyncApp,
967 ) -> Result<proto::AgentServerCommand> {
968 let (command, root_dir, login_command) = this
969 .update(&mut cx, |this, cx| {
970 let AgentServerStoreState::Local {
971 downstream_client, ..
972 } = &this.state
973 else {
974 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
975 bail!("unexpected GetAgentServerCommand request in a non-local project");
976 };
977 let agent = this
978 .external_agents
979 .get_mut(&*envelope.payload.name)
980 .map(|entry| entry.server.as_mut())
981 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
982 let (status_tx, new_version_available_tx) = downstream_client
983 .clone()
984 .map(|(project_id, downstream_client)| {
985 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
986 let (new_version_available_tx, mut new_version_available_rx) =
987 watch::channel(None);
988 cx.spawn({
989 let downstream_client = downstream_client.clone();
990 let name = envelope.payload.name.clone();
991 async move |_, _| {
992 while let Some(status) = status_rx.recv().await.ok() {
993 downstream_client.send(
994 proto::ExternalAgentLoadingStatusUpdated {
995 project_id,
996 name: name.clone(),
997 status: status.to_string(),
998 },
999 )?;
1000 }
1001 anyhow::Ok(())
1002 }
1003 })
1004 .detach_and_log_err(cx);
1005 cx.spawn({
1006 let name = envelope.payload.name.clone();
1007 async move |_, _| {
1008 if let Some(version) =
1009 new_version_available_rx.recv().await.ok().flatten()
1010 {
1011 downstream_client.send(
1012 proto::NewExternalAgentVersionAvailable {
1013 project_id,
1014 name: name.clone(),
1015 version,
1016 },
1017 )?;
1018 }
1019 anyhow::Ok(())
1020 }
1021 })
1022 .detach_and_log_err(cx);
1023 (status_tx, new_version_available_tx)
1024 })
1025 .unzip();
1026 anyhow::Ok(agent.get_command(
1027 envelope.payload.root_dir.as_deref(),
1028 HashMap::default(),
1029 status_tx,
1030 new_version_available_tx,
1031 &mut cx.to_async(),
1032 ))
1033 })?
1034 .await?;
1035 Ok(proto::AgentServerCommand {
1036 path: command.path.to_string_lossy().into_owned(),
1037 args: command.args,
1038 env: command
1039 .env
1040 .map(|env| env.into_iter().collect())
1041 .unwrap_or_default(),
1042 root_dir: root_dir,
1043 login: login_command.map(|cmd| cmd.to_proto()),
1044 })
1045 }
1046
1047 async fn handle_external_agents_updated(
1048 this: Entity<Self>,
1049 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
1050 mut cx: AsyncApp,
1051 ) -> Result<()> {
1052 this.update(&mut cx, |this, cx| {
1053 let AgentServerStoreState::Remote {
1054 project_id,
1055 upstream_client,
1056 } = &this.state
1057 else {
1058 debug_panic!(
1059 "handle_external_agents_updated should not be called for a non-remote project"
1060 );
1061 bail!("unexpected ExternalAgentsUpdated message")
1062 };
1063
1064 let mut previous_entries = std::mem::take(&mut this.external_agents);
1065 let mut status_txs = HashMap::default();
1066 let mut new_version_available_txs = HashMap::default();
1067 let mut metadata = HashMap::default();
1068
1069 for (name, mut entry) in previous_entries.drain() {
1070 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
1071 status_txs.insert(name.clone(), agent.status_tx.take());
1072 new_version_available_txs
1073 .insert(name.clone(), agent.new_version_available_tx.take());
1074 }
1075
1076 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
1077 }
1078
1079 this.external_agents = envelope
1080 .payload
1081 .names
1082 .into_iter()
1083 .map(|name| {
1084 let agent_name = ExternalAgentServerName(name.clone().into());
1085 let fallback_source =
1086 if name == GEMINI_NAME || name == CLAUDE_CODE_NAME || name == CODEX_NAME {
1087 ExternalAgentSource::Builtin
1088 } else {
1089 ExternalAgentSource::Custom
1090 };
1091 let (icon, display_name, source) =
1092 metadata
1093 .remove(&agent_name)
1094 .unwrap_or((None, None, fallback_source));
1095 let source = if fallback_source == ExternalAgentSource::Builtin {
1096 ExternalAgentSource::Builtin
1097 } else {
1098 source
1099 };
1100 let agent = RemoteExternalAgentServer {
1101 project_id: *project_id,
1102 upstream_client: upstream_client.clone(),
1103 name: agent_name.clone(),
1104 status_tx: status_txs.remove(&agent_name).flatten(),
1105 new_version_available_tx: new_version_available_txs
1106 .remove(&agent_name)
1107 .flatten(),
1108 };
1109 (
1110 agent_name,
1111 ExternalAgentEntry::new(
1112 Box::new(agent) as Box<dyn ExternalAgentServer>,
1113 source,
1114 icon,
1115 display_name,
1116 ),
1117 )
1118 })
1119 .collect();
1120 cx.emit(AgentServersUpdated);
1121 Ok(())
1122 })
1123 }
1124
1125 async fn handle_external_extension_agents_updated(
1126 this: Entity<Self>,
1127 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
1128 mut cx: AsyncApp,
1129 ) -> Result<()> {
1130 this.update(&mut cx, |this, cx| {
1131 let AgentServerStoreState::Local {
1132 extension_agents, ..
1133 } = &mut this.state
1134 else {
1135 panic!(
1136 "handle_external_extension_agents_updated \
1137 should not be called for a non-remote project"
1138 );
1139 };
1140
1141 for ExternalExtensionAgent {
1142 name,
1143 icon_path,
1144 extension_id,
1145 targets,
1146 env,
1147 } in envelope.payload.agents
1148 {
1149 extension_agents.push((
1150 Arc::from(&*name),
1151 extension_id,
1152 targets
1153 .into_iter()
1154 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
1155 .collect(),
1156 env.into_iter().collect(),
1157 icon_path,
1158 None,
1159 ));
1160 }
1161
1162 this.reregister_agents(cx);
1163 cx.emit(AgentServersUpdated);
1164 Ok(())
1165 })
1166 }
1167
1168 async fn handle_loading_status_updated(
1169 this: Entity<Self>,
1170 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
1171 mut cx: AsyncApp,
1172 ) -> Result<()> {
1173 this.update(&mut cx, |this, _| {
1174 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1175 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1176 && let Some(status_tx) = &mut agent.status_tx
1177 {
1178 status_tx.send(envelope.payload.status.into()).ok();
1179 }
1180 });
1181 Ok(())
1182 }
1183
1184 async fn handle_new_version_available(
1185 this: Entity<Self>,
1186 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
1187 mut cx: AsyncApp,
1188 ) -> Result<()> {
1189 this.update(&mut cx, |this, _| {
1190 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1191 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1192 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
1193 {
1194 new_version_available_tx
1195 .send(Some(envelope.payload.version))
1196 .ok();
1197 }
1198 });
1199 Ok(())
1200 }
1201
1202 pub fn get_extension_id_for_agent(
1203 &mut self,
1204 name: &ExternalAgentServerName,
1205 ) -> Option<Arc<str>> {
1206 self.external_agents.get_mut(name).and_then(|entry| {
1207 entry
1208 .server
1209 .as_any_mut()
1210 .downcast_ref::<LocalExtensionArchiveAgent>()
1211 .map(|ext_agent| ext_agent.extension_id.clone())
1212 })
1213 }
1214}
1215
1216fn get_or_npm_install_builtin_agent(
1217 binary_name: SharedString,
1218 package_name: SharedString,
1219 entrypoint_path: PathBuf,
1220 minimum_version: Option<semver::Version>,
1221 status_tx: Option<watch::Sender<SharedString>>,
1222 new_version_available: Option<watch::Sender<Option<String>>>,
1223 fs: Arc<dyn Fs>,
1224 node_runtime: NodeRuntime,
1225 cx: &mut AsyncApp,
1226) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
1227 cx.spawn(async move |cx| {
1228 let node_path = node_runtime.binary_path().await?;
1229 let dir = paths::external_agents_dir().join(binary_name.as_str());
1230 fs.create_dir(&dir).await?;
1231
1232 let mut stream = fs.read_dir(&dir).await?;
1233 let mut versions = Vec::new();
1234 let mut to_delete = Vec::new();
1235 while let Some(entry) = stream.next().await {
1236 let Ok(entry) = entry else { continue };
1237 let Some(file_name) = entry.file_name() else {
1238 continue;
1239 };
1240
1241 if let Some(name) = file_name.to_str()
1242 && let Some(version) = semver::Version::from_str(name).ok()
1243 && fs
1244 .is_file(&dir.join(file_name).join(&entrypoint_path))
1245 .await
1246 {
1247 versions.push((version, file_name.to_owned()));
1248 } else {
1249 to_delete.push(file_name.to_owned())
1250 }
1251 }
1252
1253 versions.sort();
1254 let newest_version = if let Some((version, _)) = versions.last().cloned()
1255 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
1256 {
1257 versions.pop()
1258 } else {
1259 None
1260 };
1261 log::debug!("existing version of {package_name}: {newest_version:?}");
1262 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
1263
1264 cx.background_spawn({
1265 let fs = fs.clone();
1266 let dir = dir.clone();
1267 async move {
1268 for file_name in to_delete {
1269 fs.remove_dir(
1270 &dir.join(file_name),
1271 RemoveOptions {
1272 recursive: true,
1273 ignore_if_not_exists: false,
1274 },
1275 )
1276 .await
1277 .ok();
1278 }
1279 }
1280 })
1281 .detach();
1282
1283 let version = if let Some((version, file_name)) = newest_version {
1284 cx.background_spawn({
1285 let dir = dir.clone();
1286 let fs = fs.clone();
1287 async move {
1288 let latest_version = node_runtime
1289 .npm_package_latest_version(&package_name)
1290 .await
1291 .ok();
1292 if let Some(latest_version) = latest_version
1293 && latest_version != version
1294 {
1295 let download_result = download_latest_version(
1296 fs,
1297 dir.clone(),
1298 node_runtime,
1299 package_name.clone(),
1300 )
1301 .await
1302 .log_err();
1303 if let Some(mut new_version_available) = new_version_available
1304 && download_result.is_some()
1305 {
1306 new_version_available
1307 .send(Some(latest_version.to_string()))
1308 .ok();
1309 }
1310 }
1311 }
1312 })
1313 .detach();
1314 file_name
1315 } else {
1316 if let Some(mut status_tx) = status_tx {
1317 status_tx.send("Installing…".into()).ok();
1318 }
1319 let dir = dir.clone();
1320 cx.background_spawn(download_latest_version(
1321 fs.clone(),
1322 dir.clone(),
1323 node_runtime,
1324 package_name.clone(),
1325 ))
1326 .await?
1327 .to_string()
1328 .into()
1329 };
1330
1331 let agent_server_path = dir.join(version).join(entrypoint_path);
1332 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1333 anyhow::ensure!(
1334 agent_server_path_exists,
1335 "Missing entrypoint path {} after installation",
1336 agent_server_path.to_string_lossy()
1337 );
1338
1339 anyhow::Ok(AgentServerCommand {
1340 path: node_path,
1341 args: vec![agent_server_path.to_string_lossy().into_owned()],
1342 env: None,
1343 })
1344 })
1345}
1346
1347fn find_bin_in_path(
1348 bin_name: SharedString,
1349 root_dir: PathBuf,
1350 env: HashMap<String, String>,
1351 cx: &mut AsyncApp,
1352) -> Task<Option<PathBuf>> {
1353 cx.background_executor().spawn(async move {
1354 let which_result = if cfg!(windows) {
1355 which::which(bin_name.as_str())
1356 } else {
1357 let shell_path = env.get("PATH").cloned();
1358 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1359 };
1360
1361 if let Err(which::Error::CannotFindBinaryPath) = which_result {
1362 return None;
1363 }
1364
1365 which_result.log_err()
1366 })
1367}
1368
1369async fn download_latest_version(
1370 fs: Arc<dyn Fs>,
1371 dir: PathBuf,
1372 node_runtime: NodeRuntime,
1373 package_name: SharedString,
1374) -> Result<Version> {
1375 log::debug!("downloading latest version of {package_name}");
1376
1377 let tmp_dir = tempfile::tempdir_in(&dir)?;
1378
1379 node_runtime
1380 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1381 .await?;
1382
1383 let version = node_runtime
1384 .npm_package_installed_version(tmp_dir.path(), &package_name)
1385 .await?
1386 .context("expected package to be installed")?;
1387
1388 fs.rename(
1389 &tmp_dir.keep(),
1390 &dir.join(version.to_string()),
1391 RenameOptions {
1392 ignore_if_exists: true,
1393 overwrite: true,
1394 create_parents: false,
1395 },
1396 )
1397 .await?;
1398
1399 anyhow::Ok(version)
1400}
1401
1402struct RemoteExternalAgentServer {
1403 project_id: u64,
1404 upstream_client: Entity<RemoteClient>,
1405 name: ExternalAgentServerName,
1406 status_tx: Option<watch::Sender<SharedString>>,
1407 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1408}
1409
1410impl ExternalAgentServer for RemoteExternalAgentServer {
1411 fn get_command(
1412 &mut self,
1413 root_dir: Option<&str>,
1414 extra_env: HashMap<String, String>,
1415 status_tx: Option<watch::Sender<SharedString>>,
1416 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1417 cx: &mut AsyncApp,
1418 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1419 let project_id = self.project_id;
1420 let name = self.name.to_string();
1421 let upstream_client = self.upstream_client.downgrade();
1422 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1423 self.status_tx = status_tx;
1424 self.new_version_available_tx = new_version_available_tx;
1425 cx.spawn(async move |cx| {
1426 let mut response = upstream_client
1427 .update(cx, |upstream_client, _| {
1428 upstream_client
1429 .proto_client()
1430 .request(proto::GetAgentServerCommand {
1431 project_id,
1432 name,
1433 root_dir: root_dir.clone(),
1434 })
1435 })?
1436 .await?;
1437 let root_dir = response.root_dir;
1438 response.env.extend(extra_env);
1439 let command = upstream_client.update(cx, |client, _| {
1440 client.build_command_with_options(
1441 Some(response.path),
1442 &response.args,
1443 &response.env.into_iter().collect(),
1444 Some(root_dir.clone()),
1445 None,
1446 Interactive::No,
1447 )
1448 })??;
1449 Ok((
1450 AgentServerCommand {
1451 path: command.program.into(),
1452 args: command.args,
1453 env: Some(command.env),
1454 },
1455 root_dir,
1456 response.login.map(SpawnInTerminal::from_proto),
1457 ))
1458 })
1459 }
1460
1461 fn as_any_mut(&mut self) -> &mut dyn Any {
1462 self
1463 }
1464}
1465
1466struct LocalGemini {
1467 fs: Arc<dyn Fs>,
1468 node_runtime: NodeRuntime,
1469 project_environment: Entity<ProjectEnvironment>,
1470 custom_command: Option<AgentServerCommand>,
1471 settings_env: Option<HashMap<String, String>>,
1472 ignore_system_version: bool,
1473}
1474
1475impl ExternalAgentServer for LocalGemini {
1476 fn get_command(
1477 &mut self,
1478 root_dir: Option<&str>,
1479 extra_env: HashMap<String, String>,
1480 status_tx: Option<watch::Sender<SharedString>>,
1481 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1482 cx: &mut AsyncApp,
1483 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1484 let fs = self.fs.clone();
1485 let node_runtime = self.node_runtime.clone();
1486 let project_environment = self.project_environment.downgrade();
1487 let custom_command = self.custom_command.clone();
1488 let settings_env = self.settings_env.clone();
1489 let ignore_system_version = self.ignore_system_version;
1490 let root_dir: Arc<Path> = root_dir
1491 .map(|root_dir| Path::new(root_dir))
1492 .unwrap_or(paths::home_dir())
1493 .into();
1494
1495 cx.spawn(async move |cx| {
1496 let mut env = project_environment
1497 .update(cx, |project_environment, cx| {
1498 project_environment.local_directory_environment(
1499 &Shell::System,
1500 root_dir.clone(),
1501 cx,
1502 )
1503 })?
1504 .await
1505 .unwrap_or_default();
1506
1507 env.extend(settings_env.unwrap_or_default());
1508
1509 let mut command = if let Some(mut custom_command) = custom_command {
1510 custom_command.env = Some(env);
1511 custom_command
1512 } else if !ignore_system_version
1513 && let Some(bin) =
1514 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1515 {
1516 AgentServerCommand {
1517 path: bin,
1518 args: Vec::new(),
1519 env: Some(env),
1520 }
1521 } else {
1522 let mut command = get_or_npm_install_builtin_agent(
1523 GEMINI_NAME.into(),
1524 "@google/gemini-cli".into(),
1525 "node_modules/@google/gemini-cli/dist/index.js".into(),
1526 if cfg!(windows) {
1527 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1528 Some("0.9.0".parse().unwrap())
1529 } else {
1530 Some("0.2.1".parse().unwrap())
1531 },
1532 status_tx,
1533 new_version_available_tx,
1534 fs,
1535 node_runtime,
1536 cx,
1537 )
1538 .await?;
1539 command.env = Some(env);
1540 command
1541 };
1542
1543 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1544 let login = task::SpawnInTerminal {
1545 command: Some(command.path.to_string_lossy().into_owned()),
1546 args: command.args.clone(),
1547 env: command.env.clone().unwrap_or_default(),
1548 label: "gemini /auth".into(),
1549 ..Default::default()
1550 };
1551
1552 command.env.get_or_insert_default().extend(extra_env);
1553 command.args.push("--experimental-acp".into());
1554 Ok((
1555 command,
1556 root_dir.to_string_lossy().into_owned(),
1557 Some(login),
1558 ))
1559 })
1560 }
1561
1562 fn as_any_mut(&mut self) -> &mut dyn Any {
1563 self
1564 }
1565}
1566
1567struct LocalClaudeCode {
1568 fs: Arc<dyn Fs>,
1569 node_runtime: NodeRuntime,
1570 project_environment: Entity<ProjectEnvironment>,
1571 custom_command: Option<AgentServerCommand>,
1572 settings_env: Option<HashMap<String, String>>,
1573}
1574
1575impl ExternalAgentServer for LocalClaudeCode {
1576 fn get_command(
1577 &mut self,
1578 root_dir: Option<&str>,
1579 extra_env: HashMap<String, String>,
1580 status_tx: Option<watch::Sender<SharedString>>,
1581 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1582 cx: &mut AsyncApp,
1583 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1584 let fs = self.fs.clone();
1585 let node_runtime = self.node_runtime.clone();
1586 let project_environment = self.project_environment.downgrade();
1587 let custom_command = self.custom_command.clone();
1588 let settings_env = self.settings_env.clone();
1589 let root_dir: Arc<Path> = root_dir
1590 .map(|root_dir| Path::new(root_dir))
1591 .unwrap_or(paths::home_dir())
1592 .into();
1593
1594 cx.spawn(async move |cx| {
1595 let mut env = project_environment
1596 .update(cx, |project_environment, cx| {
1597 project_environment.local_directory_environment(
1598 &Shell::System,
1599 root_dir.clone(),
1600 cx,
1601 )
1602 })?
1603 .await
1604 .unwrap_or_default();
1605 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1606
1607 env.extend(settings_env.unwrap_or_default());
1608
1609 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1610 custom_command.env = Some(env);
1611 (custom_command, None)
1612 } else {
1613 let mut command = get_or_npm_install_builtin_agent(
1614 "claude-code-acp".into(),
1615 "@zed-industries/claude-code-acp".into(),
1616 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1617 Some("0.5.2".parse().unwrap()),
1618 status_tx,
1619 new_version_available_tx,
1620 fs,
1621 node_runtime,
1622 cx,
1623 )
1624 .await?;
1625 command.env = Some(env);
1626 let login = command
1627 .args
1628 .first()
1629 .and_then(|path| {
1630 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1631 })
1632 .map(|path_prefix| task::SpawnInTerminal {
1633 command: Some(command.path.to_string_lossy().into_owned()),
1634 args: vec![
1635 Path::new(path_prefix)
1636 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1637 .to_string_lossy()
1638 .to_string(),
1639 "/login".into(),
1640 ],
1641 env: command.env.clone().unwrap_or_default(),
1642 label: "claude /login".into(),
1643 ..Default::default()
1644 });
1645 (command, login)
1646 };
1647
1648 command.env.get_or_insert_default().extend(extra_env);
1649 Ok((
1650 command,
1651 root_dir.to_string_lossy().into_owned(),
1652 login_command,
1653 ))
1654 })
1655 }
1656
1657 fn as_any_mut(&mut self) -> &mut dyn Any {
1658 self
1659 }
1660}
1661
1662struct LocalCodex {
1663 fs: Arc<dyn Fs>,
1664 project_environment: Entity<ProjectEnvironment>,
1665 http_client: Arc<dyn HttpClient>,
1666 custom_command: Option<AgentServerCommand>,
1667 settings_env: Option<HashMap<String, String>>,
1668 no_browser: bool,
1669}
1670
1671impl ExternalAgentServer for LocalCodex {
1672 fn get_command(
1673 &mut self,
1674 root_dir: Option<&str>,
1675 extra_env: HashMap<String, String>,
1676 mut status_tx: Option<watch::Sender<SharedString>>,
1677 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1678 cx: &mut AsyncApp,
1679 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1680 let fs = self.fs.clone();
1681 let project_environment = self.project_environment.downgrade();
1682 let http = self.http_client.clone();
1683 let custom_command = self.custom_command.clone();
1684 let settings_env = self.settings_env.clone();
1685 let root_dir: Arc<Path> = root_dir
1686 .map(|root_dir| Path::new(root_dir))
1687 .unwrap_or(paths::home_dir())
1688 .into();
1689 let no_browser = self.no_browser;
1690
1691 cx.spawn(async move |cx| {
1692 let mut env = project_environment
1693 .update(cx, |project_environment, cx| {
1694 project_environment.local_directory_environment(
1695 &Shell::System,
1696 root_dir.clone(),
1697 cx,
1698 )
1699 })?
1700 .await
1701 .unwrap_or_default();
1702 if no_browser {
1703 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1704 }
1705
1706 env.extend(settings_env.unwrap_or_default());
1707
1708 let mut command = if let Some(mut custom_command) = custom_command {
1709 custom_command.env = Some(env);
1710 custom_command
1711 } else {
1712 let dir = paths::external_agents_dir().join(CODEX_NAME);
1713 fs.create_dir(&dir).await?;
1714
1715 let bin_name = if cfg!(windows) {
1716 "codex-acp.exe"
1717 } else {
1718 "codex-acp"
1719 };
1720
1721 let find_latest_local_version = async || -> Option<PathBuf> {
1722 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1723 let mut stream = fs.read_dir(&dir).await.ok()?;
1724 while let Some(entry) = stream.next().await {
1725 let Ok(entry) = entry else { continue };
1726 let Some(file_name) = entry.file_name() else {
1727 continue;
1728 };
1729 let version_path = dir.join(&file_name);
1730 if fs.is_file(&version_path.join(bin_name)).await {
1731 let version_str = file_name.to_string_lossy();
1732 if let Ok(version) =
1733 semver::Version::from_str(version_str.trim_start_matches('v'))
1734 {
1735 local_versions.push((version, version_str.into_owned()));
1736 }
1737 }
1738 }
1739 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1740 local_versions.last().map(|(_, v)| dir.join(v))
1741 };
1742
1743 let fallback_to_latest_local_version =
1744 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1745 if let Some(local) = find_latest_local_version().await {
1746 log::info!(
1747 "Falling back to locally installed Codex version: {}",
1748 local.display()
1749 );
1750 Ok(local)
1751 } else {
1752 Err(err)
1753 }
1754 };
1755
1756 let version_dir = match ::http_client::github::latest_github_release(
1757 CODEX_ACP_REPO,
1758 true,
1759 false,
1760 http.clone(),
1761 )
1762 .await
1763 {
1764 Ok(release) => {
1765 let version_dir = dir.join(&release.tag_name);
1766 if !fs.is_dir(&version_dir).await {
1767 if let Some(ref mut status_tx) = status_tx {
1768 status_tx.send("Installing…".into()).ok();
1769 }
1770
1771 let tag = release.tag_name.clone();
1772 let version_number = tag.trim_start_matches('v');
1773 let asset_name = asset_name(version_number)
1774 .context("codex acp is not supported for this architecture")?;
1775 let asset = release
1776 .assets
1777 .into_iter()
1778 .find(|asset| asset.name == asset_name)
1779 .with_context(|| {
1780 format!("no asset found matching `{asset_name:?}`")
1781 })?;
1782 // Strip "sha256:" prefix from digest if present (GitHub API format)
1783 let digest = asset
1784 .digest
1785 .as_deref()
1786 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1787 match ::http_client::github_download::download_server_binary(
1788 &*http,
1789 &asset.browser_download_url,
1790 digest,
1791 &version_dir,
1792 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1793 AssetKind::Zip
1794 } else {
1795 AssetKind::TarGz
1796 },
1797 )
1798 .await
1799 {
1800 Ok(()) => {
1801 // remove older versions
1802 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1803 .await;
1804 version_dir
1805 }
1806 Err(err) => {
1807 log::error!(
1808 "Failed to download Codex release {}: {err:#}",
1809 release.tag_name
1810 );
1811 fallback_to_latest_local_version(err).await?
1812 }
1813 }
1814 } else {
1815 version_dir
1816 }
1817 }
1818 Err(err) => {
1819 log::error!("Failed to fetch Codex latest release: {err:#}");
1820 fallback_to_latest_local_version(err).await?
1821 }
1822 };
1823
1824 let bin_path = version_dir.join(bin_name);
1825 anyhow::ensure!(
1826 fs.is_file(&bin_path).await,
1827 "Missing Codex binary at {} after installation",
1828 bin_path.to_string_lossy()
1829 );
1830
1831 let mut cmd = AgentServerCommand {
1832 path: bin_path,
1833 args: Vec::new(),
1834 env: None,
1835 };
1836 cmd.env = Some(env);
1837 cmd
1838 };
1839
1840 command.env.get_or_insert_default().extend(extra_env);
1841 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1842 })
1843 }
1844
1845 fn as_any_mut(&mut self) -> &mut dyn Any {
1846 self
1847 }
1848}
1849
1850pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1851
1852fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1853 let arch = if cfg!(target_arch = "x86_64") {
1854 "x86_64"
1855 } else if cfg!(target_arch = "aarch64") {
1856 "aarch64"
1857 } else {
1858 return None;
1859 };
1860
1861 let platform = if cfg!(target_os = "macos") {
1862 "apple-darwin"
1863 } else if cfg!(target_os = "windows") {
1864 "pc-windows-msvc"
1865 } else if cfg!(target_os = "linux") {
1866 "unknown-linux-gnu"
1867 } else {
1868 return None;
1869 };
1870
1871 // Windows uses .zip in release assets
1872 let ext = if cfg!(target_os = "windows") {
1873 "zip"
1874 } else {
1875 "tar.gz"
1876 };
1877
1878 Some((arch, platform, ext))
1879}
1880
1881fn asset_name(version: &str) -> Option<String> {
1882 let (arch, platform, ext) = get_platform_info()?;
1883 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1884}
1885
1886struct LocalExtensionArchiveAgent {
1887 fs: Arc<dyn Fs>,
1888 http_client: Arc<dyn HttpClient>,
1889 node_runtime: NodeRuntime,
1890 project_environment: Entity<ProjectEnvironment>,
1891 extension_id: Arc<str>,
1892 agent_id: Arc<str>,
1893 targets: HashMap<String, extension::TargetConfig>,
1894 env: HashMap<String, String>,
1895}
1896
1897impl ExternalAgentServer for LocalExtensionArchiveAgent {
1898 fn get_command(
1899 &mut self,
1900 root_dir: Option<&str>,
1901 extra_env: HashMap<String, String>,
1902 _status_tx: Option<watch::Sender<SharedString>>,
1903 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1904 cx: &mut AsyncApp,
1905 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1906 let fs = self.fs.clone();
1907 let http_client = self.http_client.clone();
1908 let node_runtime = self.node_runtime.clone();
1909 let project_environment = self.project_environment.downgrade();
1910 let extension_id = self.extension_id.clone();
1911 let agent_id = self.agent_id.clone();
1912 let targets = self.targets.clone();
1913 let base_env = self.env.clone();
1914
1915 let root_dir: Arc<Path> = root_dir
1916 .map(|root_dir| Path::new(root_dir))
1917 .unwrap_or(paths::home_dir())
1918 .into();
1919
1920 cx.spawn(async move |cx| {
1921 // Get project environment
1922 let mut env = project_environment
1923 .update(cx, |project_environment, cx| {
1924 project_environment.local_directory_environment(
1925 &Shell::System,
1926 root_dir.clone(),
1927 cx,
1928 )
1929 })?
1930 .await
1931 .unwrap_or_default();
1932
1933 // Merge manifest env and extra env
1934 env.extend(base_env);
1935 env.extend(extra_env);
1936
1937 let cache_key = format!("{}/{}", extension_id, agent_id);
1938 let dir = paths::external_agents_dir().join(&cache_key);
1939 fs.create_dir(&dir).await?;
1940
1941 // Determine platform key
1942 let os = if cfg!(target_os = "macos") {
1943 "darwin"
1944 } else if cfg!(target_os = "linux") {
1945 "linux"
1946 } else if cfg!(target_os = "windows") {
1947 "windows"
1948 } else {
1949 anyhow::bail!("unsupported OS");
1950 };
1951
1952 let arch = if cfg!(target_arch = "aarch64") {
1953 "aarch64"
1954 } else if cfg!(target_arch = "x86_64") {
1955 "x86_64"
1956 } else {
1957 anyhow::bail!("unsupported architecture");
1958 };
1959
1960 let platform_key = format!("{}-{}", os, arch);
1961 let target_config = targets.get(&platform_key).with_context(|| {
1962 format!(
1963 "no target specified for platform '{}'. Available platforms: {}",
1964 platform_key,
1965 targets
1966 .keys()
1967 .map(|k| k.as_str())
1968 .collect::<Vec<_>>()
1969 .join(", ")
1970 )
1971 })?;
1972
1973 let archive_url = &target_config.archive;
1974
1975 // Use URL as version identifier for caching
1976 // Hash the URL to get a stable directory name
1977 use std::collections::hash_map::DefaultHasher;
1978 use std::hash::{Hash, Hasher};
1979 let mut hasher = DefaultHasher::new();
1980 archive_url.hash(&mut hasher);
1981 let url_hash = hasher.finish();
1982 let version_dir = dir.join(format!("v_{:x}", url_hash));
1983
1984 if !fs.is_dir(&version_dir).await {
1985 // Determine SHA256 for verification
1986 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1987 // Use provided SHA256
1988 Some(provided_sha.clone())
1989 } else if archive_url.starts_with("https://github.com/") {
1990 // Try to fetch SHA256 from GitHub API
1991 // Parse URL to extract repo and tag/file info
1992 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1993 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1994 let parts: Vec<&str> = caps.split('/').collect();
1995 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1996 let repo = format!("{}/{}", parts[0], parts[1]);
1997 let tag = parts[4];
1998 let filename = parts[5..].join("/");
1999
2000 // Try to get release info from GitHub
2001 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2002 &repo,
2003 tag,
2004 http_client.clone(),
2005 )
2006 .await
2007 {
2008 // Find matching asset
2009 if let Some(asset) =
2010 release.assets.iter().find(|a| a.name == filename)
2011 {
2012 // Strip "sha256:" prefix if present
2013 asset.digest.as_ref().and_then(|d| {
2014 d.strip_prefix("sha256:")
2015 .map(|s| s.to_string())
2016 .or_else(|| Some(d.clone()))
2017 })
2018 } else {
2019 None
2020 }
2021 } else {
2022 None
2023 }
2024 } else {
2025 None
2026 }
2027 } else {
2028 None
2029 }
2030 } else {
2031 None
2032 };
2033
2034 // Determine archive type from URL
2035 let asset_kind = if archive_url.ends_with(".zip") {
2036 AssetKind::Zip
2037 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2038 AssetKind::TarGz
2039 } else {
2040 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2041 };
2042
2043 // Download and extract
2044 ::http_client::github_download::download_server_binary(
2045 &*http_client,
2046 archive_url,
2047 sha256.as_deref(),
2048 &version_dir,
2049 asset_kind,
2050 )
2051 .await?;
2052 }
2053
2054 // Validate and resolve cmd path
2055 let cmd = &target_config.cmd;
2056
2057 let cmd_path = if cmd == "node" {
2058 // Use Zed's managed Node.js runtime
2059 node_runtime.binary_path().await?
2060 } else {
2061 if cmd.contains("..") {
2062 anyhow::bail!("command path cannot contain '..': {}", cmd);
2063 }
2064
2065 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2066 // Relative to extraction directory
2067 let cmd_path = version_dir.join(&cmd[2..]);
2068 anyhow::ensure!(
2069 fs.is_file(&cmd_path).await,
2070 "Missing command {} after extraction",
2071 cmd_path.to_string_lossy()
2072 );
2073 cmd_path
2074 } else {
2075 // On PATH
2076 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2077 }
2078 };
2079
2080 let command = AgentServerCommand {
2081 path: cmd_path,
2082 args: target_config.args.clone(),
2083 env: Some(env),
2084 };
2085
2086 Ok((command, version_dir.to_string_lossy().into_owned(), None))
2087 })
2088 }
2089
2090 fn as_any_mut(&mut self) -> &mut dyn Any {
2091 self
2092 }
2093}
2094
2095struct LocalRegistryArchiveAgent {
2096 fs: Arc<dyn Fs>,
2097 http_client: Arc<dyn HttpClient>,
2098 node_runtime: NodeRuntime,
2099 project_environment: Entity<ProjectEnvironment>,
2100 registry_id: Arc<str>,
2101 targets: HashMap<String, RegistryTargetConfig>,
2102}
2103
2104impl ExternalAgentServer for LocalRegistryArchiveAgent {
2105 fn get_command(
2106 &mut self,
2107 root_dir: Option<&str>,
2108 extra_env: HashMap<String, String>,
2109 _status_tx: Option<watch::Sender<SharedString>>,
2110 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2111 cx: &mut AsyncApp,
2112 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2113 let fs = self.fs.clone();
2114 let http_client = self.http_client.clone();
2115 let node_runtime = self.node_runtime.clone();
2116 let project_environment = self.project_environment.downgrade();
2117 let registry_id = self.registry_id.clone();
2118 let targets = self.targets.clone();
2119
2120 let root_dir: Arc<Path> = root_dir
2121 .map(|root_dir| Path::new(root_dir))
2122 .unwrap_or(paths::home_dir())
2123 .into();
2124
2125 cx.spawn(async move |cx| {
2126 let mut env = project_environment
2127 .update(cx, |project_environment, cx| {
2128 project_environment.local_directory_environment(
2129 &Shell::System,
2130 root_dir.clone(),
2131 cx,
2132 )
2133 })?
2134 .await
2135 .unwrap_or_default();
2136
2137 let dir = paths::external_agents_dir()
2138 .join("registry")
2139 .join(registry_id.as_ref());
2140 fs.create_dir(&dir).await?;
2141
2142 let os = if cfg!(target_os = "macos") {
2143 "darwin"
2144 } else if cfg!(target_os = "linux") {
2145 "linux"
2146 } else if cfg!(target_os = "windows") {
2147 "windows"
2148 } else {
2149 anyhow::bail!("unsupported OS");
2150 };
2151
2152 let arch = if cfg!(target_arch = "aarch64") {
2153 "aarch64"
2154 } else if cfg!(target_arch = "x86_64") {
2155 "x86_64"
2156 } else {
2157 anyhow::bail!("unsupported architecture");
2158 };
2159
2160 let platform_key = format!("{}-{}", os, arch);
2161 let target_config = targets.get(&platform_key).with_context(|| {
2162 format!(
2163 "no target specified for platform '{}'. Available platforms: {}",
2164 platform_key,
2165 targets
2166 .keys()
2167 .map(|k| k.as_str())
2168 .collect::<Vec<_>>()
2169 .join(", ")
2170 )
2171 })?;
2172
2173 env.extend(target_config.env.clone());
2174 env.extend(extra_env);
2175
2176 let archive_url = &target_config.archive;
2177
2178 use std::collections::hash_map::DefaultHasher;
2179 use std::hash::{Hash, Hasher};
2180 let mut hasher = DefaultHasher::new();
2181 archive_url.hash(&mut hasher);
2182 let url_hash = hasher.finish();
2183 let version_dir = dir.join(format!("v_{:x}", url_hash));
2184
2185 if !fs.is_dir(&version_dir).await {
2186 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2187 Some(provided_sha.clone())
2188 } else if archive_url.starts_with("https://github.com/") {
2189 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2190 let parts: Vec<&str> = caps.split('/').collect();
2191 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2192 let repo = format!("{}/{}", parts[0], parts[1]);
2193 let tag = parts[4];
2194 let filename = parts[5..].join("/");
2195
2196 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2197 &repo,
2198 tag,
2199 http_client.clone(),
2200 )
2201 .await
2202 {
2203 if let Some(asset) =
2204 release.assets.iter().find(|a| a.name == filename)
2205 {
2206 asset.digest.as_ref().and_then(|d| {
2207 d.strip_prefix("sha256:")
2208 .map(|s| s.to_string())
2209 .or_else(|| Some(d.clone()))
2210 })
2211 } else {
2212 None
2213 }
2214 } else {
2215 None
2216 }
2217 } else {
2218 None
2219 }
2220 } else {
2221 None
2222 }
2223 } else {
2224 None
2225 };
2226
2227 let asset_kind = if archive_url.ends_with(".zip") {
2228 AssetKind::Zip
2229 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2230 AssetKind::TarGz
2231 } else {
2232 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2233 };
2234
2235 ::http_client::github_download::download_server_binary(
2236 &*http_client,
2237 archive_url,
2238 sha256.as_deref(),
2239 &version_dir,
2240 asset_kind,
2241 )
2242 .await?;
2243 }
2244
2245 let cmd = &target_config.cmd;
2246
2247 let cmd_path = if cmd == "node" {
2248 node_runtime.binary_path().await?
2249 } else {
2250 if cmd.contains("..") {
2251 anyhow::bail!("command path cannot contain '..': {}", cmd);
2252 }
2253
2254 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2255 let cmd_path = version_dir.join(&cmd[2..]);
2256 anyhow::ensure!(
2257 fs.is_file(&cmd_path).await,
2258 "Missing command {} after extraction",
2259 cmd_path.to_string_lossy()
2260 );
2261 cmd_path
2262 } else {
2263 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2264 }
2265 };
2266
2267 let command = AgentServerCommand {
2268 path: cmd_path,
2269 args: target_config.args.clone(),
2270 env: Some(env),
2271 };
2272
2273 Ok((command, version_dir.to_string_lossy().into_owned(), None))
2274 })
2275 }
2276
2277 fn as_any_mut(&mut self) -> &mut dyn Any {
2278 self
2279 }
2280}
2281
2282struct LocalCustomAgent {
2283 project_environment: Entity<ProjectEnvironment>,
2284 command: AgentServerCommand,
2285}
2286
2287impl ExternalAgentServer for LocalCustomAgent {
2288 fn get_command(
2289 &mut self,
2290 root_dir: Option<&str>,
2291 extra_env: HashMap<String, String>,
2292 _status_tx: Option<watch::Sender<SharedString>>,
2293 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2294 cx: &mut AsyncApp,
2295 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2296 let mut command = self.command.clone();
2297 let root_dir: Arc<Path> = root_dir
2298 .map(|root_dir| Path::new(root_dir))
2299 .unwrap_or(paths::home_dir())
2300 .into();
2301 let project_environment = self.project_environment.downgrade();
2302 cx.spawn(async move |cx| {
2303 let mut env = project_environment
2304 .update(cx, |project_environment, cx| {
2305 project_environment.local_directory_environment(
2306 &Shell::System,
2307 root_dir.clone(),
2308 cx,
2309 )
2310 })?
2311 .await
2312 .unwrap_or_default();
2313 env.extend(command.env.unwrap_or_default());
2314 env.extend(extra_env);
2315 command.env = Some(env);
2316 Ok((command, root_dir.to_string_lossy().into_owned(), None))
2317 })
2318 }
2319
2320 fn as_any_mut(&mut self) -> &mut dyn Any {
2321 self
2322 }
2323}
2324
2325pub const GEMINI_NAME: &'static str = "gemini";
2326pub const CLAUDE_CODE_NAME: &'static str = "claude";
2327pub const CODEX_NAME: &'static str = "codex";
2328
2329#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2330pub struct AllAgentServersSettings {
2331 pub gemini: Option<BuiltinAgentServerSettings>,
2332 pub claude: Option<BuiltinAgentServerSettings>,
2333 pub codex: Option<BuiltinAgentServerSettings>,
2334 pub custom: HashMap<String, CustomAgentServerSettings>,
2335}
2336#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2337pub struct BuiltinAgentServerSettings {
2338 pub path: Option<PathBuf>,
2339 pub args: Option<Vec<String>>,
2340 pub env: Option<HashMap<String, String>>,
2341 pub ignore_system_version: Option<bool>,
2342 pub default_mode: Option<String>,
2343 pub default_model: Option<String>,
2344 pub favorite_models: Vec<String>,
2345 pub default_config_options: HashMap<String, String>,
2346 pub favorite_config_option_values: HashMap<String, Vec<String>>,
2347}
2348
2349impl BuiltinAgentServerSettings {
2350 fn custom_command(self) -> Option<AgentServerCommand> {
2351 self.path.map(|path| AgentServerCommand {
2352 path,
2353 args: self.args.unwrap_or_default(),
2354 // Settings env are always applied, so we don't need to supply them here as well
2355 env: None,
2356 })
2357 }
2358}
2359
2360impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2361 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2362 BuiltinAgentServerSettings {
2363 path: value
2364 .path
2365 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2366 args: value.args,
2367 env: value.env,
2368 ignore_system_version: value.ignore_system_version,
2369 default_mode: value.default_mode,
2370 default_model: value.default_model,
2371 favorite_models: value.favorite_models,
2372 default_config_options: value.default_config_options,
2373 favorite_config_option_values: value.favorite_config_option_values,
2374 }
2375 }
2376}
2377
2378impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2379 fn from(value: AgentServerCommand) -> Self {
2380 BuiltinAgentServerSettings {
2381 path: Some(value.path),
2382 args: Some(value.args),
2383 env: value.env,
2384 ..Default::default()
2385 }
2386 }
2387}
2388
2389#[derive(Clone, JsonSchema, Debug, PartialEq)]
2390pub enum CustomAgentServerSettings {
2391 Custom {
2392 command: AgentServerCommand,
2393 /// The default mode to use for this agent.
2394 ///
2395 /// Note: Not only all agents support modes.
2396 ///
2397 /// Default: None
2398 default_mode: Option<String>,
2399 /// The default model to use for this agent.
2400 ///
2401 /// This should be the model ID as reported by the agent.
2402 ///
2403 /// Default: None
2404 default_model: Option<String>,
2405 /// The favorite models for this agent.
2406 ///
2407 /// Default: []
2408 favorite_models: Vec<String>,
2409 /// Default values for session config options.
2410 ///
2411 /// This is a map from config option ID to value ID.
2412 ///
2413 /// Default: {}
2414 default_config_options: HashMap<String, String>,
2415 /// Favorited values for session config options.
2416 ///
2417 /// This is a map from config option ID to a list of favorited value IDs.
2418 ///
2419 /// Default: {}
2420 favorite_config_option_values: HashMap<String, Vec<String>>,
2421 },
2422 Extension {
2423 /// The default mode to use for this agent.
2424 ///
2425 /// Note: Not only all agents support modes.
2426 ///
2427 /// Default: None
2428 default_mode: Option<String>,
2429 /// The default model to use for this agent.
2430 ///
2431 /// This should be the model ID as reported by the agent.
2432 ///
2433 /// Default: None
2434 default_model: Option<String>,
2435 /// The favorite models for this agent.
2436 ///
2437 /// Default: []
2438 favorite_models: Vec<String>,
2439 /// Default values for session config options.
2440 ///
2441 /// This is a map from config option ID to value ID.
2442 ///
2443 /// Default: {}
2444 default_config_options: HashMap<String, String>,
2445 /// Favorited values for session config options.
2446 ///
2447 /// This is a map from config option ID to a list of favorited value IDs.
2448 ///
2449 /// Default: {}
2450 favorite_config_option_values: HashMap<String, Vec<String>>,
2451 },
2452 Registry {
2453 /// The default mode to use for this agent.
2454 ///
2455 /// Note: Not only all agents support modes.
2456 ///
2457 /// Default: None
2458 default_mode: Option<String>,
2459 /// The default model to use for this agent.
2460 ///
2461 /// This should be the model ID as reported by the agent.
2462 ///
2463 /// Default: None
2464 default_model: Option<String>,
2465 /// The favorite models for this agent.
2466 ///
2467 /// Default: []
2468 favorite_models: Vec<String>,
2469 /// Default values for session config options.
2470 ///
2471 /// This is a map from config option ID to value ID.
2472 ///
2473 /// Default: {}
2474 default_config_options: HashMap<String, String>,
2475 /// Favorited values for session config options.
2476 ///
2477 /// This is a map from config option ID to a list of favorited value IDs.
2478 ///
2479 /// Default: {}
2480 favorite_config_option_values: HashMap<String, Vec<String>>,
2481 },
2482}
2483
2484impl CustomAgentServerSettings {
2485 pub fn command(&self) -> Option<&AgentServerCommand> {
2486 match self {
2487 CustomAgentServerSettings::Custom { command, .. } => Some(command),
2488 CustomAgentServerSettings::Extension { .. }
2489 | CustomAgentServerSettings::Registry { .. } => None,
2490 }
2491 }
2492
2493 pub fn default_mode(&self) -> Option<&str> {
2494 match self {
2495 CustomAgentServerSettings::Custom { default_mode, .. }
2496 | CustomAgentServerSettings::Extension { default_mode, .. }
2497 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
2498 }
2499 }
2500
2501 pub fn default_model(&self) -> Option<&str> {
2502 match self {
2503 CustomAgentServerSettings::Custom { default_model, .. }
2504 | CustomAgentServerSettings::Extension { default_model, .. }
2505 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
2506 }
2507 }
2508
2509 pub fn favorite_models(&self) -> &[String] {
2510 match self {
2511 CustomAgentServerSettings::Custom {
2512 favorite_models, ..
2513 }
2514 | CustomAgentServerSettings::Extension {
2515 favorite_models, ..
2516 }
2517 | CustomAgentServerSettings::Registry {
2518 favorite_models, ..
2519 } => favorite_models,
2520 }
2521 }
2522
2523 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2524 match self {
2525 CustomAgentServerSettings::Custom {
2526 default_config_options,
2527 ..
2528 }
2529 | CustomAgentServerSettings::Extension {
2530 default_config_options,
2531 ..
2532 }
2533 | CustomAgentServerSettings::Registry {
2534 default_config_options,
2535 ..
2536 } => default_config_options.get(config_id).map(|s| s.as_str()),
2537 }
2538 }
2539
2540 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2541 match self {
2542 CustomAgentServerSettings::Custom {
2543 favorite_config_option_values,
2544 ..
2545 }
2546 | CustomAgentServerSettings::Extension {
2547 favorite_config_option_values,
2548 ..
2549 }
2550 | CustomAgentServerSettings::Registry {
2551 favorite_config_option_values,
2552 ..
2553 } => favorite_config_option_values
2554 .get(config_id)
2555 .map(|v| v.as_slice()),
2556 }
2557 }
2558}
2559
2560impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2561 fn from(value: settings::CustomAgentServerSettings) -> Self {
2562 match value {
2563 settings::CustomAgentServerSettings::Custom {
2564 path,
2565 args,
2566 env,
2567 default_mode,
2568 default_model,
2569 favorite_models,
2570 default_config_options,
2571 favorite_config_option_values,
2572 } => CustomAgentServerSettings::Custom {
2573 command: AgentServerCommand {
2574 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2575 args,
2576 env,
2577 },
2578 default_mode,
2579 default_model,
2580 favorite_models,
2581 default_config_options,
2582 favorite_config_option_values,
2583 },
2584 settings::CustomAgentServerSettings::Extension {
2585 default_mode,
2586 default_model,
2587 default_config_options,
2588 favorite_models,
2589 favorite_config_option_values,
2590 } => CustomAgentServerSettings::Extension {
2591 default_mode,
2592 default_model,
2593 default_config_options,
2594 favorite_models,
2595 favorite_config_option_values,
2596 },
2597 settings::CustomAgentServerSettings::Registry {
2598 default_mode,
2599 default_model,
2600 default_config_options,
2601 favorite_models,
2602 favorite_config_option_values,
2603 } => CustomAgentServerSettings::Registry {
2604 default_mode,
2605 default_model,
2606 default_config_options,
2607 favorite_models,
2608 favorite_config_option_values,
2609 },
2610 }
2611 }
2612}
2613
2614impl settings::Settings for AllAgentServersSettings {
2615 fn from_settings(content: &settings::SettingsContent) -> Self {
2616 let agent_settings = content.agent_servers.clone().unwrap();
2617 Self {
2618 gemini: agent_settings.gemini.map(Into::into),
2619 claude: agent_settings.claude.map(Into::into),
2620 codex: agent_settings.codex.map(Into::into),
2621 custom: agent_settings
2622 .custom
2623 .into_iter()
2624 .map(|(k, v)| (k, v.into()))
2625 .collect(),
2626 }
2627 }
2628}
2629
2630#[cfg(test)]
2631mod extension_agent_tests {
2632 use crate::worktree_store::WorktreeStore;
2633
2634 use super::*;
2635 use gpui::TestAppContext;
2636 use std::sync::Arc;
2637
2638 #[test]
2639 fn extension_agent_constructs_proper_display_names() {
2640 // Verify the display name format for extension-provided agents
2641 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2642 assert!(name1.0.contains(": "));
2643
2644 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2645 assert_eq!(name2.0, "MyExt: MyAgent");
2646
2647 // Non-extension agents shouldn't have the separator
2648 let custom = ExternalAgentServerName(SharedString::from("custom"));
2649 assert!(!custom.0.contains(": "));
2650 }
2651
2652 struct NoopExternalAgent;
2653
2654 impl ExternalAgentServer for NoopExternalAgent {
2655 fn get_command(
2656 &mut self,
2657 _root_dir: Option<&str>,
2658 _extra_env: HashMap<String, String>,
2659 _status_tx: Option<watch::Sender<SharedString>>,
2660 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2661 _cx: &mut AsyncApp,
2662 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2663 Task::ready(Ok((
2664 AgentServerCommand {
2665 path: PathBuf::from("noop"),
2666 args: Vec::new(),
2667 env: None,
2668 },
2669 "".to_string(),
2670 None,
2671 )))
2672 }
2673
2674 fn as_any_mut(&mut self) -> &mut dyn Any {
2675 self
2676 }
2677 }
2678
2679 #[test]
2680 fn sync_removes_only_extension_provided_agents() {
2681 let mut store = AgentServerStore {
2682 state: AgentServerStoreState::Collab,
2683 external_agents: HashMap::default(),
2684 };
2685
2686 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2687 store.external_agents.insert(
2688 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2689 ExternalAgentEntry::new(
2690 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2691 ExternalAgentSource::Extension,
2692 None,
2693 None,
2694 ),
2695 );
2696 store.external_agents.insert(
2697 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2698 ExternalAgentEntry::new(
2699 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2700 ExternalAgentSource::Extension,
2701 None,
2702 None,
2703 ),
2704 );
2705 store.external_agents.insert(
2706 ExternalAgentServerName(SharedString::from("custom-agent")),
2707 ExternalAgentEntry::new(
2708 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2709 ExternalAgentSource::Custom,
2710 None,
2711 None,
2712 ),
2713 );
2714
2715 // Simulate removal phase
2716 store
2717 .external_agents
2718 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
2719
2720 // Only custom-agent should remain
2721 assert_eq!(store.external_agents.len(), 1);
2722 assert!(
2723 store
2724 .external_agents
2725 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2726 );
2727 }
2728
2729 #[test]
2730 fn archive_launcher_constructs_with_all_fields() {
2731 use extension::AgentServerManifestEntry;
2732
2733 let mut env = HashMap::default();
2734 env.insert("GITHUB_TOKEN".into(), "secret".into());
2735
2736 let mut targets = HashMap::default();
2737 targets.insert(
2738 "darwin-aarch64".to_string(),
2739 extension::TargetConfig {
2740 archive:
2741 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2742 .into(),
2743 cmd: "./agent".into(),
2744 args: vec![],
2745 sha256: None,
2746 env: Default::default(),
2747 },
2748 );
2749
2750 let _entry = AgentServerManifestEntry {
2751 name: "GitHub Agent".into(),
2752 targets,
2753 env,
2754 icon: None,
2755 };
2756
2757 // Verify display name construction
2758 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2759 assert_eq!(expected_name.0, "GitHub Agent");
2760 }
2761
2762 #[gpui::test]
2763 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2764 let fs = fs::FakeFs::new(cx.background_executor.clone());
2765 let http_client = http_client::FakeHttpClient::with_404_response();
2766 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2767 let project_environment = cx.new(|cx| {
2768 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2769 });
2770
2771 let agent = LocalExtensionArchiveAgent {
2772 fs,
2773 http_client,
2774 node_runtime: node_runtime::NodeRuntime::unavailable(),
2775 project_environment,
2776 extension_id: Arc::from("my-extension"),
2777 agent_id: Arc::from("my-agent"),
2778 targets: {
2779 let mut map = HashMap::default();
2780 map.insert(
2781 "darwin-aarch64".to_string(),
2782 extension::TargetConfig {
2783 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2784 cmd: "./my-agent".into(),
2785 args: vec!["--serve".into()],
2786 sha256: None,
2787 env: Default::default(),
2788 },
2789 );
2790 map
2791 },
2792 env: {
2793 let mut map = HashMap::default();
2794 map.insert("PORT".into(), "8080".into());
2795 map
2796 },
2797 };
2798
2799 // Verify agent is properly constructed
2800 assert_eq!(agent.extension_id.as_ref(), "my-extension");
2801 assert_eq!(agent.agent_id.as_ref(), "my-agent");
2802 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2803 assert!(agent.targets.contains_key("darwin-aarch64"));
2804 }
2805
2806 #[test]
2807 fn sync_extension_agents_registers_archive_launcher() {
2808 use extension::AgentServerManifestEntry;
2809
2810 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2811 assert_eq!(expected_name.0, "Release Agent");
2812
2813 // Verify the manifest entry structure for archive-based installation
2814 let mut env = HashMap::default();
2815 env.insert("API_KEY".into(), "secret".into());
2816
2817 let mut targets = HashMap::default();
2818 targets.insert(
2819 "linux-x86_64".to_string(),
2820 extension::TargetConfig {
2821 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2822 cmd: "./release-agent".into(),
2823 args: vec!["serve".into()],
2824 sha256: None,
2825 env: Default::default(),
2826 },
2827 );
2828
2829 let manifest_entry = AgentServerManifestEntry {
2830 name: "Release Agent".into(),
2831 targets: targets.clone(),
2832 env,
2833 icon: None,
2834 };
2835
2836 // Verify target config is present
2837 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2838 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2839 assert_eq!(target.cmd, "./release-agent");
2840 }
2841
2842 #[gpui::test]
2843 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2844 let fs = fs::FakeFs::new(cx.background_executor.clone());
2845 let http_client = http_client::FakeHttpClient::with_404_response();
2846 let node_runtime = NodeRuntime::unavailable();
2847 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2848 let project_environment = cx.new(|cx| {
2849 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2850 });
2851
2852 let agent = LocalExtensionArchiveAgent {
2853 fs: fs.clone(),
2854 http_client,
2855 node_runtime,
2856 project_environment,
2857 extension_id: Arc::from("node-extension"),
2858 agent_id: Arc::from("node-agent"),
2859 targets: {
2860 let mut map = HashMap::default();
2861 map.insert(
2862 "darwin-aarch64".to_string(),
2863 extension::TargetConfig {
2864 archive: "https://example.com/node-agent.zip".into(),
2865 cmd: "node".into(),
2866 args: vec!["index.js".into()],
2867 sha256: None,
2868 env: Default::default(),
2869 },
2870 );
2871 map
2872 },
2873 env: HashMap::default(),
2874 };
2875
2876 // Verify that when cmd is "node", it attempts to use the node runtime
2877 assert_eq!(agent.extension_id.as_ref(), "node-extension");
2878 assert_eq!(agent.agent_id.as_ref(), "node-agent");
2879
2880 let target = agent.targets.get("darwin-aarch64").unwrap();
2881 assert_eq!(target.cmd, "node");
2882 assert_eq!(target.args, vec!["index.js"]);
2883 }
2884
2885 #[gpui::test]
2886 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2887 let fs = fs::FakeFs::new(cx.background_executor.clone());
2888 let http_client = http_client::FakeHttpClient::with_404_response();
2889 let node_runtime = NodeRuntime::unavailable();
2890 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2891 let project_environment = cx.new(|cx| {
2892 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2893 });
2894
2895 let agent = LocalExtensionArchiveAgent {
2896 fs: fs.clone(),
2897 http_client,
2898 node_runtime,
2899 project_environment,
2900 extension_id: Arc::from("test-ext"),
2901 agent_id: Arc::from("test-agent"),
2902 targets: {
2903 let mut map = HashMap::default();
2904 map.insert(
2905 "darwin-aarch64".to_string(),
2906 extension::TargetConfig {
2907 archive: "https://example.com/test.zip".into(),
2908 cmd: "node".into(),
2909 args: vec![
2910 "server.js".into(),
2911 "--config".into(),
2912 "./config.json".into(),
2913 ],
2914 sha256: None,
2915 env: Default::default(),
2916 },
2917 );
2918 map
2919 },
2920 env: HashMap::default(),
2921 };
2922
2923 // Verify the agent is configured with relative paths in args
2924 let target = agent.targets.get("darwin-aarch64").unwrap();
2925 assert_eq!(target.args[0], "server.js");
2926 assert_eq!(target.args[2], "./config.json");
2927 // These relative paths will resolve relative to the extraction directory
2928 // when the command is executed
2929 }
2930
2931 #[test]
2932 fn test_tilde_expansion_in_settings() {
2933 let settings = settings::BuiltinAgentServerSettings {
2934 path: Some(PathBuf::from("~/bin/agent")),
2935 args: Some(vec!["--flag".into()]),
2936 env: None,
2937 ignore_system_version: None,
2938 default_mode: None,
2939 default_model: None,
2940 favorite_models: vec![],
2941 default_config_options: Default::default(),
2942 favorite_config_option_values: Default::default(),
2943 };
2944
2945 let BuiltinAgentServerSettings { path, .. } = settings.into();
2946
2947 let path = path.unwrap();
2948 assert!(
2949 !path.to_string_lossy().starts_with("~"),
2950 "Tilde should be expanded for builtin agent path"
2951 );
2952
2953 let settings = settings::CustomAgentServerSettings::Custom {
2954 path: PathBuf::from("~/custom/agent"),
2955 args: vec!["serve".into()],
2956 env: None,
2957 default_mode: None,
2958 default_model: None,
2959 favorite_models: vec![],
2960 default_config_options: Default::default(),
2961 favorite_config_option_values: Default::default(),
2962 };
2963
2964 let converted: CustomAgentServerSettings = settings.into();
2965 let CustomAgentServerSettings::Custom {
2966 command: AgentServerCommand { path, .. },
2967 ..
2968 } = converted
2969 else {
2970 panic!("Expected Custom variant");
2971 };
2972
2973 assert!(
2974 !path.to_string_lossy().starts_with("~"),
2975 "Tilde should be expanded for custom agent path"
2976 );
2977 }
2978}