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