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