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