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 let tag = release.tag_name.clone();
1265 let version_number = tag.trim_start_matches('v');
1266 let asset_name = asset_name(version_number)
1267 .context("codex acp is not supported for this architecture")?;
1268 let asset = release
1269 .assets
1270 .into_iter()
1271 .find(|asset| asset.name == asset_name)
1272 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1273 // Strip "sha256:" prefix from digest if present (GitHub API format)
1274 let digest = asset
1275 .digest
1276 .as_deref()
1277 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1278 ::http_client::github_download::download_server_binary(
1279 &*http,
1280 &asset.browser_download_url,
1281 digest,
1282 &version_dir,
1283 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1284 AssetKind::Zip
1285 } else {
1286 AssetKind::TarGz
1287 },
1288 )
1289 .await?;
1290 }
1291
1292 let bin_name = if cfg!(windows) {
1293 "codex-acp.exe"
1294 } else {
1295 "codex-acp"
1296 };
1297 let bin_path = version_dir.join(bin_name);
1298 anyhow::ensure!(
1299 fs.is_file(&bin_path).await,
1300 "Missing Codex binary at {} after installation",
1301 bin_path.to_string_lossy()
1302 );
1303
1304 let mut cmd = AgentServerCommand {
1305 path: bin_path,
1306 args: Vec::new(),
1307 env: None,
1308 };
1309 cmd.env = Some(env);
1310 cmd
1311 };
1312
1313 command.env.get_or_insert_default().extend(extra_env);
1314 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1315 })
1316 }
1317
1318 fn as_any_mut(&mut self) -> &mut dyn Any {
1319 self
1320 }
1321}
1322
1323pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1324
1325fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1326 let arch = if cfg!(target_arch = "x86_64") {
1327 "x86_64"
1328 } else if cfg!(target_arch = "aarch64") {
1329 "aarch64"
1330 } else {
1331 return None;
1332 };
1333
1334 let platform = if cfg!(target_os = "macos") {
1335 "apple-darwin"
1336 } else if cfg!(target_os = "windows") {
1337 "pc-windows-msvc"
1338 } else if cfg!(target_os = "linux") {
1339 "unknown-linux-gnu"
1340 } else {
1341 return None;
1342 };
1343
1344 // Only Windows x86_64 uses .zip in release assets
1345 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1346 "zip"
1347 } else {
1348 "tar.gz"
1349 };
1350
1351 Some((arch, platform, ext))
1352}
1353
1354fn asset_name(version: &str) -> Option<String> {
1355 let (arch, platform, ext) = get_platform_info()?;
1356 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1357}
1358
1359struct LocalExtensionArchiveAgent {
1360 fs: Arc<dyn Fs>,
1361 http_client: Arc<dyn HttpClient>,
1362 project_environment: Entity<ProjectEnvironment>,
1363 extension_id: Arc<str>,
1364 agent_id: Arc<str>,
1365 targets: HashMap<String, extension::TargetConfig>,
1366 env: HashMap<String, String>,
1367}
1368
1369struct LocalCustomAgent {
1370 project_environment: Entity<ProjectEnvironment>,
1371 command: AgentServerCommand,
1372}
1373
1374impl ExternalAgentServer for LocalExtensionArchiveAgent {
1375 fn get_command(
1376 &mut self,
1377 root_dir: Option<&str>,
1378 extra_env: HashMap<String, String>,
1379 _status_tx: Option<watch::Sender<SharedString>>,
1380 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1381 cx: &mut AsyncApp,
1382 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1383 let fs = self.fs.clone();
1384 let http_client = self.http_client.clone();
1385 let project_environment = self.project_environment.downgrade();
1386 let extension_id = self.extension_id.clone();
1387 let agent_id = self.agent_id.clone();
1388 let targets = self.targets.clone();
1389 let base_env = self.env.clone();
1390
1391 let root_dir: Arc<Path> = root_dir
1392 .map(|root_dir| Path::new(root_dir))
1393 .unwrap_or(paths::home_dir())
1394 .into();
1395
1396 cx.spawn(async move |cx| {
1397 // Get project environment
1398 let mut env = project_environment
1399 .update(cx, |project_environment, cx| {
1400 project_environment.get_local_directory_environment(
1401 &Shell::System,
1402 root_dir.clone(),
1403 cx,
1404 )
1405 })?
1406 .await
1407 .unwrap_or_default();
1408
1409 // Merge manifest env and extra env
1410 env.extend(base_env);
1411 env.extend(extra_env);
1412
1413 let cache_key = format!("{}/{}", extension_id, agent_id);
1414 let dir = paths::data_dir().join("external_agents").join(&cache_key);
1415 fs.create_dir(&dir).await?;
1416
1417 // Determine platform key
1418 let os = if cfg!(target_os = "macos") {
1419 "darwin"
1420 } else if cfg!(target_os = "linux") {
1421 "linux"
1422 } else if cfg!(target_os = "windows") {
1423 "windows"
1424 } else {
1425 anyhow::bail!("unsupported OS");
1426 };
1427
1428 let arch = if cfg!(target_arch = "aarch64") {
1429 "aarch64"
1430 } else if cfg!(target_arch = "x86_64") {
1431 "x86_64"
1432 } else {
1433 anyhow::bail!("unsupported architecture");
1434 };
1435
1436 let platform_key = format!("{}-{}", os, arch);
1437 let target_config = targets.get(&platform_key).with_context(|| {
1438 format!(
1439 "no target specified for platform '{}'. Available platforms: {}",
1440 platform_key,
1441 targets
1442 .keys()
1443 .map(|k| k.as_str())
1444 .collect::<Vec<_>>()
1445 .join(", ")
1446 )
1447 })?;
1448
1449 let archive_url = &target_config.archive;
1450
1451 // Use URL as version identifier for caching
1452 // Hash the URL to get a stable directory name
1453 use std::collections::hash_map::DefaultHasher;
1454 use std::hash::{Hash, Hasher};
1455 let mut hasher = DefaultHasher::new();
1456 archive_url.hash(&mut hasher);
1457 let url_hash = hasher.finish();
1458 let version_dir = dir.join(format!("v_{:x}", url_hash));
1459
1460 if !fs.is_dir(&version_dir).await {
1461 // Determine SHA256 for verification
1462 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1463 // Use provided SHA256
1464 Some(provided_sha.clone())
1465 } else if archive_url.starts_with("https://github.com/") {
1466 // Try to fetch SHA256 from GitHub API
1467 // Parse URL to extract repo and tag/file info
1468 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1469 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1470 let parts: Vec<&str> = caps.split('/').collect();
1471 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1472 let repo = format!("{}/{}", parts[0], parts[1]);
1473 let tag = parts[4];
1474 let filename = parts[5..].join("/");
1475
1476 // Try to get release info from GitHub
1477 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1478 &repo,
1479 tag,
1480 http_client.clone(),
1481 )
1482 .await
1483 {
1484 // Find matching asset
1485 if let Some(asset) =
1486 release.assets.iter().find(|a| a.name == filename)
1487 {
1488 // Strip "sha256:" prefix if present
1489 asset.digest.as_ref().and_then(|d| {
1490 d.strip_prefix("sha256:")
1491 .map(|s| s.to_string())
1492 .or_else(|| Some(d.clone()))
1493 })
1494 } else {
1495 None
1496 }
1497 } else {
1498 None
1499 }
1500 } else {
1501 None
1502 }
1503 } else {
1504 None
1505 }
1506 } else {
1507 None
1508 };
1509
1510 // Determine archive type from URL
1511 let asset_kind = if archive_url.ends_with(".zip") {
1512 AssetKind::Zip
1513 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1514 AssetKind::TarGz
1515 } else {
1516 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1517 };
1518
1519 // Download and extract
1520 ::http_client::github_download::download_server_binary(
1521 &*http_client,
1522 archive_url,
1523 sha256.as_deref(),
1524 &version_dir,
1525 asset_kind,
1526 )
1527 .await?;
1528 }
1529
1530 // Validate and resolve cmd path
1531 let cmd = &target_config.cmd;
1532 if cmd.contains("..") {
1533 anyhow::bail!("command path cannot contain '..': {}", cmd);
1534 }
1535
1536 let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
1537 // Relative to extraction directory
1538 version_dir.join(&cmd[2..])
1539 } else {
1540 // On PATH
1541 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1542 };
1543
1544 anyhow::ensure!(
1545 fs.is_file(&cmd_path).await,
1546 "Missing command {} after extraction",
1547 cmd_path.to_string_lossy()
1548 );
1549
1550 let command = AgentServerCommand {
1551 path: cmd_path,
1552 args: target_config.args.clone(),
1553 env: Some(env),
1554 };
1555
1556 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1557 })
1558 }
1559
1560 fn as_any_mut(&mut self) -> &mut dyn Any {
1561 self
1562 }
1563}
1564
1565impl ExternalAgentServer for LocalCustomAgent {
1566 fn get_command(
1567 &mut self,
1568 root_dir: Option<&str>,
1569 extra_env: HashMap<String, String>,
1570 _status_tx: Option<watch::Sender<SharedString>>,
1571 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1572 cx: &mut AsyncApp,
1573 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1574 let mut command = self.command.clone();
1575 let root_dir: Arc<Path> = root_dir
1576 .map(|root_dir| Path::new(root_dir))
1577 .unwrap_or(paths::home_dir())
1578 .into();
1579 let project_environment = self.project_environment.downgrade();
1580 cx.spawn(async move |cx| {
1581 let mut env = project_environment
1582 .update(cx, |project_environment, cx| {
1583 project_environment.get_local_directory_environment(
1584 &Shell::System,
1585 root_dir.clone(),
1586 cx,
1587 )
1588 })?
1589 .await
1590 .unwrap_or_default();
1591 env.extend(command.env.unwrap_or_default());
1592 env.extend(extra_env);
1593 command.env = Some(env);
1594 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1595 })
1596 }
1597
1598 fn as_any_mut(&mut self) -> &mut dyn Any {
1599 self
1600 }
1601}
1602
1603pub const GEMINI_NAME: &'static str = "gemini";
1604pub const CLAUDE_CODE_NAME: &'static str = "claude";
1605pub const CODEX_NAME: &'static str = "codex";
1606
1607#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1608pub struct AllAgentServersSettings {
1609 pub gemini: Option<BuiltinAgentServerSettings>,
1610 pub claude: Option<BuiltinAgentServerSettings>,
1611 pub codex: Option<BuiltinAgentServerSettings>,
1612 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1613}
1614#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1615pub struct BuiltinAgentServerSettings {
1616 pub path: Option<PathBuf>,
1617 pub args: Option<Vec<String>>,
1618 pub env: Option<HashMap<String, String>>,
1619 pub ignore_system_version: Option<bool>,
1620 pub default_mode: Option<String>,
1621}
1622
1623impl BuiltinAgentServerSettings {
1624 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1625 self.path.map(|path| AgentServerCommand {
1626 path,
1627 args: self.args.unwrap_or_default(),
1628 env: self.env,
1629 })
1630 }
1631}
1632
1633impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1634 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1635 BuiltinAgentServerSettings {
1636 path: value.path,
1637 args: value.args,
1638 env: value.env,
1639 ignore_system_version: value.ignore_system_version,
1640 default_mode: value.default_mode,
1641 }
1642 }
1643}
1644
1645impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1646 fn from(value: AgentServerCommand) -> Self {
1647 BuiltinAgentServerSettings {
1648 path: Some(value.path),
1649 args: Some(value.args),
1650 env: value.env,
1651 ..Default::default()
1652 }
1653 }
1654}
1655
1656#[derive(Clone, JsonSchema, Debug, PartialEq)]
1657pub struct CustomAgentServerSettings {
1658 pub command: AgentServerCommand,
1659 /// The default mode to use for this agent.
1660 ///
1661 /// Note: Not only all agents support modes.
1662 ///
1663 /// Default: None
1664 pub default_mode: Option<String>,
1665}
1666
1667impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1668 fn from(value: settings::CustomAgentServerSettings) -> Self {
1669 CustomAgentServerSettings {
1670 command: AgentServerCommand {
1671 path: value.path,
1672 args: value.args,
1673 env: value.env,
1674 },
1675 default_mode: value.default_mode,
1676 }
1677 }
1678}
1679
1680impl settings::Settings for AllAgentServersSettings {
1681 fn from_settings(content: &settings::SettingsContent) -> Self {
1682 let agent_settings = content.agent_servers.clone().unwrap();
1683 Self {
1684 gemini: agent_settings.gemini.map(Into::into),
1685 claude: agent_settings.claude.map(Into::into),
1686 codex: agent_settings.codex.map(Into::into),
1687 custom: agent_settings
1688 .custom
1689 .into_iter()
1690 .map(|(k, v)| (k, v.into()))
1691 .collect(),
1692 }
1693 }
1694}
1695
1696#[cfg(test)]
1697mod extension_agent_tests {
1698 use super::*;
1699 use gpui::TestAppContext;
1700 use std::sync::Arc;
1701
1702 #[test]
1703 fn extension_agent_constructs_proper_display_names() {
1704 // Verify the display name format for extension-provided agents
1705 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1706 assert!(name1.0.contains(": "));
1707
1708 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1709 assert_eq!(name2.0, "MyExt: MyAgent");
1710
1711 // Non-extension agents shouldn't have the separator
1712 let custom = ExternalAgentServerName(SharedString::from("custom"));
1713 assert!(!custom.0.contains(": "));
1714 }
1715
1716 struct NoopExternalAgent;
1717
1718 impl ExternalAgentServer for NoopExternalAgent {
1719 fn get_command(
1720 &mut self,
1721 _root_dir: Option<&str>,
1722 _extra_env: HashMap<String, String>,
1723 _status_tx: Option<watch::Sender<SharedString>>,
1724 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1725 _cx: &mut AsyncApp,
1726 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1727 Task::ready(Ok((
1728 AgentServerCommand {
1729 path: PathBuf::from("noop"),
1730 args: Vec::new(),
1731 env: None,
1732 },
1733 "".to_string(),
1734 None,
1735 )))
1736 }
1737
1738 fn as_any_mut(&mut self) -> &mut dyn Any {
1739 self
1740 }
1741 }
1742
1743 #[test]
1744 fn sync_removes_only_extension_provided_agents() {
1745 let mut store = AgentServerStore {
1746 state: AgentServerStoreState::Collab,
1747 external_agents: HashMap::default(),
1748 agent_icons: HashMap::default(),
1749 };
1750
1751 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1752 store.external_agents.insert(
1753 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1754 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1755 );
1756 store.external_agents.insert(
1757 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1758 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1759 );
1760 store.external_agents.insert(
1761 ExternalAgentServerName(SharedString::from("custom-agent")),
1762 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1763 );
1764
1765 // Simulate removal phase
1766 let keys_to_remove: Vec<_> = store
1767 .external_agents
1768 .keys()
1769 .filter(|name| name.0.contains(": "))
1770 .cloned()
1771 .collect();
1772
1773 for key in keys_to_remove {
1774 store.external_agents.remove(&key);
1775 }
1776
1777 // Only custom-agent should remain
1778 assert_eq!(store.external_agents.len(), 1);
1779 assert!(
1780 store
1781 .external_agents
1782 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
1783 );
1784 }
1785
1786 #[test]
1787 fn archive_launcher_constructs_with_all_fields() {
1788 use extension::AgentServerManifestEntry;
1789
1790 let mut env = HashMap::default();
1791 env.insert("GITHUB_TOKEN".into(), "secret".into());
1792
1793 let mut targets = HashMap::default();
1794 targets.insert(
1795 "darwin-aarch64".to_string(),
1796 extension::TargetConfig {
1797 archive:
1798 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
1799 .into(),
1800 cmd: "./agent".into(),
1801 args: vec![],
1802 sha256: None,
1803 },
1804 );
1805
1806 let _entry = AgentServerManifestEntry {
1807 name: "GitHub Agent".into(),
1808 targets,
1809 env,
1810 icon: None,
1811 };
1812
1813 // Verify display name construction
1814 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
1815 assert_eq!(expected_name.0, "GitHub Agent");
1816 }
1817
1818 #[gpui::test]
1819 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
1820 let fs = fs::FakeFs::new(cx.background_executor.clone());
1821 let http_client = http_client::FakeHttpClient::with_404_response();
1822 let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
1823
1824 let agent = LocalExtensionArchiveAgent {
1825 fs,
1826 http_client,
1827 project_environment,
1828 extension_id: Arc::from("my-extension"),
1829 agent_id: Arc::from("my-agent"),
1830 targets: {
1831 let mut map = HashMap::default();
1832 map.insert(
1833 "darwin-aarch64".to_string(),
1834 extension::TargetConfig {
1835 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
1836 cmd: "./my-agent".into(),
1837 args: vec!["--serve".into()],
1838 sha256: None,
1839 },
1840 );
1841 map
1842 },
1843 env: {
1844 let mut map = HashMap::default();
1845 map.insert("PORT".into(), "8080".into());
1846 map
1847 },
1848 };
1849
1850 // Verify agent is properly constructed
1851 assert_eq!(agent.extension_id.as_ref(), "my-extension");
1852 assert_eq!(agent.agent_id.as_ref(), "my-agent");
1853 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
1854 assert!(agent.targets.contains_key("darwin-aarch64"));
1855 }
1856
1857 #[test]
1858 fn sync_extension_agents_registers_archive_launcher() {
1859 use extension::AgentServerManifestEntry;
1860
1861 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
1862 assert_eq!(expected_name.0, "Release Agent");
1863
1864 // Verify the manifest entry structure for archive-based installation
1865 let mut env = HashMap::default();
1866 env.insert("API_KEY".into(), "secret".into());
1867
1868 let mut targets = HashMap::default();
1869 targets.insert(
1870 "linux-x86_64".to_string(),
1871 extension::TargetConfig {
1872 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
1873 cmd: "./release-agent".into(),
1874 args: vec!["serve".into()],
1875 sha256: None,
1876 },
1877 );
1878
1879 let manifest_entry = AgentServerManifestEntry {
1880 name: "Release Agent".into(),
1881 targets: targets.clone(),
1882 env,
1883 icon: None,
1884 };
1885
1886 // Verify target config is present
1887 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
1888 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
1889 assert_eq!(target.cmd, "./release-agent");
1890 }
1891}