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 pub fn get_extension_id_for_agent(
764 &mut self,
765 name: &ExternalAgentServerName,
766 ) -> Option<Arc<str>> {
767 self.external_agents.get_mut(name).and_then(|agent| {
768 agent
769 .as_any_mut()
770 .downcast_ref::<LocalExtensionArchiveAgent>()
771 .map(|ext_agent| ext_agent.extension_id.clone())
772 })
773 }
774}
775
776fn get_or_npm_install_builtin_agent(
777 binary_name: SharedString,
778 package_name: SharedString,
779 entrypoint_path: PathBuf,
780 minimum_version: Option<semver::Version>,
781 status_tx: Option<watch::Sender<SharedString>>,
782 new_version_available: Option<watch::Sender<Option<String>>>,
783 fs: Arc<dyn Fs>,
784 node_runtime: NodeRuntime,
785 cx: &mut AsyncApp,
786) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
787 cx.spawn(async move |cx| {
788 let node_path = node_runtime.binary_path().await?;
789 let dir = paths::external_agents_dir().join(binary_name.as_str());
790 fs.create_dir(&dir).await?;
791
792 let mut stream = fs.read_dir(&dir).await?;
793 let mut versions = Vec::new();
794 let mut to_delete = Vec::new();
795 while let Some(entry) = stream.next().await {
796 let Ok(entry) = entry else { continue };
797 let Some(file_name) = entry.file_name() else {
798 continue;
799 };
800
801 if let Some(name) = file_name.to_str()
802 && let Some(version) = semver::Version::from_str(name).ok()
803 && fs
804 .is_file(&dir.join(file_name).join(&entrypoint_path))
805 .await
806 {
807 versions.push((version, file_name.to_owned()));
808 } else {
809 to_delete.push(file_name.to_owned())
810 }
811 }
812
813 versions.sort();
814 let newest_version = if let Some((version, file_name)) = versions.last().cloned()
815 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
816 {
817 versions.pop();
818 Some(file_name)
819 } else {
820 None
821 };
822 log::debug!("existing version of {package_name}: {newest_version:?}");
823 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
824
825 cx.background_spawn({
826 let fs = fs.clone();
827 let dir = dir.clone();
828 async move {
829 for file_name in to_delete {
830 fs.remove_dir(
831 &dir.join(file_name),
832 RemoveOptions {
833 recursive: true,
834 ignore_if_not_exists: false,
835 },
836 )
837 .await
838 .ok();
839 }
840 }
841 })
842 .detach();
843
844 let version = if let Some(file_name) = newest_version {
845 cx.background_spawn({
846 let file_name = file_name.clone();
847 let dir = dir.clone();
848 let fs = fs.clone();
849 async move {
850 let latest_version = node_runtime
851 .npm_package_latest_version(&package_name)
852 .await
853 .ok();
854 if let Some(latest_version) = latest_version
855 && &latest_version != &file_name.to_string_lossy()
856 {
857 let download_result = download_latest_version(
858 fs,
859 dir.clone(),
860 node_runtime,
861 package_name.clone(),
862 )
863 .await
864 .log_err();
865 if let Some(mut new_version_available) = new_version_available
866 && download_result.is_some()
867 {
868 new_version_available.send(Some(latest_version)).ok();
869 }
870 }
871 }
872 })
873 .detach();
874 file_name
875 } else {
876 if let Some(mut status_tx) = status_tx {
877 status_tx.send("Installing…".into()).ok();
878 }
879 let dir = dir.clone();
880 cx.background_spawn(download_latest_version(
881 fs.clone(),
882 dir.clone(),
883 node_runtime,
884 package_name.clone(),
885 ))
886 .await?
887 .into()
888 };
889
890 let agent_server_path = dir.join(version).join(entrypoint_path);
891 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
892 anyhow::ensure!(
893 agent_server_path_exists,
894 "Missing entrypoint path {} after installation",
895 agent_server_path.to_string_lossy()
896 );
897
898 anyhow::Ok(AgentServerCommand {
899 path: node_path,
900 args: vec![agent_server_path.to_string_lossy().into_owned()],
901 env: None,
902 })
903 })
904}
905
906fn find_bin_in_path(
907 bin_name: SharedString,
908 root_dir: PathBuf,
909 env: HashMap<String, String>,
910 cx: &mut AsyncApp,
911) -> Task<Option<PathBuf>> {
912 cx.background_executor().spawn(async move {
913 let which_result = if cfg!(windows) {
914 which::which(bin_name.as_str())
915 } else {
916 let shell_path = env.get("PATH").cloned();
917 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
918 };
919
920 if let Err(which::Error::CannotFindBinaryPath) = which_result {
921 return None;
922 }
923
924 which_result.log_err()
925 })
926}
927
928async fn download_latest_version(
929 fs: Arc<dyn Fs>,
930 dir: PathBuf,
931 node_runtime: NodeRuntime,
932 package_name: SharedString,
933) -> Result<String> {
934 log::debug!("downloading latest version of {package_name}");
935
936 let tmp_dir = tempfile::tempdir_in(&dir)?;
937
938 node_runtime
939 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
940 .await?;
941
942 let version = node_runtime
943 .npm_package_installed_version(tmp_dir.path(), &package_name)
944 .await?
945 .context("expected package to be installed")?;
946
947 fs.rename(
948 &tmp_dir.keep(),
949 &dir.join(&version),
950 RenameOptions {
951 ignore_if_exists: true,
952 overwrite: true,
953 },
954 )
955 .await?;
956
957 anyhow::Ok(version)
958}
959
960struct RemoteExternalAgentServer {
961 project_id: u64,
962 upstream_client: Entity<RemoteClient>,
963 name: ExternalAgentServerName,
964 status_tx: Option<watch::Sender<SharedString>>,
965 new_version_available_tx: Option<watch::Sender<Option<String>>>,
966}
967
968impl ExternalAgentServer for RemoteExternalAgentServer {
969 fn get_command(
970 &mut self,
971 root_dir: Option<&str>,
972 extra_env: HashMap<String, String>,
973 status_tx: Option<watch::Sender<SharedString>>,
974 new_version_available_tx: Option<watch::Sender<Option<String>>>,
975 cx: &mut AsyncApp,
976 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
977 let project_id = self.project_id;
978 let name = self.name.to_string();
979 let upstream_client = self.upstream_client.downgrade();
980 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
981 self.status_tx = status_tx;
982 self.new_version_available_tx = new_version_available_tx;
983 cx.spawn(async move |cx| {
984 let mut response = upstream_client
985 .update(cx, |upstream_client, _| {
986 upstream_client
987 .proto_client()
988 .request(proto::GetAgentServerCommand {
989 project_id,
990 name,
991 root_dir: root_dir.clone(),
992 })
993 })?
994 .await?;
995 let root_dir = response.root_dir;
996 response.env.extend(extra_env);
997 let command = upstream_client.update(cx, |client, _| {
998 client.build_command(
999 Some(response.path),
1000 &response.args,
1001 &response.env.into_iter().collect(),
1002 Some(root_dir.clone()),
1003 None,
1004 )
1005 })??;
1006 Ok((
1007 AgentServerCommand {
1008 path: command.program.into(),
1009 args: command.args,
1010 env: Some(command.env),
1011 },
1012 root_dir,
1013 None,
1014 ))
1015 })
1016 }
1017
1018 fn as_any_mut(&mut self) -> &mut dyn Any {
1019 self
1020 }
1021}
1022
1023struct LocalGemini {
1024 fs: Arc<dyn Fs>,
1025 node_runtime: NodeRuntime,
1026 project_environment: Entity<ProjectEnvironment>,
1027 custom_command: Option<AgentServerCommand>,
1028 ignore_system_version: bool,
1029}
1030
1031impl ExternalAgentServer for LocalGemini {
1032 fn get_command(
1033 &mut self,
1034 root_dir: Option<&str>,
1035 extra_env: HashMap<String, String>,
1036 status_tx: Option<watch::Sender<SharedString>>,
1037 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1038 cx: &mut AsyncApp,
1039 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1040 let fs = self.fs.clone();
1041 let node_runtime = self.node_runtime.clone();
1042 let project_environment = self.project_environment.downgrade();
1043 let custom_command = self.custom_command.clone();
1044 let ignore_system_version = self.ignore_system_version;
1045 let root_dir: Arc<Path> = root_dir
1046 .map(|root_dir| Path::new(root_dir))
1047 .unwrap_or(paths::home_dir())
1048 .into();
1049
1050 cx.spawn(async move |cx| {
1051 let mut env = project_environment
1052 .update(cx, |project_environment, cx| {
1053 project_environment.local_directory_environment(
1054 &Shell::System,
1055 root_dir.clone(),
1056 cx,
1057 )
1058 })?
1059 .await
1060 .unwrap_or_default();
1061
1062 let mut command = if let Some(mut custom_command) = custom_command {
1063 env.extend(custom_command.env.unwrap_or_default());
1064 custom_command.env = Some(env);
1065 custom_command
1066 } else if !ignore_system_version
1067 && let Some(bin) =
1068 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1069 {
1070 AgentServerCommand {
1071 path: bin,
1072 args: Vec::new(),
1073 env: Some(env),
1074 }
1075 } else {
1076 let mut command = get_or_npm_install_builtin_agent(
1077 GEMINI_NAME.into(),
1078 "@google/gemini-cli".into(),
1079 "node_modules/@google/gemini-cli/dist/index.js".into(),
1080 if cfg!(windows) {
1081 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1082 Some("0.9.0".parse().unwrap())
1083 } else {
1084 Some("0.2.1".parse().unwrap())
1085 },
1086 status_tx,
1087 new_version_available_tx,
1088 fs,
1089 node_runtime,
1090 cx,
1091 )
1092 .await?;
1093 command.env = Some(env);
1094 command
1095 };
1096
1097 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1098 let login = task::SpawnInTerminal {
1099 command: Some(command.path.to_string_lossy().into_owned()),
1100 args: command.args.clone(),
1101 env: command.env.clone().unwrap_or_default(),
1102 label: "gemini /auth".into(),
1103 ..Default::default()
1104 };
1105
1106 command.env.get_or_insert_default().extend(extra_env);
1107 command.args.push("--experimental-acp".into());
1108 Ok((
1109 command,
1110 root_dir.to_string_lossy().into_owned(),
1111 Some(login),
1112 ))
1113 })
1114 }
1115
1116 fn as_any_mut(&mut self) -> &mut dyn Any {
1117 self
1118 }
1119}
1120
1121struct LocalClaudeCode {
1122 fs: Arc<dyn Fs>,
1123 node_runtime: NodeRuntime,
1124 project_environment: Entity<ProjectEnvironment>,
1125 custom_command: Option<AgentServerCommand>,
1126}
1127
1128impl ExternalAgentServer for LocalClaudeCode {
1129 fn get_command(
1130 &mut self,
1131 root_dir: Option<&str>,
1132 extra_env: HashMap<String, String>,
1133 status_tx: Option<watch::Sender<SharedString>>,
1134 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1135 cx: &mut AsyncApp,
1136 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1137 let fs = self.fs.clone();
1138 let node_runtime = self.node_runtime.clone();
1139 let project_environment = self.project_environment.downgrade();
1140 let custom_command = self.custom_command.clone();
1141 let root_dir: Arc<Path> = root_dir
1142 .map(|root_dir| Path::new(root_dir))
1143 .unwrap_or(paths::home_dir())
1144 .into();
1145
1146 cx.spawn(async move |cx| {
1147 let mut env = project_environment
1148 .update(cx, |project_environment, cx| {
1149 project_environment.local_directory_environment(
1150 &Shell::System,
1151 root_dir.clone(),
1152 cx,
1153 )
1154 })?
1155 .await
1156 .unwrap_or_default();
1157 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1158
1159 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1160 env.extend(custom_command.env.unwrap_or_default());
1161 custom_command.env = Some(env);
1162 (custom_command, None)
1163 } else {
1164 let mut command = get_or_npm_install_builtin_agent(
1165 "claude-code-acp".into(),
1166 "@zed-industries/claude-code-acp".into(),
1167 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1168 Some("0.5.2".parse().unwrap()),
1169 status_tx,
1170 new_version_available_tx,
1171 fs,
1172 node_runtime,
1173 cx,
1174 )
1175 .await?;
1176 command.env = Some(env);
1177 let login = command
1178 .args
1179 .first()
1180 .and_then(|path| {
1181 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1182 })
1183 .map(|path_prefix| task::SpawnInTerminal {
1184 command: Some(command.path.to_string_lossy().into_owned()),
1185 args: vec![
1186 Path::new(path_prefix)
1187 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1188 .to_string_lossy()
1189 .to_string(),
1190 "/login".into(),
1191 ],
1192 env: command.env.clone().unwrap_or_default(),
1193 label: "claude /login".into(),
1194 ..Default::default()
1195 });
1196 (command, login)
1197 };
1198
1199 command.env.get_or_insert_default().extend(extra_env);
1200 Ok((
1201 command,
1202 root_dir.to_string_lossy().into_owned(),
1203 login_command,
1204 ))
1205 })
1206 }
1207
1208 fn as_any_mut(&mut self) -> &mut dyn Any {
1209 self
1210 }
1211}
1212
1213struct LocalCodex {
1214 fs: Arc<dyn Fs>,
1215 project_environment: Entity<ProjectEnvironment>,
1216 http_client: Arc<dyn HttpClient>,
1217 custom_command: Option<AgentServerCommand>,
1218 is_remote: bool,
1219}
1220
1221impl ExternalAgentServer for LocalCodex {
1222 fn get_command(
1223 &mut self,
1224 root_dir: Option<&str>,
1225 extra_env: HashMap<String, String>,
1226 status_tx: Option<watch::Sender<SharedString>>,
1227 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1228 cx: &mut AsyncApp,
1229 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1230 let fs = self.fs.clone();
1231 let project_environment = self.project_environment.downgrade();
1232 let http = self.http_client.clone();
1233 let custom_command = self.custom_command.clone();
1234 let root_dir: Arc<Path> = root_dir
1235 .map(|root_dir| Path::new(root_dir))
1236 .unwrap_or(paths::home_dir())
1237 .into();
1238 let is_remote = self.is_remote;
1239
1240 cx.spawn(async move |cx| {
1241 let mut env = project_environment
1242 .update(cx, |project_environment, cx| {
1243 project_environment.local_directory_environment(
1244 &Shell::System,
1245 root_dir.clone(),
1246 cx,
1247 )
1248 })?
1249 .await
1250 .unwrap_or_default();
1251 if is_remote {
1252 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1253 }
1254
1255 let mut command = if let Some(mut custom_command) = custom_command {
1256 env.extend(custom_command.env.unwrap_or_default());
1257 custom_command.env = Some(env);
1258 custom_command
1259 } else {
1260 let dir = paths::external_agents_dir().join(CODEX_NAME);
1261 fs.create_dir(&dir).await?;
1262
1263 // Find or install the latest Codex release (no update checks for now).
1264 let release = ::http_client::github::latest_github_release(
1265 CODEX_ACP_REPO,
1266 true,
1267 false,
1268 http.clone(),
1269 )
1270 .await
1271 .context("fetching Codex latest release")?;
1272
1273 let version_dir = dir.join(&release.tag_name);
1274 if !fs.is_dir(&version_dir).await {
1275 if let Some(mut status_tx) = status_tx {
1276 status_tx.send("Installing…".into()).ok();
1277 }
1278
1279 let tag = release.tag_name.clone();
1280 let version_number = tag.trim_start_matches('v');
1281 let asset_name = asset_name(version_number)
1282 .context("codex acp is not supported for this architecture")?;
1283 let asset = release
1284 .assets
1285 .into_iter()
1286 .find(|asset| asset.name == asset_name)
1287 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1288 // Strip "sha256:" prefix from digest if present (GitHub API format)
1289 let digest = asset
1290 .digest
1291 .as_deref()
1292 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1293 ::http_client::github_download::download_server_binary(
1294 &*http,
1295 &asset.browser_download_url,
1296 digest,
1297 &version_dir,
1298 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1299 AssetKind::Zip
1300 } else {
1301 AssetKind::TarGz
1302 },
1303 )
1304 .await?;
1305
1306 // remove older versions
1307 util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1308 }
1309
1310 let bin_name = if cfg!(windows) {
1311 "codex-acp.exe"
1312 } else {
1313 "codex-acp"
1314 };
1315 let bin_path = version_dir.join(bin_name);
1316 anyhow::ensure!(
1317 fs.is_file(&bin_path).await,
1318 "Missing Codex binary at {} after installation",
1319 bin_path.to_string_lossy()
1320 );
1321
1322 let mut cmd = AgentServerCommand {
1323 path: bin_path,
1324 args: Vec::new(),
1325 env: None,
1326 };
1327 cmd.env = Some(env);
1328 cmd
1329 };
1330
1331 command.env.get_or_insert_default().extend(extra_env);
1332 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1333 })
1334 }
1335
1336 fn as_any_mut(&mut self) -> &mut dyn Any {
1337 self
1338 }
1339}
1340
1341pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1342
1343fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1344 let arch = if cfg!(target_arch = "x86_64") {
1345 "x86_64"
1346 } else if cfg!(target_arch = "aarch64") {
1347 "aarch64"
1348 } else {
1349 return None;
1350 };
1351
1352 let platform = if cfg!(target_os = "macos") {
1353 "apple-darwin"
1354 } else if cfg!(target_os = "windows") {
1355 "pc-windows-msvc"
1356 } else if cfg!(target_os = "linux") {
1357 "unknown-linux-gnu"
1358 } else {
1359 return None;
1360 };
1361
1362 // Only Windows x86_64 uses .zip in release assets
1363 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1364 "zip"
1365 } else {
1366 "tar.gz"
1367 };
1368
1369 Some((arch, platform, ext))
1370}
1371
1372fn asset_name(version: &str) -> Option<String> {
1373 let (arch, platform, ext) = get_platform_info()?;
1374 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1375}
1376
1377struct LocalExtensionArchiveAgent {
1378 fs: Arc<dyn Fs>,
1379 http_client: Arc<dyn HttpClient>,
1380 node_runtime: NodeRuntime,
1381 project_environment: Entity<ProjectEnvironment>,
1382 extension_id: Arc<str>,
1383 agent_id: Arc<str>,
1384 targets: HashMap<String, extension::TargetConfig>,
1385 env: HashMap<String, String>,
1386}
1387
1388struct LocalCustomAgent {
1389 project_environment: Entity<ProjectEnvironment>,
1390 command: AgentServerCommand,
1391}
1392
1393impl ExternalAgentServer for LocalExtensionArchiveAgent {
1394 fn get_command(
1395 &mut self,
1396 root_dir: Option<&str>,
1397 extra_env: HashMap<String, String>,
1398 _status_tx: Option<watch::Sender<SharedString>>,
1399 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1400 cx: &mut AsyncApp,
1401 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1402 let fs = self.fs.clone();
1403 let http_client = self.http_client.clone();
1404 let node_runtime = self.node_runtime.clone();
1405 let project_environment = self.project_environment.downgrade();
1406 let extension_id = self.extension_id.clone();
1407 let agent_id = self.agent_id.clone();
1408 let targets = self.targets.clone();
1409 let base_env = self.env.clone();
1410
1411 let root_dir: Arc<Path> = root_dir
1412 .map(|root_dir| Path::new(root_dir))
1413 .unwrap_or(paths::home_dir())
1414 .into();
1415
1416 cx.spawn(async move |cx| {
1417 // Get project environment
1418 let mut env = project_environment
1419 .update(cx, |project_environment, cx| {
1420 project_environment.local_directory_environment(
1421 &Shell::System,
1422 root_dir.clone(),
1423 cx,
1424 )
1425 })?
1426 .await
1427 .unwrap_or_default();
1428
1429 // Merge manifest env and extra env
1430 env.extend(base_env);
1431 env.extend(extra_env);
1432
1433 let cache_key = format!("{}/{}", extension_id, agent_id);
1434 let dir = paths::external_agents_dir().join(&cache_key);
1435 fs.create_dir(&dir).await?;
1436
1437 // Determine platform key
1438 let os = if cfg!(target_os = "macos") {
1439 "darwin"
1440 } else if cfg!(target_os = "linux") {
1441 "linux"
1442 } else if cfg!(target_os = "windows") {
1443 "windows"
1444 } else {
1445 anyhow::bail!("unsupported OS");
1446 };
1447
1448 let arch = if cfg!(target_arch = "aarch64") {
1449 "aarch64"
1450 } else if cfg!(target_arch = "x86_64") {
1451 "x86_64"
1452 } else {
1453 anyhow::bail!("unsupported architecture");
1454 };
1455
1456 let platform_key = format!("{}-{}", os, arch);
1457 let target_config = targets.get(&platform_key).with_context(|| {
1458 format!(
1459 "no target specified for platform '{}'. Available platforms: {}",
1460 platform_key,
1461 targets
1462 .keys()
1463 .map(|k| k.as_str())
1464 .collect::<Vec<_>>()
1465 .join(", ")
1466 )
1467 })?;
1468
1469 let archive_url = &target_config.archive;
1470
1471 // Use URL as version identifier for caching
1472 // Hash the URL to get a stable directory name
1473 use std::collections::hash_map::DefaultHasher;
1474 use std::hash::{Hash, Hasher};
1475 let mut hasher = DefaultHasher::new();
1476 archive_url.hash(&mut hasher);
1477 let url_hash = hasher.finish();
1478 let version_dir = dir.join(format!("v_{:x}", url_hash));
1479
1480 if !fs.is_dir(&version_dir).await {
1481 // Determine SHA256 for verification
1482 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1483 // Use provided SHA256
1484 Some(provided_sha.clone())
1485 } else if archive_url.starts_with("https://github.com/") {
1486 // Try to fetch SHA256 from GitHub API
1487 // Parse URL to extract repo and tag/file info
1488 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1489 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1490 let parts: Vec<&str> = caps.split('/').collect();
1491 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1492 let repo = format!("{}/{}", parts[0], parts[1]);
1493 let tag = parts[4];
1494 let filename = parts[5..].join("/");
1495
1496 // Try to get release info from GitHub
1497 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1498 &repo,
1499 tag,
1500 http_client.clone(),
1501 )
1502 .await
1503 {
1504 // Find matching asset
1505 if let Some(asset) =
1506 release.assets.iter().find(|a| a.name == filename)
1507 {
1508 // Strip "sha256:" prefix if present
1509 asset.digest.as_ref().and_then(|d| {
1510 d.strip_prefix("sha256:")
1511 .map(|s| s.to_string())
1512 .or_else(|| Some(d.clone()))
1513 })
1514 } else {
1515 None
1516 }
1517 } else {
1518 None
1519 }
1520 } else {
1521 None
1522 }
1523 } else {
1524 None
1525 }
1526 } else {
1527 None
1528 };
1529
1530 // Determine archive type from URL
1531 let asset_kind = if archive_url.ends_with(".zip") {
1532 AssetKind::Zip
1533 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1534 AssetKind::TarGz
1535 } else {
1536 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1537 };
1538
1539 // Download and extract
1540 ::http_client::github_download::download_server_binary(
1541 &*http_client,
1542 archive_url,
1543 sha256.as_deref(),
1544 &version_dir,
1545 asset_kind,
1546 )
1547 .await?;
1548 }
1549
1550 // Validate and resolve cmd path
1551 let cmd = &target_config.cmd;
1552
1553 let cmd_path = if cmd == "node" {
1554 // Use Zed's managed Node.js runtime
1555 node_runtime.binary_path().await?
1556 } else {
1557 if cmd.contains("..") {
1558 anyhow::bail!("command path cannot contain '..': {}", cmd);
1559 }
1560
1561 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1562 // Relative to extraction directory
1563 let cmd_path = version_dir.join(&cmd[2..]);
1564 anyhow::ensure!(
1565 fs.is_file(&cmd_path).await,
1566 "Missing command {} after extraction",
1567 cmd_path.to_string_lossy()
1568 );
1569 cmd_path
1570 } else {
1571 // On PATH
1572 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1573 }
1574 };
1575
1576 let command = AgentServerCommand {
1577 path: cmd_path,
1578 args: target_config.args.clone(),
1579 env: Some(env),
1580 };
1581
1582 Ok((command, version_dir.to_string_lossy().into_owned(), None))
1583 })
1584 }
1585
1586 fn as_any_mut(&mut self) -> &mut dyn Any {
1587 self
1588 }
1589}
1590
1591impl ExternalAgentServer for LocalCustomAgent {
1592 fn get_command(
1593 &mut self,
1594 root_dir: Option<&str>,
1595 extra_env: HashMap<String, String>,
1596 _status_tx: Option<watch::Sender<SharedString>>,
1597 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1598 cx: &mut AsyncApp,
1599 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1600 let mut command = self.command.clone();
1601 let root_dir: Arc<Path> = root_dir
1602 .map(|root_dir| Path::new(root_dir))
1603 .unwrap_or(paths::home_dir())
1604 .into();
1605 let project_environment = self.project_environment.downgrade();
1606 cx.spawn(async move |cx| {
1607 let mut env = project_environment
1608 .update(cx, |project_environment, cx| {
1609 project_environment.local_directory_environment(
1610 &Shell::System,
1611 root_dir.clone(),
1612 cx,
1613 )
1614 })?
1615 .await
1616 .unwrap_or_default();
1617 env.extend(command.env.unwrap_or_default());
1618 env.extend(extra_env);
1619 command.env = Some(env);
1620 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1621 })
1622 }
1623
1624 fn as_any_mut(&mut self) -> &mut dyn Any {
1625 self
1626 }
1627}
1628
1629pub const GEMINI_NAME: &'static str = "gemini";
1630pub const CLAUDE_CODE_NAME: &'static str = "claude";
1631pub const CODEX_NAME: &'static str = "codex";
1632
1633#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1634pub struct AllAgentServersSettings {
1635 pub gemini: Option<BuiltinAgentServerSettings>,
1636 pub claude: Option<BuiltinAgentServerSettings>,
1637 pub codex: Option<BuiltinAgentServerSettings>,
1638 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1639}
1640#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1641pub struct BuiltinAgentServerSettings {
1642 pub path: Option<PathBuf>,
1643 pub args: Option<Vec<String>>,
1644 pub env: Option<HashMap<String, String>>,
1645 pub ignore_system_version: Option<bool>,
1646 pub default_mode: Option<String>,
1647}
1648
1649impl BuiltinAgentServerSettings {
1650 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1651 self.path.map(|path| AgentServerCommand {
1652 path,
1653 args: self.args.unwrap_or_default(),
1654 env: self.env,
1655 })
1656 }
1657}
1658
1659impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1660 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1661 BuiltinAgentServerSettings {
1662 path: value
1663 .path
1664 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1665 args: value.args,
1666 env: value.env,
1667 ignore_system_version: value.ignore_system_version,
1668 default_mode: value.default_mode,
1669 }
1670 }
1671}
1672
1673impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1674 fn from(value: AgentServerCommand) -> Self {
1675 BuiltinAgentServerSettings {
1676 path: Some(value.path),
1677 args: Some(value.args),
1678 env: value.env,
1679 ..Default::default()
1680 }
1681 }
1682}
1683
1684#[derive(Clone, JsonSchema, Debug, PartialEq)]
1685pub struct CustomAgentServerSettings {
1686 pub command: AgentServerCommand,
1687 /// The default mode to use for this agent.
1688 ///
1689 /// Note: Not only all agents support modes.
1690 ///
1691 /// Default: None
1692 pub default_mode: Option<String>,
1693}
1694
1695impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1696 fn from(value: settings::CustomAgentServerSettings) -> Self {
1697 CustomAgentServerSettings {
1698 command: AgentServerCommand {
1699 path: PathBuf::from(shellexpand::tilde(&value.path.to_string_lossy()).as_ref()),
1700 args: value.args,
1701 env: value.env,
1702 },
1703 default_mode: value.default_mode,
1704 }
1705 }
1706}
1707
1708impl settings::Settings for AllAgentServersSettings {
1709 fn from_settings(content: &settings::SettingsContent) -> Self {
1710 let agent_settings = content.agent_servers.clone().unwrap();
1711 Self {
1712 gemini: agent_settings.gemini.map(Into::into),
1713 claude: agent_settings.claude.map(Into::into),
1714 codex: agent_settings.codex.map(Into::into),
1715 custom: agent_settings
1716 .custom
1717 .into_iter()
1718 .map(|(k, v)| (k, v.into()))
1719 .collect(),
1720 }
1721 }
1722}
1723
1724#[cfg(test)]
1725mod extension_agent_tests {
1726 use crate::worktree_store::WorktreeStore;
1727
1728 use super::*;
1729 use gpui::TestAppContext;
1730 use std::sync::Arc;
1731
1732 #[test]
1733 fn extension_agent_constructs_proper_display_names() {
1734 // Verify the display name format for extension-provided agents
1735 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1736 assert!(name1.0.contains(": "));
1737
1738 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1739 assert_eq!(name2.0, "MyExt: MyAgent");
1740
1741 // Non-extension agents shouldn't have the separator
1742 let custom = ExternalAgentServerName(SharedString::from("custom"));
1743 assert!(!custom.0.contains(": "));
1744 }
1745
1746 struct NoopExternalAgent;
1747
1748 impl ExternalAgentServer for NoopExternalAgent {
1749 fn get_command(
1750 &mut self,
1751 _root_dir: Option<&str>,
1752 _extra_env: HashMap<String, String>,
1753 _status_tx: Option<watch::Sender<SharedString>>,
1754 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1755 _cx: &mut AsyncApp,
1756 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1757 Task::ready(Ok((
1758 AgentServerCommand {
1759 path: PathBuf::from("noop"),
1760 args: Vec::new(),
1761 env: None,
1762 },
1763 "".to_string(),
1764 None,
1765 )))
1766 }
1767
1768 fn as_any_mut(&mut self) -> &mut dyn Any {
1769 self
1770 }
1771 }
1772
1773 #[test]
1774 fn sync_removes_only_extension_provided_agents() {
1775 let mut store = AgentServerStore {
1776 state: AgentServerStoreState::Collab,
1777 external_agents: HashMap::default(),
1778 agent_icons: HashMap::default(),
1779 };
1780
1781 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1782 store.external_agents.insert(
1783 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1784 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1785 );
1786 store.external_agents.insert(
1787 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1788 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1789 );
1790 store.external_agents.insert(
1791 ExternalAgentServerName(SharedString::from("custom-agent")),
1792 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1793 );
1794
1795 // Simulate removal phase
1796 let keys_to_remove: Vec<_> = store
1797 .external_agents
1798 .keys()
1799 .filter(|name| name.0.contains(": "))
1800 .cloned()
1801 .collect();
1802
1803 for key in keys_to_remove {
1804 store.external_agents.remove(&key);
1805 }
1806
1807 // Only custom-agent should remain
1808 assert_eq!(store.external_agents.len(), 1);
1809 assert!(
1810 store
1811 .external_agents
1812 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
1813 );
1814 }
1815
1816 #[test]
1817 fn archive_launcher_constructs_with_all_fields() {
1818 use extension::AgentServerManifestEntry;
1819
1820 let mut env = HashMap::default();
1821 env.insert("GITHUB_TOKEN".into(), "secret".into());
1822
1823 let mut targets = HashMap::default();
1824 targets.insert(
1825 "darwin-aarch64".to_string(),
1826 extension::TargetConfig {
1827 archive:
1828 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
1829 .into(),
1830 cmd: "./agent".into(),
1831 args: vec![],
1832 sha256: None,
1833 },
1834 );
1835
1836 let _entry = AgentServerManifestEntry {
1837 name: "GitHub Agent".into(),
1838 targets,
1839 env,
1840 icon: None,
1841 };
1842
1843 // Verify display name construction
1844 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
1845 assert_eq!(expected_name.0, "GitHub Agent");
1846 }
1847
1848 #[gpui::test]
1849 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
1850 let fs = fs::FakeFs::new(cx.background_executor.clone());
1851 let http_client = http_client::FakeHttpClient::with_404_response();
1852 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
1853 let project_environment = cx.new(|cx| {
1854 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1855 });
1856
1857 let agent = LocalExtensionArchiveAgent {
1858 fs,
1859 http_client,
1860 node_runtime: node_runtime::NodeRuntime::unavailable(),
1861 project_environment,
1862 extension_id: Arc::from("my-extension"),
1863 agent_id: Arc::from("my-agent"),
1864 targets: {
1865 let mut map = HashMap::default();
1866 map.insert(
1867 "darwin-aarch64".to_string(),
1868 extension::TargetConfig {
1869 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
1870 cmd: "./my-agent".into(),
1871 args: vec!["--serve".into()],
1872 sha256: None,
1873 },
1874 );
1875 map
1876 },
1877 env: {
1878 let mut map = HashMap::default();
1879 map.insert("PORT".into(), "8080".into());
1880 map
1881 },
1882 };
1883
1884 // Verify agent is properly constructed
1885 assert_eq!(agent.extension_id.as_ref(), "my-extension");
1886 assert_eq!(agent.agent_id.as_ref(), "my-agent");
1887 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
1888 assert!(agent.targets.contains_key("darwin-aarch64"));
1889 }
1890
1891 #[test]
1892 fn sync_extension_agents_registers_archive_launcher() {
1893 use extension::AgentServerManifestEntry;
1894
1895 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
1896 assert_eq!(expected_name.0, "Release Agent");
1897
1898 // Verify the manifest entry structure for archive-based installation
1899 let mut env = HashMap::default();
1900 env.insert("API_KEY".into(), "secret".into());
1901
1902 let mut targets = HashMap::default();
1903 targets.insert(
1904 "linux-x86_64".to_string(),
1905 extension::TargetConfig {
1906 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
1907 cmd: "./release-agent".into(),
1908 args: vec!["serve".into()],
1909 sha256: None,
1910 },
1911 );
1912
1913 let manifest_entry = AgentServerManifestEntry {
1914 name: "Release Agent".into(),
1915 targets: targets.clone(),
1916 env,
1917 icon: None,
1918 };
1919
1920 // Verify target config is present
1921 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
1922 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
1923 assert_eq!(target.cmd, "./release-agent");
1924 }
1925
1926 #[gpui::test]
1927 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
1928 let fs = fs::FakeFs::new(cx.background_executor.clone());
1929 let http_client = http_client::FakeHttpClient::with_404_response();
1930 let node_runtime = NodeRuntime::unavailable();
1931 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
1932 let project_environment = cx.new(|cx| {
1933 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1934 });
1935
1936 let agent = LocalExtensionArchiveAgent {
1937 fs: fs.clone(),
1938 http_client,
1939 node_runtime,
1940 project_environment,
1941 extension_id: Arc::from("node-extension"),
1942 agent_id: Arc::from("node-agent"),
1943 targets: {
1944 let mut map = HashMap::default();
1945 map.insert(
1946 "darwin-aarch64".to_string(),
1947 extension::TargetConfig {
1948 archive: "https://example.com/node-agent.zip".into(),
1949 cmd: "node".into(),
1950 args: vec!["index.js".into()],
1951 sha256: None,
1952 },
1953 );
1954 map
1955 },
1956 env: HashMap::default(),
1957 };
1958
1959 // Verify that when cmd is "node", it attempts to use the node runtime
1960 assert_eq!(agent.extension_id.as_ref(), "node-extension");
1961 assert_eq!(agent.agent_id.as_ref(), "node-agent");
1962
1963 let target = agent.targets.get("darwin-aarch64").unwrap();
1964 assert_eq!(target.cmd, "node");
1965 assert_eq!(target.args, vec!["index.js"]);
1966 }
1967
1968 #[gpui::test]
1969 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
1970 let fs = fs::FakeFs::new(cx.background_executor.clone());
1971 let http_client = http_client::FakeHttpClient::with_404_response();
1972 let node_runtime = NodeRuntime::unavailable();
1973 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
1974 let project_environment = cx.new(|cx| {
1975 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1976 });
1977
1978 let agent = LocalExtensionArchiveAgent {
1979 fs: fs.clone(),
1980 http_client,
1981 node_runtime,
1982 project_environment,
1983 extension_id: Arc::from("test-ext"),
1984 agent_id: Arc::from("test-agent"),
1985 targets: {
1986 let mut map = HashMap::default();
1987 map.insert(
1988 "darwin-aarch64".to_string(),
1989 extension::TargetConfig {
1990 archive: "https://example.com/test.zip".into(),
1991 cmd: "node".into(),
1992 args: vec![
1993 "server.js".into(),
1994 "--config".into(),
1995 "./config.json".into(),
1996 ],
1997 sha256: None,
1998 },
1999 );
2000 map
2001 },
2002 env: HashMap::default(),
2003 };
2004
2005 // Verify the agent is configured with relative paths in args
2006 let target = agent.targets.get("darwin-aarch64").unwrap();
2007 assert_eq!(target.args[0], "server.js");
2008 assert_eq!(target.args[2], "./config.json");
2009 // These relative paths will resolve relative to the extraction directory
2010 // when the command is executed
2011 }
2012
2013 #[test]
2014 fn test_tilde_expansion_in_settings() {
2015 let settings = settings::BuiltinAgentServerSettings {
2016 path: Some(PathBuf::from("~/bin/agent")),
2017 args: Some(vec!["--flag".into()]),
2018 env: None,
2019 ignore_system_version: None,
2020 default_mode: None,
2021 };
2022
2023 let BuiltinAgentServerSettings { path, .. } = settings.into();
2024
2025 let path = path.unwrap();
2026 assert!(
2027 !path.to_string_lossy().starts_with("~"),
2028 "Tilde should be expanded for builtin agent path"
2029 );
2030
2031 let settings = settings::CustomAgentServerSettings {
2032 path: PathBuf::from("~/custom/agent"),
2033 args: vec!["serve".into()],
2034 env: None,
2035 default_mode: None,
2036 };
2037
2038 let CustomAgentServerSettings {
2039 command: AgentServerCommand { path, .. },
2040 ..
2041 } = settings.into();
2042
2043 assert!(
2044 !path.to_string_lossy().starts_with("~"),
2045 "Tilde should be expanded for custom agent path"
2046 );
2047 }
2048}