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