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 settings_env: new_settings
613 .gemini
614 .as_ref()
615 .and_then(|settings| settings.env.clone()),
616 ignore_system_version: new_settings
617 .gemini
618 .as_ref()
619 .and_then(|settings| settings.ignore_system_version)
620 .unwrap_or(true),
621 }),
622 );
623 self.external_agents.insert(
624 CODEX_NAME.into(),
625 Box::new(LocalCodex {
626 fs: fs.clone(),
627 project_environment: project_environment.clone(),
628 custom_command: new_settings
629 .codex
630 .clone()
631 .and_then(|settings| settings.custom_command()),
632 settings_env: new_settings
633 .codex
634 .as_ref()
635 .and_then(|settings| settings.env.clone()),
636 http_client: http_client.clone(),
637 no_browser: downstream_client
638 .as_ref()
639 .is_some_and(|(_, client)| !client.has_wsl_interop()),
640 }),
641 );
642 self.external_agents.insert(
643 CLAUDE_CODE_NAME.into(),
644 Box::new(LocalClaudeCode {
645 fs: fs.clone(),
646 node_runtime: node_runtime.clone(),
647 project_environment: project_environment.clone(),
648 custom_command: new_settings
649 .claude
650 .clone()
651 .and_then(|settings| settings.custom_command()),
652 settings_env: new_settings
653 .claude
654 .as_ref()
655 .and_then(|settings| settings.env.clone()),
656 }),
657 );
658 self.external_agents
659 .extend(
660 new_settings
661 .custom
662 .iter()
663 .filter_map(|(name, settings)| match settings {
664 CustomAgentServerSettings::Custom { command, .. } => Some((
665 ExternalAgentServerName(name.clone().into()),
666 Box::new(LocalCustomAgent {
667 command: command.clone(),
668 project_environment: project_environment.clone(),
669 }) as Box<dyn ExternalAgentServer>,
670 )),
671 CustomAgentServerSettings::Extension { .. } => None,
672 }),
673 );
674 self.external_agents.extend(extension_agents.iter().map(
675 |(agent_name, ext_id, targets, env, icon_path)| {
676 let name = ExternalAgentServerName(agent_name.clone().into());
677
678 // Restore icon if present
679 if let Some(icon) = icon_path {
680 self.agent_icons
681 .insert(name.clone(), SharedString::from(icon.clone()));
682 }
683
684 (
685 name,
686 Box::new(LocalExtensionArchiveAgent {
687 fs: fs.clone(),
688 http_client: http_client.clone(),
689 node_runtime: node_runtime.clone(),
690 project_environment: project_environment.clone(),
691 extension_id: Arc::from(&**ext_id),
692 targets: targets.clone(),
693 env: env.clone(),
694 agent_id: agent_name.clone(),
695 }) as Box<dyn ExternalAgentServer>,
696 )
697 },
698 ));
699
700 *old_settings = Some(new_settings.clone());
701
702 if let Some((project_id, downstream_client)) = downstream_client {
703 downstream_client
704 .send(proto::ExternalAgentsUpdated {
705 project_id: *project_id,
706 names: self
707 .external_agents
708 .keys()
709 .map(|name| name.to_string())
710 .collect(),
711 })
712 .log_err();
713 }
714 cx.emit(AgentServersUpdated);
715 }
716
717 pub fn node_runtime(&self) -> Option<NodeRuntime> {
718 match &self.state {
719 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
720 _ => None,
721 }
722 }
723
724 pub fn local(
725 node_runtime: NodeRuntime,
726 fs: Arc<dyn Fs>,
727 project_environment: Entity<ProjectEnvironment>,
728 http_client: Arc<dyn HttpClient>,
729 cx: &mut Context<Self>,
730 ) -> Self {
731 let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
732 this.agent_servers_settings_changed(cx);
733 });
734 let mut this = Self {
735 state: AgentServerStoreState::Local {
736 node_runtime,
737 fs,
738 project_environment,
739 http_client,
740 downstream_client: None,
741 settings: None,
742 extension_agents: vec![],
743 _subscriptions: [subscription],
744 },
745 external_agents: Default::default(),
746 agent_icons: Default::default(),
747 agent_display_names: Default::default(),
748 };
749 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
750 this.agent_servers_settings_changed(cx);
751 this
752 }
753
754 pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
755 // Set up the builtin agents here so they're immediately available in
756 // remote projects--we know that the HeadlessProject on the other end
757 // will have them.
758 let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
759 (
760 CLAUDE_CODE_NAME.into(),
761 Box::new(RemoteExternalAgentServer {
762 project_id,
763 upstream_client: upstream_client.clone(),
764 name: CLAUDE_CODE_NAME.into(),
765 status_tx: None,
766 new_version_available_tx: None,
767 }) as Box<dyn ExternalAgentServer>,
768 ),
769 (
770 CODEX_NAME.into(),
771 Box::new(RemoteExternalAgentServer {
772 project_id,
773 upstream_client: upstream_client.clone(),
774 name: CODEX_NAME.into(),
775 status_tx: None,
776 new_version_available_tx: None,
777 }) as Box<dyn ExternalAgentServer>,
778 ),
779 (
780 GEMINI_NAME.into(),
781 Box::new(RemoteExternalAgentServer {
782 project_id,
783 upstream_client: upstream_client.clone(),
784 name: GEMINI_NAME.into(),
785 status_tx: None,
786 new_version_available_tx: None,
787 }) as Box<dyn ExternalAgentServer>,
788 ),
789 ];
790
791 Self {
792 state: AgentServerStoreState::Remote {
793 project_id,
794 upstream_client,
795 },
796 external_agents: external_agents.into_iter().collect(),
797 agent_icons: HashMap::default(),
798 agent_display_names: HashMap::default(),
799 }
800 }
801
802 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
803 Self {
804 state: AgentServerStoreState::Collab,
805 external_agents: Default::default(),
806 agent_icons: Default::default(),
807 agent_display_names: Default::default(),
808 }
809 }
810
811 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
812 match &mut self.state {
813 AgentServerStoreState::Local {
814 downstream_client, ..
815 } => {
816 *downstream_client = Some((project_id, client.clone()));
817 // Send the current list of external agents downstream, but only after a delay,
818 // to avoid having the message arrive before the downstream project's agent server store
819 // sets up its handlers.
820 cx.spawn(async move |this, cx| {
821 cx.background_executor().timer(Duration::from_secs(1)).await;
822 let names = this.update(cx, |this, _| {
823 this.external_agents
824 .keys()
825 .map(|name| name.to_string())
826 .collect()
827 })?;
828 client
829 .send(proto::ExternalAgentsUpdated { project_id, names })
830 .log_err();
831 anyhow::Ok(())
832 })
833 .detach();
834 }
835 AgentServerStoreState::Remote { .. } => {
836 debug_panic!(
837 "external agents over collab not implemented, remote project should not be shared"
838 );
839 }
840 AgentServerStoreState::Collab => {
841 debug_panic!("external agents over collab not implemented, should not be shared");
842 }
843 }
844 }
845
846 pub fn get_external_agent(
847 &mut self,
848 name: &ExternalAgentServerName,
849 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
850 self.external_agents
851 .get_mut(name)
852 .map(|agent| agent.as_mut())
853 }
854
855 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
856 self.external_agents.keys()
857 }
858
859 async fn handle_get_agent_server_command(
860 this: Entity<Self>,
861 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
862 mut cx: AsyncApp,
863 ) -> Result<proto::AgentServerCommand> {
864 let (command, root_dir, login_command) = this
865 .update(&mut cx, |this, cx| {
866 let AgentServerStoreState::Local {
867 downstream_client, ..
868 } = &this.state
869 else {
870 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
871 bail!("unexpected GetAgentServerCommand request in a non-local project");
872 };
873 let agent = this
874 .external_agents
875 .get_mut(&*envelope.payload.name)
876 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
877 let (status_tx, new_version_available_tx) = downstream_client
878 .clone()
879 .map(|(project_id, downstream_client)| {
880 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
881 let (new_version_available_tx, mut new_version_available_rx) =
882 watch::channel(None);
883 cx.spawn({
884 let downstream_client = downstream_client.clone();
885 let name = envelope.payload.name.clone();
886 async move |_, _| {
887 while let Some(status) = status_rx.recv().await.ok() {
888 downstream_client.send(
889 proto::ExternalAgentLoadingStatusUpdated {
890 project_id,
891 name: name.clone(),
892 status: status.to_string(),
893 },
894 )?;
895 }
896 anyhow::Ok(())
897 }
898 })
899 .detach_and_log_err(cx);
900 cx.spawn({
901 let name = envelope.payload.name.clone();
902 async move |_, _| {
903 if let Some(version) =
904 new_version_available_rx.recv().await.ok().flatten()
905 {
906 downstream_client.send(
907 proto::NewExternalAgentVersionAvailable {
908 project_id,
909 name: name.clone(),
910 version,
911 },
912 )?;
913 }
914 anyhow::Ok(())
915 }
916 })
917 .detach_and_log_err(cx);
918 (status_tx, new_version_available_tx)
919 })
920 .unzip();
921 anyhow::Ok(agent.get_command(
922 envelope.payload.root_dir.as_deref(),
923 HashMap::default(),
924 status_tx,
925 new_version_available_tx,
926 &mut cx.to_async(),
927 ))
928 })?
929 .await?;
930 Ok(proto::AgentServerCommand {
931 path: command.path.to_string_lossy().into_owned(),
932 args: command.args,
933 env: command
934 .env
935 .map(|env| env.into_iter().collect())
936 .unwrap_or_default(),
937 root_dir: root_dir,
938 login: login_command.map(|cmd| cmd.to_proto()),
939 })
940 }
941
942 async fn handle_external_agents_updated(
943 this: Entity<Self>,
944 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
945 mut cx: AsyncApp,
946 ) -> Result<()> {
947 this.update(&mut cx, |this, cx| {
948 let AgentServerStoreState::Remote {
949 project_id,
950 upstream_client,
951 } = &this.state
952 else {
953 debug_panic!(
954 "handle_external_agents_updated should not be called for a non-remote project"
955 );
956 bail!("unexpected ExternalAgentsUpdated message")
957 };
958
959 let mut status_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 .status_tx
968 .take(),
969 ))
970 })
971 .collect::<HashMap<_, _>>();
972 let mut new_version_available_txs = this
973 .external_agents
974 .iter_mut()
975 .filter_map(|(name, agent)| {
976 Some((
977 name.clone(),
978 agent
979 .downcast_mut::<RemoteExternalAgentServer>()?
980 .new_version_available_tx
981 .take(),
982 ))
983 })
984 .collect::<HashMap<_, _>>();
985
986 this.external_agents = envelope
987 .payload
988 .names
989 .into_iter()
990 .map(|name| {
991 let agent = RemoteExternalAgentServer {
992 project_id: *project_id,
993 upstream_client: upstream_client.clone(),
994 name: ExternalAgentServerName(name.clone().into()),
995 status_tx: status_txs.remove(&*name).flatten(),
996 new_version_available_tx: new_version_available_txs
997 .remove(&*name)
998 .flatten(),
999 };
1000 (
1001 ExternalAgentServerName(name.into()),
1002 Box::new(agent) as Box<dyn ExternalAgentServer>,
1003 )
1004 })
1005 .collect();
1006 cx.emit(AgentServersUpdated);
1007 Ok(())
1008 })
1009 }
1010
1011 async fn handle_external_extension_agents_updated(
1012 this: Entity<Self>,
1013 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
1014 mut cx: AsyncApp,
1015 ) -> Result<()> {
1016 this.update(&mut cx, |this, cx| {
1017 let AgentServerStoreState::Local {
1018 extension_agents, ..
1019 } = &mut this.state
1020 else {
1021 panic!(
1022 "handle_external_extension_agents_updated \
1023 should not be called for a non-remote project"
1024 );
1025 };
1026
1027 for ExternalExtensionAgent {
1028 name,
1029 icon_path,
1030 extension_id,
1031 targets,
1032 env,
1033 } in envelope.payload.agents
1034 {
1035 let icon_path_string = icon_path.clone();
1036 if let Some(icon_path) = icon_path {
1037 this.agent_icons.insert(
1038 ExternalAgentServerName(name.clone().into()),
1039 icon_path.into(),
1040 );
1041 }
1042 extension_agents.push((
1043 Arc::from(&*name),
1044 extension_id,
1045 targets
1046 .into_iter()
1047 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
1048 .collect(),
1049 env.into_iter().collect(),
1050 icon_path_string,
1051 ));
1052 }
1053
1054 this.reregister_agents(cx);
1055 cx.emit(AgentServersUpdated);
1056 Ok(())
1057 })
1058 }
1059
1060 async fn handle_loading_status_updated(
1061 this: Entity<Self>,
1062 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
1063 mut cx: AsyncApp,
1064 ) -> Result<()> {
1065 this.update(&mut cx, |this, _| {
1066 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1067 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
1068 && let Some(status_tx) = &mut agent.status_tx
1069 {
1070 status_tx.send(envelope.payload.status.into()).ok();
1071 }
1072 });
1073 Ok(())
1074 }
1075
1076 async fn handle_new_version_available(
1077 this: Entity<Self>,
1078 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
1079 mut cx: AsyncApp,
1080 ) -> Result<()> {
1081 this.update(&mut cx, |this, _| {
1082 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1083 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
1084 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
1085 {
1086 new_version_available_tx
1087 .send(Some(envelope.payload.version))
1088 .ok();
1089 }
1090 });
1091 Ok(())
1092 }
1093
1094 pub fn get_extension_id_for_agent(
1095 &mut self,
1096 name: &ExternalAgentServerName,
1097 ) -> Option<Arc<str>> {
1098 self.external_agents.get_mut(name).and_then(|agent| {
1099 agent
1100 .as_any_mut()
1101 .downcast_ref::<LocalExtensionArchiveAgent>()
1102 .map(|ext_agent| ext_agent.extension_id.clone())
1103 })
1104 }
1105}
1106
1107fn get_or_npm_install_builtin_agent(
1108 binary_name: SharedString,
1109 package_name: SharedString,
1110 entrypoint_path: PathBuf,
1111 minimum_version: Option<semver::Version>,
1112 status_tx: Option<watch::Sender<SharedString>>,
1113 new_version_available: Option<watch::Sender<Option<String>>>,
1114 fs: Arc<dyn Fs>,
1115 node_runtime: NodeRuntime,
1116 cx: &mut AsyncApp,
1117) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
1118 cx.spawn(async move |cx| {
1119 let node_path = node_runtime.binary_path().await?;
1120 let dir = paths::external_agents_dir().join(binary_name.as_str());
1121 fs.create_dir(&dir).await?;
1122
1123 let mut stream = fs.read_dir(&dir).await?;
1124 let mut versions = Vec::new();
1125 let mut to_delete = Vec::new();
1126 while let Some(entry) = stream.next().await {
1127 let Ok(entry) = entry else { continue };
1128 let Some(file_name) = entry.file_name() else {
1129 continue;
1130 };
1131
1132 if let Some(name) = file_name.to_str()
1133 && let Some(version) = semver::Version::from_str(name).ok()
1134 && fs
1135 .is_file(&dir.join(file_name).join(&entrypoint_path))
1136 .await
1137 {
1138 versions.push((version, file_name.to_owned()));
1139 } else {
1140 to_delete.push(file_name.to_owned())
1141 }
1142 }
1143
1144 versions.sort();
1145 let newest_version = if let Some((version, _)) = versions.last().cloned()
1146 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
1147 {
1148 versions.pop()
1149 } else {
1150 None
1151 };
1152 log::debug!("existing version of {package_name}: {newest_version:?}");
1153 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
1154
1155 cx.background_spawn({
1156 let fs = fs.clone();
1157 let dir = dir.clone();
1158 async move {
1159 for file_name in to_delete {
1160 fs.remove_dir(
1161 &dir.join(file_name),
1162 RemoveOptions {
1163 recursive: true,
1164 ignore_if_not_exists: false,
1165 },
1166 )
1167 .await
1168 .ok();
1169 }
1170 }
1171 })
1172 .detach();
1173
1174 let version = if let Some((version, file_name)) = newest_version {
1175 cx.background_spawn({
1176 let dir = dir.clone();
1177 let fs = fs.clone();
1178 async move {
1179 let latest_version = node_runtime
1180 .npm_package_latest_version(&package_name)
1181 .await
1182 .ok();
1183 if let Some(latest_version) = latest_version
1184 && latest_version != version
1185 {
1186 let download_result = download_latest_version(
1187 fs,
1188 dir.clone(),
1189 node_runtime,
1190 package_name.clone(),
1191 )
1192 .await
1193 .log_err();
1194 if let Some(mut new_version_available) = new_version_available
1195 && download_result.is_some()
1196 {
1197 new_version_available
1198 .send(Some(latest_version.to_string()))
1199 .ok();
1200 }
1201 }
1202 }
1203 })
1204 .detach();
1205 file_name
1206 } else {
1207 if let Some(mut status_tx) = status_tx {
1208 status_tx.send("Installing…".into()).ok();
1209 }
1210 let dir = dir.clone();
1211 cx.background_spawn(download_latest_version(
1212 fs.clone(),
1213 dir.clone(),
1214 node_runtime,
1215 package_name.clone(),
1216 ))
1217 .await?
1218 .to_string()
1219 .into()
1220 };
1221
1222 let agent_server_path = dir.join(version).join(entrypoint_path);
1223 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1224 anyhow::ensure!(
1225 agent_server_path_exists,
1226 "Missing entrypoint path {} after installation",
1227 agent_server_path.to_string_lossy()
1228 );
1229
1230 anyhow::Ok(AgentServerCommand {
1231 path: node_path,
1232 args: vec![agent_server_path.to_string_lossy().into_owned()],
1233 env: None,
1234 })
1235 })
1236}
1237
1238fn find_bin_in_path(
1239 bin_name: SharedString,
1240 root_dir: PathBuf,
1241 env: HashMap<String, String>,
1242 cx: &mut AsyncApp,
1243) -> Task<Option<PathBuf>> {
1244 cx.background_executor().spawn(async move {
1245 let which_result = if cfg!(windows) {
1246 which::which(bin_name.as_str())
1247 } else {
1248 let shell_path = env.get("PATH").cloned();
1249 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1250 };
1251
1252 if let Err(which::Error::CannotFindBinaryPath) = which_result {
1253 return None;
1254 }
1255
1256 which_result.log_err()
1257 })
1258}
1259
1260async fn download_latest_version(
1261 fs: Arc<dyn Fs>,
1262 dir: PathBuf,
1263 node_runtime: NodeRuntime,
1264 package_name: SharedString,
1265) -> Result<Version> {
1266 log::debug!("downloading latest version of {package_name}");
1267
1268 let tmp_dir = tempfile::tempdir_in(&dir)?;
1269
1270 node_runtime
1271 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1272 .await?;
1273
1274 let version = node_runtime
1275 .npm_package_installed_version(tmp_dir.path(), &package_name)
1276 .await?
1277 .context("expected package to be installed")?;
1278
1279 fs.rename(
1280 &tmp_dir.keep(),
1281 &dir.join(version.to_string()),
1282 RenameOptions {
1283 ignore_if_exists: true,
1284 overwrite: true,
1285 create_parents: false,
1286 },
1287 )
1288 .await?;
1289
1290 anyhow::Ok(version)
1291}
1292
1293struct RemoteExternalAgentServer {
1294 project_id: u64,
1295 upstream_client: Entity<RemoteClient>,
1296 name: ExternalAgentServerName,
1297 status_tx: Option<watch::Sender<SharedString>>,
1298 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1299}
1300
1301impl ExternalAgentServer for RemoteExternalAgentServer {
1302 fn get_command(
1303 &mut self,
1304 root_dir: Option<&str>,
1305 extra_env: HashMap<String, String>,
1306 status_tx: Option<watch::Sender<SharedString>>,
1307 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1308 cx: &mut AsyncApp,
1309 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1310 let project_id = self.project_id;
1311 let name = self.name.to_string();
1312 let upstream_client = self.upstream_client.downgrade();
1313 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1314 self.status_tx = status_tx;
1315 self.new_version_available_tx = new_version_available_tx;
1316 cx.spawn(async move |cx| {
1317 let mut response = upstream_client
1318 .update(cx, |upstream_client, _| {
1319 upstream_client
1320 .proto_client()
1321 .request(proto::GetAgentServerCommand {
1322 project_id,
1323 name,
1324 root_dir: root_dir.clone(),
1325 })
1326 })?
1327 .await?;
1328 let root_dir = response.root_dir;
1329 response.env.extend(extra_env);
1330 let command = upstream_client.update(cx, |client, _| {
1331 client.build_command_with_options(
1332 Some(response.path),
1333 &response.args,
1334 &response.env.into_iter().collect(),
1335 Some(root_dir.clone()),
1336 None,
1337 Interactive::No,
1338 )
1339 })??;
1340 Ok((
1341 AgentServerCommand {
1342 path: command.program.into(),
1343 args: command.args,
1344 env: Some(command.env),
1345 },
1346 root_dir,
1347 response.login.map(SpawnInTerminal::from_proto),
1348 ))
1349 })
1350 }
1351
1352 fn as_any_mut(&mut self) -> &mut dyn Any {
1353 self
1354 }
1355}
1356
1357struct LocalGemini {
1358 fs: Arc<dyn Fs>,
1359 node_runtime: NodeRuntime,
1360 project_environment: Entity<ProjectEnvironment>,
1361 custom_command: Option<AgentServerCommand>,
1362 settings_env: Option<HashMap<String, String>>,
1363 ignore_system_version: bool,
1364}
1365
1366impl ExternalAgentServer for LocalGemini {
1367 fn get_command(
1368 &mut self,
1369 root_dir: Option<&str>,
1370 extra_env: HashMap<String, String>,
1371 status_tx: Option<watch::Sender<SharedString>>,
1372 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1373 cx: &mut AsyncApp,
1374 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1375 let fs = self.fs.clone();
1376 let node_runtime = self.node_runtime.clone();
1377 let project_environment = self.project_environment.downgrade();
1378 let custom_command = self.custom_command.clone();
1379 let settings_env = self.settings_env.clone();
1380 let ignore_system_version = self.ignore_system_version;
1381 let root_dir: Arc<Path> = root_dir
1382 .map(|root_dir| Path::new(root_dir))
1383 .unwrap_or(paths::home_dir())
1384 .into();
1385
1386 cx.spawn(async move |cx| {
1387 let mut env = project_environment
1388 .update(cx, |project_environment, cx| {
1389 project_environment.local_directory_environment(
1390 &Shell::System,
1391 root_dir.clone(),
1392 cx,
1393 )
1394 })?
1395 .await
1396 .unwrap_or_default();
1397
1398 env.extend(settings_env.unwrap_or_default());
1399
1400 let mut command = if let Some(mut custom_command) = custom_command {
1401 custom_command.env = Some(env);
1402 custom_command
1403 } else if !ignore_system_version
1404 && let Some(bin) =
1405 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1406 {
1407 AgentServerCommand {
1408 path: bin,
1409 args: Vec::new(),
1410 env: Some(env),
1411 }
1412 } else {
1413 let mut command = get_or_npm_install_builtin_agent(
1414 GEMINI_NAME.into(),
1415 "@google/gemini-cli".into(),
1416 "node_modules/@google/gemini-cli/dist/index.js".into(),
1417 if cfg!(windows) {
1418 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1419 Some("0.9.0".parse().unwrap())
1420 } else {
1421 Some("0.2.1".parse().unwrap())
1422 },
1423 status_tx,
1424 new_version_available_tx,
1425 fs,
1426 node_runtime,
1427 cx,
1428 )
1429 .await?;
1430 command.env = Some(env);
1431 command
1432 };
1433
1434 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1435 let login = task::SpawnInTerminal {
1436 command: Some(command.path.to_string_lossy().into_owned()),
1437 args: command.args.clone(),
1438 env: command.env.clone().unwrap_or_default(),
1439 label: "gemini /auth".into(),
1440 ..Default::default()
1441 };
1442
1443 command.env.get_or_insert_default().extend(extra_env);
1444 command.args.push("--experimental-acp".into());
1445 Ok((
1446 command,
1447 root_dir.to_string_lossy().into_owned(),
1448 Some(login),
1449 ))
1450 })
1451 }
1452
1453 fn as_any_mut(&mut self) -> &mut dyn Any {
1454 self
1455 }
1456}
1457
1458struct LocalClaudeCode {
1459 fs: Arc<dyn Fs>,
1460 node_runtime: NodeRuntime,
1461 project_environment: Entity<ProjectEnvironment>,
1462 custom_command: Option<AgentServerCommand>,
1463 settings_env: Option<HashMap<String, String>>,
1464}
1465
1466impl ExternalAgentServer for LocalClaudeCode {
1467 fn get_command(
1468 &mut self,
1469 root_dir: Option<&str>,
1470 extra_env: HashMap<String, String>,
1471 status_tx: Option<watch::Sender<SharedString>>,
1472 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1473 cx: &mut AsyncApp,
1474 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1475 let fs = self.fs.clone();
1476 let node_runtime = self.node_runtime.clone();
1477 let project_environment = self.project_environment.downgrade();
1478 let custom_command = self.custom_command.clone();
1479 let settings_env = self.settings_env.clone();
1480 let root_dir: Arc<Path> = root_dir
1481 .map(|root_dir| Path::new(root_dir))
1482 .unwrap_or(paths::home_dir())
1483 .into();
1484
1485 cx.spawn(async move |cx| {
1486 let mut env = project_environment
1487 .update(cx, |project_environment, cx| {
1488 project_environment.local_directory_environment(
1489 &Shell::System,
1490 root_dir.clone(),
1491 cx,
1492 )
1493 })?
1494 .await
1495 .unwrap_or_default();
1496 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1497
1498 env.extend(settings_env.unwrap_or_default());
1499
1500 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1501 custom_command.env = Some(env);
1502 (custom_command, None)
1503 } else {
1504 let mut command = get_or_npm_install_builtin_agent(
1505 "claude-code-acp".into(),
1506 "@zed-industries/claude-code-acp".into(),
1507 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1508 Some("0.5.2".parse().unwrap()),
1509 status_tx,
1510 new_version_available_tx,
1511 fs,
1512 node_runtime,
1513 cx,
1514 )
1515 .await?;
1516 command.env = Some(env);
1517 let login = command
1518 .args
1519 .first()
1520 .and_then(|path| {
1521 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1522 })
1523 .map(|path_prefix| task::SpawnInTerminal {
1524 command: Some(command.path.to_string_lossy().into_owned()),
1525 args: vec![
1526 Path::new(path_prefix)
1527 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1528 .to_string_lossy()
1529 .to_string(),
1530 "/login".into(),
1531 ],
1532 env: command.env.clone().unwrap_or_default(),
1533 label: "claude /login".into(),
1534 ..Default::default()
1535 });
1536 (command, login)
1537 };
1538
1539 command.env.get_or_insert_default().extend(extra_env);
1540 Ok((
1541 command,
1542 root_dir.to_string_lossy().into_owned(),
1543 login_command,
1544 ))
1545 })
1546 }
1547
1548 fn as_any_mut(&mut self) -> &mut dyn Any {
1549 self
1550 }
1551}
1552
1553struct LocalCodex {
1554 fs: Arc<dyn Fs>,
1555 project_environment: Entity<ProjectEnvironment>,
1556 http_client: Arc<dyn HttpClient>,
1557 custom_command: Option<AgentServerCommand>,
1558 settings_env: Option<HashMap<String, String>>,
1559 no_browser: bool,
1560}
1561
1562impl ExternalAgentServer for LocalCodex {
1563 fn get_command(
1564 &mut self,
1565 root_dir: Option<&str>,
1566 extra_env: HashMap<String, String>,
1567 mut status_tx: Option<watch::Sender<SharedString>>,
1568 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1569 cx: &mut AsyncApp,
1570 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1571 let fs = self.fs.clone();
1572 let project_environment = self.project_environment.downgrade();
1573 let http = self.http_client.clone();
1574 let custom_command = self.custom_command.clone();
1575 let settings_env = self.settings_env.clone();
1576 let root_dir: Arc<Path> = root_dir
1577 .map(|root_dir| Path::new(root_dir))
1578 .unwrap_or(paths::home_dir())
1579 .into();
1580 let no_browser = self.no_browser;
1581
1582 cx.spawn(async move |cx| {
1583 let mut env = project_environment
1584 .update(cx, |project_environment, cx| {
1585 project_environment.local_directory_environment(
1586 &Shell::System,
1587 root_dir.clone(),
1588 cx,
1589 )
1590 })?
1591 .await
1592 .unwrap_or_default();
1593 if no_browser {
1594 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1595 }
1596
1597 env.extend(settings_env.unwrap_or_default());
1598
1599 let mut command = if let Some(mut custom_command) = custom_command {
1600 custom_command.env = Some(env);
1601 custom_command
1602 } else {
1603 let dir = paths::external_agents_dir().join(CODEX_NAME);
1604 fs.create_dir(&dir).await?;
1605
1606 let bin_name = if cfg!(windows) {
1607 "codex-acp.exe"
1608 } else {
1609 "codex-acp"
1610 };
1611
1612 let find_latest_local_version = async || -> Option<PathBuf> {
1613 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1614 let mut stream = fs.read_dir(&dir).await.ok()?;
1615 while let Some(entry) = stream.next().await {
1616 let Ok(entry) = entry else { continue };
1617 let Some(file_name) = entry.file_name() else {
1618 continue;
1619 };
1620 let version_path = dir.join(&file_name);
1621 if fs.is_file(&version_path.join(bin_name)).await {
1622 let version_str = file_name.to_string_lossy();
1623 if let Ok(version) =
1624 semver::Version::from_str(version_str.trim_start_matches('v'))
1625 {
1626 local_versions.push((version, version_str.into_owned()));
1627 }
1628 }
1629 }
1630 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1631 local_versions.last().map(|(_, v)| dir.join(v))
1632 };
1633
1634 let fallback_to_latest_local_version =
1635 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1636 if let Some(local) = find_latest_local_version().await {
1637 log::info!(
1638 "Falling back to locally installed Codex version: {}",
1639 local.display()
1640 );
1641 Ok(local)
1642 } else {
1643 Err(err)
1644 }
1645 };
1646
1647 let version_dir = match ::http_client::github::latest_github_release(
1648 CODEX_ACP_REPO,
1649 true,
1650 false,
1651 http.clone(),
1652 )
1653 .await
1654 {
1655 Ok(release) => {
1656 let version_dir = dir.join(&release.tag_name);
1657 if !fs.is_dir(&version_dir).await {
1658 if let Some(ref mut status_tx) = status_tx {
1659 status_tx.send("Installing…".into()).ok();
1660 }
1661
1662 let tag = release.tag_name.clone();
1663 let version_number = tag.trim_start_matches('v');
1664 let asset_name = asset_name(version_number)
1665 .context("codex acp is not supported for this architecture")?;
1666 let asset = release
1667 .assets
1668 .into_iter()
1669 .find(|asset| asset.name == asset_name)
1670 .with_context(|| {
1671 format!("no asset found matching `{asset_name:?}`")
1672 })?;
1673 // Strip "sha256:" prefix from digest if present (GitHub API format)
1674 let digest = asset
1675 .digest
1676 .as_deref()
1677 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1678 match ::http_client::github_download::download_server_binary(
1679 &*http,
1680 &asset.browser_download_url,
1681 digest,
1682 &version_dir,
1683 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1684 AssetKind::Zip
1685 } else {
1686 AssetKind::TarGz
1687 },
1688 )
1689 .await
1690 {
1691 Ok(()) => {
1692 // remove older versions
1693 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1694 .await;
1695 version_dir
1696 }
1697 Err(err) => {
1698 log::error!(
1699 "Failed to download Codex release {}: {err:#}",
1700 release.tag_name
1701 );
1702 fallback_to_latest_local_version(err).await?
1703 }
1704 }
1705 } else {
1706 version_dir
1707 }
1708 }
1709 Err(err) => {
1710 log::error!("Failed to fetch Codex latest release: {err:#}");
1711 fallback_to_latest_local_version(err).await?
1712 }
1713 };
1714
1715 let bin_path = version_dir.join(bin_name);
1716 anyhow::ensure!(
1717 fs.is_file(&bin_path).await,
1718 "Missing Codex binary at {} after installation",
1719 bin_path.to_string_lossy()
1720 );
1721
1722 let mut cmd = AgentServerCommand {
1723 path: bin_path,
1724 args: Vec::new(),
1725 env: None,
1726 };
1727 cmd.env = Some(env);
1728 cmd
1729 };
1730
1731 command.env.get_or_insert_default().extend(extra_env);
1732 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1733 })
1734 }
1735
1736 fn as_any_mut(&mut self) -> &mut dyn Any {
1737 self
1738 }
1739}
1740
1741pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1742
1743fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1744 let arch = if cfg!(target_arch = "x86_64") {
1745 "x86_64"
1746 } else if cfg!(target_arch = "aarch64") {
1747 "aarch64"
1748 } else {
1749 return None;
1750 };
1751
1752 let platform = if cfg!(target_os = "macos") {
1753 "apple-darwin"
1754 } else if cfg!(target_os = "windows") {
1755 "pc-windows-msvc"
1756 } else if cfg!(target_os = "linux") {
1757 "unknown-linux-gnu"
1758 } else {
1759 return None;
1760 };
1761
1762 // Windows uses .zip in release assets
1763 let ext = if cfg!(target_os = "windows") {
1764 "zip"
1765 } else {
1766 "tar.gz"
1767 };
1768
1769 Some((arch, platform, ext))
1770}
1771
1772fn asset_name(version: &str) -> Option<String> {
1773 let (arch, platform, ext) = get_platform_info()?;
1774 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1775}
1776
1777struct LocalExtensionArchiveAgent {
1778 fs: Arc<dyn Fs>,
1779 http_client: Arc<dyn HttpClient>,
1780 node_runtime: NodeRuntime,
1781 project_environment: Entity<ProjectEnvironment>,
1782 extension_id: Arc<str>,
1783 agent_id: Arc<str>,
1784 targets: HashMap<String, extension::TargetConfig>,
1785 env: HashMap<String, String>,
1786}
1787
1788struct LocalCustomAgent {
1789 project_environment: Entity<ProjectEnvironment>,
1790 command: AgentServerCommand,
1791}
1792
1793impl ExternalAgentServer for LocalExtensionArchiveAgent {
1794 fn get_command(
1795 &mut self,
1796 root_dir: Option<&str>,
1797 extra_env: HashMap<String, String>,
1798 _status_tx: Option<watch::Sender<SharedString>>,
1799 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1800 cx: &mut AsyncApp,
1801 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1802 let fs = self.fs.clone();
1803 let http_client = self.http_client.clone();
1804 let node_runtime = self.node_runtime.clone();
1805 let project_environment = self.project_environment.downgrade();
1806 let extension_id = self.extension_id.clone();
1807 let agent_id = self.agent_id.clone();
1808 let targets = self.targets.clone();
1809 let base_env = self.env.clone();
1810
1811 let root_dir: Arc<Path> = root_dir
1812 .map(|root_dir| Path::new(root_dir))
1813 .unwrap_or(paths::home_dir())
1814 .into();
1815
1816 cx.spawn(async move |cx| {
1817 // Get project environment
1818 let mut env = project_environment
1819 .update(cx, |project_environment, cx| {
1820 project_environment.local_directory_environment(
1821 &Shell::System,
1822 root_dir.clone(),
1823 cx,
1824 )
1825 })?
1826 .await
1827 .unwrap_or_default();
1828
1829 // Merge manifest env and extra env
1830 env.extend(base_env);
1831 env.extend(extra_env);
1832
1833 let cache_key = format!("{}/{}", extension_id, agent_id);
1834 let dir = paths::external_agents_dir().join(&cache_key);
1835 fs.create_dir(&dir).await?;
1836
1837 // Determine platform key
1838 let os = if cfg!(target_os = "macos") {
1839 "darwin"
1840 } else if cfg!(target_os = "linux") {
1841 "linux"
1842 } else if cfg!(target_os = "windows") {
1843 "windows"
1844 } else {
1845 anyhow::bail!("unsupported OS");
1846 };
1847
1848 let arch = if cfg!(target_arch = "aarch64") {
1849 "aarch64"
1850 } else if cfg!(target_arch = "x86_64") {
1851 "x86_64"
1852 } else {
1853 anyhow::bail!("unsupported architecture");
1854 };
1855
1856 let platform_key = format!("{}-{}", os, arch);
1857 let target_config = targets.get(&platform_key).with_context(|| {
1858 format!(
1859 "no target specified for platform '{}'. Available platforms: {}",
1860 platform_key,
1861 targets
1862 .keys()
1863 .map(|k| k.as_str())
1864 .collect::<Vec<_>>()
1865 .join(", ")
1866 )
1867 })?;
1868
1869 let archive_url = &target_config.archive;
1870
1871 // Use URL as version identifier for caching
1872 // Hash the URL to get a stable directory name
1873 use std::collections::hash_map::DefaultHasher;
1874 use std::hash::{Hash, Hasher};
1875 let mut hasher = DefaultHasher::new();
1876 archive_url.hash(&mut hasher);
1877 let url_hash = hasher.finish();
1878 let version_dir = dir.join(format!("v_{:x}", url_hash));
1879
1880 if !fs.is_dir(&version_dir).await {
1881 // Determine SHA256 for verification
1882 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1883 // Use provided SHA256
1884 Some(provided_sha.clone())
1885 } else if archive_url.starts_with("https://github.com/") {
1886 // Try to fetch SHA256 from GitHub API
1887 // Parse URL to extract repo and tag/file info
1888 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1889 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1890 let parts: Vec<&str> = caps.split('/').collect();
1891 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1892 let repo = format!("{}/{}", parts[0], parts[1]);
1893 let tag = parts[4];
1894 let filename = parts[5..].join("/");
1895
1896 // Try to get release info from GitHub
1897 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1898 &repo,
1899 tag,
1900 http_client.clone(),
1901 )
1902 .await
1903 {
1904 // Find matching asset
1905 if let Some(asset) =
1906 release.assets.iter().find(|a| a.name == filename)
1907 {
1908 // Strip "sha256:" prefix if present
1909 asset.digest.as_ref().and_then(|d| {
1910 d.strip_prefix("sha256:")
1911 .map(|s| s.to_string())
1912 .or_else(|| Some(d.clone()))
1913 })
1914 } else {
1915 None
1916 }
1917 } else {
1918 None
1919 }
1920 } else {
1921 None
1922 }
1923 } else {
1924 None
1925 }
1926 } else {
1927 None
1928 };
1929
1930 // Determine archive type from URL
1931 let asset_kind = if archive_url.ends_with(".zip") {
1932 AssetKind::Zip
1933 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1934 AssetKind::TarGz
1935 } else {
1936 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1937 };
1938
1939 // Download and extract
1940 ::http_client::github_download::download_server_binary(
1941 &*http_client,
1942 archive_url,
1943 sha256.as_deref(),
1944 &version_dir,
1945 asset_kind,
1946 )
1947 .await?;
1948 }
1949
1950 // Validate and resolve cmd path
1951 let cmd = &target_config.cmd;
1952
1953 let cmd_path = if cmd == "node" {
1954 // Use Zed's managed Node.js runtime
1955 node_runtime.binary_path().await?
1956 } else {
1957 if cmd.contains("..") {
1958 anyhow::bail!("command path cannot contain '..': {}", cmd);
1959 }
1960
1961 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1962 // Relative to extraction directory
1963 let cmd_path = version_dir.join(&cmd[2..]);
1964 anyhow::ensure!(
1965 fs.is_file(&cmd_path).await,
1966 "Missing command {} after extraction",
1967 cmd_path.to_string_lossy()
1968 );
1969 cmd_path
1970 } else {
1971 // On PATH
1972 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1973 }
1974 };
1975
1976 let command = AgentServerCommand {
1977 path: cmd_path,
1978 args: target_config.args.clone(),
1979 env: Some(env),
1980 };
1981
1982 Ok((command, version_dir.to_string_lossy().into_owned(), None))
1983 })
1984 }
1985
1986 fn as_any_mut(&mut self) -> &mut dyn Any {
1987 self
1988 }
1989}
1990
1991impl ExternalAgentServer for LocalCustomAgent {
1992 fn get_command(
1993 &mut self,
1994 root_dir: Option<&str>,
1995 extra_env: HashMap<String, String>,
1996 _status_tx: Option<watch::Sender<SharedString>>,
1997 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1998 cx: &mut AsyncApp,
1999 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2000 let mut command = self.command.clone();
2001 let root_dir: Arc<Path> = root_dir
2002 .map(|root_dir| Path::new(root_dir))
2003 .unwrap_or(paths::home_dir())
2004 .into();
2005 let project_environment = self.project_environment.downgrade();
2006 cx.spawn(async move |cx| {
2007 let mut env = project_environment
2008 .update(cx, |project_environment, cx| {
2009 project_environment.local_directory_environment(
2010 &Shell::System,
2011 root_dir.clone(),
2012 cx,
2013 )
2014 })?
2015 .await
2016 .unwrap_or_default();
2017 env.extend(command.env.unwrap_or_default());
2018 env.extend(extra_env);
2019 command.env = Some(env);
2020 Ok((command, root_dir.to_string_lossy().into_owned(), None))
2021 })
2022 }
2023
2024 fn as_any_mut(&mut self) -> &mut dyn Any {
2025 self
2026 }
2027}
2028
2029pub const GEMINI_NAME: &'static str = "gemini";
2030pub const CLAUDE_CODE_NAME: &'static str = "claude";
2031pub const CODEX_NAME: &'static str = "codex";
2032
2033#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2034pub struct AllAgentServersSettings {
2035 pub gemini: Option<BuiltinAgentServerSettings>,
2036 pub claude: Option<BuiltinAgentServerSettings>,
2037 pub codex: Option<BuiltinAgentServerSettings>,
2038 pub custom: HashMap<String, CustomAgentServerSettings>,
2039}
2040#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2041pub struct BuiltinAgentServerSettings {
2042 pub path: Option<PathBuf>,
2043 pub args: Option<Vec<String>>,
2044 pub env: Option<HashMap<String, String>>,
2045 pub ignore_system_version: Option<bool>,
2046 pub default_mode: Option<String>,
2047 pub default_model: Option<String>,
2048 pub favorite_models: Vec<String>,
2049 pub default_config_options: HashMap<String, String>,
2050 pub favorite_config_option_values: HashMap<String, Vec<String>>,
2051}
2052
2053impl BuiltinAgentServerSettings {
2054 fn custom_command(self) -> Option<AgentServerCommand> {
2055 self.path.map(|path| AgentServerCommand {
2056 path,
2057 args: self.args.unwrap_or_default(),
2058 // Settings env are always applied, so we don't need to supply them here as well
2059 env: None,
2060 })
2061 }
2062}
2063
2064impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2065 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2066 BuiltinAgentServerSettings {
2067 path: value
2068 .path
2069 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2070 args: value.args,
2071 env: value.env,
2072 ignore_system_version: value.ignore_system_version,
2073 default_mode: value.default_mode,
2074 default_model: value.default_model,
2075 favorite_models: value.favorite_models,
2076 default_config_options: value.default_config_options,
2077 favorite_config_option_values: value.favorite_config_option_values,
2078 }
2079 }
2080}
2081
2082impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2083 fn from(value: AgentServerCommand) -> Self {
2084 BuiltinAgentServerSettings {
2085 path: Some(value.path),
2086 args: Some(value.args),
2087 env: value.env,
2088 ..Default::default()
2089 }
2090 }
2091}
2092
2093#[derive(Clone, JsonSchema, Debug, PartialEq)]
2094pub enum CustomAgentServerSettings {
2095 Custom {
2096 command: AgentServerCommand,
2097 /// The default mode to use for this agent.
2098 ///
2099 /// Note: Not only all agents support modes.
2100 ///
2101 /// Default: None
2102 default_mode: Option<String>,
2103 /// The default model to use for this agent.
2104 ///
2105 /// This should be the model ID as reported by the agent.
2106 ///
2107 /// Default: None
2108 default_model: Option<String>,
2109 /// The favorite models for this agent.
2110 ///
2111 /// Default: []
2112 favorite_models: Vec<String>,
2113 /// Default values for session config options.
2114 ///
2115 /// This is a map from config option ID to value ID.
2116 ///
2117 /// Default: {}
2118 default_config_options: HashMap<String, String>,
2119 /// Favorited values for session config options.
2120 ///
2121 /// This is a map from config option ID to a list of favorited value IDs.
2122 ///
2123 /// Default: {}
2124 favorite_config_option_values: HashMap<String, Vec<String>>,
2125 },
2126 Extension {
2127 /// The default mode to use for this agent.
2128 ///
2129 /// Note: Not only all agents support modes.
2130 ///
2131 /// Default: None
2132 default_mode: Option<String>,
2133 /// The default model to use for this agent.
2134 ///
2135 /// This should be the model ID as reported by the agent.
2136 ///
2137 /// Default: None
2138 default_model: Option<String>,
2139 /// The favorite models for this agent.
2140 ///
2141 /// Default: []
2142 favorite_models: Vec<String>,
2143 /// Default values for session config options.
2144 ///
2145 /// This is a map from config option ID to value ID.
2146 ///
2147 /// Default: {}
2148 default_config_options: HashMap<String, String>,
2149 /// Favorited values for session config options.
2150 ///
2151 /// This is a map from config option ID to a list of favorited value IDs.
2152 ///
2153 /// Default: {}
2154 favorite_config_option_values: HashMap<String, Vec<String>>,
2155 },
2156}
2157
2158impl CustomAgentServerSettings {
2159 pub fn command(&self) -> Option<&AgentServerCommand> {
2160 match self {
2161 CustomAgentServerSettings::Custom { command, .. } => Some(command),
2162 CustomAgentServerSettings::Extension { .. } => None,
2163 }
2164 }
2165
2166 pub fn default_mode(&self) -> Option<&str> {
2167 match self {
2168 CustomAgentServerSettings::Custom { default_mode, .. }
2169 | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
2170 }
2171 }
2172
2173 pub fn default_model(&self) -> Option<&str> {
2174 match self {
2175 CustomAgentServerSettings::Custom { default_model, .. }
2176 | CustomAgentServerSettings::Extension { default_model, .. } => {
2177 default_model.as_deref()
2178 }
2179 }
2180 }
2181
2182 pub fn favorite_models(&self) -> &[String] {
2183 match self {
2184 CustomAgentServerSettings::Custom {
2185 favorite_models, ..
2186 }
2187 | CustomAgentServerSettings::Extension {
2188 favorite_models, ..
2189 } => favorite_models,
2190 }
2191 }
2192
2193 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2194 match self {
2195 CustomAgentServerSettings::Custom {
2196 default_config_options,
2197 ..
2198 }
2199 | CustomAgentServerSettings::Extension {
2200 default_config_options,
2201 ..
2202 } => default_config_options.get(config_id).map(|s| s.as_str()),
2203 }
2204 }
2205
2206 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2207 match self {
2208 CustomAgentServerSettings::Custom {
2209 favorite_config_option_values,
2210 ..
2211 }
2212 | CustomAgentServerSettings::Extension {
2213 favorite_config_option_values,
2214 ..
2215 } => favorite_config_option_values
2216 .get(config_id)
2217 .map(|v| v.as_slice()),
2218 }
2219 }
2220}
2221
2222impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2223 fn from(value: settings::CustomAgentServerSettings) -> Self {
2224 match value {
2225 settings::CustomAgentServerSettings::Custom {
2226 path,
2227 args,
2228 env,
2229 default_mode,
2230 default_model,
2231 favorite_models,
2232 default_config_options,
2233 favorite_config_option_values,
2234 } => CustomAgentServerSettings::Custom {
2235 command: AgentServerCommand {
2236 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2237 args,
2238 env,
2239 },
2240 default_mode,
2241 default_model,
2242 favorite_models,
2243 default_config_options,
2244 favorite_config_option_values,
2245 },
2246 settings::CustomAgentServerSettings::Extension {
2247 default_mode,
2248 default_model,
2249 default_config_options,
2250 favorite_models,
2251 favorite_config_option_values,
2252 } => CustomAgentServerSettings::Extension {
2253 default_mode,
2254 default_model,
2255 default_config_options,
2256 favorite_models,
2257 favorite_config_option_values,
2258 },
2259 }
2260 }
2261}
2262
2263impl settings::Settings for AllAgentServersSettings {
2264 fn from_settings(content: &settings::SettingsContent) -> Self {
2265 let agent_settings = content.agent_servers.clone().unwrap();
2266 Self {
2267 gemini: agent_settings.gemini.map(Into::into),
2268 claude: agent_settings.claude.map(Into::into),
2269 codex: agent_settings.codex.map(Into::into),
2270 custom: agent_settings
2271 .custom
2272 .into_iter()
2273 .map(|(k, v)| (k, v.into()))
2274 .collect(),
2275 }
2276 }
2277}
2278
2279#[cfg(test)]
2280mod extension_agent_tests {
2281 use crate::worktree_store::WorktreeStore;
2282
2283 use super::*;
2284 use gpui::TestAppContext;
2285 use std::sync::Arc;
2286
2287 #[test]
2288 fn extension_agent_constructs_proper_display_names() {
2289 // Verify the display name format for extension-provided agents
2290 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2291 assert!(name1.0.contains(": "));
2292
2293 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2294 assert_eq!(name2.0, "MyExt: MyAgent");
2295
2296 // Non-extension agents shouldn't have the separator
2297 let custom = ExternalAgentServerName(SharedString::from("custom"));
2298 assert!(!custom.0.contains(": "));
2299 }
2300
2301 struct NoopExternalAgent;
2302
2303 impl ExternalAgentServer for NoopExternalAgent {
2304 fn get_command(
2305 &mut self,
2306 _root_dir: Option<&str>,
2307 _extra_env: HashMap<String, String>,
2308 _status_tx: Option<watch::Sender<SharedString>>,
2309 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2310 _cx: &mut AsyncApp,
2311 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2312 Task::ready(Ok((
2313 AgentServerCommand {
2314 path: PathBuf::from("noop"),
2315 args: Vec::new(),
2316 env: None,
2317 },
2318 "".to_string(),
2319 None,
2320 )))
2321 }
2322
2323 fn as_any_mut(&mut self) -> &mut dyn Any {
2324 self
2325 }
2326 }
2327
2328 #[test]
2329 fn sync_removes_only_extension_provided_agents() {
2330 let mut store = AgentServerStore {
2331 state: AgentServerStoreState::Collab,
2332 external_agents: HashMap::default(),
2333 agent_icons: HashMap::default(),
2334 agent_display_names: HashMap::default(),
2335 };
2336
2337 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2338 store.external_agents.insert(
2339 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2340 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2341 );
2342 store.external_agents.insert(
2343 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2344 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2345 );
2346 store.external_agents.insert(
2347 ExternalAgentServerName(SharedString::from("custom-agent")),
2348 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2349 );
2350
2351 // Simulate removal phase
2352 let keys_to_remove: Vec<_> = store
2353 .external_agents
2354 .keys()
2355 .filter(|name| name.0.contains(": "))
2356 .cloned()
2357 .collect();
2358
2359 for key in keys_to_remove {
2360 store.external_agents.remove(&key);
2361 }
2362
2363 // Only custom-agent should remain
2364 assert_eq!(store.external_agents.len(), 1);
2365 assert!(
2366 store
2367 .external_agents
2368 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2369 );
2370 }
2371
2372 #[test]
2373 fn archive_launcher_constructs_with_all_fields() {
2374 use extension::AgentServerManifestEntry;
2375
2376 let mut env = HashMap::default();
2377 env.insert("GITHUB_TOKEN".into(), "secret".into());
2378
2379 let mut targets = HashMap::default();
2380 targets.insert(
2381 "darwin-aarch64".to_string(),
2382 extension::TargetConfig {
2383 archive:
2384 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2385 .into(),
2386 cmd: "./agent".into(),
2387 args: vec![],
2388 sha256: None,
2389 env: Default::default(),
2390 },
2391 );
2392
2393 let _entry = AgentServerManifestEntry {
2394 name: "GitHub Agent".into(),
2395 targets,
2396 env,
2397 icon: None,
2398 };
2399
2400 // Verify display name construction
2401 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2402 assert_eq!(expected_name.0, "GitHub Agent");
2403 }
2404
2405 #[gpui::test]
2406 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2407 let fs = fs::FakeFs::new(cx.background_executor.clone());
2408 let http_client = http_client::FakeHttpClient::with_404_response();
2409 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2410 let project_environment = cx.new(|cx| {
2411 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2412 });
2413
2414 let agent = LocalExtensionArchiveAgent {
2415 fs,
2416 http_client,
2417 node_runtime: node_runtime::NodeRuntime::unavailable(),
2418 project_environment,
2419 extension_id: Arc::from("my-extension"),
2420 agent_id: Arc::from("my-agent"),
2421 targets: {
2422 let mut map = HashMap::default();
2423 map.insert(
2424 "darwin-aarch64".to_string(),
2425 extension::TargetConfig {
2426 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2427 cmd: "./my-agent".into(),
2428 args: vec!["--serve".into()],
2429 sha256: None,
2430 env: Default::default(),
2431 },
2432 );
2433 map
2434 },
2435 env: {
2436 let mut map = HashMap::default();
2437 map.insert("PORT".into(), "8080".into());
2438 map
2439 },
2440 };
2441
2442 // Verify agent is properly constructed
2443 assert_eq!(agent.extension_id.as_ref(), "my-extension");
2444 assert_eq!(agent.agent_id.as_ref(), "my-agent");
2445 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2446 assert!(agent.targets.contains_key("darwin-aarch64"));
2447 }
2448
2449 #[test]
2450 fn sync_extension_agents_registers_archive_launcher() {
2451 use extension::AgentServerManifestEntry;
2452
2453 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2454 assert_eq!(expected_name.0, "Release Agent");
2455
2456 // Verify the manifest entry structure for archive-based installation
2457 let mut env = HashMap::default();
2458 env.insert("API_KEY".into(), "secret".into());
2459
2460 let mut targets = HashMap::default();
2461 targets.insert(
2462 "linux-x86_64".to_string(),
2463 extension::TargetConfig {
2464 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2465 cmd: "./release-agent".into(),
2466 args: vec!["serve".into()],
2467 sha256: None,
2468 env: Default::default(),
2469 },
2470 );
2471
2472 let manifest_entry = AgentServerManifestEntry {
2473 name: "Release Agent".into(),
2474 targets: targets.clone(),
2475 env,
2476 icon: None,
2477 };
2478
2479 // Verify target config is present
2480 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2481 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2482 assert_eq!(target.cmd, "./release-agent");
2483 }
2484
2485 #[gpui::test]
2486 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2487 let fs = fs::FakeFs::new(cx.background_executor.clone());
2488 let http_client = http_client::FakeHttpClient::with_404_response();
2489 let node_runtime = NodeRuntime::unavailable();
2490 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2491 let project_environment = cx.new(|cx| {
2492 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2493 });
2494
2495 let agent = LocalExtensionArchiveAgent {
2496 fs: fs.clone(),
2497 http_client,
2498 node_runtime,
2499 project_environment,
2500 extension_id: Arc::from("node-extension"),
2501 agent_id: Arc::from("node-agent"),
2502 targets: {
2503 let mut map = HashMap::default();
2504 map.insert(
2505 "darwin-aarch64".to_string(),
2506 extension::TargetConfig {
2507 archive: "https://example.com/node-agent.zip".into(),
2508 cmd: "node".into(),
2509 args: vec!["index.js".into()],
2510 sha256: None,
2511 env: Default::default(),
2512 },
2513 );
2514 map
2515 },
2516 env: HashMap::default(),
2517 };
2518
2519 // Verify that when cmd is "node", it attempts to use the node runtime
2520 assert_eq!(agent.extension_id.as_ref(), "node-extension");
2521 assert_eq!(agent.agent_id.as_ref(), "node-agent");
2522
2523 let target = agent.targets.get("darwin-aarch64").unwrap();
2524 assert_eq!(target.cmd, "node");
2525 assert_eq!(target.args, vec!["index.js"]);
2526 }
2527
2528 #[gpui::test]
2529 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2530 let fs = fs::FakeFs::new(cx.background_executor.clone());
2531 let http_client = http_client::FakeHttpClient::with_404_response();
2532 let node_runtime = NodeRuntime::unavailable();
2533 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2534 let project_environment = cx.new(|cx| {
2535 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2536 });
2537
2538 let agent = LocalExtensionArchiveAgent {
2539 fs: fs.clone(),
2540 http_client,
2541 node_runtime,
2542 project_environment,
2543 extension_id: Arc::from("test-ext"),
2544 agent_id: Arc::from("test-agent"),
2545 targets: {
2546 let mut map = HashMap::default();
2547 map.insert(
2548 "darwin-aarch64".to_string(),
2549 extension::TargetConfig {
2550 archive: "https://example.com/test.zip".into(),
2551 cmd: "node".into(),
2552 args: vec![
2553 "server.js".into(),
2554 "--config".into(),
2555 "./config.json".into(),
2556 ],
2557 sha256: None,
2558 env: Default::default(),
2559 },
2560 );
2561 map
2562 },
2563 env: HashMap::default(),
2564 };
2565
2566 // Verify the agent is configured with relative paths in args
2567 let target = agent.targets.get("darwin-aarch64").unwrap();
2568 assert_eq!(target.args[0], "server.js");
2569 assert_eq!(target.args[2], "./config.json");
2570 // These relative paths will resolve relative to the extraction directory
2571 // when the command is executed
2572 }
2573
2574 #[test]
2575 fn test_tilde_expansion_in_settings() {
2576 let settings = settings::BuiltinAgentServerSettings {
2577 path: Some(PathBuf::from("~/bin/agent")),
2578 args: Some(vec!["--flag".into()]),
2579 env: None,
2580 ignore_system_version: None,
2581 default_mode: None,
2582 default_model: None,
2583 favorite_models: vec![],
2584 default_config_options: Default::default(),
2585 favorite_config_option_values: Default::default(),
2586 };
2587
2588 let BuiltinAgentServerSettings { path, .. } = settings.into();
2589
2590 let path = path.unwrap();
2591 assert!(
2592 !path.to_string_lossy().starts_with("~"),
2593 "Tilde should be expanded for builtin agent path"
2594 );
2595
2596 let settings = settings::CustomAgentServerSettings::Custom {
2597 path: PathBuf::from("~/custom/agent"),
2598 args: vec!["serve".into()],
2599 env: None,
2600 default_mode: None,
2601 default_model: None,
2602 favorite_models: vec![],
2603 default_config_options: Default::default(),
2604 favorite_config_option_values: Default::default(),
2605 };
2606
2607 let converted: CustomAgentServerSettings = settings.into();
2608 let CustomAgentServerSettings::Custom {
2609 command: AgentServerCommand { path, .. },
2610 ..
2611 } = converted
2612 else {
2613 panic!("Expected Custom variant");
2614 };
2615
2616 assert!(
2617 !path.to_string_lossy().starts_with("~"),
2618 "Tilde should be expanded for custom agent path"
2619 );
2620 }
2621}