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