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