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::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 local(
442 node_runtime: NodeRuntime,
443 fs: Arc<dyn Fs>,
444 project_environment: Entity<ProjectEnvironment>,
445 http_client: Arc<dyn HttpClient>,
446 cx: &mut Context<Self>,
447 ) -> Self {
448 let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
449 this.agent_servers_settings_changed(cx);
450 });
451 let mut this = Self {
452 state: AgentServerStoreState::Local {
453 node_runtime,
454 fs,
455 project_environment,
456 http_client,
457 downstream_client: None,
458 settings: None,
459 _subscriptions: [subscription],
460 },
461 external_agents: Default::default(),
462 agent_icons: Default::default(),
463 };
464 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
465 this.agent_servers_settings_changed(cx);
466 this
467 }
468
469 pub(crate) fn remote(project_id: u64, upstream_client: Entity<RemoteClient>) -> Self {
470 // Set up the builtin agents here so they're immediately available in
471 // remote projects--we know that the HeadlessProject on the other end
472 // will have them.
473 let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
474 (
475 CLAUDE_CODE_NAME.into(),
476 Box::new(RemoteExternalAgentServer {
477 project_id,
478 upstream_client: upstream_client.clone(),
479 name: CLAUDE_CODE_NAME.into(),
480 status_tx: None,
481 new_version_available_tx: None,
482 }) as Box<dyn ExternalAgentServer>,
483 ),
484 (
485 CODEX_NAME.into(),
486 Box::new(RemoteExternalAgentServer {
487 project_id,
488 upstream_client: upstream_client.clone(),
489 name: CODEX_NAME.into(),
490 status_tx: None,
491 new_version_available_tx: None,
492 }) as Box<dyn ExternalAgentServer>,
493 ),
494 (
495 GEMINI_NAME.into(),
496 Box::new(RemoteExternalAgentServer {
497 project_id,
498 upstream_client: upstream_client.clone(),
499 name: GEMINI_NAME.into(),
500 status_tx: None,
501 new_version_available_tx: None,
502 }) as Box<dyn ExternalAgentServer>,
503 ),
504 ];
505
506 Self {
507 state: AgentServerStoreState::Remote {
508 project_id,
509 upstream_client,
510 },
511 external_agents: external_agents.into_iter().collect(),
512 agent_icons: HashMap::default(),
513 }
514 }
515
516 pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
517 Self {
518 state: AgentServerStoreState::Collab,
519 external_agents: Default::default(),
520 agent_icons: Default::default(),
521 }
522 }
523
524 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
525 match &mut self.state {
526 AgentServerStoreState::Local {
527 downstream_client, ..
528 } => {
529 *downstream_client = Some((project_id, client.clone()));
530 // Send the current list of external agents downstream, but only after a delay,
531 // to avoid having the message arrive before the downstream project's agent server store
532 // sets up its handlers.
533 cx.spawn(async move |this, cx| {
534 cx.background_executor().timer(Duration::from_secs(1)).await;
535 let names = this.update(cx, |this, _| {
536 this.external_agents
537 .keys()
538 .map(|name| name.to_string())
539 .collect()
540 })?;
541 client
542 .send(proto::ExternalAgentsUpdated { project_id, names })
543 .log_err();
544 anyhow::Ok(())
545 })
546 .detach();
547 }
548 AgentServerStoreState::Remote { .. } => {
549 debug_panic!(
550 "external agents over collab not implemented, remote project should not be shared"
551 );
552 }
553 AgentServerStoreState::Collab => {
554 debug_panic!("external agents over collab not implemented, should not be shared");
555 }
556 }
557 }
558
559 pub fn get_external_agent(
560 &mut self,
561 name: &ExternalAgentServerName,
562 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
563 self.external_agents
564 .get_mut(name)
565 .map(|agent| agent.as_mut())
566 }
567
568 pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
569 self.external_agents.keys()
570 }
571
572 async fn handle_get_agent_server_command(
573 this: Entity<Self>,
574 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
575 mut cx: AsyncApp,
576 ) -> Result<proto::AgentServerCommand> {
577 let (command, root_dir, login_command) = this
578 .update(&mut cx, |this, cx| {
579 let AgentServerStoreState::Local {
580 downstream_client, ..
581 } = &this.state
582 else {
583 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
584 bail!("unexpected GetAgentServerCommand request in a non-local project");
585 };
586 let agent = this
587 .external_agents
588 .get_mut(&*envelope.payload.name)
589 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
590 let (status_tx, new_version_available_tx) = downstream_client
591 .clone()
592 .map(|(project_id, downstream_client)| {
593 let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
594 let (new_version_available_tx, mut new_version_available_rx) =
595 watch::channel(None);
596 cx.spawn({
597 let downstream_client = downstream_client.clone();
598 let name = envelope.payload.name.clone();
599 async move |_, _| {
600 while let Some(status) = status_rx.recv().await.ok() {
601 downstream_client.send(
602 proto::ExternalAgentLoadingStatusUpdated {
603 project_id,
604 name: name.clone(),
605 status: status.to_string(),
606 },
607 )?;
608 }
609 anyhow::Ok(())
610 }
611 })
612 .detach_and_log_err(cx);
613 cx.spawn({
614 let name = envelope.payload.name.clone();
615 async move |_, _| {
616 if let Some(version) =
617 new_version_available_rx.recv().await.ok().flatten()
618 {
619 downstream_client.send(
620 proto::NewExternalAgentVersionAvailable {
621 project_id,
622 name: name.clone(),
623 version,
624 },
625 )?;
626 }
627 anyhow::Ok(())
628 }
629 })
630 .detach_and_log_err(cx);
631 (status_tx, new_version_available_tx)
632 })
633 .unzip();
634 anyhow::Ok(agent.get_command(
635 envelope.payload.root_dir.as_deref(),
636 HashMap::default(),
637 status_tx,
638 new_version_available_tx,
639 &mut cx.to_async(),
640 ))
641 })??
642 .await?;
643 Ok(proto::AgentServerCommand {
644 path: command.path.to_string_lossy().into_owned(),
645 args: command.args,
646 env: command
647 .env
648 .map(|env| env.into_iter().collect())
649 .unwrap_or_default(),
650 root_dir: root_dir,
651 login: login_command.map(|cmd| cmd.to_proto()),
652 })
653 }
654
655 async fn handle_external_agents_updated(
656 this: Entity<Self>,
657 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
658 mut cx: AsyncApp,
659 ) -> Result<()> {
660 this.update(&mut cx, |this, cx| {
661 let AgentServerStoreState::Remote {
662 project_id,
663 upstream_client,
664 } = &this.state
665 else {
666 debug_panic!(
667 "handle_external_agents_updated should not be called for a non-remote project"
668 );
669 bail!("unexpected ExternalAgentsUpdated message")
670 };
671
672 let mut status_txs = this
673 .external_agents
674 .iter_mut()
675 .filter_map(|(name, agent)| {
676 Some((
677 name.clone(),
678 agent
679 .downcast_mut::<RemoteExternalAgentServer>()?
680 .status_tx
681 .take(),
682 ))
683 })
684 .collect::<HashMap<_, _>>();
685 let mut new_version_available_txs = this
686 .external_agents
687 .iter_mut()
688 .filter_map(|(name, agent)| {
689 Some((
690 name.clone(),
691 agent
692 .downcast_mut::<RemoteExternalAgentServer>()?
693 .new_version_available_tx
694 .take(),
695 ))
696 })
697 .collect::<HashMap<_, _>>();
698
699 this.external_agents = envelope
700 .payload
701 .names
702 .into_iter()
703 .map(|name| {
704 let agent = RemoteExternalAgentServer {
705 project_id: *project_id,
706 upstream_client: upstream_client.clone(),
707 name: ExternalAgentServerName(name.clone().into()),
708 status_tx: status_txs.remove(&*name).flatten(),
709 new_version_available_tx: new_version_available_txs
710 .remove(&*name)
711 .flatten(),
712 };
713 (
714 ExternalAgentServerName(name.into()),
715 Box::new(agent) as Box<dyn ExternalAgentServer>,
716 )
717 })
718 .collect();
719 cx.emit(AgentServersUpdated);
720 Ok(())
721 })?
722 }
723
724 async fn handle_loading_status_updated(
725 this: Entity<Self>,
726 envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
727 mut cx: AsyncApp,
728 ) -> Result<()> {
729 this.update(&mut cx, |this, _| {
730 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
731 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
732 && let Some(status_tx) = &mut agent.status_tx
733 {
734 status_tx.send(envelope.payload.status.into()).ok();
735 }
736 })
737 }
738
739 async fn handle_new_version_available(
740 this: Entity<Self>,
741 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
742 mut cx: AsyncApp,
743 ) -> Result<()> {
744 this.update(&mut cx, |this, _| {
745 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
746 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
747 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
748 {
749 new_version_available_tx
750 .send(Some(envelope.payload.version))
751 .ok();
752 }
753 })
754 }
755}
756
757fn get_or_npm_install_builtin_agent(
758 binary_name: SharedString,
759 package_name: SharedString,
760 entrypoint_path: PathBuf,
761 minimum_version: Option<semver::Version>,
762 status_tx: Option<watch::Sender<SharedString>>,
763 new_version_available: Option<watch::Sender<Option<String>>>,
764 fs: Arc<dyn Fs>,
765 node_runtime: NodeRuntime,
766 cx: &mut AsyncApp,
767) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
768 cx.spawn(async move |cx| {
769 let node_path = node_runtime.binary_path().await?;
770 let dir = paths::external_agents_dir().join(binary_name.as_str());
771 fs.create_dir(&dir).await?;
772
773 let mut stream = fs.read_dir(&dir).await?;
774 let mut versions = Vec::new();
775 let mut to_delete = Vec::new();
776 while let Some(entry) = stream.next().await {
777 let Ok(entry) = entry else { continue };
778 let Some(file_name) = entry.file_name() else {
779 continue;
780 };
781
782 if let Some(name) = file_name.to_str()
783 && let Some(version) = semver::Version::from_str(name).ok()
784 && fs
785 .is_file(&dir.join(file_name).join(&entrypoint_path))
786 .await
787 {
788 versions.push((version, file_name.to_owned()));
789 } else {
790 to_delete.push(file_name.to_owned())
791 }
792 }
793
794 versions.sort();
795 let newest_version = if let Some((version, file_name)) = versions.last().cloned()
796 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
797 {
798 versions.pop();
799 Some(file_name)
800 } else {
801 None
802 };
803 log::debug!("existing version of {package_name}: {newest_version:?}");
804 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
805
806 cx.background_spawn({
807 let fs = fs.clone();
808 let dir = dir.clone();
809 async move {
810 for file_name in to_delete {
811 fs.remove_dir(
812 &dir.join(file_name),
813 RemoveOptions {
814 recursive: true,
815 ignore_if_not_exists: false,
816 },
817 )
818 .await
819 .ok();
820 }
821 }
822 })
823 .detach();
824
825 let version = if let Some(file_name) = newest_version {
826 cx.background_spawn({
827 let file_name = file_name.clone();
828 let dir = dir.clone();
829 let fs = fs.clone();
830 async move {
831 let latest_version = node_runtime
832 .npm_package_latest_version(&package_name)
833 .await
834 .ok();
835 if let Some(latest_version) = latest_version
836 && &latest_version != &file_name.to_string_lossy()
837 {
838 let download_result = download_latest_version(
839 fs,
840 dir.clone(),
841 node_runtime,
842 package_name.clone(),
843 )
844 .await
845 .log_err();
846 if let Some(mut new_version_available) = new_version_available
847 && download_result.is_some()
848 {
849 new_version_available.send(Some(latest_version)).ok();
850 }
851 }
852 }
853 })
854 .detach();
855 file_name
856 } else {
857 if let Some(mut status_tx) = status_tx {
858 status_tx.send("Installing…".into()).ok();
859 }
860 let dir = dir.clone();
861 cx.background_spawn(download_latest_version(
862 fs.clone(),
863 dir.clone(),
864 node_runtime,
865 package_name.clone(),
866 ))
867 .await?
868 .into()
869 };
870
871 let agent_server_path = dir.join(version).join(entrypoint_path);
872 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
873 anyhow::ensure!(
874 agent_server_path_exists,
875 "Missing entrypoint path {} after installation",
876 agent_server_path.to_string_lossy()
877 );
878
879 anyhow::Ok(AgentServerCommand {
880 path: node_path,
881 args: vec![agent_server_path.to_string_lossy().into_owned()],
882 env: None,
883 })
884 })
885}
886
887fn find_bin_in_path(
888 bin_name: SharedString,
889 root_dir: PathBuf,
890 env: HashMap<String, String>,
891 cx: &mut AsyncApp,
892) -> Task<Option<PathBuf>> {
893 cx.background_executor().spawn(async move {
894 let which_result = if cfg!(windows) {
895 which::which(bin_name.as_str())
896 } else {
897 let shell_path = env.get("PATH").cloned();
898 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
899 };
900
901 if let Err(which::Error::CannotFindBinaryPath) = which_result {
902 return None;
903 }
904
905 which_result.log_err()
906 })
907}
908
909async fn download_latest_version(
910 fs: Arc<dyn Fs>,
911 dir: PathBuf,
912 node_runtime: NodeRuntime,
913 package_name: SharedString,
914) -> Result<String> {
915 log::debug!("downloading latest version of {package_name}");
916
917 let tmp_dir = tempfile::tempdir_in(&dir)?;
918
919 node_runtime
920 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
921 .await?;
922
923 let version = node_runtime
924 .npm_package_installed_version(tmp_dir.path(), &package_name)
925 .await?
926 .context("expected package to be installed")?;
927
928 fs.rename(
929 &tmp_dir.keep(),
930 &dir.join(&version),
931 RenameOptions {
932 ignore_if_exists: true,
933 overwrite: true,
934 },
935 )
936 .await?;
937
938 anyhow::Ok(version)
939}
940
941struct RemoteExternalAgentServer {
942 project_id: u64,
943 upstream_client: Entity<RemoteClient>,
944 name: ExternalAgentServerName,
945 status_tx: Option<watch::Sender<SharedString>>,
946 new_version_available_tx: Option<watch::Sender<Option<String>>>,
947}
948
949impl ExternalAgentServer for RemoteExternalAgentServer {
950 fn get_command(
951 &mut self,
952 root_dir: Option<&str>,
953 extra_env: HashMap<String, String>,
954 status_tx: Option<watch::Sender<SharedString>>,
955 new_version_available_tx: Option<watch::Sender<Option<String>>>,
956 cx: &mut AsyncApp,
957 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
958 let project_id = self.project_id;
959 let name = self.name.to_string();
960 let upstream_client = self.upstream_client.downgrade();
961 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
962 self.status_tx = status_tx;
963 self.new_version_available_tx = new_version_available_tx;
964 cx.spawn(async move |cx| {
965 let mut response = upstream_client
966 .update(cx, |upstream_client, _| {
967 upstream_client
968 .proto_client()
969 .request(proto::GetAgentServerCommand {
970 project_id,
971 name,
972 root_dir: root_dir.clone(),
973 })
974 })?
975 .await?;
976 let root_dir = response.root_dir;
977 response.env.extend(extra_env);
978 let command = upstream_client.update(cx, |client, _| {
979 client.build_command(
980 Some(response.path),
981 &response.args,
982 &response.env.into_iter().collect(),
983 Some(root_dir.clone()),
984 None,
985 )
986 })??;
987 Ok((
988 AgentServerCommand {
989 path: command.program.into(),
990 args: command.args,
991 env: Some(command.env),
992 },
993 root_dir,
994 None,
995 ))
996 })
997 }
998
999 fn as_any_mut(&mut self) -> &mut dyn Any {
1000 self
1001 }
1002}
1003
1004struct LocalGemini {
1005 fs: Arc<dyn Fs>,
1006 node_runtime: NodeRuntime,
1007 project_environment: Entity<ProjectEnvironment>,
1008 custom_command: Option<AgentServerCommand>,
1009 ignore_system_version: bool,
1010}
1011
1012impl ExternalAgentServer for LocalGemini {
1013 fn get_command(
1014 &mut self,
1015 root_dir: Option<&str>,
1016 extra_env: HashMap<String, String>,
1017 status_tx: Option<watch::Sender<SharedString>>,
1018 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1019 cx: &mut AsyncApp,
1020 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1021 let fs = self.fs.clone();
1022 let node_runtime = self.node_runtime.clone();
1023 let project_environment = self.project_environment.downgrade();
1024 let custom_command = self.custom_command.clone();
1025 let ignore_system_version = self.ignore_system_version;
1026 let root_dir: Arc<Path> = root_dir
1027 .map(|root_dir| Path::new(root_dir))
1028 .unwrap_or(paths::home_dir())
1029 .into();
1030
1031 cx.spawn(async move |cx| {
1032 let mut env = project_environment
1033 .update(cx, |project_environment, cx| {
1034 project_environment.local_directory_environment(
1035 &Shell::System,
1036 root_dir.clone(),
1037 cx,
1038 )
1039 })?
1040 .await
1041 .unwrap_or_default();
1042
1043 let mut command = if let Some(mut custom_command) = custom_command {
1044 env.extend(custom_command.env.unwrap_or_default());
1045 custom_command.env = Some(env);
1046 custom_command
1047 } else if !ignore_system_version
1048 && let Some(bin) =
1049 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1050 {
1051 AgentServerCommand {
1052 path: bin,
1053 args: Vec::new(),
1054 env: Some(env),
1055 }
1056 } else {
1057 let mut command = get_or_npm_install_builtin_agent(
1058 GEMINI_NAME.into(),
1059 "@google/gemini-cli".into(),
1060 "node_modules/@google/gemini-cli/dist/index.js".into(),
1061 if cfg!(windows) {
1062 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1063 Some("0.9.0".parse().unwrap())
1064 } else {
1065 Some("0.2.1".parse().unwrap())
1066 },
1067 status_tx,
1068 new_version_available_tx,
1069 fs,
1070 node_runtime,
1071 cx,
1072 )
1073 .await?;
1074 command.env = Some(env);
1075 command
1076 };
1077
1078 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1079 let login = task::SpawnInTerminal {
1080 command: Some(command.path.to_string_lossy().into_owned()),
1081 args: command.args.clone(),
1082 env: command.env.clone().unwrap_or_default(),
1083 label: "gemini /auth".into(),
1084 ..Default::default()
1085 };
1086
1087 command.env.get_or_insert_default().extend(extra_env);
1088 command.args.push("--experimental-acp".into());
1089 Ok((
1090 command,
1091 root_dir.to_string_lossy().into_owned(),
1092 Some(login),
1093 ))
1094 })
1095 }
1096
1097 fn as_any_mut(&mut self) -> &mut dyn Any {
1098 self
1099 }
1100}
1101
1102struct LocalClaudeCode {
1103 fs: Arc<dyn Fs>,
1104 node_runtime: NodeRuntime,
1105 project_environment: Entity<ProjectEnvironment>,
1106 custom_command: Option<AgentServerCommand>,
1107}
1108
1109impl ExternalAgentServer for LocalClaudeCode {
1110 fn get_command(
1111 &mut self,
1112 root_dir: Option<&str>,
1113 extra_env: HashMap<String, String>,
1114 status_tx: Option<watch::Sender<SharedString>>,
1115 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1116 cx: &mut AsyncApp,
1117 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1118 let fs = self.fs.clone();
1119 let node_runtime = self.node_runtime.clone();
1120 let project_environment = self.project_environment.downgrade();
1121 let custom_command = self.custom_command.clone();
1122 let root_dir: Arc<Path> = root_dir
1123 .map(|root_dir| Path::new(root_dir))
1124 .unwrap_or(paths::home_dir())
1125 .into();
1126
1127 cx.spawn(async move |cx| {
1128 let mut env = project_environment
1129 .update(cx, |project_environment, cx| {
1130 project_environment.local_directory_environment(
1131 &Shell::System,
1132 root_dir.clone(),
1133 cx,
1134 )
1135 })?
1136 .await
1137 .unwrap_or_default();
1138 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1139
1140 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1141 env.extend(custom_command.env.unwrap_or_default());
1142 custom_command.env = Some(env);
1143 (custom_command, None)
1144 } else {
1145 let mut command = get_or_npm_install_builtin_agent(
1146 "claude-code-acp".into(),
1147 "@zed-industries/claude-code-acp".into(),
1148 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1149 Some("0.5.2".parse().unwrap()),
1150 status_tx,
1151 new_version_available_tx,
1152 fs,
1153 node_runtime,
1154 cx,
1155 )
1156 .await?;
1157 command.env = Some(env);
1158 let login = command
1159 .args
1160 .first()
1161 .and_then(|path| {
1162 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1163 })
1164 .map(|path_prefix| task::SpawnInTerminal {
1165 command: Some(command.path.to_string_lossy().into_owned()),
1166 args: vec![
1167 Path::new(path_prefix)
1168 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1169 .to_string_lossy()
1170 .to_string(),
1171 "/login".into(),
1172 ],
1173 env: command.env.clone().unwrap_or_default(),
1174 label: "claude /login".into(),
1175 ..Default::default()
1176 });
1177 (command, login)
1178 };
1179
1180 command.env.get_or_insert_default().extend(extra_env);
1181 Ok((
1182 command,
1183 root_dir.to_string_lossy().into_owned(),
1184 login_command,
1185 ))
1186 })
1187 }
1188
1189 fn as_any_mut(&mut self) -> &mut dyn Any {
1190 self
1191 }
1192}
1193
1194struct LocalCodex {
1195 fs: Arc<dyn Fs>,
1196 project_environment: Entity<ProjectEnvironment>,
1197 http_client: Arc<dyn HttpClient>,
1198 custom_command: Option<AgentServerCommand>,
1199 is_remote: bool,
1200}
1201
1202impl ExternalAgentServer for LocalCodex {
1203 fn get_command(
1204 &mut self,
1205 root_dir: Option<&str>,
1206 extra_env: HashMap<String, String>,
1207 status_tx: Option<watch::Sender<SharedString>>,
1208 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1209 cx: &mut AsyncApp,
1210 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1211 let fs = self.fs.clone();
1212 let project_environment = self.project_environment.downgrade();
1213 let http = self.http_client.clone();
1214 let custom_command = self.custom_command.clone();
1215 let root_dir: Arc<Path> = root_dir
1216 .map(|root_dir| Path::new(root_dir))
1217 .unwrap_or(paths::home_dir())
1218 .into();
1219 let is_remote = self.is_remote;
1220
1221 cx.spawn(async move |cx| {
1222 let mut env = project_environment
1223 .update(cx, |project_environment, cx| {
1224 project_environment.local_directory_environment(
1225 &Shell::System,
1226 root_dir.clone(),
1227 cx,
1228 )
1229 })?
1230 .await
1231 .unwrap_or_default();
1232 if is_remote {
1233 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1234 }
1235
1236 let mut command = if let Some(mut custom_command) = custom_command {
1237 env.extend(custom_command.env.unwrap_or_default());
1238 custom_command.env = Some(env);
1239 custom_command
1240 } else {
1241 let dir = paths::external_agents_dir().join(CODEX_NAME);
1242 fs.create_dir(&dir).await?;
1243
1244 // Find or install the latest Codex release (no update checks for now).
1245 let release = ::http_client::github::latest_github_release(
1246 CODEX_ACP_REPO,
1247 true,
1248 false,
1249 http.clone(),
1250 )
1251 .await
1252 .context("fetching Codex latest release")?;
1253
1254 let version_dir = dir.join(&release.tag_name);
1255 if !fs.is_dir(&version_dir).await {
1256 if let Some(mut status_tx) = status_tx {
1257 status_tx.send("Installing…".into()).ok();
1258 }
1259
1260 let tag = release.tag_name.clone();
1261 let version_number = tag.trim_start_matches('v');
1262 let asset_name = asset_name(version_number)
1263 .context("codex acp is not supported for this architecture")?;
1264 let asset = release
1265 .assets
1266 .into_iter()
1267 .find(|asset| asset.name == asset_name)
1268 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1269 // Strip "sha256:" prefix from digest if present (GitHub API format)
1270 let digest = asset
1271 .digest
1272 .as_deref()
1273 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1274 ::http_client::github_download::download_server_binary(
1275 &*http,
1276 &asset.browser_download_url,
1277 digest,
1278 &version_dir,
1279 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1280 AssetKind::Zip
1281 } else {
1282 AssetKind::TarGz
1283 },
1284 )
1285 .await?;
1286
1287 // remove older versions
1288 util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1289 }
1290
1291 let bin_name = if cfg!(windows) {
1292 "codex-acp.exe"
1293 } else {
1294 "codex-acp"
1295 };
1296 let bin_path = version_dir.join(bin_name);
1297 anyhow::ensure!(
1298 fs.is_file(&bin_path).await,
1299 "Missing Codex binary at {} after installation",
1300 bin_path.to_string_lossy()
1301 );
1302
1303 let mut cmd = AgentServerCommand {
1304 path: bin_path,
1305 args: Vec::new(),
1306 env: None,
1307 };
1308 cmd.env = Some(env);
1309 cmd
1310 };
1311
1312 command.env.get_or_insert_default().extend(extra_env);
1313 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1314 })
1315 }
1316
1317 fn as_any_mut(&mut self) -> &mut dyn Any {
1318 self
1319 }
1320}
1321
1322pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1323
1324fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1325 let arch = if cfg!(target_arch = "x86_64") {
1326 "x86_64"
1327 } else if cfg!(target_arch = "aarch64") {
1328 "aarch64"
1329 } else {
1330 return None;
1331 };
1332
1333 let platform = if cfg!(target_os = "macos") {
1334 "apple-darwin"
1335 } else if cfg!(target_os = "windows") {
1336 "pc-windows-msvc"
1337 } else if cfg!(target_os = "linux") {
1338 "unknown-linux-gnu"
1339 } else {
1340 return None;
1341 };
1342
1343 // Only Windows x86_64 uses .zip in release assets
1344 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1345 "zip"
1346 } else {
1347 "tar.gz"
1348 };
1349
1350 Some((arch, platform, ext))
1351}
1352
1353fn asset_name(version: &str) -> Option<String> {
1354 let (arch, platform, ext) = get_platform_info()?;
1355 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1356}
1357
1358struct LocalExtensionArchiveAgent {
1359 fs: Arc<dyn Fs>,
1360 http_client: Arc<dyn HttpClient>,
1361 node_runtime: NodeRuntime,
1362 project_environment: Entity<ProjectEnvironment>,
1363 extension_id: Arc<str>,
1364 agent_id: Arc<str>,
1365 targets: HashMap<String, extension::TargetConfig>,
1366 env: HashMap<String, String>,
1367}
1368
1369struct LocalCustomAgent {
1370 project_environment: Entity<ProjectEnvironment>,
1371 command: AgentServerCommand,
1372}
1373
1374impl ExternalAgentServer for LocalExtensionArchiveAgent {
1375 fn get_command(
1376 &mut self,
1377 root_dir: Option<&str>,
1378 extra_env: HashMap<String, String>,
1379 _status_tx: Option<watch::Sender<SharedString>>,
1380 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1381 cx: &mut AsyncApp,
1382 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1383 let fs = self.fs.clone();
1384 let http_client = self.http_client.clone();
1385 let node_runtime = self.node_runtime.clone();
1386 let project_environment = self.project_environment.downgrade();
1387 let extension_id = self.extension_id.clone();
1388 let agent_id = self.agent_id.clone();
1389 let targets = self.targets.clone();
1390 let base_env = self.env.clone();
1391
1392 let root_dir: Arc<Path> = root_dir
1393 .map(|root_dir| Path::new(root_dir))
1394 .unwrap_or(paths::home_dir())
1395 .into();
1396
1397 cx.spawn(async move |cx| {
1398 // Get project environment
1399 let mut env = project_environment
1400 .update(cx, |project_environment, cx| {
1401 project_environment.local_directory_environment(
1402 &Shell::System,
1403 root_dir.clone(),
1404 cx,
1405 )
1406 })?
1407 .await
1408 .unwrap_or_default();
1409
1410 // Merge manifest env and extra env
1411 env.extend(base_env);
1412 env.extend(extra_env);
1413
1414 let cache_key = format!("{}/{}", extension_id, agent_id);
1415 let dir = paths::external_agents_dir().join(&cache_key);
1416 fs.create_dir(&dir).await?;
1417
1418 // Determine platform key
1419 let os = if cfg!(target_os = "macos") {
1420 "darwin"
1421 } else if cfg!(target_os = "linux") {
1422 "linux"
1423 } else if cfg!(target_os = "windows") {
1424 "windows"
1425 } else {
1426 anyhow::bail!("unsupported OS");
1427 };
1428
1429 let arch = if cfg!(target_arch = "aarch64") {
1430 "aarch64"
1431 } else if cfg!(target_arch = "x86_64") {
1432 "x86_64"
1433 } else {
1434 anyhow::bail!("unsupported architecture");
1435 };
1436
1437 let platform_key = format!("{}-{}", os, arch);
1438 let target_config = targets.get(&platform_key).with_context(|| {
1439 format!(
1440 "no target specified for platform '{}'. Available platforms: {}",
1441 platform_key,
1442 targets
1443 .keys()
1444 .map(|k| k.as_str())
1445 .collect::<Vec<_>>()
1446 .join(", ")
1447 )
1448 })?;
1449
1450 let archive_url = &target_config.archive;
1451
1452 // Use URL as version identifier for caching
1453 // Hash the URL to get a stable directory name
1454 use std::collections::hash_map::DefaultHasher;
1455 use std::hash::{Hash, Hasher};
1456 let mut hasher = DefaultHasher::new();
1457 archive_url.hash(&mut hasher);
1458 let url_hash = hasher.finish();
1459 let version_dir = dir.join(format!("v_{:x}", url_hash));
1460
1461 if !fs.is_dir(&version_dir).await {
1462 // Determine SHA256 for verification
1463 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1464 // Use provided SHA256
1465 Some(provided_sha.clone())
1466 } else if archive_url.starts_with("https://github.com/") {
1467 // Try to fetch SHA256 from GitHub API
1468 // Parse URL to extract repo and tag/file info
1469 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1470 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1471 let parts: Vec<&str> = caps.split('/').collect();
1472 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1473 let repo = format!("{}/{}", parts[0], parts[1]);
1474 let tag = parts[4];
1475 let filename = parts[5..].join("/");
1476
1477 // Try to get release info from GitHub
1478 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1479 &repo,
1480 tag,
1481 http_client.clone(),
1482 )
1483 .await
1484 {
1485 // Find matching asset
1486 if let Some(asset) =
1487 release.assets.iter().find(|a| a.name == filename)
1488 {
1489 // Strip "sha256:" prefix if present
1490 asset.digest.as_ref().and_then(|d| {
1491 d.strip_prefix("sha256:")
1492 .map(|s| s.to_string())
1493 .or_else(|| Some(d.clone()))
1494 })
1495 } else {
1496 None
1497 }
1498 } else {
1499 None
1500 }
1501 } else {
1502 None
1503 }
1504 } else {
1505 None
1506 }
1507 } else {
1508 None
1509 };
1510
1511 // Determine archive type from URL
1512 let asset_kind = if archive_url.ends_with(".zip") {
1513 AssetKind::Zip
1514 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1515 AssetKind::TarGz
1516 } else {
1517 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1518 };
1519
1520 // Download and extract
1521 ::http_client::github_download::download_server_binary(
1522 &*http_client,
1523 archive_url,
1524 sha256.as_deref(),
1525 &version_dir,
1526 asset_kind,
1527 )
1528 .await?;
1529 }
1530
1531 // Validate and resolve cmd path
1532 let cmd = &target_config.cmd;
1533
1534 let cmd_path = if cmd == "node" {
1535 // Use Zed's managed Node.js runtime
1536 node_runtime.binary_path().await?
1537 } else {
1538 if cmd.contains("..") {
1539 anyhow::bail!("command path cannot contain '..': {}", cmd);
1540 }
1541
1542 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1543 // Relative to extraction directory
1544 let cmd_path = version_dir.join(&cmd[2..]);
1545 anyhow::ensure!(
1546 fs.is_file(&cmd_path).await,
1547 "Missing command {} after extraction",
1548 cmd_path.to_string_lossy()
1549 );
1550 cmd_path
1551 } else {
1552 // On PATH
1553 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1554 }
1555 };
1556
1557 let command = AgentServerCommand {
1558 path: cmd_path,
1559 args: target_config.args.clone(),
1560 env: Some(env),
1561 };
1562
1563 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1564 })
1565 }
1566
1567 fn as_any_mut(&mut self) -> &mut dyn Any {
1568 self
1569 }
1570}
1571
1572impl ExternalAgentServer for LocalCustomAgent {
1573 fn get_command(
1574 &mut self,
1575 root_dir: Option<&str>,
1576 extra_env: HashMap<String, String>,
1577 _status_tx: Option<watch::Sender<SharedString>>,
1578 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1579 cx: &mut AsyncApp,
1580 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1581 let mut command = self.command.clone();
1582 let root_dir: Arc<Path> = root_dir
1583 .map(|root_dir| Path::new(root_dir))
1584 .unwrap_or(paths::home_dir())
1585 .into();
1586 let project_environment = self.project_environment.downgrade();
1587 cx.spawn(async move |cx| {
1588 let mut env = project_environment
1589 .update(cx, |project_environment, cx| {
1590 project_environment.local_directory_environment(
1591 &Shell::System,
1592 root_dir.clone(),
1593 cx,
1594 )
1595 })?
1596 .await
1597 .unwrap_or_default();
1598 env.extend(command.env.unwrap_or_default());
1599 env.extend(extra_env);
1600 command.env = Some(env);
1601 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1602 })
1603 }
1604
1605 fn as_any_mut(&mut self) -> &mut dyn Any {
1606 self
1607 }
1608}
1609
1610pub const GEMINI_NAME: &'static str = "gemini";
1611pub const CLAUDE_CODE_NAME: &'static str = "claude";
1612pub const CODEX_NAME: &'static str = "codex";
1613
1614#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1615pub struct AllAgentServersSettings {
1616 pub gemini: Option<BuiltinAgentServerSettings>,
1617 pub claude: Option<BuiltinAgentServerSettings>,
1618 pub codex: Option<BuiltinAgentServerSettings>,
1619 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1620}
1621#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1622pub struct BuiltinAgentServerSettings {
1623 pub path: Option<PathBuf>,
1624 pub args: Option<Vec<String>>,
1625 pub env: Option<HashMap<String, String>>,
1626 pub ignore_system_version: Option<bool>,
1627 pub default_mode: Option<String>,
1628}
1629
1630impl BuiltinAgentServerSettings {
1631 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1632 self.path.map(|path| AgentServerCommand {
1633 path,
1634 args: self.args.unwrap_or_default(),
1635 env: self.env,
1636 })
1637 }
1638}
1639
1640impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1641 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1642 BuiltinAgentServerSettings {
1643 path: value
1644 .path
1645 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1646 args: value.args,
1647 env: value.env,
1648 ignore_system_version: value.ignore_system_version,
1649 default_mode: value.default_mode,
1650 }
1651 }
1652}
1653
1654impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1655 fn from(value: AgentServerCommand) -> Self {
1656 BuiltinAgentServerSettings {
1657 path: Some(value.path),
1658 args: Some(value.args),
1659 env: value.env,
1660 ..Default::default()
1661 }
1662 }
1663}
1664
1665#[derive(Clone, JsonSchema, Debug, PartialEq)]
1666pub struct CustomAgentServerSettings {
1667 pub command: AgentServerCommand,
1668 /// The default mode to use for this agent.
1669 ///
1670 /// Note: Not only all agents support modes.
1671 ///
1672 /// Default: None
1673 pub default_mode: Option<String>,
1674}
1675
1676impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1677 fn from(value: settings::CustomAgentServerSettings) -> Self {
1678 CustomAgentServerSettings {
1679 command: AgentServerCommand {
1680 path: PathBuf::from(shellexpand::tilde(&value.path.to_string_lossy()).as_ref()),
1681 args: value.args,
1682 env: value.env,
1683 },
1684 default_mode: value.default_mode,
1685 }
1686 }
1687}
1688
1689impl settings::Settings for AllAgentServersSettings {
1690 fn from_settings(content: &settings::SettingsContent) -> Self {
1691 let agent_settings = content.agent_servers.clone().unwrap();
1692 Self {
1693 gemini: agent_settings.gemini.map(Into::into),
1694 claude: agent_settings.claude.map(Into::into),
1695 codex: agent_settings.codex.map(Into::into),
1696 custom: agent_settings
1697 .custom
1698 .into_iter()
1699 .map(|(k, v)| (k, v.into()))
1700 .collect(),
1701 }
1702 }
1703}
1704
1705#[cfg(test)]
1706mod extension_agent_tests {
1707 use crate::worktree_store::WorktreeStore;
1708
1709 use super::*;
1710 use gpui::TestAppContext;
1711 use std::sync::Arc;
1712
1713 #[test]
1714 fn extension_agent_constructs_proper_display_names() {
1715 // Verify the display name format for extension-provided agents
1716 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1717 assert!(name1.0.contains(": "));
1718
1719 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1720 assert_eq!(name2.0, "MyExt: MyAgent");
1721
1722 // Non-extension agents shouldn't have the separator
1723 let custom = ExternalAgentServerName(SharedString::from("custom"));
1724 assert!(!custom.0.contains(": "));
1725 }
1726
1727 struct NoopExternalAgent;
1728
1729 impl ExternalAgentServer for NoopExternalAgent {
1730 fn get_command(
1731 &mut self,
1732 _root_dir: Option<&str>,
1733 _extra_env: HashMap<String, String>,
1734 _status_tx: Option<watch::Sender<SharedString>>,
1735 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1736 _cx: &mut AsyncApp,
1737 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1738 Task::ready(Ok((
1739 AgentServerCommand {
1740 path: PathBuf::from("noop"),
1741 args: Vec::new(),
1742 env: None,
1743 },
1744 "".to_string(),
1745 None,
1746 )))
1747 }
1748
1749 fn as_any_mut(&mut self) -> &mut dyn Any {
1750 self
1751 }
1752 }
1753
1754 #[test]
1755 fn sync_removes_only_extension_provided_agents() {
1756 let mut store = AgentServerStore {
1757 state: AgentServerStoreState::Collab,
1758 external_agents: HashMap::default(),
1759 agent_icons: HashMap::default(),
1760 };
1761
1762 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1763 store.external_agents.insert(
1764 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1765 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1766 );
1767 store.external_agents.insert(
1768 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1769 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1770 );
1771 store.external_agents.insert(
1772 ExternalAgentServerName(SharedString::from("custom-agent")),
1773 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1774 );
1775
1776 // Simulate removal phase
1777 let keys_to_remove: Vec<_> = store
1778 .external_agents
1779 .keys()
1780 .filter(|name| name.0.contains(": "))
1781 .cloned()
1782 .collect();
1783
1784 for key in keys_to_remove {
1785 store.external_agents.remove(&key);
1786 }
1787
1788 // Only custom-agent should remain
1789 assert_eq!(store.external_agents.len(), 1);
1790 assert!(
1791 store
1792 .external_agents
1793 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
1794 );
1795 }
1796
1797 #[test]
1798 fn archive_launcher_constructs_with_all_fields() {
1799 use extension::AgentServerManifestEntry;
1800
1801 let mut env = HashMap::default();
1802 env.insert("GITHUB_TOKEN".into(), "secret".into());
1803
1804 let mut targets = HashMap::default();
1805 targets.insert(
1806 "darwin-aarch64".to_string(),
1807 extension::TargetConfig {
1808 archive:
1809 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
1810 .into(),
1811 cmd: "./agent".into(),
1812 args: vec![],
1813 sha256: None,
1814 },
1815 );
1816
1817 let _entry = AgentServerManifestEntry {
1818 name: "GitHub Agent".into(),
1819 targets,
1820 env,
1821 icon: None,
1822 };
1823
1824 // Verify display name construction
1825 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
1826 assert_eq!(expected_name.0, "GitHub Agent");
1827 }
1828
1829 #[gpui::test]
1830 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
1831 let fs = fs::FakeFs::new(cx.background_executor.clone());
1832 let http_client = http_client::FakeHttpClient::with_404_response();
1833 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
1834 let project_environment = cx.new(|cx| {
1835 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1836 });
1837
1838 let agent = LocalExtensionArchiveAgent {
1839 fs,
1840 http_client,
1841 node_runtime: node_runtime::NodeRuntime::unavailable(),
1842 project_environment,
1843 extension_id: Arc::from("my-extension"),
1844 agent_id: Arc::from("my-agent"),
1845 targets: {
1846 let mut map = HashMap::default();
1847 map.insert(
1848 "darwin-aarch64".to_string(),
1849 extension::TargetConfig {
1850 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
1851 cmd: "./my-agent".into(),
1852 args: vec!["--serve".into()],
1853 sha256: None,
1854 },
1855 );
1856 map
1857 },
1858 env: {
1859 let mut map = HashMap::default();
1860 map.insert("PORT".into(), "8080".into());
1861 map
1862 },
1863 };
1864
1865 // Verify agent is properly constructed
1866 assert_eq!(agent.extension_id.as_ref(), "my-extension");
1867 assert_eq!(agent.agent_id.as_ref(), "my-agent");
1868 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
1869 assert!(agent.targets.contains_key("darwin-aarch64"));
1870 }
1871
1872 #[test]
1873 fn sync_extension_agents_registers_archive_launcher() {
1874 use extension::AgentServerManifestEntry;
1875
1876 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
1877 assert_eq!(expected_name.0, "Release Agent");
1878
1879 // Verify the manifest entry structure for archive-based installation
1880 let mut env = HashMap::default();
1881 env.insert("API_KEY".into(), "secret".into());
1882
1883 let mut targets = HashMap::default();
1884 targets.insert(
1885 "linux-x86_64".to_string(),
1886 extension::TargetConfig {
1887 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
1888 cmd: "./release-agent".into(),
1889 args: vec!["serve".into()],
1890 sha256: None,
1891 },
1892 );
1893
1894 let manifest_entry = AgentServerManifestEntry {
1895 name: "Release Agent".into(),
1896 targets: targets.clone(),
1897 env,
1898 icon: None,
1899 };
1900
1901 // Verify target config is present
1902 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
1903 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
1904 assert_eq!(target.cmd, "./release-agent");
1905 }
1906
1907 #[gpui::test]
1908 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
1909 let fs = fs::FakeFs::new(cx.background_executor.clone());
1910 let http_client = http_client::FakeHttpClient::with_404_response();
1911 let node_runtime = NodeRuntime::unavailable();
1912 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
1913 let project_environment = cx.new(|cx| {
1914 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1915 });
1916
1917 let agent = LocalExtensionArchiveAgent {
1918 fs: fs.clone(),
1919 http_client,
1920 node_runtime,
1921 project_environment,
1922 extension_id: Arc::from("node-extension"),
1923 agent_id: Arc::from("node-agent"),
1924 targets: {
1925 let mut map = HashMap::default();
1926 map.insert(
1927 "darwin-aarch64".to_string(),
1928 extension::TargetConfig {
1929 archive: "https://example.com/node-agent.zip".into(),
1930 cmd: "node".into(),
1931 args: vec!["index.js".into()],
1932 sha256: None,
1933 },
1934 );
1935 map
1936 },
1937 env: HashMap::default(),
1938 };
1939
1940 // Verify that when cmd is "node", it attempts to use the node runtime
1941 assert_eq!(agent.extension_id.as_ref(), "node-extension");
1942 assert_eq!(agent.agent_id.as_ref(), "node-agent");
1943
1944 let target = agent.targets.get("darwin-aarch64").unwrap();
1945 assert_eq!(target.cmd, "node");
1946 assert_eq!(target.args, vec!["index.js"]);
1947 }
1948
1949 #[test]
1950 fn test_tilde_expansion_in_settings() {
1951 let settings = settings::BuiltinAgentServerSettings {
1952 path: Some(PathBuf::from("~/bin/agent")),
1953 args: Some(vec!["--flag".into()]),
1954 env: None,
1955 ignore_system_version: None,
1956 default_mode: None,
1957 };
1958
1959 let BuiltinAgentServerSettings { path, .. } = settings.into();
1960
1961 let path = path.unwrap();
1962 assert!(
1963 !path.to_string_lossy().starts_with("~"),
1964 "Tilde should be expanded for builtin agent path"
1965 );
1966
1967 let settings = settings::CustomAgentServerSettings {
1968 path: PathBuf::from("~/custom/agent"),
1969 args: vec!["serve".into()],
1970 env: None,
1971 default_mode: None,
1972 };
1973
1974 let CustomAgentServerSettings {
1975 command: AgentServerCommand { path, .. },
1976 ..
1977 } = settings.into();
1978
1979 assert!(
1980 !path.to_string_lossy().starts_with("~"),
1981 "Tilde should be expanded for custom agent path"
1982 );
1983 }
1984}