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