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