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, name: &AgentId) -> Option<SharedString> {
311 self.external_agents
312 .get(name)
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 external_agents(&self) -> impl Iterator<Item = &AgentId> {
699 self.external_agents.keys()
700 }
701
702 async fn handle_get_agent_server_command(
703 this: Entity<Self>,
704 envelope: TypedEnvelope<proto::GetAgentServerCommand>,
705 mut cx: AsyncApp,
706 ) -> Result<proto::AgentServerCommand> {
707 let command = this
708 .update(&mut cx, |this, cx| {
709 let AgentServerStoreState::Local {
710 downstream_client, ..
711 } = &this.state
712 else {
713 debug_panic!("should not receive GetAgentServerCommand in a non-local project");
714 bail!("unexpected GetAgentServerCommand request in a non-local project");
715 };
716 let no_browser = this.no_browser();
717 let agent = this
718 .external_agents
719 .get_mut(&*envelope.payload.name)
720 .map(|entry| entry.server.as_mut())
721 .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
722 let new_version_available_tx =
723 downstream_client
724 .clone()
725 .map(|(project_id, downstream_client)| {
726 let (new_version_available_tx, mut new_version_available_rx) =
727 watch::channel(None);
728 cx.spawn({
729 let name = envelope.payload.name.clone();
730 async move |_, _| {
731 if let Some(version) =
732 new_version_available_rx.recv().await.ok().flatten()
733 {
734 downstream_client.send(
735 proto::NewExternalAgentVersionAvailable {
736 project_id,
737 name: name.clone(),
738 version,
739 },
740 )?;
741 }
742 anyhow::Ok(())
743 }
744 })
745 .detach_and_log_err(cx);
746 new_version_available_tx
747 });
748 let mut extra_env = HashMap::default();
749 if no_browser {
750 extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
751 }
752 anyhow::Ok(agent.get_command(
753 extra_env,
754 new_version_available_tx,
755 &mut cx.to_async(),
756 ))
757 })?
758 .await?;
759 Ok(proto::AgentServerCommand {
760 path: command.path.to_string_lossy().into_owned(),
761 args: command.args,
762 env: command
763 .env
764 .map(|env| env.into_iter().collect())
765 .unwrap_or_default(),
766 root_dir: envelope
767 .payload
768 .root_dir
769 .unwrap_or_else(|| paths::home_dir().to_string_lossy().to_string()),
770 login: None,
771 })
772 }
773
774 async fn handle_external_agents_updated(
775 this: Entity<Self>,
776 envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
777 mut cx: AsyncApp,
778 ) -> Result<()> {
779 this.update(&mut cx, |this, cx| {
780 let AgentServerStoreState::Remote {
781 project_id,
782 upstream_client,
783 worktree_store,
784 } = &this.state
785 else {
786 debug_panic!(
787 "handle_external_agents_updated should not be called for a non-remote project"
788 );
789 bail!("unexpected ExternalAgentsUpdated message")
790 };
791
792 let mut previous_entries = std::mem::take(&mut this.external_agents);
793 let mut new_version_available_txs = HashMap::default();
794 let mut metadata = HashMap::default();
795
796 for (name, mut entry) in previous_entries.drain() {
797 if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
798 new_version_available_txs
799 .insert(name.clone(), agent.new_version_available_tx.take());
800 }
801
802 metadata.insert(name, (entry.icon, entry.display_name, entry.source));
803 }
804
805 this.external_agents = envelope
806 .payload
807 .names
808 .into_iter()
809 .map(|name| {
810 let agent_id = AgentId(name.into());
811 let (icon, display_name, source) = metadata
812 .remove(&agent_id)
813 .or_else(|| {
814 AgentRegistryStore::try_global(cx)
815 .and_then(|store| store.read(cx).agent(&agent_id))
816 .map(|s| {
817 (
818 s.icon_path().cloned(),
819 Some(s.name().clone()),
820 ExternalAgentSource::Registry,
821 )
822 })
823 })
824 .unwrap_or((None, None, ExternalAgentSource::default()));
825 let agent = RemoteExternalAgentServer {
826 project_id: *project_id,
827 upstream_client: upstream_client.clone(),
828 worktree_store: worktree_store.clone(),
829 name: agent_id.clone(),
830 new_version_available_tx: new_version_available_txs
831 .remove(&agent_id)
832 .flatten(),
833 };
834 (
835 agent_id,
836 ExternalAgentEntry::new(
837 Box::new(agent) as Box<dyn ExternalAgentServer>,
838 source,
839 icon,
840 display_name,
841 ),
842 )
843 })
844 .collect();
845 cx.emit(AgentServersUpdated);
846 Ok(())
847 })
848 }
849
850 async fn handle_external_extension_agents_updated(
851 this: Entity<Self>,
852 envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
853 mut cx: AsyncApp,
854 ) -> Result<()> {
855 this.update(&mut cx, |this, cx| {
856 let AgentServerStoreState::Local {
857 extension_agents, ..
858 } = &mut this.state
859 else {
860 panic!(
861 "handle_external_extension_agents_updated \
862 should not be called for a non-remote project"
863 );
864 };
865
866 for ExternalExtensionAgent {
867 name,
868 icon_path,
869 extension_id,
870 targets,
871 env,
872 } in envelope.payload.agents
873 {
874 extension_agents.push((
875 Arc::from(&*name),
876 extension_id,
877 targets
878 .into_iter()
879 .map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
880 .collect(),
881 env.into_iter().collect(),
882 icon_path,
883 None,
884 ));
885 }
886
887 this.reregister_agents(cx);
888 cx.emit(AgentServersUpdated);
889 Ok(())
890 })
891 }
892
893 async fn handle_new_version_available(
894 this: Entity<Self>,
895 envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
896 mut cx: AsyncApp,
897 ) -> Result<()> {
898 this.update(&mut cx, |this, _| {
899 if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
900 && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
901 && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
902 {
903 new_version_available_tx
904 .send(Some(envelope.payload.version))
905 .ok();
906 }
907 });
908 Ok(())
909 }
910
911 pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option<Arc<str>> {
912 self.external_agents.get_mut(name).and_then(|entry| {
913 entry
914 .server
915 .as_any_mut()
916 .downcast_ref::<LocalExtensionArchiveAgent>()
917 .map(|ext_agent| ext_agent.extension_id.clone())
918 })
919 }
920}
921
922struct RemoteExternalAgentServer {
923 project_id: u64,
924 upstream_client: Entity<RemoteClient>,
925 worktree_store: Entity<WorktreeStore>,
926 name: AgentId,
927 new_version_available_tx: Option<watch::Sender<Option<String>>>,
928}
929
930impl ExternalAgentServer for RemoteExternalAgentServer {
931 fn get_command(
932 &mut self,
933 extra_env: HashMap<String, String>,
934 new_version_available_tx: Option<watch::Sender<Option<String>>>,
935 cx: &mut AsyncApp,
936 ) -> Task<Result<AgentServerCommand>> {
937 let project_id = self.project_id;
938 let name = self.name.to_string();
939 let upstream_client = self.upstream_client.downgrade();
940 let worktree_store = self.worktree_store.clone();
941 self.new_version_available_tx = new_version_available_tx;
942 cx.spawn(async move |cx| {
943 let root_dir = worktree_store.read_with(cx, |worktree_store, cx| {
944 crate::Project::default_visible_worktree_paths(worktree_store, cx)
945 .into_iter()
946 .next()
947 .map(|path| path.display().to_string())
948 });
949
950 let mut response = upstream_client
951 .update(cx, |upstream_client, _| {
952 upstream_client
953 .proto_client()
954 .request(proto::GetAgentServerCommand {
955 project_id,
956 name,
957 root_dir,
958 })
959 })?
960 .await?;
961 let root_dir = response.root_dir;
962 response.env.extend(extra_env);
963 let command = upstream_client.update(cx, |client, _| {
964 client.build_command_with_options(
965 Some(response.path),
966 &response.args,
967 &response.env.into_iter().collect(),
968 Some(root_dir.clone()),
969 None,
970 Interactive::No,
971 )
972 })??;
973 Ok(AgentServerCommand {
974 path: command.program.into(),
975 args: command.args,
976 env: Some(command.env),
977 })
978 })
979 }
980
981 fn as_any_mut(&mut self) -> &mut dyn Any {
982 self
983 }
984}
985
986fn asset_kind_for_archive_url(archive_url: &str) -> Result<AssetKind> {
987 let archive_path = Url::parse(archive_url)
988 .ok()
989 .map(|url| url.path().to_string())
990 .unwrap_or_else(|| archive_url.to_string());
991
992 if archive_path.ends_with(".zip") {
993 Ok(AssetKind::Zip)
994 } else if archive_path.ends_with(".tar.gz") || archive_path.ends_with(".tgz") {
995 Ok(AssetKind::TarGz)
996 } else if archive_path.ends_with(".tar.bz2") || archive_path.ends_with(".tbz2") {
997 Ok(AssetKind::TarBz2)
998 } else {
999 bail!("unsupported archive type in URL: {archive_url}");
1000 }
1001}
1002
1003struct GithubReleaseArchive {
1004 repo_name_with_owner: String,
1005 tag: String,
1006 asset_name: String,
1007}
1008
1009fn github_release_archive_from_url(archive_url: &str) -> Option<GithubReleaseArchive> {
1010 fn decode_path_segment(segment: &str) -> Option<String> {
1011 percent_decode_str(segment)
1012 .decode_utf8()
1013 .ok()
1014 .map(|segment| segment.into_owned())
1015 }
1016
1017 let url = Url::parse(archive_url).ok()?;
1018 if url.scheme() != "https" || url.host_str()? != "github.com" {
1019 return None;
1020 }
1021
1022 let segments = url.path_segments()?.collect::<Vec<_>>();
1023 if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" {
1024 return None;
1025 }
1026
1027 Some(GithubReleaseArchive {
1028 repo_name_with_owner: format!("{}/{}", segments[0], segments[1]),
1029 tag: decode_path_segment(segments[4])?,
1030 asset_name: segments[5..]
1031 .iter()
1032 .map(|segment| decode_path_segment(segment))
1033 .collect::<Option<Vec<_>>>()?
1034 .join("/"),
1035 })
1036}
1037
1038pub struct LocalExtensionArchiveAgent {
1039 pub fs: Arc<dyn Fs>,
1040 pub http_client: Arc<dyn HttpClient>,
1041 pub node_runtime: NodeRuntime,
1042 pub project_environment: Entity<ProjectEnvironment>,
1043 pub extension_id: Arc<str>,
1044 pub agent_id: Arc<str>,
1045 pub targets: HashMap<String, extension::TargetConfig>,
1046 pub env: HashMap<String, String>,
1047}
1048
1049impl ExternalAgentServer for LocalExtensionArchiveAgent {
1050 fn get_command(
1051 &mut self,
1052 extra_env: HashMap<String, String>,
1053 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1054 cx: &mut AsyncApp,
1055 ) -> Task<Result<AgentServerCommand>> {
1056 let fs = self.fs.clone();
1057 let http_client = self.http_client.clone();
1058 let node_runtime = self.node_runtime.clone();
1059 let project_environment = self.project_environment.downgrade();
1060 let extension_id = self.extension_id.clone();
1061 let agent_id = self.agent_id.clone();
1062 let targets = self.targets.clone();
1063 let base_env = self.env.clone();
1064
1065 cx.spawn(async move |cx| {
1066 // Get project environment
1067 let mut env = project_environment
1068 .update(cx, |project_environment, cx| {
1069 project_environment.local_directory_environment(
1070 &Shell::System,
1071 paths::home_dir().as_path().into(),
1072 cx,
1073 )
1074 })?
1075 .await
1076 .unwrap_or_default();
1077
1078 // Merge manifest env and extra env
1079 env.extend(base_env);
1080 env.extend(extra_env);
1081
1082 let cache_key = format!("{}/{}", extension_id, agent_id);
1083 let dir = paths::external_agents_dir().join(&cache_key);
1084 fs.create_dir(&dir).await?;
1085
1086 // Determine platform key
1087 let os = if cfg!(target_os = "macos") {
1088 "darwin"
1089 } else if cfg!(target_os = "linux") {
1090 "linux"
1091 } else if cfg!(target_os = "windows") {
1092 "windows"
1093 } else {
1094 anyhow::bail!("unsupported OS");
1095 };
1096
1097 let arch = if cfg!(target_arch = "aarch64") {
1098 "aarch64"
1099 } else if cfg!(target_arch = "x86_64") {
1100 "x86_64"
1101 } else {
1102 anyhow::bail!("unsupported architecture");
1103 };
1104
1105 let platform_key = format!("{}-{}", os, arch);
1106 let target_config = targets.get(&platform_key).with_context(|| {
1107 format!(
1108 "no target specified for platform '{}'. Available platforms: {}",
1109 platform_key,
1110 targets
1111 .keys()
1112 .map(|k| k.as_str())
1113 .collect::<Vec<_>>()
1114 .join(", ")
1115 )
1116 })?;
1117
1118 let archive_url = &target_config.archive;
1119
1120 // Use URL as version identifier for caching
1121 // Hash the URL to get a stable directory name
1122 let mut hasher = Sha256::new();
1123 hasher.update(archive_url.as_bytes());
1124 let url_hash = format!("{:x}", hasher.finalize());
1125 let version_dir = dir.join(format!("v_{}", url_hash));
1126
1127 if !fs.is_dir(&version_dir).await {
1128 // Determine SHA256 for verification
1129 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1130 // Use provided SHA256
1131 Some(provided_sha.clone())
1132 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1133 // Try to fetch SHA256 from GitHub API
1134 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1135 &github_archive.repo_name_with_owner,
1136 &github_archive.tag,
1137 http_client.clone(),
1138 )
1139 .await
1140 {
1141 // Find matching asset
1142 if let Some(asset) = release
1143 .assets
1144 .iter()
1145 .find(|a| a.name == github_archive.asset_name)
1146 {
1147 // Strip "sha256:" prefix if present
1148 asset.digest.as_ref().map(|d| {
1149 d.strip_prefix("sha256:")
1150 .map(|s| s.to_string())
1151 .unwrap_or_else(|| d.clone())
1152 })
1153 } else {
1154 None
1155 }
1156 } else {
1157 None
1158 }
1159 } else {
1160 None
1161 };
1162
1163 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1164
1165 // Download and extract
1166 ::http_client::github_download::download_server_binary(
1167 &*http_client,
1168 archive_url,
1169 sha256.as_deref(),
1170 &version_dir,
1171 asset_kind,
1172 )
1173 .await?;
1174 }
1175
1176 // Validate and resolve cmd path
1177 let cmd = &target_config.cmd;
1178
1179 let cmd_path = if cmd == "node" {
1180 // Use Zed's managed Node.js runtime
1181 node_runtime.binary_path().await?
1182 } else {
1183 if cmd.contains("..") {
1184 anyhow::bail!("command path cannot contain '..': {}", cmd);
1185 }
1186
1187 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1188 // Relative to extraction directory
1189 let cmd_path = version_dir.join(&cmd[2..]);
1190 anyhow::ensure!(
1191 fs.is_file(&cmd_path).await,
1192 "Missing command {} after extraction",
1193 cmd_path.to_string_lossy()
1194 );
1195 cmd_path
1196 } else {
1197 // On PATH
1198 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1199 }
1200 };
1201
1202 let command = AgentServerCommand {
1203 path: cmd_path,
1204 args: target_config.args.clone(),
1205 env: Some(env),
1206 };
1207
1208 Ok(command)
1209 })
1210 }
1211
1212 fn as_any_mut(&mut self) -> &mut dyn Any {
1213 self
1214 }
1215}
1216
1217struct LocalRegistryArchiveAgent {
1218 fs: Arc<dyn Fs>,
1219 http_client: Arc<dyn HttpClient>,
1220 node_runtime: NodeRuntime,
1221 project_environment: Entity<ProjectEnvironment>,
1222 registry_id: Arc<str>,
1223 targets: HashMap<String, RegistryTargetConfig>,
1224 env: HashMap<String, String>,
1225}
1226
1227impl ExternalAgentServer for LocalRegistryArchiveAgent {
1228 fn get_command(
1229 &mut self,
1230 extra_env: HashMap<String, String>,
1231 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1232 cx: &mut AsyncApp,
1233 ) -> Task<Result<AgentServerCommand>> {
1234 let fs = self.fs.clone();
1235 let http_client = self.http_client.clone();
1236 let node_runtime = self.node_runtime.clone();
1237 let project_environment = self.project_environment.downgrade();
1238 let registry_id = self.registry_id.clone();
1239 let targets = self.targets.clone();
1240 let settings_env = self.env.clone();
1241
1242 cx.spawn(async move |cx| {
1243 let mut env = project_environment
1244 .update(cx, |project_environment, cx| {
1245 project_environment.local_directory_environment(
1246 &Shell::System,
1247 paths::home_dir().as_path().into(),
1248 cx,
1249 )
1250 })?
1251 .await
1252 .unwrap_or_default();
1253
1254 let dir = paths::external_agents_dir()
1255 .join("registry")
1256 .join(registry_id.as_ref());
1257 fs.create_dir(&dir).await?;
1258
1259 let os = if cfg!(target_os = "macos") {
1260 "darwin"
1261 } else if cfg!(target_os = "linux") {
1262 "linux"
1263 } else if cfg!(target_os = "windows") {
1264 "windows"
1265 } else {
1266 anyhow::bail!("unsupported OS");
1267 };
1268
1269 let arch = if cfg!(target_arch = "aarch64") {
1270 "aarch64"
1271 } else if cfg!(target_arch = "x86_64") {
1272 "x86_64"
1273 } else {
1274 anyhow::bail!("unsupported architecture");
1275 };
1276
1277 let platform_key = format!("{}-{}", os, arch);
1278 let target_config = targets.get(&platform_key).with_context(|| {
1279 format!(
1280 "no target specified for platform '{}'. Available platforms: {}",
1281 platform_key,
1282 targets
1283 .keys()
1284 .map(|k| k.as_str())
1285 .collect::<Vec<_>>()
1286 .join(", ")
1287 )
1288 })?;
1289
1290 env.extend(target_config.env.clone());
1291 env.extend(extra_env);
1292 env.extend(settings_env);
1293
1294 let archive_url = &target_config.archive;
1295
1296 let mut hasher = Sha256::new();
1297 hasher.update(archive_url.as_bytes());
1298 let url_hash = format!("{:x}", hasher.finalize());
1299 let version_dir = dir.join(format!("v_{}", url_hash));
1300
1301 if !fs.is_dir(&version_dir).await {
1302 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1303 Some(provided_sha.clone())
1304 } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1305 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1306 &github_archive.repo_name_with_owner,
1307 &github_archive.tag,
1308 http_client.clone(),
1309 )
1310 .await
1311 {
1312 if let Some(asset) = release
1313 .assets
1314 .iter()
1315 .find(|a| a.name == github_archive.asset_name)
1316 {
1317 asset.digest.as_ref().and_then(|d| {
1318 d.strip_prefix("sha256:")
1319 .map(|s| s.to_string())
1320 .or_else(|| Some(d.clone()))
1321 })
1322 } else {
1323 None
1324 }
1325 } else {
1326 None
1327 }
1328 } else {
1329 None
1330 };
1331
1332 let asset_kind = asset_kind_for_archive_url(archive_url)?;
1333
1334 ::http_client::github_download::download_server_binary(
1335 &*http_client,
1336 archive_url,
1337 sha256.as_deref(),
1338 &version_dir,
1339 asset_kind,
1340 )
1341 .await?;
1342 }
1343
1344 let cmd = &target_config.cmd;
1345
1346 let cmd_path = if cmd == "node" {
1347 node_runtime.binary_path().await?
1348 } else {
1349 if cmd.contains("..") {
1350 anyhow::bail!("command path cannot contain '..': {}", cmd);
1351 }
1352
1353 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1354 let cmd_path = version_dir.join(&cmd[2..]);
1355 anyhow::ensure!(
1356 fs.is_file(&cmd_path).await,
1357 "Missing command {} after extraction",
1358 cmd_path.to_string_lossy()
1359 );
1360 cmd_path
1361 } else {
1362 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1363 }
1364 };
1365
1366 let command = AgentServerCommand {
1367 path: cmd_path,
1368 args: target_config.args.clone(),
1369 env: Some(env),
1370 };
1371
1372 Ok(command)
1373 })
1374 }
1375
1376 fn as_any_mut(&mut self) -> &mut dyn Any {
1377 self
1378 }
1379}
1380
1381struct LocalRegistryNpxAgent {
1382 node_runtime: NodeRuntime,
1383 project_environment: Entity<ProjectEnvironment>,
1384 package: SharedString,
1385 args: Vec<String>,
1386 distribution_env: HashMap<String, String>,
1387 settings_env: HashMap<String, String>,
1388}
1389
1390impl ExternalAgentServer for LocalRegistryNpxAgent {
1391 fn get_command(
1392 &mut self,
1393 extra_env: HashMap<String, String>,
1394 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1395 cx: &mut AsyncApp,
1396 ) -> Task<Result<AgentServerCommand>> {
1397 let node_runtime = self.node_runtime.clone();
1398 let project_environment = self.project_environment.downgrade();
1399 let package = self.package.clone();
1400 let args = self.args.clone();
1401 let distribution_env = self.distribution_env.clone();
1402 let settings_env = self.settings_env.clone();
1403
1404 cx.spawn(async move |cx| {
1405 let mut env = project_environment
1406 .update(cx, |project_environment, cx| {
1407 project_environment.local_directory_environment(
1408 &Shell::System,
1409 paths::home_dir().as_path().into(),
1410 cx,
1411 )
1412 })?
1413 .await
1414 .unwrap_or_default();
1415
1416 let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package.to_string()];
1417 exec_args.extend(args);
1418
1419 let npm_command = node_runtime
1420 .npm_command(
1421 "exec",
1422 &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1423 )
1424 .await?;
1425
1426 env.extend(npm_command.env);
1427 env.extend(distribution_env);
1428 env.extend(extra_env);
1429 env.extend(settings_env);
1430
1431 let command = AgentServerCommand {
1432 path: npm_command.path,
1433 args: npm_command.args,
1434 env: Some(env),
1435 };
1436
1437 Ok(command)
1438 })
1439 }
1440
1441 fn as_any_mut(&mut self) -> &mut dyn Any {
1442 self
1443 }
1444}
1445
1446struct LocalCustomAgent {
1447 project_environment: Entity<ProjectEnvironment>,
1448 command: AgentServerCommand,
1449}
1450
1451impl ExternalAgentServer for LocalCustomAgent {
1452 fn get_command(
1453 &mut self,
1454 extra_env: HashMap<String, String>,
1455 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1456 cx: &mut AsyncApp,
1457 ) -> Task<Result<AgentServerCommand>> {
1458 let mut command = self.command.clone();
1459 let project_environment = self.project_environment.downgrade();
1460 cx.spawn(async move |cx| {
1461 let mut env = project_environment
1462 .update(cx, |project_environment, cx| {
1463 project_environment.local_directory_environment(
1464 &Shell::System,
1465 paths::home_dir().as_path().into(),
1466 cx,
1467 )
1468 })?
1469 .await
1470 .unwrap_or_default();
1471 env.extend(command.env.unwrap_or_default());
1472 env.extend(extra_env);
1473 command.env = Some(env);
1474 Ok(command)
1475 })
1476 }
1477
1478 fn as_any_mut(&mut self) -> &mut dyn Any {
1479 self
1480 }
1481}
1482
1483#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1484pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
1485
1486impl std::ops::Deref for AllAgentServersSettings {
1487 type Target = HashMap<String, CustomAgentServerSettings>;
1488
1489 fn deref(&self) -> &Self::Target {
1490 &self.0
1491 }
1492}
1493
1494impl std::ops::DerefMut for AllAgentServersSettings {
1495 fn deref_mut(&mut self) -> &mut Self::Target {
1496 &mut self.0
1497 }
1498}
1499
1500impl AllAgentServersSettings {
1501 pub fn has_registry_agents(&self) -> bool {
1502 self.values()
1503 .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
1504 }
1505}
1506
1507#[derive(Clone, JsonSchema, Debug, PartialEq)]
1508pub enum CustomAgentServerSettings {
1509 Custom {
1510 command: AgentServerCommand,
1511 /// The default mode to use for this agent.
1512 ///
1513 /// Note: Not only all agents support modes.
1514 ///
1515 /// Default: None
1516 default_mode: Option<String>,
1517 /// The default model to use for this agent.
1518 ///
1519 /// This should be the model ID as reported by the agent.
1520 ///
1521 /// Default: None
1522 default_model: Option<String>,
1523 /// The favorite models for this agent.
1524 ///
1525 /// Default: []
1526 favorite_models: Vec<String>,
1527 /// Default values for session config options.
1528 ///
1529 /// This is a map from config option ID to value ID.
1530 ///
1531 /// Default: {}
1532 default_config_options: HashMap<String, String>,
1533 /// Favorited values for session config options.
1534 ///
1535 /// This is a map from config option ID to a list of favorited value IDs.
1536 ///
1537 /// Default: {}
1538 favorite_config_option_values: HashMap<String, Vec<String>>,
1539 },
1540 Extension {
1541 /// Additional environment variables to pass to the agent.
1542 ///
1543 /// Default: {}
1544 env: HashMap<String, String>,
1545 /// The default mode to use for this agent.
1546 ///
1547 /// Note: Not only all agents support modes.
1548 ///
1549 /// Default: None
1550 default_mode: Option<String>,
1551 /// The default model to use for this agent.
1552 ///
1553 /// This should be the model ID as reported by the agent.
1554 ///
1555 /// Default: None
1556 default_model: Option<String>,
1557 /// The favorite models for this agent.
1558 ///
1559 /// Default: []
1560 favorite_models: Vec<String>,
1561 /// Default values for session config options.
1562 ///
1563 /// This is a map from config option ID to value ID.
1564 ///
1565 /// Default: {}
1566 default_config_options: HashMap<String, String>,
1567 /// Favorited values for session config options.
1568 ///
1569 /// This is a map from config option ID to a list of favorited value IDs.
1570 ///
1571 /// Default: {}
1572 favorite_config_option_values: HashMap<String, Vec<String>>,
1573 },
1574 Registry {
1575 /// Additional environment variables to pass to the agent.
1576 ///
1577 /// Default: {}
1578 env: HashMap<String, String>,
1579 /// The default mode to use for this agent.
1580 ///
1581 /// Note: Not only all agents support modes.
1582 ///
1583 /// Default: None
1584 default_mode: Option<String>,
1585 /// The default model to use for this agent.
1586 ///
1587 /// This should be the model ID as reported by the agent.
1588 ///
1589 /// Default: None
1590 default_model: Option<String>,
1591 /// The favorite models for this agent.
1592 ///
1593 /// Default: []
1594 favorite_models: Vec<String>,
1595 /// Default values for session config options.
1596 ///
1597 /// This is a map from config option ID to value ID.
1598 ///
1599 /// Default: {}
1600 default_config_options: HashMap<String, String>,
1601 /// Favorited values for session config options.
1602 ///
1603 /// This is a map from config option ID to a list of favorited value IDs.
1604 ///
1605 /// Default: {}
1606 favorite_config_option_values: HashMap<String, Vec<String>>,
1607 },
1608}
1609
1610impl CustomAgentServerSettings {
1611 pub fn command(&self) -> Option<&AgentServerCommand> {
1612 match self {
1613 CustomAgentServerSettings::Custom { command, .. } => Some(command),
1614 CustomAgentServerSettings::Extension { .. }
1615 | CustomAgentServerSettings::Registry { .. } => None,
1616 }
1617 }
1618
1619 pub fn default_mode(&self) -> Option<&str> {
1620 match self {
1621 CustomAgentServerSettings::Custom { default_mode, .. }
1622 | CustomAgentServerSettings::Extension { default_mode, .. }
1623 | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
1624 }
1625 }
1626
1627 pub fn default_model(&self) -> Option<&str> {
1628 match self {
1629 CustomAgentServerSettings::Custom { default_model, .. }
1630 | CustomAgentServerSettings::Extension { default_model, .. }
1631 | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(),
1632 }
1633 }
1634
1635 pub fn favorite_models(&self) -> &[String] {
1636 match self {
1637 CustomAgentServerSettings::Custom {
1638 favorite_models, ..
1639 }
1640 | CustomAgentServerSettings::Extension {
1641 favorite_models, ..
1642 }
1643 | CustomAgentServerSettings::Registry {
1644 favorite_models, ..
1645 } => favorite_models,
1646 }
1647 }
1648
1649 pub fn default_config_option(&self, config_id: &str) -> Option<&str> {
1650 match self {
1651 CustomAgentServerSettings::Custom {
1652 default_config_options,
1653 ..
1654 }
1655 | CustomAgentServerSettings::Extension {
1656 default_config_options,
1657 ..
1658 }
1659 | CustomAgentServerSettings::Registry {
1660 default_config_options,
1661 ..
1662 } => default_config_options.get(config_id).map(|s| s.as_str()),
1663 }
1664 }
1665
1666 pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
1667 match self {
1668 CustomAgentServerSettings::Custom {
1669 favorite_config_option_values,
1670 ..
1671 }
1672 | CustomAgentServerSettings::Extension {
1673 favorite_config_option_values,
1674 ..
1675 }
1676 | CustomAgentServerSettings::Registry {
1677 favorite_config_option_values,
1678 ..
1679 } => favorite_config_option_values
1680 .get(config_id)
1681 .map(|v| v.as_slice()),
1682 }
1683 }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688 use super::*;
1689
1690 #[test]
1691 fn detects_supported_archive_suffixes() {
1692 assert!(matches!(
1693 asset_kind_for_archive_url("https://example.com/agent.zip"),
1694 Ok(AssetKind::Zip)
1695 ));
1696 assert!(matches!(
1697 asset_kind_for_archive_url("https://example.com/agent.zip?download=1"),
1698 Ok(AssetKind::Zip)
1699 ));
1700 assert!(matches!(
1701 asset_kind_for_archive_url("https://example.com/agent.tar.gz"),
1702 Ok(AssetKind::TarGz)
1703 ));
1704 assert!(matches!(
1705 asset_kind_for_archive_url("https://example.com/agent.tar.gz?download=1#latest"),
1706 Ok(AssetKind::TarGz)
1707 ));
1708 assert!(matches!(
1709 asset_kind_for_archive_url("https://example.com/agent.tgz"),
1710 Ok(AssetKind::TarGz)
1711 ));
1712 assert!(matches!(
1713 asset_kind_for_archive_url("https://example.com/agent.tgz#download"),
1714 Ok(AssetKind::TarGz)
1715 ));
1716 assert!(matches!(
1717 asset_kind_for_archive_url("https://example.com/agent.tar.bz2"),
1718 Ok(AssetKind::TarBz2)
1719 ));
1720 assert!(matches!(
1721 asset_kind_for_archive_url("https://example.com/agent.tar.bz2?download=1"),
1722 Ok(AssetKind::TarBz2)
1723 ));
1724 assert!(matches!(
1725 asset_kind_for_archive_url("https://example.com/agent.tbz2"),
1726 Ok(AssetKind::TarBz2)
1727 ));
1728 assert!(matches!(
1729 asset_kind_for_archive_url("https://example.com/agent.tbz2#download"),
1730 Ok(AssetKind::TarBz2)
1731 ));
1732 }
1733
1734 #[test]
1735 fn parses_github_release_archive_urls() {
1736 let github_archive = github_release_archive_from_url(
1737 "https://github.com/owner/repo/releases/download/release%2F2.3.5/agent.tar.bz2?download=1",
1738 )
1739 .unwrap();
1740
1741 assert_eq!(github_archive.repo_name_with_owner, "owner/repo");
1742 assert_eq!(github_archive.tag, "release/2.3.5");
1743 assert_eq!(github_archive.asset_name, "agent.tar.bz2");
1744 }
1745
1746 #[test]
1747 fn rejects_unsupported_archive_suffixes() {
1748 let error = asset_kind_for_archive_url("https://example.com/agent.tar.xz")
1749 .err()
1750 .map(|error| error.to_string());
1751
1752 assert_eq!(
1753 error,
1754 Some("unsupported archive type in URL: https://example.com/agent.tar.xz".to_string())
1755 );
1756 }
1757}
1758
1759impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1760 fn from(value: settings::CustomAgentServerSettings) -> Self {
1761 match value {
1762 settings::CustomAgentServerSettings::Custom {
1763 path,
1764 args,
1765 env,
1766 default_mode,
1767 default_model,
1768 favorite_models,
1769 default_config_options,
1770 favorite_config_option_values,
1771 } => CustomAgentServerSettings::Custom {
1772 command: AgentServerCommand {
1773 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1774 args,
1775 env: Some(env),
1776 },
1777 default_mode,
1778 default_model,
1779 favorite_models,
1780 default_config_options,
1781 favorite_config_option_values,
1782 },
1783 settings::CustomAgentServerSettings::Extension {
1784 env,
1785 default_mode,
1786 default_model,
1787 default_config_options,
1788 favorite_models,
1789 favorite_config_option_values,
1790 } => CustomAgentServerSettings::Extension {
1791 env,
1792 default_mode,
1793 default_model,
1794 default_config_options,
1795 favorite_models,
1796 favorite_config_option_values,
1797 },
1798 settings::CustomAgentServerSettings::Registry {
1799 env,
1800 default_mode,
1801 default_model,
1802 default_config_options,
1803 favorite_models,
1804 favorite_config_option_values,
1805 } => CustomAgentServerSettings::Registry {
1806 env,
1807 default_mode,
1808 default_model,
1809 default_config_options,
1810 favorite_models,
1811 favorite_config_option_values,
1812 },
1813 }
1814 }
1815}
1816
1817impl settings::Settings for AllAgentServersSettings {
1818 fn from_settings(content: &settings::SettingsContent) -> Self {
1819 let agent_settings = content.agent_servers.clone().unwrap();
1820 Self(
1821 agent_settings
1822 .0
1823 .into_iter()
1824 .map(|(k, v)| (k, v.into()))
1825 .collect(),
1826 )
1827 }
1828}