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