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::data_dir()
777 .join("external_agents")
778 .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::data_dir().join("external_agents").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 project_environment: Entity<ProjectEnvironment>,
1370 extension_id: Arc<str>,
1371 agent_id: Arc<str>,
1372 targets: HashMap<String, extension::TargetConfig>,
1373 env: HashMap<String, String>,
1374}
1375
1376struct LocalCustomAgent {
1377 project_environment: Entity<ProjectEnvironment>,
1378 command: AgentServerCommand,
1379}
1380
1381impl ExternalAgentServer for LocalExtensionArchiveAgent {
1382 fn get_command(
1383 &mut self,
1384 root_dir: Option<&str>,
1385 extra_env: HashMap<String, String>,
1386 _status_tx: Option<watch::Sender<SharedString>>,
1387 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1388 cx: &mut AsyncApp,
1389 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1390 let fs = self.fs.clone();
1391 let http_client = self.http_client.clone();
1392 let project_environment = self.project_environment.downgrade();
1393 let extension_id = self.extension_id.clone();
1394 let agent_id = self.agent_id.clone();
1395 let targets = self.targets.clone();
1396 let base_env = self.env.clone();
1397
1398 let root_dir: Arc<Path> = root_dir
1399 .map(|root_dir| Path::new(root_dir))
1400 .unwrap_or(paths::home_dir())
1401 .into();
1402
1403 cx.spawn(async move |cx| {
1404 // Get project environment
1405 let mut env = project_environment
1406 .update(cx, |project_environment, cx| {
1407 project_environment.get_local_directory_environment(
1408 &Shell::System,
1409 root_dir.clone(),
1410 cx,
1411 )
1412 })?
1413 .await
1414 .unwrap_or_default();
1415
1416 // Merge manifest env and extra env
1417 env.extend(base_env);
1418 env.extend(extra_env);
1419
1420 let cache_key = format!("{}/{}", extension_id, agent_id);
1421 let dir = paths::data_dir().join("external_agents").join(&cache_key);
1422 fs.create_dir(&dir).await?;
1423
1424 // Determine platform key
1425 let os = if cfg!(target_os = "macos") {
1426 "darwin"
1427 } else if cfg!(target_os = "linux") {
1428 "linux"
1429 } else if cfg!(target_os = "windows") {
1430 "windows"
1431 } else {
1432 anyhow::bail!("unsupported OS");
1433 };
1434
1435 let arch = if cfg!(target_arch = "aarch64") {
1436 "aarch64"
1437 } else if cfg!(target_arch = "x86_64") {
1438 "x86_64"
1439 } else {
1440 anyhow::bail!("unsupported architecture");
1441 };
1442
1443 let platform_key = format!("{}-{}", os, arch);
1444 let target_config = targets.get(&platform_key).with_context(|| {
1445 format!(
1446 "no target specified for platform '{}'. Available platforms: {}",
1447 platform_key,
1448 targets
1449 .keys()
1450 .map(|k| k.as_str())
1451 .collect::<Vec<_>>()
1452 .join(", ")
1453 )
1454 })?;
1455
1456 let archive_url = &target_config.archive;
1457
1458 // Use URL as version identifier for caching
1459 // Hash the URL to get a stable directory name
1460 use std::collections::hash_map::DefaultHasher;
1461 use std::hash::{Hash, Hasher};
1462 let mut hasher = DefaultHasher::new();
1463 archive_url.hash(&mut hasher);
1464 let url_hash = hasher.finish();
1465 let version_dir = dir.join(format!("v_{:x}", url_hash));
1466
1467 if !fs.is_dir(&version_dir).await {
1468 // Determine SHA256 for verification
1469 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1470 // Use provided SHA256
1471 Some(provided_sha.clone())
1472 } else if archive_url.starts_with("https://github.com/") {
1473 // Try to fetch SHA256 from GitHub API
1474 // Parse URL to extract repo and tag/file info
1475 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1476 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1477 let parts: Vec<&str> = caps.split('/').collect();
1478 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1479 let repo = format!("{}/{}", parts[0], parts[1]);
1480 let tag = parts[4];
1481 let filename = parts[5..].join("/");
1482
1483 // Try to get release info from GitHub
1484 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1485 &repo,
1486 tag,
1487 http_client.clone(),
1488 )
1489 .await
1490 {
1491 // Find matching asset
1492 if let Some(asset) =
1493 release.assets.iter().find(|a| a.name == filename)
1494 {
1495 // Strip "sha256:" prefix if present
1496 asset.digest.as_ref().and_then(|d| {
1497 d.strip_prefix("sha256:")
1498 .map(|s| s.to_string())
1499 .or_else(|| Some(d.clone()))
1500 })
1501 } else {
1502 None
1503 }
1504 } else {
1505 None
1506 }
1507 } else {
1508 None
1509 }
1510 } else {
1511 None
1512 }
1513 } else {
1514 None
1515 };
1516
1517 // Determine archive type from URL
1518 let asset_kind = if archive_url.ends_with(".zip") {
1519 AssetKind::Zip
1520 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1521 AssetKind::TarGz
1522 } else {
1523 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1524 };
1525
1526 // Download and extract
1527 ::http_client::github_download::download_server_binary(
1528 &*http_client,
1529 archive_url,
1530 sha256.as_deref(),
1531 &version_dir,
1532 asset_kind,
1533 )
1534 .await?;
1535 }
1536
1537 // Validate and resolve cmd path
1538 let cmd = &target_config.cmd;
1539 if cmd.contains("..") {
1540 anyhow::bail!("command path cannot contain '..': {}", cmd);
1541 }
1542
1543 let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
1544 // Relative to extraction directory
1545 version_dir.join(&cmd[2..])
1546 } else {
1547 // On PATH
1548 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1549 };
1550
1551 anyhow::ensure!(
1552 fs.is_file(&cmd_path).await,
1553 "Missing command {} after extraction",
1554 cmd_path.to_string_lossy()
1555 );
1556
1557 let command = AgentServerCommand {
1558 path: cmd_path,
1559 args: target_config.args.clone(),
1560 env: Some(env),
1561 };
1562
1563 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1564 })
1565 }
1566
1567 fn as_any_mut(&mut self) -> &mut dyn Any {
1568 self
1569 }
1570}
1571
1572impl ExternalAgentServer for LocalCustomAgent {
1573 fn get_command(
1574 &mut self,
1575 root_dir: Option<&str>,
1576 extra_env: HashMap<String, String>,
1577 _status_tx: Option<watch::Sender<SharedString>>,
1578 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1579 cx: &mut AsyncApp,
1580 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1581 let mut command = self.command.clone();
1582 let root_dir: Arc<Path> = root_dir
1583 .map(|root_dir| Path::new(root_dir))
1584 .unwrap_or(paths::home_dir())
1585 .into();
1586 let project_environment = self.project_environment.downgrade();
1587 cx.spawn(async move |cx| {
1588 let mut env = project_environment
1589 .update(cx, |project_environment, cx| {
1590 project_environment.get_local_directory_environment(
1591 &Shell::System,
1592 root_dir.clone(),
1593 cx,
1594 )
1595 })?
1596 .await
1597 .unwrap_or_default();
1598 env.extend(command.env.unwrap_or_default());
1599 env.extend(extra_env);
1600 command.env = Some(env);
1601 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1602 })
1603 }
1604
1605 fn as_any_mut(&mut self) -> &mut dyn Any {
1606 self
1607 }
1608}
1609
1610pub const GEMINI_NAME: &'static str = "gemini";
1611pub const CLAUDE_CODE_NAME: &'static str = "claude";
1612pub const CODEX_NAME: &'static str = "codex";
1613
1614#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1615pub struct AllAgentServersSettings {
1616 pub gemini: Option<BuiltinAgentServerSettings>,
1617 pub claude: Option<BuiltinAgentServerSettings>,
1618 pub codex: Option<BuiltinAgentServerSettings>,
1619 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1620}
1621#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1622pub struct BuiltinAgentServerSettings {
1623 pub path: Option<PathBuf>,
1624 pub args: Option<Vec<String>>,
1625 pub env: Option<HashMap<String, String>>,
1626 pub ignore_system_version: Option<bool>,
1627 pub default_mode: Option<String>,
1628}
1629
1630impl BuiltinAgentServerSettings {
1631 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1632 self.path.map(|path| AgentServerCommand {
1633 path,
1634 args: self.args.unwrap_or_default(),
1635 env: self.env,
1636 })
1637 }
1638}
1639
1640impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1641 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1642 BuiltinAgentServerSettings {
1643 path: value.path,
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: value.path,
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}