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