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