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