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 for (name, settings) in &new_settings.custom {
727 match settings {
728 CustomAgentServerSettings::Custom { command, .. } => {
729 let agent_name = ExternalAgentServerName(name.clone().into());
730 self.external_agents.insert(
731 agent_name.clone(),
732 ExternalAgentEntry::new(
733 Box::new(LocalCustomAgent {
734 command: command.clone(),
735 project_environment: project_environment.clone(),
736 }) as Box<dyn ExternalAgentServer>,
737 ExternalAgentSource::Custom,
738 None,
739 None,
740 ),
741 );
742 }
743 CustomAgentServerSettings::Registry { env, .. } => {
744 let Some(agent) = registry_agents_by_id.get(name) else {
745 if registry_store.is_some() {
746 log::warn!("Registry agent '{}' not found in ACP registry", name);
747 }
748 continue;
749 };
750
751 let agent_name = ExternalAgentServerName(name.clone().into());
752 match agent {
753 RegistryAgent::Binary(agent) => {
754 if !agent.supports_current_platform {
755 log::warn!(
756 "Registry agent '{}' has no compatible binary for this platform",
757 name
758 );
759 continue;
760 }
761
762 self.external_agents.insert(
763 agent_name.clone(),
764 ExternalAgentEntry::new(
765 Box::new(LocalRegistryArchiveAgent {
766 fs: fs.clone(),
767 http_client: http_client.clone(),
768 node_runtime: node_runtime.clone(),
769 project_environment: project_environment.clone(),
770 registry_id: Arc::from(name.as_str()),
771 targets: agent.targets.clone(),
772 env: env.clone(),
773 })
774 as Box<dyn ExternalAgentServer>,
775 ExternalAgentSource::Registry,
776 agent.metadata.icon_path.clone(),
777 Some(agent.metadata.name.clone()),
778 ),
779 );
780 }
781 RegistryAgent::Npx(agent) => {
782 self.external_agents.insert(
783 agent_name.clone(),
784 ExternalAgentEntry::new(
785 Box::new(LocalRegistryNpxAgent {
786 node_runtime: node_runtime.clone(),
787 project_environment: project_environment.clone(),
788 package: agent.package.clone(),
789 args: agent.args.clone(),
790 distribution_env: agent.env.clone(),
791 settings_env: env.clone(),
792 })
793 as Box<dyn ExternalAgentServer>,
794 ExternalAgentSource::Registry,
795 agent.metadata.icon_path.clone(),
796 Some(agent.metadata.name.clone()),
797 ),
798 );
799 }
800 }
801 }
802 CustomAgentServerSettings::Extension { .. } => {}
803 }
804 }
805
806 for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
807 let name = ExternalAgentServerName(agent_name.clone().into());
808 let mut env = env.clone();
809 if let Some(settings_env) =
810 new_settings
811 .custom
812 .get(agent_name.as_ref())
813 .and_then(|settings| match settings {
814 CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
815 _ => None,
816 })
817 {
818 env.extend(settings_env);
819 }
820 let icon = icon_path
821 .as_ref()
822 .map(|path| SharedString::from(path.clone()));
823
824 self.external_agents.insert(
825 name.clone(),
826 ExternalAgentEntry::new(
827 Box::new(LocalExtensionArchiveAgent {
828 fs: fs.clone(),
829 http_client: http_client.clone(),
830 node_runtime: node_runtime.clone(),
831 project_environment: project_environment.clone(),
832 extension_id: Arc::from(&**ext_id),
833 targets: targets.clone(),
834 env,
835 agent_id: agent_name.clone(),
836 }) as Box<dyn ExternalAgentServer>,
837 ExternalAgentSource::Extension,
838 icon,
839 display_name.clone(),
840 ),
841 );
842 }
843
844 *old_settings = Some(new_settings);
845
846 if let Some((project_id, downstream_client)) = downstream_client {
847 downstream_client
848 .send(proto::ExternalAgentsUpdated {
849 project_id: *project_id,
850 names: self
851 .external_agents
852 .keys()
853 .map(|name| name.to_string())
854 .collect(),
855 })
856 .log_err();
857 }
858 cx.emit(AgentServersUpdated);
859 }
860
861 pub fn node_runtime(&self) -> Option<NodeRuntime> {
862 match &self.state {
863 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
864 _ => None,
865 }
866 }
867
868 pub fn local(
869 node_runtime: NodeRuntime,
870 fs: Arc<dyn Fs>,
871 project_environment: Entity<ProjectEnvironment>,
872 http_client: Arc<dyn HttpClient>,
873 cx: &mut Context<Self>,
874 ) -> Self {
875 let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
876 this.agent_servers_settings_changed(cx);
877 })];
878 if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
879 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
880 this.reregister_agents(cx);
881 }));
882 }
883 let mut this = Self {
884 state: AgentServerStoreState::Local {
885 node_runtime,
886 fs,
887 project_environment,
888 http_client,
889 downstream_client: None,
890 settings: None,
891 extension_agents: vec![],
892 _subscriptions: subscriptions,
893 },
894 external_agents: Default::default(),
895 };
896 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
897 this.agent_servers_settings_changed(cx);
898 this
899 }
900
901 pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
902 // Set up the builtin agents here so they're immediately available in
903 // remote projects--we know that the HeadlessProject on the other end
904 // will have them.
905 let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [
906 (
907 CLAUDE_CODE_NAME.into(),
908 ExternalAgentEntry::new(
909 Box::new(RemoteExternalAgentServer {
910 project_id,
911 upstream_client: upstream_client.clone(),
912 name: CLAUDE_CODE_NAME.into(),
913 status_tx: None,
914 new_version_available_tx: None,
915 }) as Box<dyn ExternalAgentServer>,
916 ExternalAgentSource::Builtin,
917 None,
918 None,
919 ),
920 ),
921 (
922 CODEX_NAME.into(),
923 ExternalAgentEntry::new(
924 Box::new(RemoteExternalAgentServer {
925 project_id,
926 upstream_client: upstream_client.clone(),
927 name: CODEX_NAME.into(),
928 status_tx: None,
929 new_version_available_tx: None,
930 }) as Box<dyn ExternalAgentServer>,
931 ExternalAgentSource::Builtin,
932 None,
933 None,
934 ),
935 ),
936 (
937 GEMINI_NAME.into(),
938 ExternalAgentEntry::new(
939 Box::new(RemoteExternalAgentServer {
940 project_id,
941 upstream_client: upstream_client.clone(),
942 name: GEMINI_NAME.into(),
943 status_tx: None,
944 new_version_available_tx: None,
945 }) as Box<dyn ExternalAgentServer>,
946 ExternalAgentSource::Builtin,
947 None,
948 None,
949 ),
950 ),
951 ];
952
953 Self {
954 state: AgentServerStoreState::Remote {
955 project_id,
956 upstream_client,
957 },
958 external_agents: external_agents.into_iter().collect(),
959 }
960 }
961
962 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
963 Self {
964 state: AgentServerStoreState::Collab,
965 external_agents: Default::default(),
966 }
967 }
968
969 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
970 match &mut self.state {
971 AgentServerStoreState::Local {
972 downstream_client, ..
973 } => {
974 *downstream_client = Some((project_id, client.clone()));
975 // Send the current list of external agents downstream, but only after a delay,
976 // to avoid having the message arrive before the downstream project's agent server store
977 // sets up its handlers.
978 cx.spawn(async move |this, cx| {
979 cx.background_executor().timer(Duration::from_secs(1)).await;
980 let names = this.update(cx, |this, _| {
981 this.external_agents()
982 .map(|name| name.to_string())
983 .collect()
984 })?;
985 client
986 .send(proto::ExternalAgentsUpdated { project_id, names })
987 .log_err();
988 anyhow::Ok(())
989 })
990 .detach();
991 }
992 AgentServerStoreState::Remote { .. } => {
993 debug_panic!(
994 "external agents over collab not implemented, remote project should not be shared"
995 );
996 }
997 AgentServerStoreState::Collab => {
998 debug_panic!("external agents over collab not implemented, should not be shared");
999 }
1000 }
1001 }
1002
1003 pub fn get_external_agent(
1004 &mut self,
1005 name: &ExternalAgentServerName,
1006 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
1007 self.external_agents
1008 .get_mut(name)
1009 .map(|entry| entry.server.as_mut())
1010 }
1011
1012 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
1013 self.external_agents.keys()
1014 }
1015
1016 async fn handle_get_agent_server_command(
1017 this: Entity<Self>,
1018 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
1019 mut cx: AsyncApp,
1020 ) -> Result<proto::AgentServerCommand> {
1021 let (command, root_dir, login_command) = this
1022 .update(&mut cx, |this, cx| {
1023 let AgentServerStoreState::Local {
1024 downstream_client, ..
1025 } = &this.state
1026 else {
1027 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
1028 bail!("unexpected GetAgentServerCommand request in a non-local project");
1029 };
1030 let agent = this
1031 .external_agents
1032 .get_mut(&*envelope.payload.name)
1033 .map(|entry| entry.server.as_mut())
1034 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
1035 let (status_tx, new_version_available_tx) = downstream_client
1036 .clone()
1037 .map(|(project_id, downstream_client)| {
1038 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
1039 let (new_version_available_tx, mut new_version_available_rx) =
1040 watch::channel(None);
1041 cx.spawn({
1042 let downstream_client = downstream_client.clone();
1043 let name = envelope.payload.name.clone();
1044 async move |_, _| {
1045 while let Some(status) = status_rx.recv().await.ok() {
1046 downstream_client.send(
1047 proto::ExternalAgentLoadingStatusUpdated {
1048 project_id,
1049 name: name.clone(),
1050 status: status.to_string(),
1051 },
1052 )?;
1053 }
1054 anyhow::Ok(())
1055 }
1056 })
1057 .detach_and_log_err(cx);
1058 cx.spawn({
1059 let name = envelope.payload.name.clone();
1060 async move |_, _| {
1061 if let Some(version) =
1062 new_version_available_rx.recv().await.ok().flatten()
1063 {
1064 downstream_client.send(
1065 proto::NewExternalAgentVersionAvailable {
1066 project_id,
1067 name: name.clone(),
1068 version,
1069 },
1070 )?;
1071 }
1072 anyhow::Ok(())
1073 }
1074 })
1075 .detach_and_log_err(cx);
1076 (status_tx, new_version_available_tx)
1077 })
1078 .unzip();
1079 anyhow::Ok(agent.get_command(
1080 envelope.payload.root_dir.as_deref(),
1081 HashMap::default(),
1082 status_tx,
1083 new_version_available_tx,
1084 &mut cx.to_async(),
1085 ))
1086 })?
1087 .await?;
1088 Ok(proto::AgentServerCommand {
1089 path: command.path.to_string_lossy().into_owned(),
1090 args: command.args,
1091 env: command
1092 .env
1093 .map(|env| env.into_iter().collect())
1094 .unwrap_or_default(),
1095 root_dir: root_dir,
1096 login: login_command.map(|cmd| cmd.to_proto()),
1097 })
1098 }
1099
1100 async fn handle_external_agents_updated(
1101 this: Entity<Self>,
1102 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
1103 mut cx: AsyncApp,
1104 ) -> Result<()> {
1105 this.update(&mut cx, |this, cx| {
1106 let AgentServerStoreState::Remote {
1107 project_id,
1108 upstream_client,
1109 } = &this.state
1110 else {
1111 debug_panic!(
1112 "handle_external_agents_updated should not be called for a non-remote project"
1113 );
1114 bail!("unexpected ExternalAgentsUpdated message")
1115 };
1116
1117 let mut previous_entries = std::mem::take(&mut this.external_agents);
1118 let mut status_txs = HashMap::default();
1119 let mut new_version_available_txs = HashMap::default();
1120 let mut metadata = HashMap::default();
1121
1122 for (name, mut entry) in previous_entries.drain() {
1123 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
1124 status_txs.insert(name.clone(), agent.status_tx.take());
1125 new_version_available_txs
1126 .insert(name.clone(), agent.new_version_available_tx.take());
1127 }
1128
1129 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
1130 }
1131
1132 this.external_agents = envelope
1133 .payload
1134 .names
1135 .into_iter()
1136 .map(|name| {
1137 let agent_name = ExternalAgentServerName(name.clone().into());
1138 let fallback_source =
1139 if name == GEMINI_NAME || name == CLAUDE_CODE_NAME || name == CODEX_NAME {
1140 ExternalAgentSource::Builtin
1141 } else {
1142 ExternalAgentSource::Custom
1143 };
1144 let (icon, display_name, source) =
1145 metadata
1146 .remove(&agent_name)
1147 .unwrap_or((None, None, fallback_source));
1148 let source = if fallback_source == ExternalAgentSource::Builtin {
1149 ExternalAgentSource::Builtin
1150 } else {
1151 source
1152 };
1153 let agent = RemoteExternalAgentServer {
1154 project_id: *project_id,
1155 upstream_client: upstream_client.clone(),
1156 name: agent_name.clone(),
1157 status_tx: status_txs.remove(&agent_name).flatten(),
1158 new_version_available_tx: new_version_available_txs
1159 .remove(&agent_name)
1160 .flatten(),
1161 };
1162 (
1163 agent_name,
1164 ExternalAgentEntry::new(
1165 Box::new(agent) as Box<dyn ExternalAgentServer>,
1166 source,
1167 icon,
1168 display_name,
1169 ),
1170 )
1171 })
1172 .collect();
1173 cx.emit(AgentServersUpdated);
1174 Ok(())
1175 })
1176 }
1177
1178 async fn handle_external_extension_agents_updated(
1179 this: Entity<Self>,
1180 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
1181 mut cx: AsyncApp,
1182 ) -> Result<()> {
1183 this.update(&mut cx, |this, cx| {
1184 let AgentServerStoreState::Local {
1185 extension_agents, ..
1186 } = &mut this.state
1187 else {
1188 panic!(
1189 "handle_external_extension_agents_updated \
1190 should not be called for a non-remote project"
1191 );
1192 };
1193
1194 for ExternalExtensionAgent {
1195 name,
1196 icon_path,
1197 extension_id,
1198 targets,
1199 env,
1200 } in envelope.payload.agents
1201 {
1202 extension_agents.push((
1203 Arc::from(&*name),
1204 extension_id,
1205 targets
1206 .into_iter()
1207 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
1208 .collect(),
1209 env.into_iter().collect(),
1210 icon_path,
1211 None,
1212 ));
1213 }
1214
1215 this.reregister_agents(cx);
1216 cx.emit(AgentServersUpdated);
1217 Ok(())
1218 })
1219 }
1220
1221 async fn handle_loading_status_updated(
1222 this: Entity<Self>,
1223 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
1224 mut cx: AsyncApp,
1225 ) -> Result<()> {
1226 this.update(&mut cx, |this, _| {
1227 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1228 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1229 && let Some(status_tx) = &mut agent.status_tx
1230 {
1231 status_tx.send(envelope.payload.status.into()).ok();
1232 }
1233 });
1234 Ok(())
1235 }
1236
1237 async fn handle_new_version_available(
1238 this: Entity<Self>,
1239 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
1240 mut cx: AsyncApp,
1241 ) -> Result<()> {
1242 this.update(&mut cx, |this, _| {
1243 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
1244 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
1245 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
1246 {
1247 new_version_available_tx
1248 .send(Some(envelope.payload.version))
1249 .ok();
1250 }
1251 });
1252 Ok(())
1253 }
1254
1255 pub fn get_extension_id_for_agent(
1256 &mut self,
1257 name: &ExternalAgentServerName,
1258 ) -> Option<Arc<str>> {
1259 self.external_agents.get_mut(name).and_then(|entry| {
1260 entry
1261 .server
1262 .as_any_mut()
1263 .downcast_ref::<LocalExtensionArchiveAgent>()
1264 .map(|ext_agent| ext_agent.extension_id.clone())
1265 })
1266 }
1267}
1268
1269fn get_or_npm_install_builtin_agent(
1270 binary_name: SharedString,
1271 package_name: SharedString,
1272 entrypoint_path: PathBuf,
1273 minimum_version: Option<semver::Version>,
1274 status_tx: Option<watch::Sender<SharedString>>,
1275 new_version_available: Option<watch::Sender<Option<String>>>,
1276 fs: Arc<dyn Fs>,
1277 node_runtime: NodeRuntime,
1278 cx: &mut AsyncApp,
1279) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
1280 cx.spawn(async move |cx| {
1281 let node_path = node_runtime.binary_path().await?;
1282 let dir = paths::external_agents_dir().join(binary_name.as_str());
1283 fs.create_dir(&dir).await?;
1284
1285 let mut stream = fs.read_dir(&dir).await?;
1286 let mut versions = Vec::new();
1287 let mut to_delete = Vec::new();
1288 while let Some(entry) = stream.next().await {
1289 let Ok(entry) = entry else { continue };
1290 let Some(file_name) = entry.file_name() else {
1291 continue;
1292 };
1293
1294 if let Some(name) = file_name.to_str()
1295 && let Some(version) = semver::Version::from_str(name).ok()
1296 && fs
1297 .is_file(&dir.join(file_name).join(&entrypoint_path))
1298 .await
1299 {
1300 versions.push((version, file_name.to_owned()));
1301 } else {
1302 to_delete.push(file_name.to_owned())
1303 }
1304 }
1305
1306 versions.sort();
1307 let newest_version = if let Some((version, _)) = versions.last().cloned()
1308 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
1309 {
1310 versions.pop()
1311 } else {
1312 None
1313 };
1314 log::debug!("existing version of {package_name}: {newest_version:?}");
1315 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
1316
1317 cx.background_spawn({
1318 let fs = fs.clone();
1319 let dir = dir.clone();
1320 async move {
1321 for file_name in to_delete {
1322 fs.remove_dir(
1323 &dir.join(file_name),
1324 RemoveOptions {
1325 recursive: true,
1326 ignore_if_not_exists: false,
1327 },
1328 )
1329 .await
1330 .ok();
1331 }
1332 }
1333 })
1334 .detach();
1335
1336 let version = if let Some((version, file_name)) = newest_version {
1337 cx.background_spawn({
1338 let dir = dir.clone();
1339 let fs = fs.clone();
1340 async move {
1341 let latest_version = node_runtime
1342 .npm_package_latest_version(&package_name)
1343 .await
1344 .ok();
1345 if let Some(latest_version) = latest_version
1346 && latest_version != version
1347 {
1348 let download_result = download_latest_version(
1349 fs,
1350 dir.clone(),
1351 node_runtime,
1352 package_name.clone(),
1353 )
1354 .await
1355 .log_err();
1356 if let Some(mut new_version_available) = new_version_available
1357 && download_result.is_some()
1358 {
1359 new_version_available
1360 .send(Some(latest_version.to_string()))
1361 .ok();
1362 }
1363 }
1364 }
1365 })
1366 .detach();
1367 file_name
1368 } else {
1369 if let Some(mut status_tx) = status_tx {
1370 status_tx.send("Installing…".into()).ok();
1371 }
1372 let dir = dir.clone();
1373 cx.background_spawn(download_latest_version(
1374 fs.clone(),
1375 dir.clone(),
1376 node_runtime,
1377 package_name.clone(),
1378 ))
1379 .await?
1380 .to_string()
1381 .into()
1382 };
1383
1384 let agent_server_path = dir.join(version).join(entrypoint_path);
1385 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1386 anyhow::ensure!(
1387 agent_server_path_exists,
1388 "Missing entrypoint path {} after installation",
1389 agent_server_path.to_string_lossy()
1390 );
1391
1392 anyhow::Ok(AgentServerCommand {
1393 path: node_path,
1394 args: vec![agent_server_path.to_string_lossy().into_owned()],
1395 env: None,
1396 })
1397 })
1398}
1399
1400fn find_bin_in_path(
1401 bin_name: SharedString,
1402 root_dir: PathBuf,
1403 env: HashMap<String, String>,
1404 cx: &mut AsyncApp,
1405) -> Task<Option<PathBuf>> {
1406 cx.background_executor().spawn(async move {
1407 let which_result = if cfg!(windows) {
1408 which::which(bin_name.as_str())
1409 } else {
1410 let shell_path = env.get("PATH").cloned();
1411 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1412 };
1413
1414 if let Err(which::Error::CannotFindBinaryPath) = which_result {
1415 return None;
1416 }
1417
1418 which_result.log_err()
1419 })
1420}
1421
1422async fn download_latest_version(
1423 fs: Arc<dyn Fs>,
1424 dir: PathBuf,
1425 node_runtime: NodeRuntime,
1426 package_name: SharedString,
1427) -> Result<Version> {
1428 log::debug!("downloading latest version of {package_name}");
1429
1430 let tmp_dir = tempfile::tempdir_in(&dir)?;
1431
1432 node_runtime
1433 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1434 .await?;
1435
1436 let version = node_runtime
1437 .npm_package_installed_version(tmp_dir.path(), &package_name)
1438 .await?
1439 .context("expected package to be installed")?;
1440
1441 fs.rename(
1442 &tmp_dir.keep(),
1443 &dir.join(version.to_string()),
1444 RenameOptions {
1445 ignore_if_exists: true,
1446 overwrite: true,
1447 create_parents: false,
1448 },
1449 )
1450 .await?;
1451
1452 anyhow::Ok(version)
1453}
1454
1455struct RemoteExternalAgentServer {
1456 project_id: u64,
1457 upstream_client: Entity<RemoteClient>,
1458 name: ExternalAgentServerName,
1459 status_tx: Option<watch::Sender<SharedString>>,
1460 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1461}
1462
1463impl ExternalAgentServer for RemoteExternalAgentServer {
1464 fn get_command(
1465 &mut self,
1466 root_dir: Option<&str>,
1467 extra_env: HashMap<String, String>,
1468 status_tx: Option<watch::Sender<SharedString>>,
1469 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1470 cx: &mut AsyncApp,
1471 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1472 let project_id = self.project_id;
1473 let name = self.name.to_string();
1474 let upstream_client = self.upstream_client.downgrade();
1475 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1476 self.status_tx = status_tx;
1477 self.new_version_available_tx = new_version_available_tx;
1478 cx.spawn(async move |cx| {
1479 let mut response = upstream_client
1480 .update(cx, |upstream_client, _| {
1481 upstream_client
1482 .proto_client()
1483 .request(proto::GetAgentServerCommand {
1484 project_id,
1485 name,
1486 root_dir: root_dir.clone(),
1487 })
1488 })?
1489 .await?;
1490 let root_dir = response.root_dir;
1491 response.env.extend(extra_env);
1492 let command = upstream_client.update(cx, |client, _| {
1493 client.build_command_with_options(
1494 Some(response.path),
1495 &response.args,
1496 &response.env.into_iter().collect(),
1497 Some(root_dir.clone()),
1498 None,
1499 Interactive::No,
1500 )
1501 })??;
1502 Ok((
1503 AgentServerCommand {
1504 path: command.program.into(),
1505 args: command.args,
1506 env: Some(command.env),
1507 },
1508 root_dir,
1509 response.login.map(SpawnInTerminal::from_proto),
1510 ))
1511 })
1512 }
1513
1514 fn as_any_mut(&mut self) -> &mut dyn Any {
1515 self
1516 }
1517}
1518
1519struct LocalGemini {
1520 fs: Arc<dyn Fs>,
1521 node_runtime: NodeRuntime,
1522 project_environment: Entity<ProjectEnvironment>,
1523 custom_command: Option<AgentServerCommand>,
1524 settings_env: Option<HashMap<String, String>>,
1525 ignore_system_version: bool,
1526}
1527
1528impl ExternalAgentServer for LocalGemini {
1529 fn get_command(
1530 &mut self,
1531 root_dir: Option<&str>,
1532 extra_env: HashMap<String, String>,
1533 status_tx: Option<watch::Sender<SharedString>>,
1534 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1535 cx: &mut AsyncApp,
1536 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1537 let fs = self.fs.clone();
1538 let node_runtime = self.node_runtime.clone();
1539 let project_environment = self.project_environment.downgrade();
1540 let custom_command = self.custom_command.clone();
1541 let settings_env = self.settings_env.clone();
1542 let ignore_system_version = self.ignore_system_version;
1543 let root_dir: Arc<Path> = root_dir
1544 .map(|root_dir| Path::new(root_dir))
1545 .unwrap_or(paths::home_dir())
1546 .into();
1547
1548 cx.spawn(async move |cx| {
1549 let mut env = project_environment
1550 .update(cx, |project_environment, cx| {
1551 project_environment.local_directory_environment(
1552 &Shell::System,
1553 root_dir.clone(),
1554 cx,
1555 )
1556 })?
1557 .await
1558 .unwrap_or_default();
1559
1560 env.extend(settings_env.unwrap_or_default());
1561
1562 let mut command = if let Some(mut custom_command) = custom_command {
1563 custom_command.env = Some(env);
1564 custom_command
1565 } else if !ignore_system_version
1566 && let Some(bin) =
1567 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1568 {
1569 AgentServerCommand {
1570 path: bin,
1571 args: Vec::new(),
1572 env: Some(env),
1573 }
1574 } else {
1575 let mut command = get_or_npm_install_builtin_agent(
1576 GEMINI_NAME.into(),
1577 "@google/gemini-cli".into(),
1578 "node_modules/@google/gemini-cli/dist/index.js".into(),
1579 if cfg!(windows) {
1580 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1581 Some("0.9.0".parse().unwrap())
1582 } else {
1583 Some("0.2.1".parse().unwrap())
1584 },
1585 status_tx,
1586 new_version_available_tx,
1587 fs,
1588 node_runtime,
1589 cx,
1590 )
1591 .await?;
1592 command.env = Some(env);
1593 command
1594 };
1595
1596 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1597 let login = task::SpawnInTerminal {
1598 command: Some(command.path.to_string_lossy().into_owned()),
1599 args: command.args.clone(),
1600 env: command.env.clone().unwrap_or_default(),
1601 label: "gemini /auth".into(),
1602 ..Default::default()
1603 };
1604
1605 command.env.get_or_insert_default().extend(extra_env);
1606 command.args.push("--experimental-acp".into());
1607 Ok((
1608 command,
1609 root_dir.to_string_lossy().into_owned(),
1610 Some(login),
1611 ))
1612 })
1613 }
1614
1615 fn as_any_mut(&mut self) -> &mut dyn Any {
1616 self
1617 }
1618}
1619
1620struct LocalClaudeCode {
1621 fs: Arc<dyn Fs>,
1622 node_runtime: NodeRuntime,
1623 project_environment: Entity<ProjectEnvironment>,
1624 custom_command: Option<AgentServerCommand>,
1625 settings_env: Option<HashMap<String, String>>,
1626}
1627
1628impl ExternalAgentServer for LocalClaudeCode {
1629 fn get_command(
1630 &mut self,
1631 root_dir: Option<&str>,
1632 extra_env: HashMap<String, String>,
1633 status_tx: Option<watch::Sender<SharedString>>,
1634 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1635 cx: &mut AsyncApp,
1636 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1637 let fs = self.fs.clone();
1638 let node_runtime = self.node_runtime.clone();
1639 let project_environment = self.project_environment.downgrade();
1640 let custom_command = self.custom_command.clone();
1641 let settings_env = self.settings_env.clone();
1642 let root_dir: Arc<Path> = root_dir
1643 .map(|root_dir| Path::new(root_dir))
1644 .unwrap_or(paths::home_dir())
1645 .into();
1646
1647 cx.spawn(async move |cx| {
1648 let mut env = project_environment
1649 .update(cx, |project_environment, cx| {
1650 project_environment.local_directory_environment(
1651 &Shell::System,
1652 root_dir.clone(),
1653 cx,
1654 )
1655 })?
1656 .await
1657 .unwrap_or_default();
1658 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1659
1660 env.extend(settings_env.unwrap_or_default());
1661
1662 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1663 custom_command.env = Some(env);
1664 (custom_command, None)
1665 } else {
1666 let mut command = get_or_npm_install_builtin_agent(
1667 "claude-code-acp".into(),
1668 "@zed-industries/claude-code-acp".into(),
1669 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1670 Some("0.5.2".parse().unwrap()),
1671 status_tx,
1672 new_version_available_tx,
1673 fs,
1674 node_runtime,
1675 cx,
1676 )
1677 .await?;
1678 command.env = Some(env);
1679 let login = command
1680 .args
1681 .first()
1682 .and_then(|path| {
1683 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1684 })
1685 .map(|path_prefix| task::SpawnInTerminal {
1686 command: Some(command.path.to_string_lossy().into_owned()),
1687 args: vec![
1688 Path::new(path_prefix)
1689 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1690 .to_string_lossy()
1691 .to_string(),
1692 "/login".into(),
1693 ],
1694 env: command.env.clone().unwrap_or_default(),
1695 label: "claude /login".into(),
1696 ..Default::default()
1697 });
1698 (command, login)
1699 };
1700
1701 command.env.get_or_insert_default().extend(extra_env);
1702 Ok((
1703 command,
1704 root_dir.to_string_lossy().into_owned(),
1705 login_command,
1706 ))
1707 })
1708 }
1709
1710 fn as_any_mut(&mut self) -> &mut dyn Any {
1711 self
1712 }
1713}
1714
1715struct LocalCodex {
1716 fs: Arc<dyn Fs>,
1717 project_environment: Entity<ProjectEnvironment>,
1718 http_client: Arc<dyn HttpClient>,
1719 custom_command: Option<AgentServerCommand>,
1720 settings_env: Option<HashMap<String, String>>,
1721 no_browser: bool,
1722}
1723
1724impl ExternalAgentServer for LocalCodex {
1725 fn get_command(
1726 &mut self,
1727 root_dir: Option<&str>,
1728 extra_env: HashMap<String, String>,
1729 mut status_tx: Option<watch::Sender<SharedString>>,
1730 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1731 cx: &mut AsyncApp,
1732 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1733 let fs = self.fs.clone();
1734 let project_environment = self.project_environment.downgrade();
1735 let http = self.http_client.clone();
1736 let custom_command = self.custom_command.clone();
1737 let settings_env = self.settings_env.clone();
1738 let root_dir: Arc<Path> = root_dir
1739 .map(|root_dir| Path::new(root_dir))
1740 .unwrap_or(paths::home_dir())
1741 .into();
1742 let no_browser = self.no_browser;
1743
1744 cx.spawn(async move |cx| {
1745 let mut env = project_environment
1746 .update(cx, |project_environment, cx| {
1747 project_environment.local_directory_environment(
1748 &Shell::System,
1749 root_dir.clone(),
1750 cx,
1751 )
1752 })?
1753 .await
1754 .unwrap_or_default();
1755 if no_browser {
1756 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1757 }
1758
1759 env.extend(settings_env.unwrap_or_default());
1760
1761 let mut command = if let Some(mut custom_command) = custom_command {
1762 custom_command.env = Some(env);
1763 custom_command
1764 } else {
1765 let dir = paths::external_agents_dir().join(CODEX_NAME);
1766 fs.create_dir(&dir).await?;
1767
1768 let bin_name = if cfg!(windows) {
1769 "codex-acp.exe"
1770 } else {
1771 "codex-acp"
1772 };
1773
1774 let find_latest_local_version = async || -> Option<PathBuf> {
1775 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1776 let mut stream = fs.read_dir(&dir).await.ok()?;
1777 while let Some(entry) = stream.next().await {
1778 let Ok(entry) = entry else { continue };
1779 let Some(file_name) = entry.file_name() else {
1780 continue;
1781 };
1782 let version_path = dir.join(&file_name);
1783 if fs.is_file(&version_path.join(bin_name)).await {
1784 let version_str = file_name.to_string_lossy();
1785 if let Ok(version) =
1786 semver::Version::from_str(version_str.trim_start_matches('v'))
1787 {
1788 local_versions.push((version, version_str.into_owned()));
1789 }
1790 }
1791 }
1792 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1793 local_versions.last().map(|(_, v)| dir.join(v))
1794 };
1795
1796 let fallback_to_latest_local_version =
1797 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1798 if let Some(local) = find_latest_local_version().await {
1799 log::info!(
1800 "Falling back to locally installed Codex version: {}",
1801 local.display()
1802 );
1803 Ok(local)
1804 } else {
1805 Err(err)
1806 }
1807 };
1808
1809 let version_dir = match ::http_client::github::latest_github_release(
1810 CODEX_ACP_REPO,
1811 true,
1812 false,
1813 http.clone(),
1814 )
1815 .await
1816 {
1817 Ok(release) => {
1818 let version_dir = dir.join(&release.tag_name);
1819 if !fs.is_dir(&version_dir).await {
1820 if let Some(ref mut status_tx) = status_tx {
1821 status_tx.send("Installing…".into()).ok();
1822 }
1823
1824 let tag = release.tag_name.clone();
1825 let version_number = tag.trim_start_matches('v');
1826 let asset_name = asset_name(version_number)
1827 .context("codex acp is not supported for this architecture")?;
1828 let asset = release
1829 .assets
1830 .into_iter()
1831 .find(|asset| asset.name == asset_name)
1832 .with_context(|| {
1833 format!("no asset found matching `{asset_name:?}`")
1834 })?;
1835 // Strip "sha256:" prefix from digest if present (GitHub API format)
1836 let digest = asset
1837 .digest
1838 .as_deref()
1839 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1840 match ::http_client::github_download::download_server_binary(
1841 &*http,
1842 &asset.browser_download_url,
1843 digest,
1844 &version_dir,
1845 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1846 AssetKind::Zip
1847 } else {
1848 AssetKind::TarGz
1849 },
1850 )
1851 .await
1852 {
1853 Ok(()) => {
1854 // remove older versions
1855 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1856 .await;
1857 version_dir
1858 }
1859 Err(err) => {
1860 log::error!(
1861 "Failed to download Codex release {}: {err:#}",
1862 release.tag_name
1863 );
1864 fallback_to_latest_local_version(err).await?
1865 }
1866 }
1867 } else {
1868 version_dir
1869 }
1870 }
1871 Err(err) => {
1872 log::error!("Failed to fetch Codex latest release: {err:#}");
1873 fallback_to_latest_local_version(err).await?
1874 }
1875 };
1876
1877 let bin_path = version_dir.join(bin_name);
1878 anyhow::ensure!(
1879 fs.is_file(&bin_path).await,
1880 "Missing Codex binary at {} after installation",
1881 bin_path.to_string_lossy()
1882 );
1883
1884 let mut cmd = AgentServerCommand {
1885 path: bin_path,
1886 args: Vec::new(),
1887 env: None,
1888 };
1889 cmd.env = Some(env);
1890 cmd
1891 };
1892
1893 command.env.get_or_insert_default().extend(extra_env);
1894 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1895 })
1896 }
1897
1898 fn as_any_mut(&mut self) -> &mut dyn Any {
1899 self
1900 }
1901}
1902
1903pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1904
1905fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1906 let arch = if cfg!(target_arch = "x86_64") {
1907 "x86_64"
1908 } else if cfg!(target_arch = "aarch64") {
1909 "aarch64"
1910 } else {
1911 return None;
1912 };
1913
1914 let platform = if cfg!(target_os = "macos") {
1915 "apple-darwin"
1916 } else if cfg!(target_os = "windows") {
1917 "pc-windows-msvc"
1918 } else if cfg!(target_os = "linux") {
1919 "unknown-linux-gnu"
1920 } else {
1921 return None;
1922 };
1923
1924 // Windows uses .zip in release assets
1925 let ext = if cfg!(target_os = "windows") {
1926 "zip"
1927 } else {
1928 "tar.gz"
1929 };
1930
1931 Some((arch, platform, ext))
1932}
1933
1934fn asset_name(version: &str) -> Option<String> {
1935 let (arch, platform, ext) = get_platform_info()?;
1936 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1937}
1938
1939struct LocalExtensionArchiveAgent {
1940 fs: Arc<dyn Fs>,
1941 http_client: Arc<dyn HttpClient>,
1942 node_runtime: NodeRuntime,
1943 project_environment: Entity<ProjectEnvironment>,
1944 extension_id: Arc<str>,
1945 agent_id: Arc<str>,
1946 targets: HashMap<String, extension::TargetConfig>,
1947 env: HashMap<String, String>,
1948}
1949
1950impl ExternalAgentServer for LocalExtensionArchiveAgent {
1951 fn get_command(
1952 &mut self,
1953 root_dir: Option<&str>,
1954 extra_env: HashMap<String, String>,
1955 _status_tx: Option<watch::Sender<SharedString>>,
1956 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1957 cx: &mut AsyncApp,
1958 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1959 let fs = self.fs.clone();
1960 let http_client = self.http_client.clone();
1961 let node_runtime = self.node_runtime.clone();
1962 let project_environment = self.project_environment.downgrade();
1963 let extension_id = self.extension_id.clone();
1964 let agent_id = self.agent_id.clone();
1965 let targets = self.targets.clone();
1966 let base_env = self.env.clone();
1967
1968 let root_dir: Arc<Path> = root_dir
1969 .map(|root_dir| Path::new(root_dir))
1970 .unwrap_or(paths::home_dir())
1971 .into();
1972
1973 cx.spawn(async move |cx| {
1974 // Get project environment
1975 let mut env = project_environment
1976 .update(cx, |project_environment, cx| {
1977 project_environment.local_directory_environment(
1978 &Shell::System,
1979 root_dir.clone(),
1980 cx,
1981 )
1982 })?
1983 .await
1984 .unwrap_or_default();
1985
1986 // Merge manifest env and extra env
1987 env.extend(base_env);
1988 env.extend(extra_env);
1989
1990 let cache_key = format!("{}/{}", extension_id, agent_id);
1991 let dir = paths::external_agents_dir().join(&cache_key);
1992 fs.create_dir(&dir).await?;
1993
1994 // Determine platform key
1995 let os = if cfg!(target_os = "macos") {
1996 "darwin"
1997 } else if cfg!(target_os = "linux") {
1998 "linux"
1999 } else if cfg!(target_os = "windows") {
2000 "windows"
2001 } else {
2002 anyhow::bail!("unsupported OS");
2003 };
2004
2005 let arch = if cfg!(target_arch = "aarch64") {
2006 "aarch64"
2007 } else if cfg!(target_arch = "x86_64") {
2008 "x86_64"
2009 } else {
2010 anyhow::bail!("unsupported architecture");
2011 };
2012
2013 let platform_key = format!("{}-{}", os, arch);
2014 let target_config = targets.get(&platform_key).with_context(|| {
2015 format!(
2016 "no target specified for platform '{}'. Available platforms: {}",
2017 platform_key,
2018 targets
2019 .keys()
2020 .map(|k| k.as_str())
2021 .collect::<Vec<_>>()
2022 .join(", ")
2023 )
2024 })?;
2025
2026 let archive_url = &target_config.archive;
2027
2028 // Use URL as version identifier for caching
2029 // Hash the URL to get a stable directory name
2030 use std::collections::hash_map::DefaultHasher;
2031 use std::hash::{Hash, Hasher};
2032 let mut hasher = DefaultHasher::new();
2033 archive_url.hash(&mut hasher);
2034 let url_hash = hasher.finish();
2035 let version_dir = dir.join(format!("v_{:x}", url_hash));
2036
2037 if !fs.is_dir(&version_dir).await {
2038 // Determine SHA256 for verification
2039 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2040 // Use provided SHA256
2041 Some(provided_sha.clone())
2042 } else if archive_url.starts_with("https://github.com/") {
2043 // Try to fetch SHA256 from GitHub API
2044 // Parse URL to extract repo and tag/file info
2045 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
2046 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2047 let parts: Vec<&str> = caps.split('/').collect();
2048 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2049 let repo = format!("{}/{}", parts[0], parts[1]);
2050 let tag = parts[4];
2051 let filename = parts[5..].join("/");
2052
2053 // Try to get release info from GitHub
2054 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2055 &repo,
2056 tag,
2057 http_client.clone(),
2058 )
2059 .await
2060 {
2061 // Find matching asset
2062 if let Some(asset) =
2063 release.assets.iter().find(|a| a.name == filename)
2064 {
2065 // Strip "sha256:" prefix if present
2066 asset.digest.as_ref().and_then(|d| {
2067 d.strip_prefix("sha256:")
2068 .map(|s| s.to_string())
2069 .or_else(|| Some(d.clone()))
2070 })
2071 } else {
2072 None
2073 }
2074 } else {
2075 None
2076 }
2077 } else {
2078 None
2079 }
2080 } else {
2081 None
2082 }
2083 } else {
2084 None
2085 };
2086
2087 // Determine archive type from URL
2088 let asset_kind = if archive_url.ends_with(".zip") {
2089 AssetKind::Zip
2090 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2091 AssetKind::TarGz
2092 } else {
2093 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2094 };
2095
2096 // Download and extract
2097 ::http_client::github_download::download_server_binary(
2098 &*http_client,
2099 archive_url,
2100 sha256.as_deref(),
2101 &version_dir,
2102 asset_kind,
2103 )
2104 .await?;
2105 }
2106
2107 // Validate and resolve cmd path
2108 let cmd = &target_config.cmd;
2109
2110 let cmd_path = if cmd == "node" {
2111 // Use Zed's managed Node.js runtime
2112 node_runtime.binary_path().await?
2113 } else {
2114 if cmd.contains("..") {
2115 anyhow::bail!("command path cannot contain '..': {}", cmd);
2116 }
2117
2118 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2119 // Relative to extraction directory
2120 let cmd_path = version_dir.join(&cmd[2..]);
2121 anyhow::ensure!(
2122 fs.is_file(&cmd_path).await,
2123 "Missing command {} after extraction",
2124 cmd_path.to_string_lossy()
2125 );
2126 cmd_path
2127 } else {
2128 // On PATH
2129 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2130 }
2131 };
2132
2133 let command = AgentServerCommand {
2134 path: cmd_path,
2135 args: target_config.args.clone(),
2136 env: Some(env),
2137 };
2138
2139 Ok((command, version_dir.to_string_lossy().into_owned(), None))
2140 })
2141 }
2142
2143 fn as_any_mut(&mut self) -> &mut dyn Any {
2144 self
2145 }
2146}
2147
2148struct LocalRegistryArchiveAgent {
2149 fs: Arc<dyn Fs>,
2150 http_client: Arc<dyn HttpClient>,
2151 node_runtime: NodeRuntime,
2152 project_environment: Entity<ProjectEnvironment>,
2153 registry_id: Arc<str>,
2154 targets: HashMap<String, RegistryTargetConfig>,
2155 env: HashMap<String, String>,
2156}
2157
2158impl ExternalAgentServer for LocalRegistryArchiveAgent {
2159 fn get_command(
2160 &mut self,
2161 root_dir: Option<&str>,
2162 extra_env: HashMap<String, String>,
2163 _status_tx: Option<watch::Sender<SharedString>>,
2164 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2165 cx: &mut AsyncApp,
2166 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2167 let fs = self.fs.clone();
2168 let http_client = self.http_client.clone();
2169 let node_runtime = self.node_runtime.clone();
2170 let project_environment = self.project_environment.downgrade();
2171 let registry_id = self.registry_id.clone();
2172 let targets = self.targets.clone();
2173 let settings_env = self.env.clone();
2174
2175 let root_dir: Arc<Path> = root_dir
2176 .map(|root_dir| Path::new(root_dir))
2177 .unwrap_or(paths::home_dir())
2178 .into();
2179
2180 cx.spawn(async move |cx| {
2181 let mut env = project_environment
2182 .update(cx, |project_environment, cx| {
2183 project_environment.local_directory_environment(
2184 &Shell::System,
2185 root_dir.clone(),
2186 cx,
2187 )
2188 })?
2189 .await
2190 .unwrap_or_default();
2191
2192 let dir = paths::external_agents_dir()
2193 .join("registry")
2194 .join(registry_id.as_ref());
2195 fs.create_dir(&dir).await?;
2196
2197 let os = if cfg!(target_os = "macos") {
2198 "darwin"
2199 } else if cfg!(target_os = "linux") {
2200 "linux"
2201 } else if cfg!(target_os = "windows") {
2202 "windows"
2203 } else {
2204 anyhow::bail!("unsupported OS");
2205 };
2206
2207 let arch = if cfg!(target_arch = "aarch64") {
2208 "aarch64"
2209 } else if cfg!(target_arch = "x86_64") {
2210 "x86_64"
2211 } else {
2212 anyhow::bail!("unsupported architecture");
2213 };
2214
2215 let platform_key = format!("{}-{}", os, arch);
2216 let target_config = targets.get(&platform_key).with_context(|| {
2217 format!(
2218 "no target specified for platform '{}'. Available platforms: {}",
2219 platform_key,
2220 targets
2221 .keys()
2222 .map(|k| k.as_str())
2223 .collect::<Vec<_>>()
2224 .join(", ")
2225 )
2226 })?;
2227
2228 env.extend(target_config.env.clone());
2229 env.extend(extra_env);
2230 env.extend(settings_env);
2231
2232 let archive_url = &target_config.archive;
2233
2234 use std::collections::hash_map::DefaultHasher;
2235 use std::hash::{Hash, Hasher};
2236 let mut hasher = DefaultHasher::new();
2237 archive_url.hash(&mut hasher);
2238 let url_hash = hasher.finish();
2239 let version_dir = dir.join(format!("v_{:x}", url_hash));
2240
2241 if !fs.is_dir(&version_dir).await {
2242 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
2243 Some(provided_sha.clone())
2244 } else if archive_url.starts_with("https://github.com/") {
2245 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
2246 let parts: Vec<&str> = caps.split('/').collect();
2247 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
2248 let repo = format!("{}/{}", parts[0], parts[1]);
2249 let tag = parts[4];
2250 let filename = parts[5..].join("/");
2251
2252 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
2253 &repo,
2254 tag,
2255 http_client.clone(),
2256 )
2257 .await
2258 {
2259 if let Some(asset) =
2260 release.assets.iter().find(|a| a.name == filename)
2261 {
2262 asset.digest.as_ref().and_then(|d| {
2263 d.strip_prefix("sha256:")
2264 .map(|s| s.to_string())
2265 .or_else(|| Some(d.clone()))
2266 })
2267 } else {
2268 None
2269 }
2270 } else {
2271 None
2272 }
2273 } else {
2274 None
2275 }
2276 } else {
2277 None
2278 }
2279 } else {
2280 None
2281 };
2282
2283 let asset_kind = if archive_url.ends_with(".zip") {
2284 AssetKind::Zip
2285 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
2286 AssetKind::TarGz
2287 } else {
2288 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
2289 };
2290
2291 ::http_client::github_download::download_server_binary(
2292 &*http_client,
2293 archive_url,
2294 sha256.as_deref(),
2295 &version_dir,
2296 asset_kind,
2297 )
2298 .await?;
2299 }
2300
2301 let cmd = &target_config.cmd;
2302
2303 let cmd_path = if cmd == "node" {
2304 node_runtime.binary_path().await?
2305 } else {
2306 if cmd.contains("..") {
2307 anyhow::bail!("command path cannot contain '..': {}", cmd);
2308 }
2309
2310 if cmd.starts_with("./") || cmd.starts_with(".\\") {
2311 let cmd_path = version_dir.join(&cmd[2..]);
2312 anyhow::ensure!(
2313 fs.is_file(&cmd_path).await,
2314 "Missing command {} after extraction",
2315 cmd_path.to_string_lossy()
2316 );
2317 cmd_path
2318 } else {
2319 anyhow::bail!("command must be relative (start with './'): {}", cmd);
2320 }
2321 };
2322
2323 let command = AgentServerCommand {
2324 path: cmd_path,
2325 args: target_config.args.clone(),
2326 env: Some(env),
2327 };
2328
2329 Ok((command, version_dir.to_string_lossy().into_owned(), None))
2330 })
2331 }
2332
2333 fn as_any_mut(&mut self) -> &mut dyn Any {
2334 self
2335 }
2336}
2337
2338struct LocalRegistryNpxAgent {
2339 node_runtime: NodeRuntime,
2340 project_environment: Entity<ProjectEnvironment>,
2341 package: SharedString,
2342 args: Vec<String>,
2343 distribution_env: HashMap<String, String>,
2344 settings_env: HashMap<String, String>,
2345}
2346
2347impl ExternalAgentServer for LocalRegistryNpxAgent {
2348 fn get_command(
2349 &mut self,
2350 root_dir: Option<&str>,
2351 extra_env: HashMap<String, String>,
2352 _status_tx: Option<watch::Sender<SharedString>>,
2353 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2354 cx: &mut AsyncApp,
2355 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2356 let node_runtime = self.node_runtime.clone();
2357 let project_environment = self.project_environment.downgrade();
2358 let package = self.package.clone();
2359 let args = self.args.clone();
2360 let distribution_env = self.distribution_env.clone();
2361 let settings_env = self.settings_env.clone();
2362
2363 let env_root_dir: Arc<Path> = root_dir
2364 .map(|root_dir| Path::new(root_dir))
2365 .unwrap_or(paths::home_dir())
2366 .into();
2367
2368 cx.spawn(async move |cx| {
2369 let mut env = project_environment
2370 .update(cx, |project_environment, cx| {
2371 project_environment.local_directory_environment(
2372 &Shell::System,
2373 env_root_dir.clone(),
2374 cx,
2375 )
2376 })?
2377 .await
2378 .unwrap_or_default();
2379
2380 let mut exec_args = Vec::new();
2381 exec_args.push("--yes".to_string());
2382 exec_args.push(package.to_string());
2383 if !args.is_empty() {
2384 exec_args.push("--".to_string());
2385 exec_args.extend(args);
2386 }
2387
2388 let npm_command = node_runtime
2389 .npm_command(
2390 "exec",
2391 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
2392 )
2393 .await?;
2394
2395 env.extend(npm_command.env);
2396 env.extend(distribution_env);
2397 env.extend(extra_env);
2398 env.extend(settings_env);
2399
2400 let command = AgentServerCommand {
2401 path: npm_command.path,
2402 args: npm_command.args,
2403 env: Some(env),
2404 };
2405
2406 Ok((command, env_root_dir.to_string_lossy().into_owned(), None))
2407 })
2408 }
2409
2410 fn as_any_mut(&mut self) -> &mut dyn Any {
2411 self
2412 }
2413}
2414
2415struct LocalCustomAgent {
2416 project_environment: Entity<ProjectEnvironment>,
2417 command: AgentServerCommand,
2418}
2419
2420impl ExternalAgentServer for LocalCustomAgent {
2421 fn get_command(
2422 &mut self,
2423 root_dir: Option<&str>,
2424 extra_env: HashMap<String, String>,
2425 _status_tx: Option<watch::Sender<SharedString>>,
2426 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2427 cx: &mut AsyncApp,
2428 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2429 let mut command = self.command.clone();
2430 let root_dir: Arc<Path> = root_dir
2431 .map(|root_dir| Path::new(root_dir))
2432 .unwrap_or(paths::home_dir())
2433 .into();
2434 let project_environment = self.project_environment.downgrade();
2435 cx.spawn(async move |cx| {
2436 let mut env = project_environment
2437 .update(cx, |project_environment, cx| {
2438 project_environment.local_directory_environment(
2439 &Shell::System,
2440 root_dir.clone(),
2441 cx,
2442 )
2443 })?
2444 .await
2445 .unwrap_or_default();
2446 env.extend(command.env.unwrap_or_default());
2447 env.extend(extra_env);
2448 command.env = Some(env);
2449 Ok((command, root_dir.to_string_lossy().into_owned(), None))
2450 })
2451 }
2452
2453 fn as_any_mut(&mut self) -> &mut dyn Any {
2454 self
2455 }
2456}
2457
2458pub const GEMINI_NAME: &'static str = "gemini";
2459pub const CLAUDE_CODE_NAME: &'static str = "claude";
2460pub const CODEX_NAME: &'static str = "codex";
2461
2462#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
2463pub struct AllAgentServersSettings {
2464 pub gemini: Option<BuiltinAgentServerSettings>,
2465 pub claude: Option<BuiltinAgentServerSettings>,
2466 pub codex: Option<BuiltinAgentServerSettings>,
2467 pub custom: HashMap<String, CustomAgentServerSettings>,
2468}
2469#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
2470pub struct BuiltinAgentServerSettings {
2471 pub path: Option<PathBuf>,
2472 pub args: Option<Vec<String>>,
2473 pub env: Option<HashMap<String, String>>,
2474 pub ignore_system_version: Option<bool>,
2475 pub default_mode: Option<String>,
2476 pub default_model: Option<String>,
2477 pub favorite_models: Vec<String>,
2478 pub default_config_options: HashMap<String, String>,
2479 pub favorite_config_option_values: HashMap<String, Vec<String>>,
2480}
2481
2482impl BuiltinAgentServerSettings {
2483 fn custom_command(self) -> Option<AgentServerCommand> {
2484 self.path.map(|path| AgentServerCommand {
2485 path,
2486 args: self.args.unwrap_or_default(),
2487 // Settings env are always applied, so we don't need to supply them here as well
2488 env: None,
2489 })
2490 }
2491}
2492
2493impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
2494 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
2495 BuiltinAgentServerSettings {
2496 path: value
2497 .path
2498 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
2499 args: value.args,
2500 env: value.env,
2501 ignore_system_version: value.ignore_system_version,
2502 default_mode: value.default_mode,
2503 default_model: value.default_model,
2504 favorite_models: value.favorite_models,
2505 default_config_options: value.default_config_options,
2506 favorite_config_option_values: value.favorite_config_option_values,
2507 }
2508 }
2509}
2510
2511impl From<AgentServerCommand> for BuiltinAgentServerSettings {
2512 fn from(value: AgentServerCommand) -> Self {
2513 BuiltinAgentServerSettings {
2514 path: Some(value.path),
2515 args: Some(value.args),
2516 env: value.env,
2517 ..Default::default()
2518 }
2519 }
2520}
2521
2522#[derive(Clone, JsonSchema, Debug, PartialEq)]
2523pub enum CustomAgentServerSettings {
2524 Custom {
2525 command: AgentServerCommand,
2526 /// The default mode to use for this agent.
2527 ///
2528 /// Note: Not only all agents support modes.
2529 ///
2530 /// Default: None
2531 default_mode: Option<String>,
2532 /// The default model to use for this agent.
2533 ///
2534 /// This should be the model ID as reported by the agent.
2535 ///
2536 /// Default: None
2537 default_model: Option<String>,
2538 /// The favorite models for this agent.
2539 ///
2540 /// Default: []
2541 favorite_models: Vec<String>,
2542 /// Default values for session config options.
2543 ///
2544 /// This is a map from config option ID to value ID.
2545 ///
2546 /// Default: {}
2547 default_config_options: HashMap<String, String>,
2548 /// Favorited values for session config options.
2549 ///
2550 /// This is a map from config option ID to a list of favorited value IDs.
2551 ///
2552 /// Default: {}
2553 favorite_config_option_values: HashMap<String, Vec<String>>,
2554 },
2555 Extension {
2556 /// Additional environment variables to pass to the agent.
2557 ///
2558 /// Default: {}
2559 env: HashMap<String, String>,
2560 /// The default mode to use for this agent.
2561 ///
2562 /// Note: Not only all agents support modes.
2563 ///
2564 /// Default: None
2565 default_mode: Option<String>,
2566 /// The default model to use for this agent.
2567 ///
2568 /// This should be the model ID as reported by the agent.
2569 ///
2570 /// Default: None
2571 default_model: Option<String>,
2572 /// The favorite models for this agent.
2573 ///
2574 /// Default: []
2575 favorite_models: Vec<String>,
2576 /// Default values for session config options.
2577 ///
2578 /// This is a map from config option ID to value ID.
2579 ///
2580 /// Default: {}
2581 default_config_options: HashMap<String, String>,
2582 /// Favorited values for session config options.
2583 ///
2584 /// This is a map from config option ID to a list of favorited value IDs.
2585 ///
2586 /// Default: {}
2587 favorite_config_option_values: HashMap<String, Vec<String>>,
2588 },
2589 Registry {
2590 /// Additional environment variables to pass to the agent.
2591 ///
2592 /// Default: {}
2593 env: HashMap<String, String>,
2594 /// The default mode to use for this agent.
2595 ///
2596 /// Note: Not only all agents support modes.
2597 ///
2598 /// Default: None
2599 default_mode: Option<String>,
2600 /// The default model to use for this agent.
2601 ///
2602 /// This should be the model ID as reported by the agent.
2603 ///
2604 /// Default: None
2605 default_model: Option<String>,
2606 /// The favorite models for this agent.
2607 ///
2608 /// Default: []
2609 favorite_models: Vec<String>,
2610 /// Default values for session config options.
2611 ///
2612 /// This is a map from config option ID to value ID.
2613 ///
2614 /// Default: {}
2615 default_config_options: HashMap<String, String>,
2616 /// Favorited values for session config options.
2617 ///
2618 /// This is a map from config option ID to a list of favorited value IDs.
2619 ///
2620 /// Default: {}
2621 favorite_config_option_values: HashMap<String, Vec<String>>,
2622 },
2623}
2624
2625impl CustomAgentServerSettings {
2626 pub fn command(&self) -> Option<&AgentServerCommand> {
2627 match self {
2628 CustomAgentServerSettings::Custom { command, .. } => Some(command),
2629 CustomAgentServerSettings::Extension { .. }
2630 | CustomAgentServerSettings::Registry { .. } => None,
2631 }
2632 }
2633
2634 pub fn default_mode(&self) -> Option<&str> {
2635 match self {
2636 CustomAgentServerSettings::Custom { default_mode, .. }
2637 | CustomAgentServerSettings::Extension { default_mode, .. }
2638 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
2639 }
2640 }
2641
2642 pub fn default_model(&self) -> Option<&str> {
2643 match self {
2644 CustomAgentServerSettings::Custom { default_model, .. }
2645 | CustomAgentServerSettings::Extension { default_model, .. }
2646 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
2647 }
2648 }
2649
2650 pub fn favorite_models(&self) -> &[String] {
2651 match self {
2652 CustomAgentServerSettings::Custom {
2653 favorite_models, ..
2654 }
2655 | CustomAgentServerSettings::Extension {
2656 favorite_models, ..
2657 }
2658 | CustomAgentServerSettings::Registry {
2659 favorite_models, ..
2660 } => favorite_models,
2661 }
2662 }
2663
2664 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2665 match self {
2666 CustomAgentServerSettings::Custom {
2667 default_config_options,
2668 ..
2669 }
2670 | CustomAgentServerSettings::Extension {
2671 default_config_options,
2672 ..
2673 }
2674 | CustomAgentServerSettings::Registry {
2675 default_config_options,
2676 ..
2677 } => default_config_options.get(config_id).map(|s| s.as_str()),
2678 }
2679 }
2680
2681 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2682 match self {
2683 CustomAgentServerSettings::Custom {
2684 favorite_config_option_values,
2685 ..
2686 }
2687 | CustomAgentServerSettings::Extension {
2688 favorite_config_option_values,
2689 ..
2690 }
2691 | CustomAgentServerSettings::Registry {
2692 favorite_config_option_values,
2693 ..
2694 } => favorite_config_option_values
2695 .get(config_id)
2696 .map(|v| v.as_slice()),
2697 }
2698 }
2699}
2700
2701impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2702 fn from(value: settings::CustomAgentServerSettings) -> Self {
2703 match value {
2704 settings::CustomAgentServerSettings::Custom {
2705 path,
2706 args,
2707 env,
2708 default_mode,
2709 default_model,
2710 favorite_models,
2711 default_config_options,
2712 favorite_config_option_values,
2713 } => CustomAgentServerSettings::Custom {
2714 command: AgentServerCommand {
2715 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2716 args,
2717 env: Some(env),
2718 },
2719 default_mode,
2720 default_model,
2721 favorite_models,
2722 default_config_options,
2723 favorite_config_option_values,
2724 },
2725 settings::CustomAgentServerSettings::Extension {
2726 env,
2727 default_mode,
2728 default_model,
2729 default_config_options,
2730 favorite_models,
2731 favorite_config_option_values,
2732 } => CustomAgentServerSettings::Extension {
2733 env,
2734 default_mode,
2735 default_model,
2736 default_config_options,
2737 favorite_models,
2738 favorite_config_option_values,
2739 },
2740 settings::CustomAgentServerSettings::Registry {
2741 env,
2742 default_mode,
2743 default_model,
2744 default_config_options,
2745 favorite_models,
2746 favorite_config_option_values,
2747 } => CustomAgentServerSettings::Registry {
2748 env,
2749 default_mode,
2750 default_model,
2751 default_config_options,
2752 favorite_models,
2753 favorite_config_option_values,
2754 },
2755 }
2756 }
2757}
2758
2759impl settings::Settings for AllAgentServersSettings {
2760 fn from_settings(content: &settings::SettingsContent) -> Self {
2761 let agent_settings = content.agent_servers.clone().unwrap();
2762 Self {
2763 gemini: agent_settings.gemini.map(Into::into),
2764 claude: agent_settings.claude.map(Into::into),
2765 codex: agent_settings.codex.map(Into::into),
2766 custom: agent_settings
2767 .custom
2768 .into_iter()
2769 .map(|(k, v)| (k, v.into()))
2770 .collect(),
2771 }
2772 }
2773}
2774
2775#[cfg(test)]
2776mod extension_agent_tests {
2777 use crate::worktree_store::WorktreeStore;
2778
2779 use super::*;
2780 use gpui::TestAppContext;
2781 use std::sync::Arc;
2782
2783 #[test]
2784 fn extension_agent_constructs_proper_display_names() {
2785 // Verify the display name format for extension-provided agents
2786 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2787 assert!(name1.0.contains(": "));
2788
2789 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2790 assert_eq!(name2.0, "MyExt: MyAgent");
2791
2792 // Non-extension agents shouldn't have the separator
2793 let custom = ExternalAgentServerName(SharedString::from("custom"));
2794 assert!(!custom.0.contains(": "));
2795 }
2796
2797 struct NoopExternalAgent;
2798
2799 impl ExternalAgentServer for NoopExternalAgent {
2800 fn get_command(
2801 &mut self,
2802 _root_dir: Option<&str>,
2803 _extra_env: HashMap<String, String>,
2804 _status_tx: Option<watch::Sender<SharedString>>,
2805 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2806 _cx: &mut AsyncApp,
2807 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2808 Task::ready(Ok((
2809 AgentServerCommand {
2810 path: PathBuf::from("noop"),
2811 args: Vec::new(),
2812 env: None,
2813 },
2814 "".to_string(),
2815 None,
2816 )))
2817 }
2818
2819 fn as_any_mut(&mut self) -> &mut dyn Any {
2820 self
2821 }
2822 }
2823
2824 #[test]
2825 fn sync_removes_only_extension_provided_agents() {
2826 let mut store = AgentServerStore {
2827 state: AgentServerStoreState::Collab,
2828 external_agents: HashMap::default(),
2829 };
2830
2831 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2832 store.external_agents.insert(
2833 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2834 ExternalAgentEntry::new(
2835 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2836 ExternalAgentSource::Extension,
2837 None,
2838 None,
2839 ),
2840 );
2841 store.external_agents.insert(
2842 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2843 ExternalAgentEntry::new(
2844 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2845 ExternalAgentSource::Extension,
2846 None,
2847 None,
2848 ),
2849 );
2850 store.external_agents.insert(
2851 ExternalAgentServerName(SharedString::from("custom-agent")),
2852 ExternalAgentEntry::new(
2853 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2854 ExternalAgentSource::Custom,
2855 None,
2856 None,
2857 ),
2858 );
2859
2860 // Simulate removal phase
2861 store
2862 .external_agents
2863 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
2864
2865 // Only custom-agent should remain
2866 assert_eq!(store.external_agents.len(), 1);
2867 assert!(
2868 store
2869 .external_agents
2870 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2871 );
2872 }
2873
2874 #[test]
2875 fn archive_launcher_constructs_with_all_fields() {
2876 use extension::AgentServerManifestEntry;
2877
2878 let mut env = HashMap::default();
2879 env.insert("GITHUB_TOKEN".into(), "secret".into());
2880
2881 let mut targets = HashMap::default();
2882 targets.insert(
2883 "darwin-aarch64".to_string(),
2884 extension::TargetConfig {
2885 archive:
2886 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2887 .into(),
2888 cmd: "./agent".into(),
2889 args: vec![],
2890 sha256: None,
2891 env: Default::default(),
2892 },
2893 );
2894
2895 let _entry = AgentServerManifestEntry {
2896 name: "GitHub Agent".into(),
2897 targets,
2898 env,
2899 icon: None,
2900 };
2901
2902 // Verify display name construction
2903 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2904 assert_eq!(expected_name.0, "GitHub Agent");
2905 }
2906
2907 #[gpui::test]
2908 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2909 let fs = fs::FakeFs::new(cx.background_executor.clone());
2910 let http_client = http_client::FakeHttpClient::with_404_response();
2911 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2912 let project_environment = cx.new(|cx| {
2913 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2914 });
2915
2916 let agent = LocalExtensionArchiveAgent {
2917 fs,
2918 http_client,
2919 node_runtime: node_runtime::NodeRuntime::unavailable(),
2920 project_environment,
2921 extension_id: Arc::from("my-extension"),
2922 agent_id: Arc::from("my-agent"),
2923 targets: {
2924 let mut map = HashMap::default();
2925 map.insert(
2926 "darwin-aarch64".to_string(),
2927 extension::TargetConfig {
2928 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2929 cmd: "./my-agent".into(),
2930 args: vec!["--serve".into()],
2931 sha256: None,
2932 env: Default::default(),
2933 },
2934 );
2935 map
2936 },
2937 env: {
2938 let mut map = HashMap::default();
2939 map.insert("PORT".into(), "8080".into());
2940 map
2941 },
2942 };
2943
2944 // Verify agent is properly constructed
2945 assert_eq!(agent.extension_id.as_ref(), "my-extension");
2946 assert_eq!(agent.agent_id.as_ref(), "my-agent");
2947 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2948 assert!(agent.targets.contains_key("darwin-aarch64"));
2949 }
2950
2951 #[test]
2952 fn sync_extension_agents_registers_archive_launcher() {
2953 use extension::AgentServerManifestEntry;
2954
2955 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2956 assert_eq!(expected_name.0, "Release Agent");
2957
2958 // Verify the manifest entry structure for archive-based installation
2959 let mut env = HashMap::default();
2960 env.insert("API_KEY".into(), "secret".into());
2961
2962 let mut targets = HashMap::default();
2963 targets.insert(
2964 "linux-x86_64".to_string(),
2965 extension::TargetConfig {
2966 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2967 cmd: "./release-agent".into(),
2968 args: vec!["serve".into()],
2969 sha256: None,
2970 env: Default::default(),
2971 },
2972 );
2973
2974 let manifest_entry = AgentServerManifestEntry {
2975 name: "Release Agent".into(),
2976 targets: targets.clone(),
2977 env,
2978 icon: None,
2979 };
2980
2981 // Verify target config is present
2982 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2983 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2984 assert_eq!(target.cmd, "./release-agent");
2985 }
2986
2987 #[gpui::test]
2988 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2989 let fs = fs::FakeFs::new(cx.background_executor.clone());
2990 let http_client = http_client::FakeHttpClient::with_404_response();
2991 let node_runtime = NodeRuntime::unavailable();
2992 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2993 let project_environment = cx.new(|cx| {
2994 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2995 });
2996
2997 let agent = LocalExtensionArchiveAgent {
2998 fs: fs.clone(),
2999 http_client,
3000 node_runtime,
3001 project_environment,
3002 extension_id: Arc::from("node-extension"),
3003 agent_id: Arc::from("node-agent"),
3004 targets: {
3005 let mut map = HashMap::default();
3006 map.insert(
3007 "darwin-aarch64".to_string(),
3008 extension::TargetConfig {
3009 archive: "https://example.com/node-agent.zip".into(),
3010 cmd: "node".into(),
3011 args: vec!["index.js".into()],
3012 sha256: None,
3013 env: Default::default(),
3014 },
3015 );
3016 map
3017 },
3018 env: HashMap::default(),
3019 };
3020
3021 // Verify that when cmd is "node", it attempts to use the node runtime
3022 assert_eq!(agent.extension_id.as_ref(), "node-extension");
3023 assert_eq!(agent.agent_id.as_ref(), "node-agent");
3024
3025 let target = agent.targets.get("darwin-aarch64").unwrap();
3026 assert_eq!(target.cmd, "node");
3027 assert_eq!(target.args, vec!["index.js"]);
3028 }
3029
3030 #[gpui::test]
3031 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
3032 let fs = fs::FakeFs::new(cx.background_executor.clone());
3033 let http_client = http_client::FakeHttpClient::with_404_response();
3034 let node_runtime = NodeRuntime::unavailable();
3035 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
3036 let project_environment = cx.new(|cx| {
3037 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
3038 });
3039
3040 let agent = LocalExtensionArchiveAgent {
3041 fs: fs.clone(),
3042 http_client,
3043 node_runtime,
3044 project_environment,
3045 extension_id: Arc::from("test-ext"),
3046 agent_id: Arc::from("test-agent"),
3047 targets: {
3048 let mut map = HashMap::default();
3049 map.insert(
3050 "darwin-aarch64".to_string(),
3051 extension::TargetConfig {
3052 archive: "https://example.com/test.zip".into(),
3053 cmd: "node".into(),
3054 args: vec![
3055 "server.js".into(),
3056 "--config".into(),
3057 "./config.json".into(),
3058 ],
3059 sha256: None,
3060 env: Default::default(),
3061 },
3062 );
3063 map
3064 },
3065 env: HashMap::default(),
3066 };
3067
3068 // Verify the agent is configured with relative paths in args
3069 let target = agent.targets.get("darwin-aarch64").unwrap();
3070 assert_eq!(target.args[0], "server.js");
3071 assert_eq!(target.args[2], "./config.json");
3072 // These relative paths will resolve relative to the extraction directory
3073 // when the command is executed
3074 }
3075
3076 #[test]
3077 fn test_tilde_expansion_in_settings() {
3078 let settings = settings::BuiltinAgentServerSettings {
3079 path: Some(PathBuf::from("~/bin/agent")),
3080 args: Some(vec!["--flag".into()]),
3081 env: None,
3082 ignore_system_version: None,
3083 default_mode: None,
3084 default_model: None,
3085 favorite_models: vec![],
3086 default_config_options: Default::default(),
3087 favorite_config_option_values: Default::default(),
3088 };
3089
3090 let BuiltinAgentServerSettings { path, .. } = settings.into();
3091
3092 let path = path.unwrap();
3093 assert!(
3094 !path.to_string_lossy().starts_with("~"),
3095 "Tilde should be expanded for builtin agent path"
3096 );
3097
3098 let settings = settings::CustomAgentServerSettings::Custom {
3099 path: PathBuf::from("~/custom/agent"),
3100 args: vec!["serve".into()],
3101 env: Default::default(),
3102 default_mode: None,
3103 default_model: None,
3104 favorite_models: vec![],
3105 default_config_options: Default::default(),
3106 favorite_config_option_values: Default::default(),
3107 };
3108
3109 let converted: CustomAgentServerSettings = settings.into();
3110 let CustomAgentServerSettings::Custom {
3111 command: AgentServerCommand { path, .. },
3112 ..
3113 } = converted
3114 else {
3115 panic!("Expected Custom variant");
3116 };
3117
3118 assert!(
3119 !path.to_string_lossy().starts_with("~"),
3120 "Tilde should be expanded for custom agent path"
3121 );
3122 }
3123}