1use remote::Interactive;
2use std::{
3 any::Any,
4 path::{Path, PathBuf},
5 sync::Arc,
6 time::Duration,
7};
8
9use anyhow::{Context as _, Result, bail};
10use collections::HashMap;
11use fs::Fs;
12use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task};
13use http_client::{HttpClient, github::AssetKind};
14use node_runtime::NodeRuntime;
15use percent_encoding::percent_decode_str;
16use remote::RemoteClient;
17use rpc::{
18 AnyProtoClient, TypedEnvelope,
19 proto::{self, ExternalExtensionAgent},
20};
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use settings::{RegisterSetting, SettingsStore};
24use sha2::{Digest, Sha256};
25use task::Shell;
26use url::Url;
27use util::{ResultExt as _, debug_panic};
28
29use crate::ProjectEnvironment;
30use crate::agent_registry_store::{AgentRegistryStore, RegistryAgent, RegistryTargetConfig};
31
32use crate::worktree_store::WorktreeStore;
33
34#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
35pub struct AgentServerCommand {
36 #[serde(rename = "command")]
37 pub path: PathBuf,
38 #[serde(default)]
39 pub args: Vec<String>,
40 pub env: Option<HashMap<String, String>>,
41}
42
43impl std::fmt::Debug for AgentServerCommand {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 let filtered_env = self.env.as_ref().map(|env| {
46 env.iter()
47 .map(|(k, v)| {
48 (
49 k,
50 if util::redact::should_redact(k) {
51 "[REDACTED]"
52 } else {
53 v
54 },
55 )
56 })
57 .collect::<Vec<_>>()
58 });
59
60 f.debug_struct("AgentServerCommand")
61 .field("path", &self.path)
62 .field("args", &self.args)
63 .field("env", &filtered_env)
64 .finish()
65 }
66}
67
68#[derive(
69 Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
70)]
71#[serde(transparent)]
72pub struct AgentId(pub SharedString);
73
74impl AgentId {
75 pub fn new(id: impl Into<SharedString>) -> Self {
76 AgentId(id.into())
77 }
78}
79
80impl std::fmt::Display for AgentId {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 write!(f, "{}", self.0)
83 }
84}
85
86impl From<&'static str> for AgentId {
87 fn from(value: &'static str) -> Self {
88 AgentId(value.into())
89 }
90}
91
92impl From<AgentId> for SharedString {
93 fn from(value: AgentId) -> Self {
94 value.0
95 }
96}
97
98impl AsRef<str> for AgentId {
99 fn as_ref(&self) -> &str {
100 &self.0
101 }
102}
103
104impl std::borrow::Borrow<str> for AgentId {
105 fn borrow(&self) -> &str {
106 &self.0
107 }
108}
109
110#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
111pub enum ExternalAgentSource {
112 #[default]
113 Custom,
114 Extension,
115 Registry,
116}
117
118pub trait ExternalAgentServer {
119 fn get_command(
120 &mut self,
121 extra_env: HashMap<String, String>,
122 new_version_available_tx: Option<watch::Sender<Option<String>>>,
123 cx: &mut AsyncApp,
124 ) -> Task<Result<AgentServerCommand>>;
125
126 fn version(&self) -> Option<&SharedString> {
127 None
128 }
129
130 fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
131 None
132 }
133
134 fn set_new_version_available_tx(&mut self, _tx: watch::Sender<Option<String>>) {}
135
136 fn as_any(&self) -> &dyn Any;
137 fn as_any_mut(&mut self) -> &mut dyn Any;
138}
139
140struct ExtensionAgentEntry {
141 agent_name: Arc<str>,
142 extension_id: String,
143 targets: HashMap<String, extension::TargetConfig>,
144 env: HashMap<String, String>,
145 icon_path: Option<String>,
146 display_name: Option<SharedString>,
147 version: Option<SharedString>,
148}
149
150enum AgentServerStoreState {
151 Local {
152 node_runtime: NodeRuntime,
153 fs: Arc<dyn Fs>,
154 project_environment: Entity<ProjectEnvironment>,
155 downstream_client: Option<(u64, AnyProtoClient)>,
156 settings: Option<AllAgentServersSettings>,
157 http_client: Arc<dyn HttpClient>,
158 extension_agents: Vec<ExtensionAgentEntry>,
159 _subscriptions: Vec<Subscription>,
160 },
161 Remote {
162 project_id: u64,
163 upstream_client: Entity<RemoteClient>,
164 worktree_store: Entity<WorktreeStore>,
165 },
166 Collab,
167}
168
169pub struct ExternalAgentEntry {
170 server: Box<dyn ExternalAgentServer>,
171 icon: Option<SharedString>,
172 display_name: Option<SharedString>,
173 pub source: ExternalAgentSource,
174}
175
176impl ExternalAgentEntry {
177 pub fn new(
178 server: Box<dyn ExternalAgentServer>,
179 source: ExternalAgentSource,
180 icon: Option<SharedString>,
181 display_name: Option<SharedString>,
182 ) -> Self {
183 Self {
184 server,
185 icon,
186 display_name,
187 source,
188 }
189 }
190}
191
192pub struct AgentServerStore {
193 state: AgentServerStoreState,
194 pub external_agents: HashMap<AgentId, ExternalAgentEntry>,
195}
196
197pub struct AgentServersUpdated;
198
199impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
200
201impl AgentServerStore {
202 /// Synchronizes extension-provided agent servers with the store.
203 pub fn sync_extension_agents<'a, I>(
204 &mut self,
205 manifests: I,
206 extensions_dir: PathBuf,
207 cx: &mut Context<Self>,
208 ) where
209 I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
210 {
211 // Collect manifests first so we can iterate twice
212 let manifests: Vec<_> = manifests.into_iter().collect();
213
214 // Remove all extension-provided agents
215 // (They will be re-added below if they're in the currently installed extensions)
216 self.external_agents
217 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
218
219 // Insert agent servers from extension manifests
220 match &mut self.state {
221 AgentServerStoreState::Local {
222 extension_agents, ..
223 } => {
224 extension_agents.clear();
225 for (ext_id, manifest) in manifests {
226 for (agent_name, agent_entry) in &manifest.agent_servers {
227 let display_name = SharedString::from(agent_entry.name.clone());
228 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
229 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
230 });
231
232 extension_agents.push(ExtensionAgentEntry {
233 agent_name: agent_name.clone(),
234 extension_id: ext_id.to_owned(),
235 targets: agent_entry.targets.clone(),
236 env: agent_entry.env.clone(),
237 icon_path,
238 display_name: Some(display_name),
239 version: Some(SharedString::from(manifest.version.clone())),
240 });
241 }
242 }
243 self.reregister_agents(cx);
244 }
245 AgentServerStoreState::Remote {
246 project_id,
247 upstream_client,
248 worktree_store,
249 } => {
250 let mut agents = vec![];
251 for (ext_id, manifest) in manifests {
252 for (agent_name, agent_entry) in &manifest.agent_servers {
253 let display_name = SharedString::from(agent_entry.name.clone());
254 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
255 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
256 });
257 let icon_shared = icon_path
258 .as_ref()
259 .map(|path| SharedString::from(path.clone()));
260 let icon = icon_path;
261 let agent_server_name = AgentId(agent_name.clone().into());
262 self.external_agents
263 .entry(agent_server_name.clone())
264 .and_modify(|entry| {
265 entry.icon = icon_shared.clone();
266 entry.display_name = Some(display_name.clone());
267 entry.source = ExternalAgentSource::Extension;
268 })
269 .or_insert_with(|| {
270 ExternalAgentEntry::new(
271 Box::new(RemoteExternalAgentServer {
272 project_id: *project_id,
273 upstream_client: upstream_client.clone(),
274 worktree_store: worktree_store.clone(),
275 name: agent_server_name.clone(),
276 new_version_available_tx: None,
277 })
278 as Box<dyn ExternalAgentServer>,
279 ExternalAgentSource::Extension,
280 icon_shared.clone(),
281 Some(display_name.clone()),
282 )
283 });
284
285 agents.push(ExternalExtensionAgent {
286 name: agent_name.to_string(),
287 icon_path: icon,
288 extension_id: ext_id.to_string(),
289 targets: agent_entry
290 .targets
291 .iter()
292 .map(|(k, v)| (k.clone(), v.to_proto()))
293 .collect(),
294 env: agent_entry
295 .env
296 .iter()
297 .map(|(k, v)| (k.clone(), v.clone()))
298 .collect(),
299 version: Some(manifest.version.to_string()),
300 });
301 }
302 }
303 upstream_client
304 .read(cx)
305 .proto_client()
306 .send(proto::ExternalExtensionAgentsUpdated {
307 project_id: *project_id,
308 agents,
309 })
310 .log_err();
311 }
312 AgentServerStoreState::Collab => {
313 // Do nothing
314 }
315 }
316
317 cx.emit(AgentServersUpdated);
318 }
319
320 pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
321 self.external_agents
322 .get(id)
323 .and_then(|entry| entry.icon.clone())
324 }
325
326 pub fn agent_source(&self, name: &AgentId) -> Option<ExternalAgentSource> {
327 self.external_agents.get(name).map(|entry| entry.source)
328 }
329}
330
331/// Safely resolves an extension icon path, ensuring it stays within the extension directory.
332/// Returns `None` if the path would escape the extension directory (path traversal attack).
333pub fn resolve_extension_icon_path(
334 extensions_dir: &Path,
335 extension_id: &str,
336 icon_relative_path: &str,
337) -> Option<String> {
338 let extension_root = extensions_dir.join(extension_id);
339 let icon_path = extension_root.join(icon_relative_path);
340
341 // Canonicalize both paths to resolve symlinks and normalize the paths.
342 // For the extension root, we need to handle the case where it might be a symlink
343 // (common for dev extensions).
344 let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root);
345 let canonical_icon_path = match icon_path.canonicalize() {
346 Ok(path) => path,
347 Err(err) => {
348 log::warn!(
349 "Failed to canonicalize icon path for extension '{}': {} (path: {})",
350 extension_id,
351 err,
352 icon_relative_path
353 );
354 return None;
355 }
356 };
357
358 // Verify the resolved icon path is within the extension directory
359 if canonical_icon_path.starts_with(&canonical_extension_root) {
360 Some(canonical_icon_path.to_string_lossy().to_string())
361 } else {
362 log::warn!(
363 "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security",
364 icon_relative_path,
365 extension_id
366 );
367 None
368 }
369}
370
371impl AgentServerStore {
372 pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> {
373 self.external_agents
374 .get(name)
375 .and_then(|entry| entry.display_name.clone())
376 }
377
378 pub fn init_remote(session: &AnyProtoClient) {
379 session.add_entity_message_handler(Self::handle_external_agents_updated);
380 session.add_entity_message_handler(Self::handle_new_version_available);
381 }
382
383 pub fn init_headless(session: &AnyProtoClient) {
384 session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
385 session.add_entity_request_handler(Self::handle_get_agent_server_command);
386 }
387
388 fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
389 let AgentServerStoreState::Local {
390 settings: old_settings,
391 ..
392 } = &mut self.state
393 else {
394 debug_panic!(
395 "should not be subscribed to agent server settings changes in non-local project"
396 );
397 return;
398 };
399
400 let new_settings = cx
401 .global::<SettingsStore>()
402 .get::<AllAgentServersSettings>(None)
403 .clone();
404 if Some(&new_settings) == old_settings.as_ref() {
405 return;
406 }
407
408 self.reregister_agents(cx);
409 }
410
411 fn reregister_agents(&mut self, cx: &mut Context<Self>) {
412 let AgentServerStoreState::Local {
413 node_runtime,
414 fs,
415 project_environment,
416 downstream_client,
417 settings: old_settings,
418 http_client,
419 extension_agents,
420 ..
421 } = &mut self.state
422 else {
423 debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
424
425 return;
426 };
427
428 let new_settings = cx
429 .global::<SettingsStore>()
430 .get::<AllAgentServersSettings>(None)
431 .clone();
432
433 // If we don't have agents from the registry loaded yet, trigger a
434 // refresh, which will cause this function to be called again
435 let registry_store = AgentRegistryStore::try_global(cx);
436 if new_settings.has_registry_agents()
437 && let Some(registry) = registry_store.as_ref()
438 {
439 registry.update(cx, |registry, cx| registry.refresh_if_stale(cx));
440 }
441
442 let registry_agents_by_id = registry_store
443 .as_ref()
444 .map(|store| {
445 store
446 .read(cx)
447 .agents()
448 .iter()
449 .cloned()
450 .map(|agent| (agent.id().to_string(), agent))
451 .collect::<HashMap<_, _>>()
452 })
453 .unwrap_or_default();
454
455 // Drain the existing versioned agents, extracting reconnect state
456 // from any active connection so we can preserve it or trigger a
457 // reconnect when the version changes.
458 let mut old_versioned_agents: HashMap<
459 AgentId,
460 (SharedString, watch::Sender<Option<String>>),
461 > = HashMap::default();
462 for (name, mut entry) in self.external_agents.drain() {
463 if let Some(version) = entry.server.version().cloned() {
464 if let Some(tx) = entry.server.take_new_version_available_tx() {
465 old_versioned_agents.insert(name, (version, tx));
466 }
467 }
468 }
469
470 // Insert extension agents before custom/registry so registry entries override extensions.
471 for entry in extension_agents.iter() {
472 let name = AgentId(entry.agent_name.clone().into());
473 let mut env = entry.env.clone();
474 if let Some(settings_env) =
475 new_settings
476 .get(entry.agent_name.as_ref())
477 .and_then(|settings| match settings {
478 CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
479 _ => None,
480 })
481 {
482 env.extend(settings_env);
483 }
484 let icon = entry
485 .icon_path
486 .as_ref()
487 .map(|path| SharedString::from(path.clone()));
488
489 self.external_agents.insert(
490 name.clone(),
491 ExternalAgentEntry::new(
492 Box::new(LocalExtensionArchiveAgent {
493 fs: fs.clone(),
494 http_client: http_client.clone(),
495 node_runtime: node_runtime.clone(),
496 project_environment: project_environment.clone(),
497 extension_id: Arc::from(&*entry.extension_id),
498 targets: entry.targets.clone(),
499 env,
500 agent_id: entry.agent_name.clone(),
501 version: entry.version.clone(),
502 new_version_available_tx: None,
503 }) as Box<dyn ExternalAgentServer>,
504 ExternalAgentSource::Extension,
505 icon,
506 entry.display_name.clone(),
507 ),
508 );
509 }
510
511 for (name, settings) in new_settings.iter() {
512 match settings {
513 CustomAgentServerSettings::Custom { command, .. } => {
514 let agent_name = AgentId(name.clone().into());
515 self.external_agents.insert(
516 agent_name.clone(),
517 ExternalAgentEntry::new(
518 Box::new(LocalCustomAgent {
519 command: command.clone(),
520 project_environment: project_environment.clone(),
521 }) as Box<dyn ExternalAgentServer>,
522 ExternalAgentSource::Custom,
523 None,
524 None,
525 ),
526 );
527 }
528 CustomAgentServerSettings::Registry { env, .. } => {
529 let Some(agent) = registry_agents_by_id.get(name) else {
530 if registry_store.is_some() {
531 log::debug!("Registry agent '{}' not found in ACP registry", name);
532 }
533 continue;
534 };
535
536 let agent_name = AgentId(name.clone().into());
537 match agent {
538 RegistryAgent::Binary(agent) => {
539 if !agent.supports_current_platform {
540 log::warn!(
541 "Registry agent '{}' has no compatible binary for this platform",
542 name
543 );
544 continue;
545 }
546
547 self.external_agents.insert(
548 agent_name.clone(),
549 ExternalAgentEntry::new(
550 Box::new(LocalRegistryArchiveAgent {
551 fs: fs.clone(),
552 http_client: http_client.clone(),
553 node_runtime: node_runtime.clone(),
554 project_environment: project_environment.clone(),
555 registry_id: Arc::from(name.as_str()),
556 version: agent.metadata.version.clone(),
557 targets: agent.targets.clone(),
558 env: env.clone(),
559 new_version_available_tx: None,
560 })
561 as Box<dyn ExternalAgentServer>,
562 ExternalAgentSource::Registry,
563 agent.metadata.icon_path.clone(),
564 Some(agent.metadata.name.clone()),
565 ),
566 );
567 }
568 RegistryAgent::Npx(agent) => {
569 self.external_agents.insert(
570 agent_name.clone(),
571 ExternalAgentEntry::new(
572 Box::new(LocalRegistryNpxAgent {
573 node_runtime: node_runtime.clone(),
574 project_environment: project_environment.clone(),
575 version: agent.metadata.version.clone(),
576 package: agent.package.clone(),
577 args: agent.args.clone(),
578 distribution_env: agent.env.clone(),
579 settings_env: env.clone(),
580 new_version_available_tx: None,
581 })
582 as Box<dyn ExternalAgentServer>,
583 ExternalAgentSource::Registry,
584 agent.metadata.icon_path.clone(),
585 Some(agent.metadata.name.clone()),
586 ),
587 );
588 }
589 }
590 }
591 CustomAgentServerSettings::Extension { .. } => {}
592 }
593 }
594
595 // For each rebuilt versioned agent, compare the version. If it
596 // changed, notify the active connection to reconnect. Otherwise,
597 // transfer the channel to the new entry so future updates can use it.
598 for (name, entry) in &mut self.external_agents {
599 let Some((old_version, mut tx)) = old_versioned_agents.remove(name) else {
600 continue;
601 };
602 let Some(new_version) = entry.server.version() else {
603 continue;
604 };
605
606 if new_version != &old_version {
607 tx.send(Some(new_version.to_string())).ok();
608 } else {
609 entry.server.set_new_version_available_tx(tx);
610 }
611 }
612
613 *old_settings = Some(new_settings);
614
615 if let Some((project_id, downstream_client)) = downstream_client {
616 downstream_client
617 .send(proto::ExternalAgentsUpdated {
618 project_id: *project_id,
619 names: self
620 .external_agents
621 .keys()
622 .map(|name| name.to_string())
623 .collect(),
624 })
625 .log_err();
626 }
627 cx.emit(AgentServersUpdated);
628 }
629
630 pub fn node_runtime(&self) -> Option<NodeRuntime> {
631 match &self.state {
632 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
633 _ => None,
634 }
635 }
636
637 pub fn local(
638 node_runtime: NodeRuntime,
639 fs: Arc<dyn Fs>,
640 project_environment: Entity<ProjectEnvironment>,
641 http_client: Arc<dyn HttpClient>,
642 cx: &mut Context<Self>,
643 ) -> Self {
644 let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
645 this.agent_servers_settings_changed(cx);
646 })];
647 if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
648 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
649 this.reregister_agents(cx);
650 }));
651 }
652 let mut this = Self {
653 state: AgentServerStoreState::Local {
654 node_runtime,
655 fs,
656 project_environment,
657 http_client,
658 downstream_client: None,
659 settings: None,
660 extension_agents: vec![],
661 _subscriptions: subscriptions,
662 },
663 external_agents: HashMap::default(),
664 };
665 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
666 this.agent_servers_settings_changed(cx);
667 this
668 }
669
670 pub(crate) fn remote(
671 project_id: u64,
672 upstream_client: Entity<RemoteClient>,
673 worktree_store: Entity<WorktreeStore>,
674 ) -> Self {
675 Self {
676 state: AgentServerStoreState::Remote {
677 project_id,
678 upstream_client,
679 worktree_store,
680 },
681 external_agents: HashMap::default(),
682 }
683 }
684
685 pub fn collab() -> Self {
686 Self {
687 state: AgentServerStoreState::Collab,
688 external_agents: HashMap::default(),
689 }
690 }
691
692 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
693 match &mut self.state {
694 AgentServerStoreState::Local {
695 downstream_client, ..
696 } => {
697 *downstream_client = Some((project_id, client.clone()));
698 // Send the current list of external agents downstream, but only after a delay,
699 // to avoid having the message arrive before the downstream project's agent server store
700 // sets up its handlers.
701 cx.spawn(async move |this, cx| {
702 cx.background_executor().timer(Duration::from_secs(1)).await;
703 let names = this.update(cx, |this, _| {
704 this.external_agents()
705 .map(|name| name.to_string())
706 .collect()
707 })?;
708 client
709 .send(proto::ExternalAgentsUpdated { project_id, names })
710 .log_err();
711 anyhow::Ok(())
712 })
713 .detach();
714 }
715 AgentServerStoreState::Remote { .. } => {
716 debug_panic!(
717 "external agents over collab not implemented, remote project should not be shared"
718 );
719 }
720 AgentServerStoreState::Collab => {
721 debug_panic!("external agents over collab not implemented, should not be shared");
722 }
723 }
724 }
725
726 pub fn get_external_agent(
727 &mut self,
728 name: &AgentId,
729 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
730 self.external_agents
731 .get_mut(name)
732 .map(|entry| entry.server.as_mut())
733 }
734
735 pub fn no_browser(&self) -> bool {
736 match &self.state {
737 AgentServerStoreState::Local {
738 downstream_client, ..
739 } => downstream_client
740 .as_ref()
741 .is_some_and(|(_, client)| !client.has_wsl_interop()),
742 _ => false,
743 }
744 }
745
746 pub fn has_external_agents(&self) -> bool {
747 !self.external_agents.is_empty()
748 }
749
750 pub fn external_agents(&self) -> impl Iterator<Item = &AgentId> {
751 self.external_agents.keys()
752 }
753
754 async fn handle_get_agent_server_command(
755 this: Entity<Self>,
756 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
757 mut cx: AsyncApp,
758 ) -> Result<proto::AgentServerCommand> {
759 let command = this
760 .update(&mut cx, |this, cx| {
761 let AgentServerStoreState::Local {
762 downstream_client, ..
763 } = &this.state
764 else {
765 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
766 bail!("unexpected GetAgentServerCommand request in a non-local project");
767 };
768 let no_browser = this.no_browser();
769 let agent = this
770 .external_agents
771 .get_mut(&*envelope.payload.name)
772 .map(|entry| entry.server.as_mut())
773 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
774 let new_version_available_tx =
775 downstream_client
776 .clone()
777 .map(|(project_id, downstream_client)| {
778 let (new_version_available_tx, mut new_version_available_rx) =
779 watch::channel(None);
780 cx.spawn({
781 let name = envelope.payload.name.clone();
782 async move |_, _| {
783 if let Some(version) =
784 new_version_available_rx.recv().await.ok().flatten()
785 {
786 downstream_client.send(
787 proto::NewExternalAgentVersionAvailable {
788 project_id,
789 name: name.clone(),
790 version,
791 },
792 )?;
793 }
794 anyhow::Ok(())
795 }
796 })
797 .detach_and_log_err(cx);
798 new_version_available_tx
799 });
800 let mut extra_env = HashMap::default();
801 if no_browser {
802 extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
803 }
804 anyhow::Ok(agent.get_command(
805 extra_env,
806 new_version_available_tx,
807 &mut cx.to_async(),
808 ))
809 })?
810 .await?;
811 Ok(proto::AgentServerCommand {
812 path: command.path.to_string_lossy().into_owned(),
813 args: command.args,
814 env: command
815 .env
816 .map(|env| env.into_iter().collect())
817 .unwrap_or_default(),
818 root_dir: envelope
819 .payload
820 .root_dir
821 .unwrap_or_else(|| paths::home_dir().to_string_lossy().to_string()),
822 login: None,
823 })
824 }
825
826 async fn handle_external_agents_updated(
827 this: Entity<Self>,
828 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
829 mut cx: AsyncApp,
830 ) -> Result<()> {
831 this.update(&mut cx, |this, cx| {
832 let AgentServerStoreState::Remote {
833 project_id,
834 upstream_client,
835 worktree_store,
836 } = &this.state
837 else {
838 debug_panic!(
839 "handle_external_agents_updated should not be called for a non-remote project"
840 );
841 bail!("unexpected ExternalAgentsUpdated message")
842 };
843
844 let mut previous_entries = std::mem::take(&mut this.external_agents);
845 let mut new_version_available_txs = HashMap::default();
846 let mut metadata = HashMap::default();
847
848 for (name, mut entry) in previous_entries.drain() {
849 if let Some(tx) = entry.server.take_new_version_available_tx() {
850 new_version_available_txs.insert(name.clone(), tx);
851 }
852
853 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
854 }
855
856 this.external_agents = envelope
857 .payload
858 .names
859 .into_iter()
860 .map(|name| {
861 let agent_id = AgentId(name.into());
862 let (icon, display_name, source) = metadata
863 .remove(&agent_id)
864 .or_else(|| {
865 AgentRegistryStore::try_global(cx)
866 .and_then(|store| store.read(cx).agent(&agent_id))
867 .map(|s| {
868 (
869 s.icon_path().cloned(),
870 Some(s.name().clone()),
871 ExternalAgentSource::Registry,
872 )
873 })
874 })
875 .unwrap_or((None, None, ExternalAgentSource::default()));
876 let agent = RemoteExternalAgentServer {
877 project_id: *project_id,
878 upstream_client: upstream_client.clone(),
879 worktree_store: worktree_store.clone(),
880 name: agent_id.clone(),
881 new_version_available_tx: new_version_available_txs.remove(&agent_id),
882 };
883 (
884 agent_id,
885 ExternalAgentEntry::new(
886 Box::new(agent) as Box<dyn ExternalAgentServer>,
887 source,
888 icon,
889 display_name,
890 ),
891 )
892 })
893 .collect();
894 cx.emit(AgentServersUpdated);
895 Ok(())
896 })
897 }
898
899 async fn handle_external_extension_agents_updated(
900 this: Entity<Self>,
901 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
902 mut cx: AsyncApp,
903 ) -> Result<()> {
904 this.update(&mut cx, |this, cx| {
905 let AgentServerStoreState::Local {
906 extension_agents, ..
907 } = &mut this.state
908 else {
909 panic!(
910 "handle_external_extension_agents_updated \
911 should not be called for a non-remote project"
912 );
913 };
914
915 extension_agents.clear();
916 for ExternalExtensionAgent {
917 name,
918 icon_path,
919 extension_id,
920 targets,
921 env,
922 version,
923 } in envelope.payload.agents
924 {
925 extension_agents.push(ExtensionAgentEntry {
926 agent_name: Arc::from(&*name),
927 extension_id,
928 targets: targets
929 .into_iter()
930 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
931 .collect(),
932 env: env.into_iter().collect(),
933 icon_path,
934 display_name: None,
935 version: version.map(SharedString::from),
936 });
937 }
938
939 this.reregister_agents(cx);
940 cx.emit(AgentServersUpdated);
941 Ok(())
942 })
943 }
944
945 async fn handle_new_version_available(
946 this: Entity<Self>,
947 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
948 mut cx: AsyncApp,
949 ) -> Result<()> {
950 this.update(&mut cx, |this, _| {
951 if let Some(entry) = this.external_agents.get_mut(&*envelope.payload.name)
952 && let Some(mut tx) = entry.server.take_new_version_available_tx()
953 {
954 tx.send(Some(envelope.payload.version)).ok();
955 entry.server.set_new_version_available_tx(tx);
956 }
957 });
958 Ok(())
959 }
960
961 pub fn get_extension_id_for_agent(&self, name: &AgentId) -> Option<Arc<str>> {
962 self.external_agents.get(name).and_then(|entry| {
963 entry
964 .server
965 .as_any()
966 .downcast_ref::<LocalExtensionArchiveAgent>()
967 .map(|ext_agent| ext_agent.extension_id.clone())
968 })
969 }
970}
971
972struct RemoteExternalAgentServer {
973 project_id: u64,
974 upstream_client: Entity<RemoteClient>,
975 worktree_store: Entity<WorktreeStore>,
976 name: AgentId,
977 new_version_available_tx: Option<watch::Sender<Option<String>>>,
978}
979
980impl ExternalAgentServer for RemoteExternalAgentServer {
981 fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
982 self.new_version_available_tx.take()
983 }
984
985 fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
986 self.new_version_available_tx = Some(tx);
987 }
988
989 fn get_command(
990 &mut self,
991 extra_env: HashMap<String, String>,
992 new_version_available_tx: Option<watch::Sender<Option<String>>>,
993 cx: &mut AsyncApp,
994 ) -> Task<Result<AgentServerCommand>> {
995 let project_id = self.project_id;
996 let name = self.name.to_string();
997 let upstream_client = self.upstream_client.downgrade();
998 let worktree_store = self.worktree_store.clone();
999 self.new_version_available_tx = new_version_available_tx;
1000 cx.spawn(async move |cx| {
1001 let root_dir = worktree_store.read_with(cx, |worktree_store, cx| {
1002 crate::Project::default_visible_worktree_paths(worktree_store, cx)
1003 .into_iter()
1004 .next()
1005 .map(|path| path.display().to_string())
1006 });
1007
1008 let mut response = upstream_client
1009 .update(cx, |upstream_client, _| {
1010 upstream_client
1011 .proto_client()
1012 .request(proto::GetAgentServerCommand {
1013 project_id,
1014 name,
1015 root_dir,
1016 })
1017 })?
1018 .await?;
1019 let root_dir = response.root_dir;
1020 response.env.extend(extra_env);
1021 let command = upstream_client.update(cx, |client, _| {
1022 client.build_command_with_options(
1023 Some(response.path),
1024 &response.args,
1025 &response.env.into_iter().collect(),
1026 Some(root_dir.clone()),
1027 None,
1028 Interactive::No,
1029 )
1030 })??;
1031 Ok(AgentServerCommand {
1032 path: command.program.into(),
1033 args: command.args,
1034 env: Some(command.env),
1035 })
1036 })
1037 }
1038
1039 fn as_any(&self) -> &dyn Any {
1040 self
1041 }
1042
1043 fn as_any_mut(&mut self) -> &mut dyn Any {
1044 self
1045 }
1046}
1047
1048fn asset_kind_for_archive_url(archive_url: &str) -> Result<AssetKind> {
1049 let archive_path = Url::parse(archive_url)
1050 .ok()
1051 .map(|url| url.path().to_string())
1052 .unwrap_or_else(|| archive_url.to_string());
1053
1054 if archive_path.ends_with(".zip") {
1055 Ok(AssetKind::Zip)
1056 } else if archive_path.ends_with(".tar.gz") || archive_path.ends_with(".tgz") {
1057 Ok(AssetKind::TarGz)
1058 } else if archive_path.ends_with(".tar.bz2") || archive_path.ends_with(".tbz2") {
1059 Ok(AssetKind::TarBz2)
1060 } else {
1061 bail!("unsupported archive type in URL: {archive_url}");
1062 }
1063}
1064
1065struct GithubReleaseArchive {
1066 repo_name_with_owner: String,
1067 tag: String,
1068 asset_name: String,
1069}
1070
1071fn github_release_archive_from_url(archive_url: &str) -> Option<GithubReleaseArchive> {
1072 fn decode_path_segment(segment: &str) -> Option<String> {
1073 percent_decode_str(segment)
1074 .decode_utf8()
1075 .ok()
1076 .map(|segment| segment.into_owned())
1077 }
1078
1079 let url = Url::parse(archive_url).ok()?;
1080 if url.scheme() != "https" || url.host_str()? != "github.com" {
1081 return None;
1082 }
1083
1084 let segments = url.path_segments()?.collect::<Vec<_>>();
1085 if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" {
1086 return None;
1087 }
1088
1089 Some(GithubReleaseArchive {
1090 repo_name_with_owner: format!("{}/{}", segments[0], segments[1]),
1091 tag: decode_path_segment(segments[4])?,
1092 asset_name: segments[5..]
1093 .iter()
1094 .map(|segment| decode_path_segment(segment))
1095 .collect::<Option<Vec<_>>>()?
1096 .join("/"),
1097 })
1098}
1099
1100fn sanitized_version_component(version: &str) -> String {
1101 let sanitized = version
1102 .chars()
1103 .map(|character| match character {
1104 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
1105 _ => '-',
1106 })
1107 .collect::<String>();
1108
1109 if sanitized.is_empty() {
1110 "unknown".to_string()
1111 } else {
1112 sanitized
1113 }
1114}
1115
1116fn versioned_archive_cache_dir(
1117 base_dir: &Path,
1118 version: Option<&str>,
1119 archive_url: &str,
1120) -> PathBuf {
1121 let version = version.unwrap_or_default();
1122 let sanitized_version = sanitized_version_component(version);
1123
1124 let mut version_hasher = Sha256::new();
1125 version_hasher.update(version.as_bytes());
1126 let version_hash = format!("{:x}", version_hasher.finalize());
1127
1128 let mut url_hasher = Sha256::new();
1129 url_hasher.update(archive_url.as_bytes());
1130 let url_hash = format!("{:x}", url_hasher.finalize());
1131
1132 base_dir.join(format!(
1133 "v_{sanitized_version}_{}_{}",
1134 &version_hash[..16],
1135 &url_hash[..16],
1136 ))
1137}
1138
1139pub struct LocalExtensionArchiveAgent {
1140 pub fs: Arc<dyn Fs>,
1141 pub http_client: Arc<dyn HttpClient>,
1142 pub node_runtime: NodeRuntime,
1143 pub project_environment: Entity<ProjectEnvironment>,
1144 pub extension_id: Arc<str>,
1145 pub agent_id: Arc<str>,
1146 pub targets: HashMap<String, extension::TargetConfig>,
1147 pub env: HashMap<String, String>,
1148 pub version: Option<SharedString>,
1149 pub new_version_available_tx: Option<watch::Sender<Option<String>>>,
1150}
1151
1152impl ExternalAgentServer for LocalExtensionArchiveAgent {
1153 fn version(&self) -> Option<&SharedString> {
1154 self.version.as_ref()
1155 }
1156
1157 fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1158 self.new_version_available_tx.take()
1159 }
1160
1161 fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
1162 self.new_version_available_tx = Some(tx);
1163 }
1164
1165 fn get_command(
1166 &mut self,
1167 extra_env: HashMap<String, String>,
1168 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1169 cx: &mut AsyncApp,
1170 ) -> Task<Result<AgentServerCommand>> {
1171 self.new_version_available_tx = new_version_available_tx;
1172 let fs = self.fs.clone();
1173 let http_client = self.http_client.clone();
1174 let node_runtime = self.node_runtime.clone();
1175 let project_environment = self.project_environment.downgrade();
1176 let extension_id = self.extension_id.clone();
1177 let agent_id = self.agent_id.clone();
1178 let targets = self.targets.clone();
1179 let base_env = self.env.clone();
1180 let version = self.version.clone();
1181
1182 cx.spawn(async move |cx| {
1183 // Get project environment
1184 let mut env = project_environment
1185 .update(cx, |project_environment, cx| {
1186 project_environment.local_directory_environment(
1187 &Shell::System,
1188 paths::home_dir().as_path().into(),
1189 cx,
1190 )
1191 })?
1192 .await
1193 .unwrap_or_default();
1194
1195 // Merge manifest env and extra env
1196 env.extend(base_env);
1197 env.extend(extra_env);
1198
1199 let cache_key = format!("{}/{}", extension_id, agent_id);
1200 let dir = paths::external_agents_dir().join(&cache_key);
1201 fs.create_dir(&dir).await?;
1202
1203 // Determine platform key
1204 let os = if cfg!(target_os = "macos") {
1205 "darwin"
1206 } else if cfg!(target_os = "linux") {
1207 "linux"
1208 } else if cfg!(target_os = "windows") {
1209 "windows"
1210 } else {
1211 anyhow::bail!("unsupported OS");
1212 };
1213
1214 let arch = if cfg!(target_arch = "aarch64") {
1215 "aarch64"
1216 } else if cfg!(target_arch = "x86_64") {
1217 "x86_64"
1218 } else {
1219 anyhow::bail!("unsupported architecture");
1220 };
1221
1222 let platform_key = format!("{}-{}", os, arch);
1223 let target_config = targets.get(&platform_key).with_context(|| {
1224 format!(
1225 "no target specified for platform '{}'. Available platforms: {}",
1226 platform_key,
1227 targets
1228 .keys()
1229 .map(|k| k.as_str())
1230 .collect::<Vec<_>>()
1231 .join(", ")
1232 )
1233 })?;
1234
1235 let archive_url = &target_config.archive;
1236 let version_dir = versioned_archive_cache_dir(
1237 &dir,
1238 version.as_ref().map(|version| version.as_ref()),
1239 archive_url,
1240 );
1241
1242 if !fs.is_dir(&version_dir).await {
1243 // Determine SHA256 for verification
1244 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1245 // Use provided SHA256
1246 Some(provided_sha.clone())
1247 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1248 // Try to fetch SHA256 from GitHub API
1249 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1250 &github_archive.repo_name_with_owner,
1251 &github_archive.tag,
1252 http_client.clone(),
1253 )
1254 .await
1255 {
1256 // Find matching asset
1257 if let Some(asset) = release
1258 .assets
1259 .iter()
1260 .find(|a| a.name == github_archive.asset_name)
1261 {
1262 // Strip "sha256:" prefix if present
1263 asset.digest.as_ref().map(|d| {
1264 d.strip_prefix("sha256:")
1265 .map(|s| s.to_string())
1266 .unwrap_or_else(|| d.clone())
1267 })
1268 } else {
1269 None
1270 }
1271 } else {
1272 None
1273 }
1274 } else {
1275 None
1276 };
1277
1278 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1279
1280 // Download and extract
1281 ::http_client::github_download::download_server_binary(
1282 &*http_client,
1283 archive_url,
1284 sha256.as_deref(),
1285 &version_dir,
1286 asset_kind,
1287 )
1288 .await?;
1289 }
1290
1291 // Validate and resolve cmd path
1292 let cmd = &target_config.cmd;
1293
1294 let cmd_path = if cmd == "node" {
1295 // Use Zed's managed Node.js runtime
1296 node_runtime.binary_path().await?
1297 } else {
1298 if cmd.contains("..") {
1299 anyhow::bail!("command path cannot contain '..': {}", cmd);
1300 }
1301
1302 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1303 // Relative to extraction directory
1304 let cmd_path = version_dir.join(&cmd[2..]);
1305 anyhow::ensure!(
1306 fs.is_file(&cmd_path).await,
1307 "Missing command {} after extraction",
1308 cmd_path.to_string_lossy()
1309 );
1310 cmd_path
1311 } else {
1312 // On PATH
1313 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1314 }
1315 };
1316
1317 let command = AgentServerCommand {
1318 path: cmd_path,
1319 args: target_config.args.clone(),
1320 env: Some(env),
1321 };
1322
1323 Ok(command)
1324 })
1325 }
1326
1327 fn as_any(&self) -> &dyn Any {
1328 self
1329 }
1330
1331 fn as_any_mut(&mut self) -> &mut dyn Any {
1332 self
1333 }
1334}
1335
1336struct LocalRegistryArchiveAgent {
1337 fs: Arc<dyn Fs>,
1338 http_client: Arc<dyn HttpClient>,
1339 node_runtime: NodeRuntime,
1340 project_environment: Entity<ProjectEnvironment>,
1341 registry_id: Arc<str>,
1342 version: SharedString,
1343 targets: HashMap<String, RegistryTargetConfig>,
1344 env: HashMap<String, String>,
1345 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1346}
1347
1348impl ExternalAgentServer for LocalRegistryArchiveAgent {
1349 fn version(&self) -> Option<&SharedString> {
1350 Some(&self.version)
1351 }
1352
1353 fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1354 self.new_version_available_tx.take()
1355 }
1356
1357 fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
1358 self.new_version_available_tx = Some(tx);
1359 }
1360
1361 fn get_command(
1362 &mut self,
1363 extra_env: HashMap<String, String>,
1364 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1365 cx: &mut AsyncApp,
1366 ) -> Task<Result<AgentServerCommand>> {
1367 self.new_version_available_tx = new_version_available_tx;
1368 let fs = self.fs.clone();
1369 let http_client = self.http_client.clone();
1370 let node_runtime = self.node_runtime.clone();
1371 let project_environment = self.project_environment.downgrade();
1372 let registry_id = self.registry_id.clone();
1373 let targets = self.targets.clone();
1374 let settings_env = self.env.clone();
1375 let version = self.version.clone();
1376
1377 cx.spawn(async move |cx| {
1378 let mut env = project_environment
1379 .update(cx, |project_environment, cx| {
1380 project_environment.local_directory_environment(
1381 &Shell::System,
1382 paths::home_dir().as_path().into(),
1383 cx,
1384 )
1385 })?
1386 .await
1387 .unwrap_or_default();
1388
1389 let dir = paths::external_agents_dir()
1390 .join("registry")
1391 .join(registry_id.as_ref());
1392 fs.create_dir(&dir).await?;
1393
1394 let os = if cfg!(target_os = "macos") {
1395 "darwin"
1396 } else if cfg!(target_os = "linux") {
1397 "linux"
1398 } else if cfg!(target_os = "windows") {
1399 "windows"
1400 } else {
1401 anyhow::bail!("unsupported OS");
1402 };
1403
1404 let arch = if cfg!(target_arch = "aarch64") {
1405 "aarch64"
1406 } else if cfg!(target_arch = "x86_64") {
1407 "x86_64"
1408 } else {
1409 anyhow::bail!("unsupported architecture");
1410 };
1411
1412 let platform_key = format!("{}-{}", os, arch);
1413 let target_config = targets.get(&platform_key).with_context(|| {
1414 format!(
1415 "no target specified for platform '{}'. Available platforms: {}",
1416 platform_key,
1417 targets
1418 .keys()
1419 .map(|k| k.as_str())
1420 .collect::<Vec<_>>()
1421 .join(", ")
1422 )
1423 })?;
1424
1425 env.extend(target_config.env.clone());
1426 env.extend(extra_env);
1427 env.extend(settings_env);
1428
1429 let archive_url = &target_config.archive;
1430 let version_dir =
1431 versioned_archive_cache_dir(&dir, Some(version.as_ref()), archive_url);
1432
1433 if !fs.is_dir(&version_dir).await {
1434 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1435 Some(provided_sha.clone())
1436 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1437 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1438 &github_archive.repo_name_with_owner,
1439 &github_archive.tag,
1440 http_client.clone(),
1441 )
1442 .await
1443 {
1444 if let Some(asset) = release
1445 .assets
1446 .iter()
1447 .find(|a| a.name == github_archive.asset_name)
1448 {
1449 asset.digest.as_ref().and_then(|d| {
1450 d.strip_prefix("sha256:")
1451 .map(|s| s.to_string())
1452 .or_else(|| Some(d.clone()))
1453 })
1454 } else {
1455 None
1456 }
1457 } else {
1458 None
1459 }
1460 } else {
1461 None
1462 };
1463
1464 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1465
1466 ::http_client::github_download::download_server_binary(
1467 &*http_client,
1468 archive_url,
1469 sha256.as_deref(),
1470 &version_dir,
1471 asset_kind,
1472 )
1473 .await?;
1474 }
1475
1476 let cmd = &target_config.cmd;
1477
1478 let cmd_path = if cmd == "node" {
1479 node_runtime.binary_path().await?
1480 } else {
1481 if cmd.contains("..") {
1482 anyhow::bail!("command path cannot contain '..': {}", cmd);
1483 }
1484
1485 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1486 let cmd_path = version_dir.join(&cmd[2..]);
1487 anyhow::ensure!(
1488 fs.is_file(&cmd_path).await,
1489 "Missing command {} after extraction",
1490 cmd_path.to_string_lossy()
1491 );
1492 cmd_path
1493 } else {
1494 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1495 }
1496 };
1497
1498 let command = AgentServerCommand {
1499 path: cmd_path,
1500 args: target_config.args.clone(),
1501 env: Some(env),
1502 };
1503
1504 Ok(command)
1505 })
1506 }
1507
1508 fn as_any(&self) -> &dyn Any {
1509 self
1510 }
1511
1512 fn as_any_mut(&mut self) -> &mut dyn Any {
1513 self
1514 }
1515}
1516
1517struct LocalRegistryNpxAgent {
1518 node_runtime: NodeRuntime,
1519 project_environment: Entity<ProjectEnvironment>,
1520 version: SharedString,
1521 package: SharedString,
1522 args: Vec<String>,
1523 distribution_env: HashMap<String, String>,
1524 settings_env: HashMap<String, String>,
1525 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1526}
1527
1528impl ExternalAgentServer for LocalRegistryNpxAgent {
1529 fn version(&self) -> Option<&SharedString> {
1530 Some(&self.version)
1531 }
1532
1533 fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1534 self.new_version_available_tx.take()
1535 }
1536
1537 fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
1538 self.new_version_available_tx = Some(tx);
1539 }
1540
1541 fn get_command(
1542 &mut self,
1543 extra_env: HashMap<String, String>,
1544 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1545 cx: &mut AsyncApp,
1546 ) -> Task<Result<AgentServerCommand>> {
1547 self.new_version_available_tx = new_version_available_tx;
1548 let node_runtime = self.node_runtime.clone();
1549 let project_environment = self.project_environment.downgrade();
1550 let package = self.package.clone();
1551 let args = self.args.clone();
1552 let distribution_env = self.distribution_env.clone();
1553 let settings_env = self.settings_env.clone();
1554
1555 cx.spawn(async move |cx| {
1556 let mut env = project_environment
1557 .update(cx, |project_environment, cx| {
1558 project_environment.local_directory_environment(
1559 &Shell::System,
1560 paths::home_dir().as_path().into(),
1561 cx,
1562 )
1563 })?
1564 .await
1565 .unwrap_or_default();
1566
1567 let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package.to_string()];
1568 exec_args.extend(args);
1569
1570 let npm_command = node_runtime
1571 .npm_command(
1572 "exec",
1573 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1574 )
1575 .await?;
1576
1577 env.extend(npm_command.env);
1578 env.extend(distribution_env);
1579 env.extend(extra_env);
1580 env.extend(settings_env);
1581
1582 let command = AgentServerCommand {
1583 path: npm_command.path,
1584 args: npm_command.args,
1585 env: Some(env),
1586 };
1587
1588 Ok(command)
1589 })
1590 }
1591
1592 fn as_any(&self) -> &dyn Any {
1593 self
1594 }
1595
1596 fn as_any_mut(&mut self) -> &mut dyn Any {
1597 self
1598 }
1599}
1600
1601struct LocalCustomAgent {
1602 project_environment: Entity<ProjectEnvironment>,
1603 command: AgentServerCommand,
1604}
1605
1606impl ExternalAgentServer for LocalCustomAgent {
1607 fn get_command(
1608 &mut self,
1609 extra_env: HashMap<String, String>,
1610 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1611 cx: &mut AsyncApp,
1612 ) -> Task<Result<AgentServerCommand>> {
1613 let mut command = self.command.clone();
1614 let project_environment = self.project_environment.downgrade();
1615 cx.spawn(async move |cx| {
1616 let mut env = project_environment
1617 .update(cx, |project_environment, cx| {
1618 project_environment.local_directory_environment(
1619 &Shell::System,
1620 paths::home_dir().as_path().into(),
1621 cx,
1622 )
1623 })?
1624 .await
1625 .unwrap_or_default();
1626 env.extend(command.env.unwrap_or_default());
1627 env.extend(extra_env);
1628 command.env = Some(env);
1629 Ok(command)
1630 })
1631 }
1632
1633 fn as_any(&self) -> &dyn Any {
1634 self
1635 }
1636
1637 fn as_any_mut(&mut self) -> &mut dyn Any {
1638 self
1639 }
1640}
1641
1642#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1643pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
1644
1645impl std::ops::Deref for AllAgentServersSettings {
1646 type Target = HashMap<String, CustomAgentServerSettings>;
1647
1648 fn deref(&self) -> &Self::Target {
1649 &self.0
1650 }
1651}
1652
1653impl std::ops::DerefMut for AllAgentServersSettings {
1654 fn deref_mut(&mut self) -> &mut Self::Target {
1655 &mut self.0
1656 }
1657}
1658
1659impl AllAgentServersSettings {
1660 pub fn has_registry_agents(&self) -> bool {
1661 self.values()
1662 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
1663 }
1664}
1665
1666#[derive(Clone, JsonSchema, Debug, PartialEq)]
1667pub enum CustomAgentServerSettings {
1668 Custom {
1669 command: AgentServerCommand,
1670 /// The default mode to use for this agent.
1671 ///
1672 /// Note: Not only all agents support modes.
1673 ///
1674 /// Default: None
1675 default_mode: Option<String>,
1676 /// The default model to use for this agent.
1677 ///
1678 /// This should be the model ID as reported by the agent.
1679 ///
1680 /// Default: None
1681 default_model: Option<String>,
1682 /// The favorite models for this agent.
1683 ///
1684 /// Default: []
1685 favorite_models: Vec<String>,
1686 /// Default values for session config options.
1687 ///
1688 /// This is a map from config option ID to value ID.
1689 ///
1690 /// Default: {}
1691 default_config_options: HashMap<String, String>,
1692 /// Favorited values for session config options.
1693 ///
1694 /// This is a map from config option ID to a list of favorited value IDs.
1695 ///
1696 /// Default: {}
1697 favorite_config_option_values: HashMap<String, Vec<String>>,
1698 },
1699 Extension {
1700 /// Additional environment variables to pass to the agent.
1701 ///
1702 /// Default: {}
1703 env: HashMap<String, String>,
1704 /// The default mode to use for this agent.
1705 ///
1706 /// Note: Not only all agents support modes.
1707 ///
1708 /// Default: None
1709 default_mode: Option<String>,
1710 /// The default model to use for this agent.
1711 ///
1712 /// This should be the model ID as reported by the agent.
1713 ///
1714 /// Default: None
1715 default_model: Option<String>,
1716 /// The favorite models for this agent.
1717 ///
1718 /// Default: []
1719 favorite_models: Vec<String>,
1720 /// Default values for session config options.
1721 ///
1722 /// This is a map from config option ID to value ID.
1723 ///
1724 /// Default: {}
1725 default_config_options: HashMap<String, String>,
1726 /// Favorited values for session config options.
1727 ///
1728 /// This is a map from config option ID to a list of favorited value IDs.
1729 ///
1730 /// Default: {}
1731 favorite_config_option_values: HashMap<String, Vec<String>>,
1732 },
1733 Registry {
1734 /// Additional environment variables to pass to the agent.
1735 ///
1736 /// Default: {}
1737 env: HashMap<String, String>,
1738 /// The default mode to use for this agent.
1739 ///
1740 /// Note: Not only all agents support modes.
1741 ///
1742 /// Default: None
1743 default_mode: Option<String>,
1744 /// The default model to use for this agent.
1745 ///
1746 /// This should be the model ID as reported by the agent.
1747 ///
1748 /// Default: None
1749 default_model: Option<String>,
1750 /// The favorite models for this agent.
1751 ///
1752 /// Default: []
1753 favorite_models: Vec<String>,
1754 /// Default values for session config options.
1755 ///
1756 /// This is a map from config option ID to value ID.
1757 ///
1758 /// Default: {}
1759 default_config_options: HashMap<String, String>,
1760 /// Favorited values for session config options.
1761 ///
1762 /// This is a map from config option ID to a list of favorited value IDs.
1763 ///
1764 /// Default: {}
1765 favorite_config_option_values: HashMap<String, Vec<String>>,
1766 },
1767}
1768
1769impl CustomAgentServerSettings {
1770 pub fn command(&self) -> Option<&AgentServerCommand> {
1771 match self {
1772 CustomAgentServerSettings::Custom { command, .. } => Some(command),
1773 CustomAgentServerSettings::Extension { .. }
1774 | CustomAgentServerSettings::Registry { .. } => None,
1775 }
1776 }
1777
1778 pub fn default_mode(&self) -> Option<&str> {
1779 match self {
1780 CustomAgentServerSettings::Custom { default_mode, .. }
1781 | CustomAgentServerSettings::Extension { default_mode, .. }
1782 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
1783 }
1784 }
1785
1786 pub fn default_model(&self) -> Option<&str> {
1787 match self {
1788 CustomAgentServerSettings::Custom { default_model, .. }
1789 | CustomAgentServerSettings::Extension { default_model, .. }
1790 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
1791 }
1792 }
1793
1794 pub fn favorite_models(&self) -> &[String] {
1795 match self {
1796 CustomAgentServerSettings::Custom {
1797 favorite_models, ..
1798 }
1799 | CustomAgentServerSettings::Extension {
1800 favorite_models, ..
1801 }
1802 | CustomAgentServerSettings::Registry {
1803 favorite_models, ..
1804 } => favorite_models,
1805 }
1806 }
1807
1808 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
1809 match self {
1810 CustomAgentServerSettings::Custom {
1811 default_config_options,
1812 ..
1813 }
1814 | CustomAgentServerSettings::Extension {
1815 default_config_options,
1816 ..
1817 }
1818 | CustomAgentServerSettings::Registry {
1819 default_config_options,
1820 ..
1821 } => default_config_options.get(config_id).map(|s| s.as_str()),
1822 }
1823 }
1824
1825 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
1826 match self {
1827 CustomAgentServerSettings::Custom {
1828 favorite_config_option_values,
1829 ..
1830 }
1831 | CustomAgentServerSettings::Extension {
1832 favorite_config_option_values,
1833 ..
1834 }
1835 | CustomAgentServerSettings::Registry {
1836 favorite_config_option_values,
1837 ..
1838 } => favorite_config_option_values
1839 .get(config_id)
1840 .map(|v| v.as_slice()),
1841 }
1842 }
1843}
1844
1845impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1846 fn from(value: settings::CustomAgentServerSettings) -> Self {
1847 match value {
1848 settings::CustomAgentServerSettings::Custom {
1849 path,
1850 args,
1851 env,
1852 default_mode,
1853 default_model,
1854 favorite_models,
1855 default_config_options,
1856 favorite_config_option_values,
1857 } => CustomAgentServerSettings::Custom {
1858 command: AgentServerCommand {
1859 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1860 args,
1861 env: Some(env),
1862 },
1863 default_mode,
1864 default_model,
1865 favorite_models,
1866 default_config_options,
1867 favorite_config_option_values,
1868 },
1869 settings::CustomAgentServerSettings::Extension {
1870 env,
1871 default_mode,
1872 default_model,
1873 default_config_options,
1874 favorite_models,
1875 favorite_config_option_values,
1876 } => CustomAgentServerSettings::Extension {
1877 env,
1878 default_mode,
1879 default_model,
1880 default_config_options,
1881 favorite_models,
1882 favorite_config_option_values,
1883 },
1884 settings::CustomAgentServerSettings::Registry {
1885 env,
1886 default_mode,
1887 default_model,
1888 default_config_options,
1889 favorite_models,
1890 favorite_config_option_values,
1891 } => CustomAgentServerSettings::Registry {
1892 env,
1893 default_mode,
1894 default_model,
1895 default_config_options,
1896 favorite_models,
1897 favorite_config_option_values,
1898 },
1899 }
1900 }
1901}
1902
1903impl settings::Settings for AllAgentServersSettings {
1904 fn from_settings(content: &settings::SettingsContent) -> Self {
1905 let agent_settings = content.agent_servers.clone().unwrap();
1906 Self(
1907 agent_settings
1908 .0
1909 .into_iter()
1910 .map(|(k, v)| (k, v.into()))
1911 .collect(),
1912 )
1913 }
1914}
1915
1916#[cfg(test)]
1917mod tests {
1918 use super::*;
1919 use crate::agent_registry_store::{
1920 AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent,
1921 };
1922 use crate::worktree_store::{WorktreeIdCounter, WorktreeStore};
1923 use gpui::{AppContext as _, TestAppContext};
1924 use node_runtime::NodeRuntime;
1925 use settings::Settings as _;
1926
1927 fn make_npx_agent(id: &str, version: &str) -> RegistryAgent {
1928 let id = SharedString::from(id.to_string());
1929 RegistryAgent::Npx(RegistryNpxAgent {
1930 metadata: RegistryAgentMetadata {
1931 id: AgentId::new(id.clone()),
1932 name: id.clone(),
1933 description: SharedString::from(""),
1934 version: SharedString::from(version.to_string()),
1935 repository: None,
1936 website: None,
1937 icon_path: None,
1938 },
1939 package: id,
1940 args: Vec::new(),
1941 env: HashMap::default(),
1942 })
1943 }
1944
1945 fn init_test_settings(cx: &mut TestAppContext) {
1946 cx.update(|cx| {
1947 let settings_store = SettingsStore::test(cx);
1948 cx.set_global(settings_store);
1949 });
1950 }
1951
1952 fn init_registry(
1953 cx: &mut TestAppContext,
1954 agents: Vec<RegistryAgent>,
1955 ) -> gpui::Entity<AgentRegistryStore> {
1956 cx.update(|cx| AgentRegistryStore::init_test_global(cx, agents))
1957 }
1958
1959 fn set_registry_settings(cx: &mut TestAppContext, agent_names: &[&str]) {
1960 cx.update(|cx| {
1961 AllAgentServersSettings::override_global(
1962 AllAgentServersSettings(
1963 agent_names
1964 .iter()
1965 .map(|name| {
1966 (
1967 name.to_string(),
1968 settings::CustomAgentServerSettings::Registry {
1969 env: HashMap::default(),
1970 default_mode: None,
1971 default_model: None,
1972 favorite_models: Vec::new(),
1973 default_config_options: HashMap::default(),
1974 favorite_config_option_values: HashMap::default(),
1975 }
1976 .into(),
1977 )
1978 })
1979 .collect(),
1980 ),
1981 cx,
1982 );
1983 });
1984 }
1985
1986 fn create_agent_server_store(cx: &mut TestAppContext) -> gpui::Entity<AgentServerStore> {
1987 cx.update(|cx| {
1988 let fs: Arc<dyn Fs> = fs::FakeFs::new(cx.background_executor().clone());
1989 let worktree_store =
1990 cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
1991 let project_environment = cx.new(|cx| {
1992 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1993 });
1994 let http_client = http_client::FakeHttpClient::with_404_response();
1995
1996 cx.new(|cx| {
1997 AgentServerStore::local(
1998 NodeRuntime::unavailable(),
1999 fs,
2000 project_environment,
2001 http_client,
2002 cx,
2003 )
2004 })
2005 })
2006 }
2007
2008 #[test]
2009 fn detects_supported_archive_suffixes() {
2010 assert!(matches!(
2011 asset_kind_for_archive_url("https://example.com/agent.zip"),
2012 Ok(AssetKind::Zip)
2013 ));
2014 assert!(matches!(
2015 asset_kind_for_archive_url("https://example.com/agent.zip?download=1"),
2016 Ok(AssetKind::Zip)
2017 ));
2018 assert!(matches!(
2019 asset_kind_for_archive_url("https://example.com/agent.tar.gz"),
2020 Ok(AssetKind::TarGz)
2021 ));
2022 assert!(matches!(
2023 asset_kind_for_archive_url("https://example.com/agent.tar.gz?download=1#latest"),
2024 Ok(AssetKind::TarGz)
2025 ));
2026 assert!(matches!(
2027 asset_kind_for_archive_url("https://example.com/agent.tgz"),
2028 Ok(AssetKind::TarGz)
2029 ));
2030 assert!(matches!(
2031 asset_kind_for_archive_url("https://example.com/agent.tgz#download"),
2032 Ok(AssetKind::TarGz)
2033 ));
2034 assert!(matches!(
2035 asset_kind_for_archive_url("https://example.com/agent.tar.bz2"),
2036 Ok(AssetKind::TarBz2)
2037 ));
2038 assert!(matches!(
2039 asset_kind_for_archive_url("https://example.com/agent.tar.bz2?download=1"),
2040 Ok(AssetKind::TarBz2)
2041 ));
2042 assert!(matches!(
2043 asset_kind_for_archive_url("https://example.com/agent.tbz2"),
2044 Ok(AssetKind::TarBz2)
2045 ));
2046 assert!(matches!(
2047 asset_kind_for_archive_url("https://example.com/agent.tbz2#download"),
2048 Ok(AssetKind::TarBz2)
2049 ));
2050 }
2051
2052 #[test]
2053 fn parses_github_release_archive_urls() {
2054 let github_archive = github_release_archive_from_url(
2055 "https://github.com/owner/repo/releases/download/release%2F2.3.5/agent.tar.bz2?download=1",
2056 )
2057 .unwrap();
2058
2059 assert_eq!(github_archive.repo_name_with_owner, "owner/repo");
2060 assert_eq!(github_archive.tag, "release/2.3.5");
2061 assert_eq!(github_archive.asset_name, "agent.tar.bz2");
2062 }
2063
2064 #[test]
2065 fn rejects_unsupported_archive_suffixes() {
2066 let error = asset_kind_for_archive_url("https://example.com/agent.tar.xz")
2067 .err()
2068 .map(|error| error.to_string());
2069
2070 assert_eq!(
2071 error,
2072 Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string()),
2073 );
2074 }
2075
2076 #[test]
2077 fn versioned_archive_cache_dir_includes_version_before_url_hash() {
2078 let slash_version_dir = versioned_archive_cache_dir(
2079 Path::new("/tmp/agents"),
2080 Some("release/2.3.5"),
2081 "https://example.com/agent.zip",
2082 );
2083 let colon_version_dir = versioned_archive_cache_dir(
2084 Path::new("/tmp/agents"),
2085 Some("release:2.3.5"),
2086 "https://example.com/agent.zip",
2087 );
2088 let file_name = slash_version_dir
2089 .file_name()
2090 .and_then(|name| name.to_str())
2091 .expect("cache directory should have a file name");
2092
2093 assert!(file_name.starts_with("v_release-2.3.5_"));
2094 assert_ne!(slash_version_dir, colon_version_dir);
2095 }
2096
2097 #[gpui::test]
2098 fn test_version_change_sends_notification(cx: &mut TestAppContext) {
2099 init_test_settings(cx);
2100 let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2101 set_registry_settings(cx, &["test-agent"]);
2102 let store = create_agent_server_store(cx);
2103
2104 // Verify the agent was registered with version 1.0.0.
2105 store.read_with(cx, |store, _| {
2106 let entry = store
2107 .external_agents
2108 .get(&AgentId::new("test-agent"))
2109 .expect("agent should be registered");
2110 assert_eq!(
2111 entry.server.version().map(|v| v.to_string()),
2112 Some("1.0.0".to_string())
2113 );
2114 });
2115
2116 // Set up a watch channel and store the tx on the agent.
2117 let (tx, mut rx) = watch::channel::<Option<String>>(None);
2118 store.update(cx, |store, _| {
2119 let entry = store
2120 .external_agents
2121 .get_mut(&AgentId::new("test-agent"))
2122 .expect("agent should be registered");
2123 entry.server.set_new_version_available_tx(tx);
2124 });
2125
2126 // Update the registry to version 2.0.0.
2127 registry.update(cx, |store, cx| {
2128 store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx);
2129 });
2130 cx.run_until_parked();
2131
2132 // The watch channel should have received the new version.
2133 assert_eq!(rx.borrow().as_deref(), Some("2.0.0"));
2134 }
2135
2136 #[gpui::test]
2137 fn test_same_version_preserves_tx(cx: &mut TestAppContext) {
2138 init_test_settings(cx);
2139 let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2140 set_registry_settings(cx, &["test-agent"]);
2141 let store = create_agent_server_store(cx);
2142
2143 let (tx, mut rx) = watch::channel::<Option<String>>(None);
2144 store.update(cx, |store, _| {
2145 let entry = store
2146 .external_agents
2147 .get_mut(&AgentId::new("test-agent"))
2148 .expect("agent should be registered");
2149 entry.server.set_new_version_available_tx(tx);
2150 });
2151
2152 // "Refresh" the registry with the same version.
2153 registry.update(cx, |store, cx| {
2154 store.set_agents(vec![make_npx_agent("test-agent", "1.0.0")], cx);
2155 });
2156 cx.run_until_parked();
2157
2158 // No notification should have been sent.
2159 assert_eq!(rx.borrow().as_deref(), None);
2160
2161 // The tx should have been transferred to the rebuilt agent entry.
2162 store.update(cx, |store, _| {
2163 let entry = store
2164 .external_agents
2165 .get_mut(&AgentId::new("test-agent"))
2166 .expect("agent should be registered");
2167 assert!(
2168 entry.server.take_new_version_available_tx().is_some(),
2169 "tx should have been transferred to the rebuilt agent"
2170 );
2171 });
2172 }
2173
2174 #[gpui::test]
2175 fn test_no_tx_stored_does_not_panic_on_version_change(cx: &mut TestAppContext) {
2176 init_test_settings(cx);
2177 let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2178 set_registry_settings(cx, &["test-agent"]);
2179 let _store = create_agent_server_store(cx);
2180
2181 // Update the registry without having stored any tx — should not panic.
2182 registry.update(cx, |store, cx| {
2183 store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx);
2184 });
2185 cx.run_until_parked();
2186 }
2187
2188 #[gpui::test]
2189 fn test_multiple_agents_independent_notifications(cx: &mut TestAppContext) {
2190 init_test_settings(cx);
2191 let registry = init_registry(
2192 cx,
2193 vec![
2194 make_npx_agent("agent-a", "1.0.0"),
2195 make_npx_agent("agent-b", "3.0.0"),
2196 ],
2197 );
2198 set_registry_settings(cx, &["agent-a", "agent-b"]);
2199 let store = create_agent_server_store(cx);
2200
2201 let (tx_a, mut rx_a) = watch::channel::<Option<String>>(None);
2202 let (tx_b, mut rx_b) = watch::channel::<Option<String>>(None);
2203 store.update(cx, |store, _| {
2204 store
2205 .external_agents
2206 .get_mut(&AgentId::new("agent-a"))
2207 .expect("agent-a should be registered")
2208 .server
2209 .set_new_version_available_tx(tx_a);
2210 store
2211 .external_agents
2212 .get_mut(&AgentId::new("agent-b"))
2213 .expect("agent-b should be registered")
2214 .server
2215 .set_new_version_available_tx(tx_b);
2216 });
2217
2218 // Update only agent-a to a new version; agent-b stays the same.
2219 registry.update(cx, |store, cx| {
2220 store.set_agents(
2221 vec![
2222 make_npx_agent("agent-a", "2.0.0"),
2223 make_npx_agent("agent-b", "3.0.0"),
2224 ],
2225 cx,
2226 );
2227 });
2228 cx.run_until_parked();
2229
2230 // agent-a should have received a notification.
2231 assert_eq!(rx_a.borrow().as_deref(), Some("2.0.0"));
2232
2233 // agent-b should NOT have received a notification.
2234 assert_eq!(rx_b.borrow().as_deref(), None);
2235
2236 // agent-b's tx should have been transferred.
2237 store.update(cx, |store, _| {
2238 assert!(
2239 store
2240 .external_agents
2241 .get_mut(&AgentId::new("agent-b"))
2242 .expect("agent-b should be registered")
2243 .server
2244 .take_new_version_available_tx()
2245 .is_some(),
2246 "agent-b tx should have been transferred"
2247 );
2248 });
2249 }
2250}