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 }
909
910 async fn handle_new_version_available(
911 this: Entity<Self>,
912 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
913 mut cx: AsyncApp,
914 ) -> Result<()> {
915 this.update(&mut cx, |this, _| {
916 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
917 && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
918 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
919 {
920 new_version_available_tx
921 .send(Some(envelope.payload.version))
922 .ok();
923 }
924 })
925 }
926
927 pub fn get_extension_id_for_agent(
928 &mut self,
929 name: &ExternalAgentServerName,
930 ) -> Option<Arc<str>> {
931 self.external_agents.get_mut(name).and_then(|agent| {
932 agent
933 .as_any_mut()
934 .downcast_ref::<LocalExtensionArchiveAgent>()
935 .map(|ext_agent| ext_agent.extension_id.clone())
936 })
937 }
938}
939
940fn get_or_npm_install_builtin_agent(
941 binary_name: SharedString,
942 package_name: SharedString,
943 entrypoint_path: PathBuf,
944 minimum_version: Option<semver::Version>,
945 status_tx: Option<watch::Sender<SharedString>>,
946 new_version_available: Option<watch::Sender<Option<String>>>,
947 fs: Arc<dyn Fs>,
948 node_runtime: NodeRuntime,
949 cx: &mut AsyncApp,
950) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
951 cx.spawn(async move |cx| {
952 let node_path = node_runtime.binary_path().await?;
953 let dir = paths::external_agents_dir().join(binary_name.as_str());
954 fs.create_dir(&dir).await?;
955
956 let mut stream = fs.read_dir(&dir).await?;
957 let mut versions = Vec::new();
958 let mut to_delete = Vec::new();
959 while let Some(entry) = stream.next().await {
960 let Ok(entry) = entry else { continue };
961 let Some(file_name) = entry.file_name() else {
962 continue;
963 };
964
965 if let Some(name) = file_name.to_str()
966 && let Some(version) = semver::Version::from_str(name).ok()
967 && fs
968 .is_file(&dir.join(file_name).join(&entrypoint_path))
969 .await
970 {
971 versions.push((version, file_name.to_owned()));
972 } else {
973 to_delete.push(file_name.to_owned())
974 }
975 }
976
977 versions.sort();
978 let newest_version = if let Some((version, _)) = versions.last().cloned()
979 && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
980 {
981 versions.pop()
982 } else {
983 None
984 };
985 log::debug!("existing version of {package_name}: {newest_version:?}");
986 to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
987
988 cx.background_spawn({
989 let fs = fs.clone();
990 let dir = dir.clone();
991 async move {
992 for file_name in to_delete {
993 fs.remove_dir(
994 &dir.join(file_name),
995 RemoveOptions {
996 recursive: true,
997 ignore_if_not_exists: false,
998 },
999 )
1000 .await
1001 .ok();
1002 }
1003 }
1004 })
1005 .detach();
1006
1007 let version = if let Some((version, file_name)) = newest_version {
1008 cx.background_spawn({
1009 let dir = dir.clone();
1010 let fs = fs.clone();
1011 async move {
1012 let latest_version = node_runtime
1013 .npm_package_latest_version(&package_name)
1014 .await
1015 .ok();
1016 if let Some(latest_version) = latest_version
1017 && latest_version != version
1018 {
1019 let download_result = download_latest_version(
1020 fs,
1021 dir.clone(),
1022 node_runtime,
1023 package_name.clone(),
1024 )
1025 .await
1026 .log_err();
1027 if let Some(mut new_version_available) = new_version_available
1028 && download_result.is_some()
1029 {
1030 new_version_available
1031 .send(Some(latest_version.to_string()))
1032 .ok();
1033 }
1034 }
1035 }
1036 })
1037 .detach();
1038 file_name
1039 } else {
1040 if let Some(mut status_tx) = status_tx {
1041 status_tx.send("Installing…".into()).ok();
1042 }
1043 let dir = dir.clone();
1044 cx.background_spawn(download_latest_version(
1045 fs.clone(),
1046 dir.clone(),
1047 node_runtime,
1048 package_name.clone(),
1049 ))
1050 .await?
1051 .to_string()
1052 .into()
1053 };
1054
1055 let agent_server_path = dir.join(version).join(entrypoint_path);
1056 let agent_server_path_exists = fs.is_file(&agent_server_path).await;
1057 anyhow::ensure!(
1058 agent_server_path_exists,
1059 "Missing entrypoint path {} after installation",
1060 agent_server_path.to_string_lossy()
1061 );
1062
1063 anyhow::Ok(AgentServerCommand {
1064 path: node_path,
1065 args: vec![agent_server_path.to_string_lossy().into_owned()],
1066 env: None,
1067 })
1068 })
1069}
1070
1071fn find_bin_in_path(
1072 bin_name: SharedString,
1073 root_dir: PathBuf,
1074 env: HashMap<String, String>,
1075 cx: &mut AsyncApp,
1076) -> Task<Option<PathBuf>> {
1077 cx.background_executor().spawn(async move {
1078 let which_result = if cfg!(windows) {
1079 which::which(bin_name.as_str())
1080 } else {
1081 let shell_path = env.get("PATH").cloned();
1082 which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
1083 };
1084
1085 if let Err(which::Error::CannotFindBinaryPath) = which_result {
1086 return None;
1087 }
1088
1089 which_result.log_err()
1090 })
1091}
1092
1093async fn download_latest_version(
1094 fs: Arc<dyn Fs>,
1095 dir: PathBuf,
1096 node_runtime: NodeRuntime,
1097 package_name: SharedString,
1098) -> Result<Version> {
1099 log::debug!("downloading latest version of {package_name}");
1100
1101 let tmp_dir = tempfile::tempdir_in(&dir)?;
1102
1103 node_runtime
1104 .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
1105 .await?;
1106
1107 let version = node_runtime
1108 .npm_package_installed_version(tmp_dir.path(), &package_name)
1109 .await?
1110 .context("expected package to be installed")?;
1111
1112 fs.rename(
1113 &tmp_dir.keep(),
1114 &dir.join(version.to_string()),
1115 RenameOptions {
1116 ignore_if_exists: true,
1117 overwrite: true,
1118 create_parents: false,
1119 },
1120 )
1121 .await?;
1122
1123 anyhow::Ok(version)
1124}
1125
1126struct RemoteExternalAgentServer {
1127 project_id: u64,
1128 upstream_client: Entity<RemoteClient>,
1129 name: ExternalAgentServerName,
1130 status_tx: Option<watch::Sender<SharedString>>,
1131 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1132}
1133
1134impl ExternalAgentServer for RemoteExternalAgentServer {
1135 fn get_command(
1136 &mut self,
1137 root_dir: Option<&str>,
1138 extra_env: HashMap<String, String>,
1139 status_tx: Option<watch::Sender<SharedString>>,
1140 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1141 cx: &mut AsyncApp,
1142 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1143 let project_id = self.project_id;
1144 let name = self.name.to_string();
1145 let upstream_client = self.upstream_client.downgrade();
1146 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1147 self.status_tx = status_tx;
1148 self.new_version_available_tx = new_version_available_tx;
1149 cx.spawn(async move |cx| {
1150 let mut response = upstream_client
1151 .update(cx, |upstream_client, _| {
1152 upstream_client
1153 .proto_client()
1154 .request(proto::GetAgentServerCommand {
1155 project_id,
1156 name,
1157 root_dir: root_dir.clone(),
1158 })
1159 })?
1160 .await?;
1161 let root_dir = response.root_dir;
1162 response.env.extend(extra_env);
1163 let command = upstream_client.update(cx, |client, _| {
1164 client.build_command(
1165 Some(response.path),
1166 &response.args,
1167 &response.env.into_iter().collect(),
1168 Some(root_dir.clone()),
1169 None,
1170 )
1171 })??;
1172 Ok((
1173 AgentServerCommand {
1174 path: command.program.into(),
1175 args: command.args,
1176 env: Some(command.env),
1177 },
1178 root_dir,
1179 response.login.map(SpawnInTerminal::from_proto),
1180 ))
1181 })
1182 }
1183
1184 fn as_any_mut(&mut self) -> &mut dyn Any {
1185 self
1186 }
1187}
1188
1189struct LocalGemini {
1190 fs: Arc<dyn Fs>,
1191 node_runtime: NodeRuntime,
1192 project_environment: Entity<ProjectEnvironment>,
1193 custom_command: Option<AgentServerCommand>,
1194 ignore_system_version: bool,
1195}
1196
1197impl ExternalAgentServer for LocalGemini {
1198 fn get_command(
1199 &mut self,
1200 root_dir: Option<&str>,
1201 extra_env: HashMap<String, String>,
1202 status_tx: Option<watch::Sender<SharedString>>,
1203 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1204 cx: &mut AsyncApp,
1205 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1206 let fs = self.fs.clone();
1207 let node_runtime = self.node_runtime.clone();
1208 let project_environment = self.project_environment.downgrade();
1209 let custom_command = self.custom_command.clone();
1210 let ignore_system_version = self.ignore_system_version;
1211 let root_dir: Arc<Path> = root_dir
1212 .map(|root_dir| Path::new(root_dir))
1213 .unwrap_or(paths::home_dir())
1214 .into();
1215
1216 cx.spawn(async move |cx| {
1217 let mut env = project_environment
1218 .update(cx, |project_environment, cx| {
1219 project_environment.local_directory_environment(
1220 &Shell::System,
1221 root_dir.clone(),
1222 cx,
1223 )
1224 })?
1225 .await
1226 .unwrap_or_default();
1227
1228 let mut command = if let Some(mut custom_command) = custom_command {
1229 env.extend(custom_command.env.unwrap_or_default());
1230 custom_command.env = Some(env);
1231 custom_command
1232 } else if !ignore_system_version
1233 && let Some(bin) =
1234 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1235 {
1236 AgentServerCommand {
1237 path: bin,
1238 args: Vec::new(),
1239 env: Some(env),
1240 }
1241 } else {
1242 let mut command = get_or_npm_install_builtin_agent(
1243 GEMINI_NAME.into(),
1244 "@google/gemini-cli".into(),
1245 "node_modules/@google/gemini-cli/dist/index.js".into(),
1246 if cfg!(windows) {
1247 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1248 Some("0.9.0".parse().unwrap())
1249 } else {
1250 Some("0.2.1".parse().unwrap())
1251 },
1252 status_tx,
1253 new_version_available_tx,
1254 fs,
1255 node_runtime,
1256 cx,
1257 )
1258 .await?;
1259 command.env = Some(env);
1260 command
1261 };
1262
1263 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1264 let login = task::SpawnInTerminal {
1265 command: Some(command.path.to_string_lossy().into_owned()),
1266 args: command.args.clone(),
1267 env: command.env.clone().unwrap_or_default(),
1268 label: "gemini /auth".into(),
1269 ..Default::default()
1270 };
1271
1272 command.env.get_or_insert_default().extend(extra_env);
1273 command.args.push("--experimental-acp".into());
1274 Ok((
1275 command,
1276 root_dir.to_string_lossy().into_owned(),
1277 Some(login),
1278 ))
1279 })
1280 }
1281
1282 fn as_any_mut(&mut self) -> &mut dyn Any {
1283 self
1284 }
1285}
1286
1287struct LocalClaudeCode {
1288 fs: Arc<dyn Fs>,
1289 node_runtime: NodeRuntime,
1290 project_environment: Entity<ProjectEnvironment>,
1291 custom_command: Option<AgentServerCommand>,
1292}
1293
1294impl ExternalAgentServer for LocalClaudeCode {
1295 fn get_command(
1296 &mut self,
1297 root_dir: Option<&str>,
1298 extra_env: HashMap<String, String>,
1299 status_tx: Option<watch::Sender<SharedString>>,
1300 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1301 cx: &mut AsyncApp,
1302 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1303 let fs = self.fs.clone();
1304 let node_runtime = self.node_runtime.clone();
1305 let project_environment = self.project_environment.downgrade();
1306 let custom_command = self.custom_command.clone();
1307 let root_dir: Arc<Path> = root_dir
1308 .map(|root_dir| Path::new(root_dir))
1309 .unwrap_or(paths::home_dir())
1310 .into();
1311
1312 cx.spawn(async move |cx| {
1313 let mut env = project_environment
1314 .update(cx, |project_environment, cx| {
1315 project_environment.local_directory_environment(
1316 &Shell::System,
1317 root_dir.clone(),
1318 cx,
1319 )
1320 })?
1321 .await
1322 .unwrap_or_default();
1323 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1324
1325 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1326 env.extend(custom_command.env.unwrap_or_default());
1327 custom_command.env = Some(env);
1328 (custom_command, None)
1329 } else {
1330 let mut command = get_or_npm_install_builtin_agent(
1331 "claude-code-acp".into(),
1332 "@zed-industries/claude-code-acp".into(),
1333 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1334 Some("0.5.2".parse().unwrap()),
1335 status_tx,
1336 new_version_available_tx,
1337 fs,
1338 node_runtime,
1339 cx,
1340 )
1341 .await?;
1342 command.env = Some(env);
1343 let login = command
1344 .args
1345 .first()
1346 .and_then(|path| {
1347 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1348 })
1349 .map(|path_prefix| task::SpawnInTerminal {
1350 command: Some(command.path.to_string_lossy().into_owned()),
1351 args: vec![
1352 Path::new(path_prefix)
1353 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1354 .to_string_lossy()
1355 .to_string(),
1356 "/login".into(),
1357 ],
1358 env: command.env.clone().unwrap_or_default(),
1359 label: "claude /login".into(),
1360 ..Default::default()
1361 });
1362 (command, login)
1363 };
1364
1365 command.env.get_or_insert_default().extend(extra_env);
1366 Ok((
1367 command,
1368 root_dir.to_string_lossy().into_owned(),
1369 login_command,
1370 ))
1371 })
1372 }
1373
1374 fn as_any_mut(&mut self) -> &mut dyn Any {
1375 self
1376 }
1377}
1378
1379struct LocalCodex {
1380 fs: Arc<dyn Fs>,
1381 project_environment: Entity<ProjectEnvironment>,
1382 http_client: Arc<dyn HttpClient>,
1383 custom_command: Option<AgentServerCommand>,
1384 no_browser: bool,
1385}
1386
1387impl ExternalAgentServer for LocalCodex {
1388 fn get_command(
1389 &mut self,
1390 root_dir: Option<&str>,
1391 extra_env: HashMap<String, String>,
1392 mut status_tx: Option<watch::Sender<SharedString>>,
1393 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1394 cx: &mut AsyncApp,
1395 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1396 let fs = self.fs.clone();
1397 let project_environment = self.project_environment.downgrade();
1398 let http = self.http_client.clone();
1399 let custom_command = self.custom_command.clone();
1400 let root_dir: Arc<Path> = root_dir
1401 .map(|root_dir| Path::new(root_dir))
1402 .unwrap_or(paths::home_dir())
1403 .into();
1404 let no_browser = self.no_browser;
1405
1406 cx.spawn(async move |cx| {
1407 let mut env = project_environment
1408 .update(cx, |project_environment, cx| {
1409 project_environment.local_directory_environment(
1410 &Shell::System,
1411 root_dir.clone(),
1412 cx,
1413 )
1414 })?
1415 .await
1416 .unwrap_or_default();
1417 if no_browser {
1418 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1419 }
1420
1421 let mut command = if let Some(mut custom_command) = custom_command {
1422 env.extend(custom_command.env.unwrap_or_default());
1423 custom_command.env = Some(env);
1424 custom_command
1425 } else {
1426 let dir = paths::external_agents_dir().join(CODEX_NAME);
1427 fs.create_dir(&dir).await?;
1428
1429 let bin_name = if cfg!(windows) {
1430 "codex-acp.exe"
1431 } else {
1432 "codex-acp"
1433 };
1434
1435 let find_latest_local_version = async || -> Option<PathBuf> {
1436 let mut local_versions: Vec<(semver::Version, String)> = Vec::new();
1437 let mut stream = fs.read_dir(&dir).await.ok()?;
1438 while let Some(entry) = stream.next().await {
1439 let Ok(entry) = entry else { continue };
1440 let Some(file_name) = entry.file_name() else {
1441 continue;
1442 };
1443 let version_path = dir.join(&file_name);
1444 if fs.is_file(&version_path.join(bin_name)).await {
1445 let version_str = file_name.to_string_lossy();
1446 if let Ok(version) =
1447 semver::Version::from_str(version_str.trim_start_matches('v'))
1448 {
1449 local_versions.push((version, version_str.into_owned()));
1450 }
1451 }
1452 }
1453 local_versions.sort_by(|(a, _), (b, _)| a.cmp(b));
1454 local_versions.last().map(|(_, v)| dir.join(v))
1455 };
1456
1457 let fallback_to_latest_local_version =
1458 async |err: anyhow::Error| -> Result<PathBuf, anyhow::Error> {
1459 if let Some(local) = find_latest_local_version().await {
1460 log::info!(
1461 "Falling back to locally installed Codex version: {}",
1462 local.display()
1463 );
1464 Ok(local)
1465 } else {
1466 Err(err)
1467 }
1468 };
1469
1470 let version_dir = match ::http_client::github::latest_github_release(
1471 CODEX_ACP_REPO,
1472 true,
1473 false,
1474 http.clone(),
1475 )
1476 .await
1477 {
1478 Ok(release) => {
1479 let version_dir = dir.join(&release.tag_name);
1480 if !fs.is_dir(&version_dir).await {
1481 if let Some(ref mut status_tx) = status_tx {
1482 status_tx.send("Installing…".into()).ok();
1483 }
1484
1485 let tag = release.tag_name.clone();
1486 let version_number = tag.trim_start_matches('v');
1487 let asset_name = asset_name(version_number)
1488 .context("codex acp is not supported for this architecture")?;
1489 let asset = release
1490 .assets
1491 .into_iter()
1492 .find(|asset| asset.name == asset_name)
1493 .with_context(|| {
1494 format!("no asset found matching `{asset_name:?}`")
1495 })?;
1496 // Strip "sha256:" prefix from digest if present (GitHub API format)
1497 let digest = asset
1498 .digest
1499 .as_deref()
1500 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1501 match ::http_client::github_download::download_server_binary(
1502 &*http,
1503 &asset.browser_download_url,
1504 digest,
1505 &version_dir,
1506 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1507 AssetKind::Zip
1508 } else {
1509 AssetKind::TarGz
1510 },
1511 )
1512 .await
1513 {
1514 Ok(()) => {
1515 // remove older versions
1516 util::fs::remove_matching(&dir, |entry| entry != version_dir)
1517 .await;
1518 version_dir
1519 }
1520 Err(err) => {
1521 log::error!(
1522 "Failed to download Codex release {}: {err:#}",
1523 release.tag_name
1524 );
1525 fallback_to_latest_local_version(err).await?
1526 }
1527 }
1528 } else {
1529 version_dir
1530 }
1531 }
1532 Err(err) => {
1533 log::error!("Failed to fetch Codex latest release: {err:#}");
1534 fallback_to_latest_local_version(err).await?
1535 }
1536 };
1537
1538 let bin_path = version_dir.join(bin_name);
1539 anyhow::ensure!(
1540 fs.is_file(&bin_path).await,
1541 "Missing Codex binary at {} after installation",
1542 bin_path.to_string_lossy()
1543 );
1544
1545 let mut cmd = AgentServerCommand {
1546 path: bin_path,
1547 args: Vec::new(),
1548 env: None,
1549 };
1550 cmd.env = Some(env);
1551 cmd
1552 };
1553
1554 command.env.get_or_insert_default().extend(extra_env);
1555 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1556 })
1557 }
1558
1559 fn as_any_mut(&mut self) -> &mut dyn Any {
1560 self
1561 }
1562}
1563
1564pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1565
1566fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1567 let arch = if cfg!(target_arch = "x86_64") {
1568 "x86_64"
1569 } else if cfg!(target_arch = "aarch64") {
1570 "aarch64"
1571 } else {
1572 return None;
1573 };
1574
1575 let platform = if cfg!(target_os = "macos") {
1576 "apple-darwin"
1577 } else if cfg!(target_os = "windows") {
1578 "pc-windows-msvc"
1579 } else if cfg!(target_os = "linux") {
1580 "unknown-linux-gnu"
1581 } else {
1582 return None;
1583 };
1584
1585 // Windows uses .zip in release assets
1586 let ext = if cfg!(target_os = "windows") {
1587 "zip"
1588 } else {
1589 "tar.gz"
1590 };
1591
1592 Some((arch, platform, ext))
1593}
1594
1595fn asset_name(version: &str) -> Option<String> {
1596 let (arch, platform, ext) = get_platform_info()?;
1597 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1598}
1599
1600struct LocalExtensionArchiveAgent {
1601 fs: Arc<dyn Fs>,
1602 http_client: Arc<dyn HttpClient>,
1603 node_runtime: NodeRuntime,
1604 project_environment: Entity<ProjectEnvironment>,
1605 extension_id: Arc<str>,
1606 agent_id: Arc<str>,
1607 targets: HashMap<String, extension::TargetConfig>,
1608 env: HashMap<String, String>,
1609}
1610
1611struct LocalCustomAgent {
1612 project_environment: Entity<ProjectEnvironment>,
1613 command: AgentServerCommand,
1614}
1615
1616impl ExternalAgentServer for LocalExtensionArchiveAgent {
1617 fn get_command(
1618 &mut self,
1619 root_dir: Option<&str>,
1620 extra_env: HashMap<String, String>,
1621 _status_tx: Option<watch::Sender<SharedString>>,
1622 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1623 cx: &mut AsyncApp,
1624 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1625 let fs = self.fs.clone();
1626 let http_client = self.http_client.clone();
1627 let node_runtime = self.node_runtime.clone();
1628 let project_environment = self.project_environment.downgrade();
1629 let extension_id = self.extension_id.clone();
1630 let agent_id = self.agent_id.clone();
1631 let targets = self.targets.clone();
1632 let base_env = self.env.clone();
1633
1634 let root_dir: Arc<Path> = root_dir
1635 .map(|root_dir| Path::new(root_dir))
1636 .unwrap_or(paths::home_dir())
1637 .into();
1638
1639 cx.spawn(async move |cx| {
1640 // Get project environment
1641 let mut env = project_environment
1642 .update(cx, |project_environment, cx| {
1643 project_environment.local_directory_environment(
1644 &Shell::System,
1645 root_dir.clone(),
1646 cx,
1647 )
1648 })?
1649 .await
1650 .unwrap_or_default();
1651
1652 // Merge manifest env and extra env
1653 env.extend(base_env);
1654 env.extend(extra_env);
1655
1656 let cache_key = format!("{}/{}", extension_id, agent_id);
1657 let dir = paths::external_agents_dir().join(&cache_key);
1658 fs.create_dir(&dir).await?;
1659
1660 // Determine platform key
1661 let os = if cfg!(target_os = "macos") {
1662 "darwin"
1663 } else if cfg!(target_os = "linux") {
1664 "linux"
1665 } else if cfg!(target_os = "windows") {
1666 "windows"
1667 } else {
1668 anyhow::bail!("unsupported OS");
1669 };
1670
1671 let arch = if cfg!(target_arch = "aarch64") {
1672 "aarch64"
1673 } else if cfg!(target_arch = "x86_64") {
1674 "x86_64"
1675 } else {
1676 anyhow::bail!("unsupported architecture");
1677 };
1678
1679 let platform_key = format!("{}-{}", os, arch);
1680 let target_config = targets.get(&platform_key).with_context(|| {
1681 format!(
1682 "no target specified for platform '{}'. Available platforms: {}",
1683 platform_key,
1684 targets
1685 .keys()
1686 .map(|k| k.as_str())
1687 .collect::<Vec<_>>()
1688 .join(", ")
1689 )
1690 })?;
1691
1692 let archive_url = &target_config.archive;
1693
1694 // Use URL as version identifier for caching
1695 // Hash the URL to get a stable directory name
1696 use std::collections::hash_map::DefaultHasher;
1697 use std::hash::{Hash, Hasher};
1698 let mut hasher = DefaultHasher::new();
1699 archive_url.hash(&mut hasher);
1700 let url_hash = hasher.finish();
1701 let version_dir = dir.join(format!("v_{:x}", url_hash));
1702
1703 if !fs.is_dir(&version_dir).await {
1704 // Determine SHA256 for verification
1705 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1706 // Use provided SHA256
1707 Some(provided_sha.clone())
1708 } else if archive_url.starts_with("https://github.com/") {
1709 // Try to fetch SHA256 from GitHub API
1710 // Parse URL to extract repo and tag/file info
1711 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1712 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1713 let parts: Vec<&str> = caps.split('/').collect();
1714 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1715 let repo = format!("{}/{}", parts[0], parts[1]);
1716 let tag = parts[4];
1717 let filename = parts[5..].join("/");
1718
1719 // Try to get release info from GitHub
1720 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1721 &repo,
1722 tag,
1723 http_client.clone(),
1724 )
1725 .await
1726 {
1727 // Find matching asset
1728 if let Some(asset) =
1729 release.assets.iter().find(|a| a.name == filename)
1730 {
1731 // Strip "sha256:" prefix if present
1732 asset.digest.as_ref().and_then(|d| {
1733 d.strip_prefix("sha256:")
1734 .map(|s| s.to_string())
1735 .or_else(|| Some(d.clone()))
1736 })
1737 } else {
1738 None
1739 }
1740 } else {
1741 None
1742 }
1743 } else {
1744 None
1745 }
1746 } else {
1747 None
1748 }
1749 } else {
1750 None
1751 };
1752
1753 // Determine archive type from URL
1754 let asset_kind = if archive_url.ends_with(".zip") {
1755 AssetKind::Zip
1756 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1757 AssetKind::TarGz
1758 } else {
1759 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1760 };
1761
1762 // Download and extract
1763 ::http_client::github_download::download_server_binary(
1764 &*http_client,
1765 archive_url,
1766 sha256.as_deref(),
1767 &version_dir,
1768 asset_kind,
1769 )
1770 .await?;
1771 }
1772
1773 // Validate and resolve cmd path
1774 let cmd = &target_config.cmd;
1775
1776 let cmd_path = if cmd == "node" {
1777 // Use Zed's managed Node.js runtime
1778 node_runtime.binary_path().await?
1779 } else {
1780 if cmd.contains("..") {
1781 anyhow::bail!("command path cannot contain '..': {}", cmd);
1782 }
1783
1784 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1785 // Relative to extraction directory
1786 let cmd_path = version_dir.join(&cmd[2..]);
1787 anyhow::ensure!(
1788 fs.is_file(&cmd_path).await,
1789 "Missing command {} after extraction",
1790 cmd_path.to_string_lossy()
1791 );
1792 cmd_path
1793 } else {
1794 // On PATH
1795 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1796 }
1797 };
1798
1799 let command = AgentServerCommand {
1800 path: cmd_path,
1801 args: target_config.args.clone(),
1802 env: Some(env),
1803 };
1804
1805 Ok((command, version_dir.to_string_lossy().into_owned(), None))
1806 })
1807 }
1808
1809 fn as_any_mut(&mut self) -> &mut dyn Any {
1810 self
1811 }
1812}
1813
1814impl ExternalAgentServer for LocalCustomAgent {
1815 fn get_command(
1816 &mut self,
1817 root_dir: Option<&str>,
1818 extra_env: HashMap<String, String>,
1819 _status_tx: Option<watch::Sender<SharedString>>,
1820 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1821 cx: &mut AsyncApp,
1822 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1823 let mut command = self.command.clone();
1824 let root_dir: Arc<Path> = root_dir
1825 .map(|root_dir| Path::new(root_dir))
1826 .unwrap_or(paths::home_dir())
1827 .into();
1828 let project_environment = self.project_environment.downgrade();
1829 cx.spawn(async move |cx| {
1830 let mut env = project_environment
1831 .update(cx, |project_environment, cx| {
1832 project_environment.local_directory_environment(
1833 &Shell::System,
1834 root_dir.clone(),
1835 cx,
1836 )
1837 })?
1838 .await
1839 .unwrap_or_default();
1840 env.extend(command.env.unwrap_or_default());
1841 env.extend(extra_env);
1842 command.env = Some(env);
1843 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1844 })
1845 }
1846
1847 fn as_any_mut(&mut self) -> &mut dyn Any {
1848 self
1849 }
1850}
1851
1852pub const GEMINI_NAME: &'static str = "gemini";
1853pub const CLAUDE_CODE_NAME: &'static str = "claude";
1854pub const CODEX_NAME: &'static str = "codex";
1855
1856#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1857pub struct AllAgentServersSettings {
1858 pub gemini: Option<BuiltinAgentServerSettings>,
1859 pub claude: Option<BuiltinAgentServerSettings>,
1860 pub codex: Option<BuiltinAgentServerSettings>,
1861 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1862}
1863#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1864pub struct BuiltinAgentServerSettings {
1865 pub path: Option<PathBuf>,
1866 pub args: Option<Vec<String>>,
1867 pub env: Option<HashMap<String, String>>,
1868 pub ignore_system_version: Option<bool>,
1869 pub default_mode: Option<String>,
1870 pub default_model: Option<String>,
1871 pub favorite_models: Vec<String>,
1872 pub default_config_options: HashMap<String, String>,
1873 pub favorite_config_option_values: HashMap<String, Vec<String>>,
1874}
1875
1876impl BuiltinAgentServerSettings {
1877 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1878 self.path.map(|path| AgentServerCommand {
1879 path,
1880 args: self.args.unwrap_or_default(),
1881 env: self.env,
1882 })
1883 }
1884}
1885
1886impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1887 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1888 BuiltinAgentServerSettings {
1889 path: value
1890 .path
1891 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1892 args: value.args,
1893 env: value.env,
1894 ignore_system_version: value.ignore_system_version,
1895 default_mode: value.default_mode,
1896 default_model: value.default_model,
1897 favorite_models: value.favorite_models,
1898 default_config_options: value.default_config_options,
1899 favorite_config_option_values: value.favorite_config_option_values,
1900 }
1901 }
1902}
1903
1904impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1905 fn from(value: AgentServerCommand) -> Self {
1906 BuiltinAgentServerSettings {
1907 path: Some(value.path),
1908 args: Some(value.args),
1909 env: value.env,
1910 ..Default::default()
1911 }
1912 }
1913}
1914
1915#[derive(Clone, JsonSchema, Debug, PartialEq)]
1916pub enum CustomAgentServerSettings {
1917 Custom {
1918 command: AgentServerCommand,
1919 /// The default mode to use for this agent.
1920 ///
1921 /// Note: Not only all agents support modes.
1922 ///
1923 /// Default: None
1924 default_mode: Option<String>,
1925 /// The default model to use for this agent.
1926 ///
1927 /// This should be the model ID as reported by the agent.
1928 ///
1929 /// Default: None
1930 default_model: Option<String>,
1931 /// The favorite models for this agent.
1932 ///
1933 /// Default: []
1934 favorite_models: Vec<String>,
1935 /// Default values for session config options.
1936 ///
1937 /// This is a map from config option ID to value ID.
1938 ///
1939 /// Default: {}
1940 default_config_options: HashMap<String, String>,
1941 /// Favorited values for session config options.
1942 ///
1943 /// This is a map from config option ID to a list of favorited value IDs.
1944 ///
1945 /// Default: {}
1946 favorite_config_option_values: HashMap<String, Vec<String>>,
1947 },
1948 Extension {
1949 /// The default mode to use for this agent.
1950 ///
1951 /// Note: Not only all agents support modes.
1952 ///
1953 /// Default: None
1954 default_mode: Option<String>,
1955 /// The default model to use for this agent.
1956 ///
1957 /// This should be the model ID as reported by the agent.
1958 ///
1959 /// Default: None
1960 default_model: Option<String>,
1961 /// The favorite models for this agent.
1962 ///
1963 /// Default: []
1964 favorite_models: Vec<String>,
1965 /// Default values for session config options.
1966 ///
1967 /// This is a map from config option ID to value ID.
1968 ///
1969 /// Default: {}
1970 default_config_options: HashMap<String, String>,
1971 /// Favorited values for session config options.
1972 ///
1973 /// This is a map from config option ID to a list of favorited value IDs.
1974 ///
1975 /// Default: {}
1976 favorite_config_option_values: HashMap<String, Vec<String>>,
1977 },
1978}
1979
1980impl CustomAgentServerSettings {
1981 pub fn command(&self) -> Option<&AgentServerCommand> {
1982 match self {
1983 CustomAgentServerSettings::Custom { command, .. } => Some(command),
1984 CustomAgentServerSettings::Extension { .. } => None,
1985 }
1986 }
1987
1988 pub fn default_mode(&self) -> Option<&str> {
1989 match self {
1990 CustomAgentServerSettings::Custom { default_mode, .. }
1991 | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1992 }
1993 }
1994
1995 pub fn default_model(&self) -> Option<&str> {
1996 match self {
1997 CustomAgentServerSettings::Custom { default_model, .. }
1998 | CustomAgentServerSettings::Extension { default_model, .. } => {
1999 default_model.as_deref()
2000 }
2001 }
2002 }
2003
2004 pub fn favorite_models(&self) -> &[String] {
2005 match self {
2006 CustomAgentServerSettings::Custom {
2007 favorite_models, ..
2008 }
2009 | CustomAgentServerSettings::Extension {
2010 favorite_models, ..
2011 } => favorite_models,
2012 }
2013 }
2014
2015 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
2016 match self {
2017 CustomAgentServerSettings::Custom {
2018 default_config_options,
2019 ..
2020 }
2021 | CustomAgentServerSettings::Extension {
2022 default_config_options,
2023 ..
2024 } => default_config_options.get(config_id).map(|s| s.as_str()),
2025 }
2026 }
2027
2028 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
2029 match self {
2030 CustomAgentServerSettings::Custom {
2031 favorite_config_option_values,
2032 ..
2033 }
2034 | CustomAgentServerSettings::Extension {
2035 favorite_config_option_values,
2036 ..
2037 } => favorite_config_option_values
2038 .get(config_id)
2039 .map(|v| v.as_slice()),
2040 }
2041 }
2042}
2043
2044impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
2045 fn from(value: settings::CustomAgentServerSettings) -> Self {
2046 match value {
2047 settings::CustomAgentServerSettings::Custom {
2048 path,
2049 args,
2050 env,
2051 default_mode,
2052 default_model,
2053 favorite_models,
2054 default_config_options,
2055 favorite_config_option_values,
2056 } => CustomAgentServerSettings::Custom {
2057 command: AgentServerCommand {
2058 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
2059 args,
2060 env,
2061 },
2062 default_mode,
2063 default_model,
2064 favorite_models,
2065 default_config_options,
2066 favorite_config_option_values,
2067 },
2068 settings::CustomAgentServerSettings::Extension {
2069 default_mode,
2070 default_model,
2071 default_config_options,
2072 favorite_models,
2073 favorite_config_option_values,
2074 } => CustomAgentServerSettings::Extension {
2075 default_mode,
2076 default_model,
2077 default_config_options,
2078 favorite_models,
2079 favorite_config_option_values,
2080 },
2081 }
2082 }
2083}
2084
2085impl settings::Settings for AllAgentServersSettings {
2086 fn from_settings(content: &settings::SettingsContent) -> Self {
2087 let agent_settings = content.agent_servers.clone().unwrap();
2088 Self {
2089 gemini: agent_settings.gemini.map(Into::into),
2090 claude: agent_settings.claude.map(Into::into),
2091 codex: agent_settings.codex.map(Into::into),
2092 custom: agent_settings
2093 .custom
2094 .into_iter()
2095 .map(|(k, v)| (k, v.into()))
2096 .collect(),
2097 }
2098 }
2099}
2100
2101#[cfg(test)]
2102mod extension_agent_tests {
2103 use crate::worktree_store::WorktreeStore;
2104
2105 use super::*;
2106 use gpui::TestAppContext;
2107 use std::sync::Arc;
2108
2109 #[test]
2110 fn extension_agent_constructs_proper_display_names() {
2111 // Verify the display name format for extension-provided agents
2112 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
2113 assert!(name1.0.contains(": "));
2114
2115 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
2116 assert_eq!(name2.0, "MyExt: MyAgent");
2117
2118 // Non-extension agents shouldn't have the separator
2119 let custom = ExternalAgentServerName(SharedString::from("custom"));
2120 assert!(!custom.0.contains(": "));
2121 }
2122
2123 struct NoopExternalAgent;
2124
2125 impl ExternalAgentServer for NoopExternalAgent {
2126 fn get_command(
2127 &mut self,
2128 _root_dir: Option<&str>,
2129 _extra_env: HashMap<String, String>,
2130 _status_tx: Option<watch::Sender<SharedString>>,
2131 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
2132 _cx: &mut AsyncApp,
2133 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
2134 Task::ready(Ok((
2135 AgentServerCommand {
2136 path: PathBuf::from("noop"),
2137 args: Vec::new(),
2138 env: None,
2139 },
2140 "".to_string(),
2141 None,
2142 )))
2143 }
2144
2145 fn as_any_mut(&mut self) -> &mut dyn Any {
2146 self
2147 }
2148 }
2149
2150 #[test]
2151 fn sync_removes_only_extension_provided_agents() {
2152 let mut store = AgentServerStore {
2153 state: AgentServerStoreState::Collab,
2154 external_agents: HashMap::default(),
2155 agent_icons: HashMap::default(),
2156 agent_display_names: HashMap::default(),
2157 };
2158
2159 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
2160 store.external_agents.insert(
2161 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
2162 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2163 );
2164 store.external_agents.insert(
2165 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
2166 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2167 );
2168 store.external_agents.insert(
2169 ExternalAgentServerName(SharedString::from("custom-agent")),
2170 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
2171 );
2172
2173 // Simulate removal phase
2174 let keys_to_remove: Vec<_> = store
2175 .external_agents
2176 .keys()
2177 .filter(|name| name.0.contains(": "))
2178 .cloned()
2179 .collect();
2180
2181 for key in keys_to_remove {
2182 store.external_agents.remove(&key);
2183 }
2184
2185 // Only custom-agent should remain
2186 assert_eq!(store.external_agents.len(), 1);
2187 assert!(
2188 store
2189 .external_agents
2190 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2191 );
2192 }
2193
2194 #[test]
2195 fn archive_launcher_constructs_with_all_fields() {
2196 use extension::AgentServerManifestEntry;
2197
2198 let mut env = HashMap::default();
2199 env.insert("GITHUB_TOKEN".into(), "secret".into());
2200
2201 let mut targets = HashMap::default();
2202 targets.insert(
2203 "darwin-aarch64".to_string(),
2204 extension::TargetConfig {
2205 archive:
2206 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2207 .into(),
2208 cmd: "./agent".into(),
2209 args: vec![],
2210 sha256: None,
2211 env: Default::default(),
2212 },
2213 );
2214
2215 let _entry = AgentServerManifestEntry {
2216 name: "GitHub Agent".into(),
2217 targets,
2218 env,
2219 icon: None,
2220 };
2221
2222 // Verify display name construction
2223 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2224 assert_eq!(expected_name.0, "GitHub Agent");
2225 }
2226
2227 #[gpui::test]
2228 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2229 let fs = fs::FakeFs::new(cx.background_executor.clone());
2230 let http_client = http_client::FakeHttpClient::with_404_response();
2231 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2232 let project_environment = cx.new(|cx| {
2233 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2234 });
2235
2236 let agent = LocalExtensionArchiveAgent {
2237 fs,
2238 http_client,
2239 node_runtime: node_runtime::NodeRuntime::unavailable(),
2240 project_environment,
2241 extension_id: Arc::from("my-extension"),
2242 agent_id: Arc::from("my-agent"),
2243 targets: {
2244 let mut map = HashMap::default();
2245 map.insert(
2246 "darwin-aarch64".to_string(),
2247 extension::TargetConfig {
2248 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2249 cmd: "./my-agent".into(),
2250 args: vec!["--serve".into()],
2251 sha256: None,
2252 env: Default::default(),
2253 },
2254 );
2255 map
2256 },
2257 env: {
2258 let mut map = HashMap::default();
2259 map.insert("PORT".into(), "8080".into());
2260 map
2261 },
2262 };
2263
2264 // Verify agent is properly constructed
2265 assert_eq!(agent.extension_id.as_ref(), "my-extension");
2266 assert_eq!(agent.agent_id.as_ref(), "my-agent");
2267 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2268 assert!(agent.targets.contains_key("darwin-aarch64"));
2269 }
2270
2271 #[test]
2272 fn sync_extension_agents_registers_archive_launcher() {
2273 use extension::AgentServerManifestEntry;
2274
2275 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2276 assert_eq!(expected_name.0, "Release Agent");
2277
2278 // Verify the manifest entry structure for archive-based installation
2279 let mut env = HashMap::default();
2280 env.insert("API_KEY".into(), "secret".into());
2281
2282 let mut targets = HashMap::default();
2283 targets.insert(
2284 "linux-x86_64".to_string(),
2285 extension::TargetConfig {
2286 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2287 cmd: "./release-agent".into(),
2288 args: vec!["serve".into()],
2289 sha256: None,
2290 env: Default::default(),
2291 },
2292 );
2293
2294 let manifest_entry = AgentServerManifestEntry {
2295 name: "Release Agent".into(),
2296 targets: targets.clone(),
2297 env,
2298 icon: None,
2299 };
2300
2301 // Verify target config is present
2302 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2303 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2304 assert_eq!(target.cmd, "./release-agent");
2305 }
2306
2307 #[gpui::test]
2308 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2309 let fs = fs::FakeFs::new(cx.background_executor.clone());
2310 let http_client = http_client::FakeHttpClient::with_404_response();
2311 let node_runtime = NodeRuntime::unavailable();
2312 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2313 let project_environment = cx.new(|cx| {
2314 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2315 });
2316
2317 let agent = LocalExtensionArchiveAgent {
2318 fs: fs.clone(),
2319 http_client,
2320 node_runtime,
2321 project_environment,
2322 extension_id: Arc::from("node-extension"),
2323 agent_id: Arc::from("node-agent"),
2324 targets: {
2325 let mut map = HashMap::default();
2326 map.insert(
2327 "darwin-aarch64".to_string(),
2328 extension::TargetConfig {
2329 archive: "https://example.com/node-agent.zip".into(),
2330 cmd: "node".into(),
2331 args: vec!["index.js".into()],
2332 sha256: None,
2333 env: Default::default(),
2334 },
2335 );
2336 map
2337 },
2338 env: HashMap::default(),
2339 };
2340
2341 // Verify that when cmd is "node", it attempts to use the node runtime
2342 assert_eq!(agent.extension_id.as_ref(), "node-extension");
2343 assert_eq!(agent.agent_id.as_ref(), "node-agent");
2344
2345 let target = agent.targets.get("darwin-aarch64").unwrap();
2346 assert_eq!(target.cmd, "node");
2347 assert_eq!(target.args, vec!["index.js"]);
2348 }
2349
2350 #[gpui::test]
2351 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2352 let fs = fs::FakeFs::new(cx.background_executor.clone());
2353 let http_client = http_client::FakeHttpClient::with_404_response();
2354 let node_runtime = NodeRuntime::unavailable();
2355 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2356 let project_environment = cx.new(|cx| {
2357 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2358 });
2359
2360 let agent = LocalExtensionArchiveAgent {
2361 fs: fs.clone(),
2362 http_client,
2363 node_runtime,
2364 project_environment,
2365 extension_id: Arc::from("test-ext"),
2366 agent_id: Arc::from("test-agent"),
2367 targets: {
2368 let mut map = HashMap::default();
2369 map.insert(
2370 "darwin-aarch64".to_string(),
2371 extension::TargetConfig {
2372 archive: "https://example.com/test.zip".into(),
2373 cmd: "node".into(),
2374 args: vec![
2375 "server.js".into(),
2376 "--config".into(),
2377 "./config.json".into(),
2378 ],
2379 sha256: None,
2380 env: Default::default(),
2381 },
2382 );
2383 map
2384 },
2385 env: HashMap::default(),
2386 };
2387
2388 // Verify the agent is configured with relative paths in args
2389 let target = agent.targets.get("darwin-aarch64").unwrap();
2390 assert_eq!(target.args[0], "server.js");
2391 assert_eq!(target.args[2], "./config.json");
2392 // These relative paths will resolve relative to the extraction directory
2393 // when the command is executed
2394 }
2395
2396 #[test]
2397 fn test_tilde_expansion_in_settings() {
2398 let settings = settings::BuiltinAgentServerSettings {
2399 path: Some(PathBuf::from("~/bin/agent")),
2400 args: Some(vec!["--flag".into()]),
2401 env: None,
2402 ignore_system_version: None,
2403 default_mode: None,
2404 default_model: None,
2405 favorite_models: vec![],
2406 default_config_options: Default::default(),
2407 favorite_config_option_values: Default::default(),
2408 };
2409
2410 let BuiltinAgentServerSettings { path, .. } = settings.into();
2411
2412 let path = path.unwrap();
2413 assert!(
2414 !path.to_string_lossy().starts_with("~"),
2415 "Tilde should be expanded for builtin agent path"
2416 );
2417
2418 let settings = settings::CustomAgentServerSettings::Custom {
2419 path: PathBuf::from("~/custom/agent"),
2420 args: vec!["serve".into()],
2421 env: None,
2422 default_mode: None,
2423 default_model: None,
2424 favorite_models: vec![],
2425 default_config_options: Default::default(),
2426 favorite_config_option_values: Default::default(),
2427 };
2428
2429 let converted: CustomAgentServerSettings = settings.into();
2430 let CustomAgentServerSettings::Custom {
2431 command: AgentServerCommand { path, .. },
2432 ..
2433 } = converted
2434 else {
2435 panic!("Expected Custom variant");
2436 };
2437
2438 assert!(
2439 !path.to_string_lossy().starts_with("~"),
2440 "Tilde should be expanded for custom agent path"
2441 );
2442 }
2443}