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