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