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 as_any_mut(&mut self) -> &mut dyn Any;
127}
128
129impl dyn ExternalAgentServer {
130 fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
131 self.as_any_mut().downcast_mut()
132 }
133}
134
135enum AgentServerStoreState {
136 Local {
137 node_runtime: NodeRuntime,
138 fs: Arc<dyn Fs>,
139 project_environment: Entity<ProjectEnvironment>,
140 downstream_client: Option<(u64, AnyProtoClient)>,
141 settings: Option<AllAgentServersSettings>,
142 http_client: Arc<dyn HttpClient>,
143 extension_agents: Vec<(
144 Arc<str>,
145 String,
146 HashMap<String, extension::TargetConfig>,
147 HashMap<String, String>,
148 Option<String>,
149 Option<SharedString>,
150 )>,
151 _subscriptions: Vec<Subscription>,
152 },
153 Remote {
154 project_id: u64,
155 upstream_client: Entity<RemoteClient>,
156 worktree_store: Entity<WorktreeStore>,
157 },
158 Collab,
159}
160
161pub struct ExternalAgentEntry {
162 server: Box<dyn ExternalAgentServer>,
163 icon: Option<SharedString>,
164 display_name: Option<SharedString>,
165 pub source: ExternalAgentSource,
166}
167
168impl ExternalAgentEntry {
169 pub fn new(
170 server: Box<dyn ExternalAgentServer>,
171 source: ExternalAgentSource,
172 icon: Option<SharedString>,
173 display_name: Option<SharedString>,
174 ) -> Self {
175 Self {
176 server,
177 icon,
178 display_name,
179 source,
180 }
181 }
182}
183
184pub struct AgentServerStore {
185 state: AgentServerStoreState,
186 pub external_agents: HashMap<AgentId, ExternalAgentEntry>,
187}
188
189pub struct AgentServersUpdated;
190
191impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
192
193impl AgentServerStore {
194 /// Synchronizes extension-provided agent servers with the store.
195 pub fn sync_extension_agents<'a, I>(
196 &mut self,
197 manifests: I,
198 extensions_dir: PathBuf,
199 cx: &mut Context<Self>,
200 ) where
201 I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
202 {
203 // Collect manifests first so we can iterate twice
204 let manifests: Vec<_> = manifests.into_iter().collect();
205
206 // Remove all extension-provided agents
207 // (They will be re-added below if they're in the currently installed extensions)
208 self.external_agents
209 .retain(|_, entry| entry.source != ExternalAgentSource::Extension);
210
211 // Insert agent servers from extension manifests
212 match &mut self.state {
213 AgentServerStoreState::Local {
214 extension_agents, ..
215 } => {
216 extension_agents.clear();
217 for (ext_id, manifest) in manifests {
218 for (agent_name, agent_entry) in &manifest.agent_servers {
219 let display_name = SharedString::from(agent_entry.name.clone());
220 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
221 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
222 });
223
224 extension_agents.push((
225 agent_name.clone(),
226 ext_id.to_owned(),
227 agent_entry.targets.clone(),
228 agent_entry.env.clone(),
229 icon_path,
230 Some(display_name),
231 ));
232 }
233 }
234 self.reregister_agents(cx);
235 }
236 AgentServerStoreState::Remote {
237 project_id,
238 upstream_client,
239 worktree_store,
240 } => {
241 let mut agents = vec![];
242 for (ext_id, manifest) in manifests {
243 for (agent_name, agent_entry) in &manifest.agent_servers {
244 let display_name = SharedString::from(agent_entry.name.clone());
245 let icon_path = agent_entry.icon.as_ref().and_then(|icon| {
246 resolve_extension_icon_path(&extensions_dir, ext_id, icon)
247 });
248 let icon_shared = icon_path
249 .as_ref()
250 .map(|path| SharedString::from(path.clone()));
251 let icon = icon_path;
252 let agent_server_name = AgentId(agent_name.clone().into());
253 self.external_agents
254 .entry(agent_server_name.clone())
255 .and_modify(|entry| {
256 entry.icon = icon_shared.clone();
257 entry.display_name = Some(display_name.clone());
258 entry.source = ExternalAgentSource::Extension;
259 })
260 .or_insert_with(|| {
261 ExternalAgentEntry::new(
262 Box::new(RemoteExternalAgentServer {
263 project_id: *project_id,
264 upstream_client: upstream_client.clone(),
265 worktree_store: worktree_store.clone(),
266 name: agent_server_name.clone(),
267 new_version_available_tx: None,
268 })
269 as Box<dyn ExternalAgentServer>,
270 ExternalAgentSource::Extension,
271 icon_shared.clone(),
272 Some(display_name.clone()),
273 )
274 });
275
276 agents.push(ExternalExtensionAgent {
277 name: agent_name.to_string(),
278 icon_path: icon,
279 extension_id: ext_id.to_string(),
280 targets: agent_entry
281 .targets
282 .iter()
283 .map(|(k, v)| (k.clone(), v.to_proto()))
284 .collect(),
285 env: agent_entry
286 .env
287 .iter()
288 .map(|(k, v)| (k.clone(), v.clone()))
289 .collect(),
290 });
291 }
292 }
293 upstream_client
294 .read(cx)
295 .proto_client()
296 .send(proto::ExternalExtensionAgentsUpdated {
297 project_id: *project_id,
298 agents,
299 })
300 .log_err();
301 }
302 AgentServerStoreState::Collab => {
303 // Do nothing
304 }
305 }
306
307 cx.emit(AgentServersUpdated);
308 }
309
310 pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
311 self.external_agents
312 .get(id)
313 .and_then(|entry| entry.icon.clone())
314 }
315
316 pub fn agent_source(&self, name: &AgentId) -> Option<ExternalAgentSource> {
317 self.external_agents.get(name).map(|entry| entry.source)
318 }
319}
320
321/// Safely resolves an extension icon path, ensuring it stays within the extension directory.
322/// Returns `None` if the path would escape the extension directory (path traversal attack).
323pub fn resolve_extension_icon_path(
324 extensions_dir: &Path,
325 extension_id: &str,
326 icon_relative_path: &str,
327) -> Option<String> {
328 let extension_root = extensions_dir.join(extension_id);
329 let icon_path = extension_root.join(icon_relative_path);
330
331 // Canonicalize both paths to resolve symlinks and normalize the paths.
332 // For the extension root, we need to handle the case where it might be a symlink
333 // (common for dev extensions).
334 let canonical_extension_root = extension_root.canonicalize().unwrap_or(extension_root);
335 let canonical_icon_path = match icon_path.canonicalize() {
336 Ok(path) => path,
337 Err(err) => {
338 log::warn!(
339 "Failed to canonicalize icon path for extension '{}': {} (path: {})",
340 extension_id,
341 err,
342 icon_relative_path
343 );
344 return None;
345 }
346 };
347
348 // Verify the resolved icon path is within the extension directory
349 if canonical_icon_path.starts_with(&canonical_extension_root) {
350 Some(canonical_icon_path.to_string_lossy().to_string())
351 } else {
352 log::warn!(
353 "Icon path '{}' for extension '{}' escapes extension directory, ignoring for security",
354 icon_relative_path,
355 extension_id
356 );
357 None
358 }
359}
360
361impl AgentServerStore {
362 pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> {
363 self.external_agents
364 .get(name)
365 .and_then(|entry| entry.display_name.clone())
366 }
367
368 pub fn init_remote(session: &AnyProtoClient) {
369 session.add_entity_message_handler(Self::handle_external_agents_updated);
370 session.add_entity_message_handler(Self::handle_new_version_available);
371 }
372
373 pub fn init_headless(session: &AnyProtoClient) {
374 session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
375 session.add_entity_request_handler(Self::handle_get_agent_server_command);
376 }
377
378 fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
379 let AgentServerStoreState::Local {
380 settings: old_settings,
381 ..
382 } = &mut self.state
383 else {
384 debug_panic!(
385 "should not be subscribed to agent server settings changes in non-local project"
386 );
387 return;
388 };
389
390 let new_settings = cx
391 .global::<SettingsStore>()
392 .get::<AllAgentServersSettings>(None)
393 .clone();
394 if Some(&new_settings) == old_settings.as_ref() {
395 return;
396 }
397
398 self.reregister_agents(cx);
399 }
400
401 fn reregister_agents(&mut self, cx: &mut Context<Self>) {
402 let AgentServerStoreState::Local {
403 node_runtime,
404 fs,
405 project_environment,
406 downstream_client,
407 settings: old_settings,
408 http_client,
409 extension_agents,
410 ..
411 } = &mut self.state
412 else {
413 debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
414
415 return;
416 };
417
418 let new_settings = cx
419 .global::<SettingsStore>()
420 .get::<AllAgentServersSettings>(None)
421 .clone();
422
423 // If we don't have agents from the registry loaded yet, trigger a
424 // refresh, which will cause this function to be called again
425 let registry_store = AgentRegistryStore::try_global(cx);
426 if new_settings.has_registry_agents()
427 && let Some(registry) = registry_store.as_ref()
428 {
429 registry.update(cx, |registry, cx| registry.refresh_if_stale(cx));
430 }
431
432 let registry_agents_by_id = registry_store
433 .as_ref()
434 .map(|store| {
435 store
436 .read(cx)
437 .agents()
438 .iter()
439 .cloned()
440 .map(|agent| (agent.id().to_string(), agent))
441 .collect::<HashMap<_, _>>()
442 })
443 .unwrap_or_default();
444
445 self.external_agents.clear();
446
447 // Insert extension agents before custom/registry so registry entries override extensions.
448 for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() {
449 let name = AgentId(agent_name.clone().into());
450 let mut env = env.clone();
451 if let Some(settings_env) =
452 new_settings
453 .get(agent_name.as_ref())
454 .and_then(|settings| match settings {
455 CustomAgentServerSettings::Extension { env, .. } => Some(env.clone()),
456 _ => None,
457 })
458 {
459 env.extend(settings_env);
460 }
461 let icon = icon_path
462 .as_ref()
463 .map(|path| SharedString::from(path.clone()));
464
465 self.external_agents.insert(
466 name.clone(),
467 ExternalAgentEntry::new(
468 Box::new(LocalExtensionArchiveAgent {
469 fs: fs.clone(),
470 http_client: http_client.clone(),
471 node_runtime: node_runtime.clone(),
472 project_environment: project_environment.clone(),
473 extension_id: Arc::from(&**ext_id),
474 targets: targets.clone(),
475 env,
476 agent_id: agent_name.clone(),
477 }) as Box<dyn ExternalAgentServer>,
478 ExternalAgentSource::Extension,
479 icon,
480 display_name.clone(),
481 ),
482 );
483 }
484
485 for (name, settings) in new_settings.iter() {
486 match settings {
487 CustomAgentServerSettings::Custom { command, .. } => {
488 let agent_name = AgentId(name.clone().into());
489 self.external_agents.insert(
490 agent_name.clone(),
491 ExternalAgentEntry::new(
492 Box::new(LocalCustomAgent {
493 command: command.clone(),
494 project_environment: project_environment.clone(),
495 }) as Box<dyn ExternalAgentServer>,
496 ExternalAgentSource::Custom,
497 None,
498 None,
499 ),
500 );
501 }
502 CustomAgentServerSettings::Registry { env, .. } => {
503 let Some(agent) = registry_agents_by_id.get(name) else {
504 if registry_store.is_some() {
505 log::debug!("Registry agent '{}' not found in ACP registry", name);
506 }
507 continue;
508 };
509
510 let agent_name = AgentId(name.clone().into());
511 match agent {
512 RegistryAgent::Binary(agent) => {
513 if !agent.supports_current_platform {
514 log::warn!(
515 "Registry agent '{}' has no compatible binary for this platform",
516 name
517 );
518 continue;
519 }
520
521 self.external_agents.insert(
522 agent_name.clone(),
523 ExternalAgentEntry::new(
524 Box::new(LocalRegistryArchiveAgent {
525 fs: fs.clone(),
526 http_client: http_client.clone(),
527 node_runtime: node_runtime.clone(),
528 project_environment: project_environment.clone(),
529 registry_id: Arc::from(name.as_str()),
530 targets: agent.targets.clone(),
531 env: env.clone(),
532 })
533 as Box<dyn ExternalAgentServer>,
534 ExternalAgentSource::Registry,
535 agent.metadata.icon_path.clone(),
536 Some(agent.metadata.name.clone()),
537 ),
538 );
539 }
540 RegistryAgent::Npx(agent) => {
541 self.external_agents.insert(
542 agent_name.clone(),
543 ExternalAgentEntry::new(
544 Box::new(LocalRegistryNpxAgent {
545 node_runtime: node_runtime.clone(),
546 project_environment: project_environment.clone(),
547 package: agent.package.clone(),
548 args: agent.args.clone(),
549 distribution_env: agent.env.clone(),
550 settings_env: env.clone(),
551 })
552 as Box<dyn ExternalAgentServer>,
553 ExternalAgentSource::Registry,
554 agent.metadata.icon_path.clone(),
555 Some(agent.metadata.name.clone()),
556 ),
557 );
558 }
559 }
560 }
561 CustomAgentServerSettings::Extension { .. } => {}
562 }
563 }
564
565 *old_settings = Some(new_settings);
566
567 if let Some((project_id, downstream_client)) = downstream_client {
568 downstream_client
569 .send(proto::ExternalAgentsUpdated {
570 project_id: *project_id,
571 names: self
572 .external_agents
573 .keys()
574 .map(|name| name.to_string())
575 .collect(),
576 })
577 .log_err();
578 }
579 cx.emit(AgentServersUpdated);
580 }
581
582 pub fn node_runtime(&self) -> Option<NodeRuntime> {
583 match &self.state {
584 AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
585 _ => None,
586 }
587 }
588
589 pub fn local(
590 node_runtime: NodeRuntime,
591 fs: Arc<dyn Fs>,
592 project_environment: Entity<ProjectEnvironment>,
593 http_client: Arc<dyn HttpClient>,
594 cx: &mut Context<Self>,
595 ) -> Self {
596 let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
597 this.agent_servers_settings_changed(cx);
598 })];
599 if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
600 subscriptions.push(cx.observe(®istry_store, |this, _, cx| {
601 this.reregister_agents(cx);
602 }));
603 }
604 let mut this = Self {
605 state: AgentServerStoreState::Local {
606 node_runtime,
607 fs,
608 project_environment,
609 http_client,
610 downstream_client: None,
611 settings: None,
612 extension_agents: vec![],
613 _subscriptions: subscriptions,
614 },
615 external_agents: HashMap::default(),
616 };
617 if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
618 this.agent_servers_settings_changed(cx);
619 this
620 }
621
622 pub(crate) fn remote(
623 project_id: u64,
624 upstream_client: Entity<RemoteClient>,
625 worktree_store: Entity<WorktreeStore>,
626 ) -> Self {
627 Self {
628 state: AgentServerStoreState::Remote {
629 project_id,
630 upstream_client,
631 worktree_store,
632 },
633 external_agents: HashMap::default(),
634 }
635 }
636
637 pub fn collab() -> Self {
638 Self {
639 state: AgentServerStoreState::Collab,
640 external_agents: HashMap::default(),
641 }
642 }
643
644 pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
645 match &mut self.state {
646 AgentServerStoreState::Local {
647 downstream_client, ..
648 } => {
649 *downstream_client = Some((project_id, client.clone()));
650 // Send the current list of external agents downstream, but only after a delay,
651 // to avoid having the message arrive before the downstream project's agent server store
652 // sets up its handlers.
653 cx.spawn(async move |this, cx| {
654 cx.background_executor().timer(Duration::from_secs(1)).await;
655 let names = this.update(cx, |this, _| {
656 this.external_agents()
657 .map(|name| name.to_string())
658 .collect()
659 })?;
660 client
661 .send(proto::ExternalAgentsUpdated { project_id, names })
662 .log_err();
663 anyhow::Ok(())
664 })
665 .detach();
666 }
667 AgentServerStoreState::Remote { .. } => {
668 debug_panic!(
669 "external agents over collab not implemented, remote project should not be shared"
670 );
671 }
672 AgentServerStoreState::Collab => {
673 debug_panic!("external agents over collab not implemented, should not be shared");
674 }
675 }
676 }
677
678 pub fn get_external_agent(
679 &mut self,
680 name: &AgentId,
681 ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
682 self.external_agents
683 .get_mut(name)
684 .map(|entry| entry.server.as_mut())
685 }
686
687 pub fn no_browser(&self) -> bool {
688 match &self.state {
689 AgentServerStoreState::Local {
690 downstream_client, ..
691 } => downstream_client
692 .as_ref()
693 .is_some_and(|(_, client)| !client.has_wsl_interop()),
694 _ => false,
695 }
696 }
697
698 pub fn has_external_agents(&self) -> bool {
699 !self.external_agents.is_empty()
700 }
701
702 pub fn external_agents(&self) -> impl Iterator<Item = &AgentId> {
703 self.external_agents.keys()
704 }
705
706 async fn handle_get_agent_server_command(
707 this: Entity<Self>,
708 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
709 mut cx: AsyncApp,
710 ) -> Result<proto::AgentServerCommand> {
711 let command = this
712 .update(&mut cx, |this, cx| {
713 let AgentServerStoreState::Local {
714 downstream_client, ..
715 } = &this.state
716 else {
717 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
718 bail!("unexpected GetAgentServerCommand request in a non-local project");
719 };
720 let no_browser = this.no_browser();
721 let agent = this
722 .external_agents
723 .get_mut(&*envelope.payload.name)
724 .map(|entry| entry.server.as_mut())
725 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
726 let new_version_available_tx =
727 downstream_client
728 .clone()
729 .map(|(project_id, downstream_client)| {
730 let (new_version_available_tx, mut new_version_available_rx) =
731 watch::channel(None);
732 cx.spawn({
733 let name = envelope.payload.name.clone();
734 async move |_, _| {
735 if let Some(version) =
736 new_version_available_rx.recv().await.ok().flatten()
737 {
738 downstream_client.send(
739 proto::NewExternalAgentVersionAvailable {
740 project_id,
741 name: name.clone(),
742 version,
743 },
744 )?;
745 }
746 anyhow::Ok(())
747 }
748 })
749 .detach_and_log_err(cx);
750 new_version_available_tx
751 });
752 let mut extra_env = HashMap::default();
753 if no_browser {
754 extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
755 }
756 anyhow::Ok(agent.get_command(
757 extra_env,
758 new_version_available_tx,
759 &mut cx.to_async(),
760 ))
761 })?
762 .await?;
763 Ok(proto::AgentServerCommand {
764 path: command.path.to_string_lossy().into_owned(),
765 args: command.args,
766 env: command
767 .env
768 .map(|env| env.into_iter().collect())
769 .unwrap_or_default(),
770 root_dir: envelope
771 .payload
772 .root_dir
773 .unwrap_or_else(|| paths::home_dir().to_string_lossy().to_string()),
774 login: None,
775 })
776 }
777
778 async fn handle_external_agents_updated(
779 this: Entity<Self>,
780 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
781 mut cx: AsyncApp,
782 ) -> Result<()> {
783 this.update(&mut cx, |this, cx| {
784 let AgentServerStoreState::Remote {
785 project_id,
786 upstream_client,
787 worktree_store,
788 } = &this.state
789 else {
790 debug_panic!(
791 "handle_external_agents_updated should not be called for a non-remote project"
792 );
793 bail!("unexpected ExternalAgentsUpdated message")
794 };
795
796 let mut previous_entries = std::mem::take(&mut this.external_agents);
797 let mut new_version_available_txs = HashMap::default();
798 let mut metadata = HashMap::default();
799
800 for (name, mut entry) in previous_entries.drain() {
801 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
802 new_version_available_txs
803 .insert(name.clone(), agent.new_version_available_tx.take());
804 }
805
806 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
807 }
808
809 this.external_agents = envelope
810 .payload
811 .names
812 .into_iter()
813 .map(|name| {
814 let agent_id = AgentId(name.into());
815 let (icon, display_name, source) = metadata
816 .remove(&agent_id)
817 .or_else(|| {
818 AgentRegistryStore::try_global(cx)
819 .and_then(|store| store.read(cx).agent(&agent_id))
820 .map(|s| {
821 (
822 s.icon_path().cloned(),
823 Some(s.name().clone()),
824 ExternalAgentSource::Registry,
825 )
826 })
827 })
828 .unwrap_or((None, None, ExternalAgentSource::default()));
829 let agent = RemoteExternalAgentServer {
830 project_id: *project_id,
831 upstream_client: upstream_client.clone(),
832 worktree_store: worktree_store.clone(),
833 name: agent_id.clone(),
834 new_version_available_tx: new_version_available_txs
835 .remove(&agent_id)
836 .flatten(),
837 };
838 (
839 agent_id,
840 ExternalAgentEntry::new(
841 Box::new(agent) as Box<dyn ExternalAgentServer>,
842 source,
843 icon,
844 display_name,
845 ),
846 )
847 })
848 .collect();
849 cx.emit(AgentServersUpdated);
850 Ok(())
851 })
852 }
853
854 async fn handle_external_extension_agents_updated(
855 this: Entity<Self>,
856 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
857 mut cx: AsyncApp,
858 ) -> Result<()> {
859 this.update(&mut cx, |this, cx| {
860 let AgentServerStoreState::Local {
861 extension_agents, ..
862 } = &mut this.state
863 else {
864 panic!(
865 "handle_external_extension_agents_updated \
866 should not be called for a non-remote project"
867 );
868 };
869
870 for ExternalExtensionAgent {
871 name,
872 icon_path,
873 extension_id,
874 targets,
875 env,
876 } in envelope.payload.agents
877 {
878 extension_agents.push((
879 Arc::from(&*name),
880 extension_id,
881 targets
882 .into_iter()
883 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
884 .collect(),
885 env.into_iter().collect(),
886 icon_path,
887 None,
888 ));
889 }
890
891 this.reregister_agents(cx);
892 cx.emit(AgentServersUpdated);
893 Ok(())
894 })
895 }
896
897 async fn handle_new_version_available(
898 this: Entity<Self>,
899 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
900 mut cx: AsyncApp,
901 ) -> Result<()> {
902 this.update(&mut cx, |this, _| {
903 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
904 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
905 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
906 {
907 new_version_available_tx
908 .send(Some(envelope.payload.version))
909 .ok();
910 }
911 });
912 Ok(())
913 }
914
915 pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option<Arc<str>> {
916 self.external_agents.get_mut(name).and_then(|entry| {
917 entry
918 .server
919 .as_any_mut()
920 .downcast_ref::<LocalExtensionArchiveAgent>()
921 .map(|ext_agent| ext_agent.extension_id.clone())
922 })
923 }
924}
925
926struct RemoteExternalAgentServer {
927 project_id: u64,
928 upstream_client: Entity<RemoteClient>,
929 worktree_store: Entity<WorktreeStore>,
930 name: AgentId,
931 new_version_available_tx: Option<watch::Sender<Option<String>>>,
932}
933
934impl ExternalAgentServer for RemoteExternalAgentServer {
935 fn get_command(
936 &mut self,
937 extra_env: HashMap<String, String>,
938 new_version_available_tx: Option<watch::Sender<Option<String>>>,
939 cx: &mut AsyncApp,
940 ) -> Task<Result<AgentServerCommand>> {
941 let project_id = self.project_id;
942 let name = self.name.to_string();
943 let upstream_client = self.upstream_client.downgrade();
944 let worktree_store = self.worktree_store.clone();
945 self.new_version_available_tx = new_version_available_tx;
946 cx.spawn(async move |cx| {
947 let root_dir = worktree_store.read_with(cx, |worktree_store, cx| {
948 crate::Project::default_visible_worktree_paths(worktree_store, cx)
949 .into_iter()
950 .next()
951 .map(|path| path.display().to_string())
952 });
953
954 let mut response = upstream_client
955 .update(cx, |upstream_client, _| {
956 upstream_client
957 .proto_client()
958 .request(proto::GetAgentServerCommand {
959 project_id,
960 name,
961 root_dir,
962 })
963 })?
964 .await?;
965 let root_dir = response.root_dir;
966 response.env.extend(extra_env);
967 let command = upstream_client.update(cx, |client, _| {
968 client.build_command_with_options(
969 Some(response.path),
970 &response.args,
971 &response.env.into_iter().collect(),
972 Some(root_dir.clone()),
973 None,
974 Interactive::No,
975 )
976 })??;
977 Ok(AgentServerCommand {
978 path: command.program.into(),
979 args: command.args,
980 env: Some(command.env),
981 })
982 })
983 }
984
985 fn as_any_mut(&mut self) -> &mut dyn Any {
986 self
987 }
988}
989
990fn asset_kind_for_archive_url(archive_url: &str) -> Result<AssetKind> {
991 let archive_path = Url::parse(archive_url)
992 .ok()
993 .map(|url| url.path().to_string())
994 .unwrap_or_else(|| archive_url.to_string());
995
996 if archive_path.ends_with(".zip") {
997 Ok(AssetKind::Zip)
998 } else if archive_path.ends_with(".tar.gz") || archive_path.ends_with(".tgz") {
999 Ok(AssetKind::TarGz)
1000 } else if archive_path.ends_with(".tar.bz2") || archive_path.ends_with(".tbz2") {
1001 Ok(AssetKind::TarBz2)
1002 } else {
1003 bail!("unsupported archive type in URL: {archive_url}");
1004 }
1005}
1006
1007struct GithubReleaseArchive {
1008 repo_name_with_owner: String,
1009 tag: String,
1010 asset_name: String,
1011}
1012
1013fn github_release_archive_from_url(archive_url: &str) -> Option<GithubReleaseArchive> {
1014 fn decode_path_segment(segment: &str) -> Option<String> {
1015 percent_decode_str(segment)
1016 .decode_utf8()
1017 .ok()
1018 .map(|segment| segment.into_owned())
1019 }
1020
1021 let url = Url::parse(archive_url).ok()?;
1022 if url.scheme() != "https" || url.host_str()? != "github.com" {
1023 return None;
1024 }
1025
1026 let segments = url.path_segments()?.collect::<Vec<_>>();
1027 if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" {
1028 return None;
1029 }
1030
1031 Some(GithubReleaseArchive {
1032 repo_name_with_owner: format!("{}/{}", segments[0], segments[1]),
1033 tag: decode_path_segment(segments[4])?,
1034 asset_name: segments[5..]
1035 .iter()
1036 .map(|segment| decode_path_segment(segment))
1037 .collect::<Option<Vec<_>>>()?
1038 .join("/"),
1039 })
1040}
1041
1042pub struct LocalExtensionArchiveAgent {
1043 pub fs: Arc<dyn Fs>,
1044 pub http_client: Arc<dyn HttpClient>,
1045 pub node_runtime: NodeRuntime,
1046 pub project_environment: Entity<ProjectEnvironment>,
1047 pub extension_id: Arc<str>,
1048 pub agent_id: Arc<str>,
1049 pub targets: HashMap<String, extension::TargetConfig>,
1050 pub env: HashMap<String, String>,
1051}
1052
1053impl ExternalAgentServer for LocalExtensionArchiveAgent {
1054 fn get_command(
1055 &mut self,
1056 extra_env: HashMap<String, String>,
1057 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1058 cx: &mut AsyncApp,
1059 ) -> Task<Result<AgentServerCommand>> {
1060 let fs = self.fs.clone();
1061 let http_client = self.http_client.clone();
1062 let node_runtime = self.node_runtime.clone();
1063 let project_environment = self.project_environment.downgrade();
1064 let extension_id = self.extension_id.clone();
1065 let agent_id = self.agent_id.clone();
1066 let targets = self.targets.clone();
1067 let base_env = self.env.clone();
1068
1069 cx.spawn(async move |cx| {
1070 // Get project environment
1071 let mut env = project_environment
1072 .update(cx, |project_environment, cx| {
1073 project_environment.local_directory_environment(
1074 &Shell::System,
1075 paths::home_dir().as_path().into(),
1076 cx,
1077 )
1078 })?
1079 .await
1080 .unwrap_or_default();
1081
1082 // Merge manifest env and extra env
1083 env.extend(base_env);
1084 env.extend(extra_env);
1085
1086 let cache_key = format!("{}/{}", extension_id, agent_id);
1087 let dir = paths::external_agents_dir().join(&cache_key);
1088 fs.create_dir(&dir).await?;
1089
1090 // Determine platform key
1091 let os = if cfg!(target_os = "macos") {
1092 "darwin"
1093 } else if cfg!(target_os = "linux") {
1094 "linux"
1095 } else if cfg!(target_os = "windows") {
1096 "windows"
1097 } else {
1098 anyhow::bail!("unsupported OS");
1099 };
1100
1101 let arch = if cfg!(target_arch = "aarch64") {
1102 "aarch64"
1103 } else if cfg!(target_arch = "x86_64") {
1104 "x86_64"
1105 } else {
1106 anyhow::bail!("unsupported architecture");
1107 };
1108
1109 let platform_key = format!("{}-{}", os, arch);
1110 let target_config = targets.get(&platform_key).with_context(|| {
1111 format!(
1112 "no target specified for platform '{}'. Available platforms: {}",
1113 platform_key,
1114 targets
1115 .keys()
1116 .map(|k| k.as_str())
1117 .collect::<Vec<_>>()
1118 .join(", ")
1119 )
1120 })?;
1121
1122 let archive_url = &target_config.archive;
1123
1124 // Use URL as version identifier for caching
1125 // Hash the URL to get a stable directory name
1126 let mut hasher = Sha256::new();
1127 hasher.update(archive_url.as_bytes());
1128 let url_hash = format!("{:x}", hasher.finalize());
1129 let version_dir = dir.join(format!("v_{}", url_hash));
1130
1131 if !fs.is_dir(&version_dir).await {
1132 // Determine SHA256 for verification
1133 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1134 // Use provided SHA256
1135 Some(provided_sha.clone())
1136 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1137 // Try to fetch SHA256 from GitHub API
1138 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1139 &github_archive.repo_name_with_owner,
1140 &github_archive.tag,
1141 http_client.clone(),
1142 )
1143 .await
1144 {
1145 // Find matching asset
1146 if let Some(asset) = release
1147 .assets
1148 .iter()
1149 .find(|a| a.name == github_archive.asset_name)
1150 {
1151 // Strip "sha256:" prefix if present
1152 asset.digest.as_ref().map(|d| {
1153 d.strip_prefix("sha256:")
1154 .map(|s| s.to_string())
1155 .unwrap_or_else(|| d.clone())
1156 })
1157 } else {
1158 None
1159 }
1160 } else {
1161 None
1162 }
1163 } else {
1164 None
1165 };
1166
1167 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1168
1169 // Download and extract
1170 ::http_client::github_download::download_server_binary(
1171 &*http_client,
1172 archive_url,
1173 sha256.as_deref(),
1174 &version_dir,
1175 asset_kind,
1176 )
1177 .await?;
1178 }
1179
1180 // Validate and resolve cmd path
1181 let cmd = &target_config.cmd;
1182
1183 let cmd_path = if cmd == "node" {
1184 // Use Zed's managed Node.js runtime
1185 node_runtime.binary_path().await?
1186 } else {
1187 if cmd.contains("..") {
1188 anyhow::bail!("command path cannot contain '..': {}", cmd);
1189 }
1190
1191 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1192 // Relative to extraction directory
1193 let cmd_path = version_dir.join(&cmd[2..]);
1194 anyhow::ensure!(
1195 fs.is_file(&cmd_path).await,
1196 "Missing command {} after extraction",
1197 cmd_path.to_string_lossy()
1198 );
1199 cmd_path
1200 } else {
1201 // On PATH
1202 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1203 }
1204 };
1205
1206 let command = AgentServerCommand {
1207 path: cmd_path,
1208 args: target_config.args.clone(),
1209 env: Some(env),
1210 };
1211
1212 Ok(command)
1213 })
1214 }
1215
1216 fn as_any_mut(&mut self) -> &mut dyn Any {
1217 self
1218 }
1219}
1220
1221struct LocalRegistryArchiveAgent {
1222 fs: Arc<dyn Fs>,
1223 http_client: Arc<dyn HttpClient>,
1224 node_runtime: NodeRuntime,
1225 project_environment: Entity<ProjectEnvironment>,
1226 registry_id: Arc<str>,
1227 targets: HashMap<String, RegistryTargetConfig>,
1228 env: HashMap<String, String>,
1229}
1230
1231impl ExternalAgentServer for LocalRegistryArchiveAgent {
1232 fn get_command(
1233 &mut self,
1234 extra_env: HashMap<String, String>,
1235 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1236 cx: &mut AsyncApp,
1237 ) -> Task<Result<AgentServerCommand>> {
1238 let fs = self.fs.clone();
1239 let http_client = self.http_client.clone();
1240 let node_runtime = self.node_runtime.clone();
1241 let project_environment = self.project_environment.downgrade();
1242 let registry_id = self.registry_id.clone();
1243 let targets = self.targets.clone();
1244 let settings_env = self.env.clone();
1245
1246 cx.spawn(async move |cx| {
1247 let mut env = project_environment
1248 .update(cx, |project_environment, cx| {
1249 project_environment.local_directory_environment(
1250 &Shell::System,
1251 paths::home_dir().as_path().into(),
1252 cx,
1253 )
1254 })?
1255 .await
1256 .unwrap_or_default();
1257
1258 let dir = paths::external_agents_dir()
1259 .join("registry")
1260 .join(registry_id.as_ref());
1261 fs.create_dir(&dir).await?;
1262
1263 let os = if cfg!(target_os = "macos") {
1264 "darwin"
1265 } else if cfg!(target_os = "linux") {
1266 "linux"
1267 } else if cfg!(target_os = "windows") {
1268 "windows"
1269 } else {
1270 anyhow::bail!("unsupported OS");
1271 };
1272
1273 let arch = if cfg!(target_arch = "aarch64") {
1274 "aarch64"
1275 } else if cfg!(target_arch = "x86_64") {
1276 "x86_64"
1277 } else {
1278 anyhow::bail!("unsupported architecture");
1279 };
1280
1281 let platform_key = format!("{}-{}", os, arch);
1282 let target_config = targets.get(&platform_key).with_context(|| {
1283 format!(
1284 "no target specified for platform '{}'. Available platforms: {}",
1285 platform_key,
1286 targets
1287 .keys()
1288 .map(|k| k.as_str())
1289 .collect::<Vec<_>>()
1290 .join(", ")
1291 )
1292 })?;
1293
1294 env.extend(target_config.env.clone());
1295 env.extend(extra_env);
1296 env.extend(settings_env);
1297
1298 let archive_url = &target_config.archive;
1299
1300 let mut hasher = Sha256::new();
1301 hasher.update(archive_url.as_bytes());
1302 let url_hash = format!("{:x}", hasher.finalize());
1303 let version_dir = dir.join(format!("v_{}", url_hash));
1304
1305 if !fs.is_dir(&version_dir).await {
1306 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1307 Some(provided_sha.clone())
1308 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1309 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1310 &github_archive.repo_name_with_owner,
1311 &github_archive.tag,
1312 http_client.clone(),
1313 )
1314 .await
1315 {
1316 if let Some(asset) = release
1317 .assets
1318 .iter()
1319 .find(|a| a.name == github_archive.asset_name)
1320 {
1321 asset.digest.as_ref().and_then(|d| {
1322 d.strip_prefix("sha256:")
1323 .map(|s| s.to_string())
1324 .or_else(|| Some(d.clone()))
1325 })
1326 } else {
1327 None
1328 }
1329 } else {
1330 None
1331 }
1332 } else {
1333 None
1334 };
1335
1336 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1337
1338 ::http_client::github_download::download_server_binary(
1339 &*http_client,
1340 archive_url,
1341 sha256.as_deref(),
1342 &version_dir,
1343 asset_kind,
1344 )
1345 .await?;
1346 }
1347
1348 let cmd = &target_config.cmd;
1349
1350 let cmd_path = if cmd == "node" {
1351 node_runtime.binary_path().await?
1352 } else {
1353 if cmd.contains("..") {
1354 anyhow::bail!("command path cannot contain '..': {}", cmd);
1355 }
1356
1357 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1358 let cmd_path = version_dir.join(&cmd[2..]);
1359 anyhow::ensure!(
1360 fs.is_file(&cmd_path).await,
1361 "Missing command {} after extraction",
1362 cmd_path.to_string_lossy()
1363 );
1364 cmd_path
1365 } else {
1366 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1367 }
1368 };
1369
1370 let command = AgentServerCommand {
1371 path: cmd_path,
1372 args: target_config.args.clone(),
1373 env: Some(env),
1374 };
1375
1376 Ok(command)
1377 })
1378 }
1379
1380 fn as_any_mut(&mut self) -> &mut dyn Any {
1381 self
1382 }
1383}
1384
1385struct LocalRegistryNpxAgent {
1386 node_runtime: NodeRuntime,
1387 project_environment: Entity<ProjectEnvironment>,
1388 package: SharedString,
1389 args: Vec<String>,
1390 distribution_env: HashMap<String, String>,
1391 settings_env: HashMap<String, String>,
1392}
1393
1394impl ExternalAgentServer for LocalRegistryNpxAgent {
1395 fn get_command(
1396 &mut self,
1397 extra_env: HashMap<String, String>,
1398 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1399 cx: &mut AsyncApp,
1400 ) -> Task<Result<AgentServerCommand>> {
1401 let node_runtime = self.node_runtime.clone();
1402 let project_environment = self.project_environment.downgrade();
1403 let package = self.package.clone();
1404 let args = self.args.clone();
1405 let distribution_env = self.distribution_env.clone();
1406 let settings_env = self.settings_env.clone();
1407
1408 cx.spawn(async move |cx| {
1409 let mut env = project_environment
1410 .update(cx, |project_environment, cx| {
1411 project_environment.local_directory_environment(
1412 &Shell::System,
1413 paths::home_dir().as_path().into(),
1414 cx,
1415 )
1416 })?
1417 .await
1418 .unwrap_or_default();
1419
1420 let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package.to_string()];
1421 exec_args.extend(args);
1422
1423 let npm_command = node_runtime
1424 .npm_command(
1425 "exec",
1426 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1427 )
1428 .await?;
1429
1430 env.extend(npm_command.env);
1431 env.extend(distribution_env);
1432 env.extend(extra_env);
1433 env.extend(settings_env);
1434
1435 let command = AgentServerCommand {
1436 path: npm_command.path,
1437 args: npm_command.args,
1438 env: Some(env),
1439 };
1440
1441 Ok(command)
1442 })
1443 }
1444
1445 fn as_any_mut(&mut self) -> &mut dyn Any {
1446 self
1447 }
1448}
1449
1450struct LocalCustomAgent {
1451 project_environment: Entity<ProjectEnvironment>,
1452 command: AgentServerCommand,
1453}
1454
1455impl ExternalAgentServer for LocalCustomAgent {
1456 fn get_command(
1457 &mut self,
1458 extra_env: HashMap<String, String>,
1459 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1460 cx: &mut AsyncApp,
1461 ) -> Task<Result<AgentServerCommand>> {
1462 let mut command = self.command.clone();
1463 let project_environment = self.project_environment.downgrade();
1464 cx.spawn(async move |cx| {
1465 let mut env = project_environment
1466 .update(cx, |project_environment, cx| {
1467 project_environment.local_directory_environment(
1468 &Shell::System,
1469 paths::home_dir().as_path().into(),
1470 cx,
1471 )
1472 })?
1473 .await
1474 .unwrap_or_default();
1475 env.extend(command.env.unwrap_or_default());
1476 env.extend(extra_env);
1477 command.env = Some(env);
1478 Ok(command)
1479 })
1480 }
1481
1482 fn as_any_mut(&mut self) -> &mut dyn Any {
1483 self
1484 }
1485}
1486
1487#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1488pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
1489
1490impl std::ops::Deref for AllAgentServersSettings {
1491 type Target = HashMap<String, CustomAgentServerSettings>;
1492
1493 fn deref(&self) -> &Self::Target {
1494 &self.0
1495 }
1496}
1497
1498impl std::ops::DerefMut for AllAgentServersSettings {
1499 fn deref_mut(&mut self) -> &mut Self::Target {
1500 &mut self.0
1501 }
1502}
1503
1504impl AllAgentServersSettings {
1505 pub fn has_registry_agents(&self) -> bool {
1506 self.values()
1507 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
1508 }
1509}
1510
1511#[derive(Clone, JsonSchema, Debug, PartialEq)]
1512pub enum CustomAgentServerSettings {
1513 Custom {
1514 command: AgentServerCommand,
1515 /// The default mode to use for this agent.
1516 ///
1517 /// Note: Not only all agents support modes.
1518 ///
1519 /// Default: None
1520 default_mode: Option<String>,
1521 /// The default model to use for this agent.
1522 ///
1523 /// This should be the model ID as reported by the agent.
1524 ///
1525 /// Default: None
1526 default_model: Option<String>,
1527 /// The favorite models for this agent.
1528 ///
1529 /// Default: []
1530 favorite_models: Vec<String>,
1531 /// Default values for session config options.
1532 ///
1533 /// This is a map from config option ID to value ID.
1534 ///
1535 /// Default: {}
1536 default_config_options: HashMap<String, String>,
1537 /// Favorited values for session config options.
1538 ///
1539 /// This is a map from config option ID to a list of favorited value IDs.
1540 ///
1541 /// Default: {}
1542 favorite_config_option_values: HashMap<String, Vec<String>>,
1543 },
1544 Extension {
1545 /// Additional environment variables to pass to the agent.
1546 ///
1547 /// Default: {}
1548 env: HashMap<String, String>,
1549 /// The default mode to use for this agent.
1550 ///
1551 /// Note: Not only all agents support modes.
1552 ///
1553 /// Default: None
1554 default_mode: Option<String>,
1555 /// The default model to use for this agent.
1556 ///
1557 /// This should be the model ID as reported by the agent.
1558 ///
1559 /// Default: None
1560 default_model: Option<String>,
1561 /// The favorite models for this agent.
1562 ///
1563 /// Default: []
1564 favorite_models: Vec<String>,
1565 /// Default values for session config options.
1566 ///
1567 /// This is a map from config option ID to value ID.
1568 ///
1569 /// Default: {}
1570 default_config_options: HashMap<String, String>,
1571 /// Favorited values for session config options.
1572 ///
1573 /// This is a map from config option ID to a list of favorited value IDs.
1574 ///
1575 /// Default: {}
1576 favorite_config_option_values: HashMap<String, Vec<String>>,
1577 },
1578 Registry {
1579 /// Additional environment variables to pass to the agent.
1580 ///
1581 /// Default: {}
1582 env: HashMap<String, String>,
1583 /// The default mode to use for this agent.
1584 ///
1585 /// Note: Not only all agents support modes.
1586 ///
1587 /// Default: None
1588 default_mode: Option<String>,
1589 /// The default model to use for this agent.
1590 ///
1591 /// This should be the model ID as reported by the agent.
1592 ///
1593 /// Default: None
1594 default_model: Option<String>,
1595 /// The favorite models for this agent.
1596 ///
1597 /// Default: []
1598 favorite_models: Vec<String>,
1599 /// Default values for session config options.
1600 ///
1601 /// This is a map from config option ID to value ID.
1602 ///
1603 /// Default: {}
1604 default_config_options: HashMap<String, String>,
1605 /// Favorited values for session config options.
1606 ///
1607 /// This is a map from config option ID to a list of favorited value IDs.
1608 ///
1609 /// Default: {}
1610 favorite_config_option_values: HashMap<String, Vec<String>>,
1611 },
1612}
1613
1614impl CustomAgentServerSettings {
1615 pub fn command(&self) -> Option<&AgentServerCommand> {
1616 match self {
1617 CustomAgentServerSettings::Custom { command, .. } => Some(command),
1618 CustomAgentServerSettings::Extension { .. }
1619 | CustomAgentServerSettings::Registry { .. } => None,
1620 }
1621 }
1622
1623 pub fn default_mode(&self) -> Option<&str> {
1624 match self {
1625 CustomAgentServerSettings::Custom { default_mode, .. }
1626 | CustomAgentServerSettings::Extension { default_mode, .. }
1627 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
1628 }
1629 }
1630
1631 pub fn default_model(&self) -> Option<&str> {
1632 match self {
1633 CustomAgentServerSettings::Custom { default_model, .. }
1634 | CustomAgentServerSettings::Extension { default_model, .. }
1635 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
1636 }
1637 }
1638
1639 pub fn favorite_models(&self) -> &[String] {
1640 match self {
1641 CustomAgentServerSettings::Custom {
1642 favorite_models, ..
1643 }
1644 | CustomAgentServerSettings::Extension {
1645 favorite_models, ..
1646 }
1647 | CustomAgentServerSettings::Registry {
1648 favorite_models, ..
1649 } => favorite_models,
1650 }
1651 }
1652
1653 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
1654 match self {
1655 CustomAgentServerSettings::Custom {
1656 default_config_options,
1657 ..
1658 }
1659 | CustomAgentServerSettings::Extension {
1660 default_config_options,
1661 ..
1662 }
1663 | CustomAgentServerSettings::Registry {
1664 default_config_options,
1665 ..
1666 } => default_config_options.get(config_id).map(|s| s.as_str()),
1667 }
1668 }
1669
1670 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
1671 match self {
1672 CustomAgentServerSettings::Custom {
1673 favorite_config_option_values,
1674 ..
1675 }
1676 | CustomAgentServerSettings::Extension {
1677 favorite_config_option_values,
1678 ..
1679 }
1680 | CustomAgentServerSettings::Registry {
1681 favorite_config_option_values,
1682 ..
1683 } => favorite_config_option_values
1684 .get(config_id)
1685 .map(|v| v.as_slice()),
1686 }
1687 }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693
1694 #[test]
1695 fn detects_supported_archive_suffixes() {
1696 assert!(matches!(
1697 asset_kind_for_archive_url("https://example.com/agent.zip"),
1698 Ok(AssetKind::Zip)
1699 ));
1700 assert!(matches!(
1701 asset_kind_for_archive_url("https://example.com/agent.zip?download=1"),
1702 Ok(AssetKind::Zip)
1703 ));
1704 assert!(matches!(
1705 asset_kind_for_archive_url("https://example.com/agent.tar.gz"),
1706 Ok(AssetKind::TarGz)
1707 ));
1708 assert!(matches!(
1709 asset_kind_for_archive_url("https://example.com/agent.tar.gz?download=1#latest"),
1710 Ok(AssetKind::TarGz)
1711 ));
1712 assert!(matches!(
1713 asset_kind_for_archive_url("https://example.com/agent.tgz"),
1714 Ok(AssetKind::TarGz)
1715 ));
1716 assert!(matches!(
1717 asset_kind_for_archive_url("https://example.com/agent.tgz#download"),
1718 Ok(AssetKind::TarGz)
1719 ));
1720 assert!(matches!(
1721 asset_kind_for_archive_url("https://example.com/agent.tar.bz2"),
1722 Ok(AssetKind::TarBz2)
1723 ));
1724 assert!(matches!(
1725 asset_kind_for_archive_url("https://example.com/agent.tar.bz2?download=1"),
1726 Ok(AssetKind::TarBz2)
1727 ));
1728 assert!(matches!(
1729 asset_kind_for_archive_url("https://example.com/agent.tbz2"),
1730 Ok(AssetKind::TarBz2)
1731 ));
1732 assert!(matches!(
1733 asset_kind_for_archive_url("https://example.com/agent.tbz2#download"),
1734 Ok(AssetKind::TarBz2)
1735 ));
1736 }
1737
1738 #[test]
1739 fn parses_github_release_archive_urls() {
1740 let github_archive = github_release_archive_from_url(
1741 "https://github.com/owner/repo/releases/download/release%2F2.3.5/agent.tar.bz2?download=1",
1742 )
1743 .unwrap();
1744
1745 assert_eq!(github_archive.repo_name_with_owner, "owner/repo");
1746 assert_eq!(github_archive.tag, "release/2.3.5");
1747 assert_eq!(github_archive.asset_name, "agent.tar.bz2");
1748 }
1749
1750 #[test]
1751 fn rejects_unsupported_archive_suffixes() {
1752 let error = asset_kind_for_archive_url("https://example.com/agent.tar.xz")
1753 .err()
1754 .map(|error| error.to_string());
1755
1756 assert_eq!(
1757 error,
1758 Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string())
1759 );
1760 }
1761}
1762
1763impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1764 fn from(value: settings::CustomAgentServerSettings) -> Self {
1765 match value {
1766 settings::CustomAgentServerSettings::Custom {
1767 path,
1768 args,
1769 env,
1770 default_mode,
1771 default_model,
1772 favorite_models,
1773 default_config_options,
1774 favorite_config_option_values,
1775 } => CustomAgentServerSettings::Custom {
1776 command: AgentServerCommand {
1777 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1778 args,
1779 env: Some(env),
1780 },
1781 default_mode,
1782 default_model,
1783 favorite_models,
1784 default_config_options,
1785 favorite_config_option_values,
1786 },
1787 settings::CustomAgentServerSettings::Extension {
1788 env,
1789 default_mode,
1790 default_model,
1791 default_config_options,
1792 favorite_models,
1793 favorite_config_option_values,
1794 } => CustomAgentServerSettings::Extension {
1795 env,
1796 default_mode,
1797 default_model,
1798 default_config_options,
1799 favorite_models,
1800 favorite_config_option_values,
1801 },
1802 settings::CustomAgentServerSettings::Registry {
1803 env,
1804 default_mode,
1805 default_model,
1806 default_config_options,
1807 favorite_models,
1808 favorite_config_option_values,
1809 } => CustomAgentServerSettings::Registry {
1810 env,
1811 default_mode,
1812 default_model,
1813 default_config_options,
1814 favorite_models,
1815 favorite_config_option_values,
1816 },
1817 }
1818 }
1819}
1820
1821impl settings::Settings for AllAgentServersSettings {
1822 fn from_settings(content: &settings::SettingsContent) -> Self {
1823 let agent_settings = content.agent_servers.clone().unwrap();
1824 Self(
1825 agent_settings
1826 .0
1827 .into_iter()
1828 .map(|(k, v)| (k, v.into()))
1829 .collect(),
1830 )
1831 }
1832}