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