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 create_parents: false,
1093 },
1094 )
1095 .await?;
1096
1097 anyhow::Ok(version)
1098}
1099
1100struct RemoteExternalAgentServer {
1101 project_id: u64,
1102 upstream_client: Entity<RemoteClient>,
1103 name: ExternalAgentServerName,
1104 status_tx: Option<watch::Sender<SharedString>>,
1105 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1106}
1107
1108impl ExternalAgentServer for RemoteExternalAgentServer {
1109 fn get_command(
1110 &mut self,
1111 root_dir: Option<&str>,
1112 extra_env: HashMap<String, String>,
1113 status_tx: Option<watch::Sender<SharedString>>,
1114 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1115 cx: &mut AsyncApp,
1116 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1117 let project_id = self.project_id;
1118 let name = self.name.to_string();
1119 let upstream_client = self.upstream_client.downgrade();
1120 let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
1121 self.status_tx = status_tx;
1122 self.new_version_available_tx = new_version_available_tx;
1123 cx.spawn(async move |cx| {
1124 let mut response = upstream_client
1125 .update(cx, |upstream_client, _| {
1126 upstream_client
1127 .proto_client()
1128 .request(proto::GetAgentServerCommand {
1129 project_id,
1130 name,
1131 root_dir: root_dir.clone(),
1132 })
1133 })?
1134 .await?;
1135 let root_dir = response.root_dir;
1136 response.env.extend(extra_env);
1137 let command = upstream_client.update(cx, |client, _| {
1138 client.build_command(
1139 Some(response.path),
1140 &response.args,
1141 &response.env.into_iter().collect(),
1142 Some(root_dir.clone()),
1143 None,
1144 )
1145 })??;
1146 Ok((
1147 AgentServerCommand {
1148 path: command.program.into(),
1149 args: command.args,
1150 env: Some(command.env),
1151 },
1152 root_dir,
1153 response.login.map(SpawnInTerminal::from_proto),
1154 ))
1155 })
1156 }
1157
1158 fn as_any_mut(&mut self) -> &mut dyn Any {
1159 self
1160 }
1161}
1162
1163struct LocalGemini {
1164 fs: Arc<dyn Fs>,
1165 node_runtime: NodeRuntime,
1166 project_environment: Entity<ProjectEnvironment>,
1167 custom_command: Option<AgentServerCommand>,
1168 ignore_system_version: bool,
1169}
1170
1171impl ExternalAgentServer for LocalGemini {
1172 fn get_command(
1173 &mut self,
1174 root_dir: Option<&str>,
1175 extra_env: HashMap<String, String>,
1176 status_tx: Option<watch::Sender<SharedString>>,
1177 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1178 cx: &mut AsyncApp,
1179 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1180 let fs = self.fs.clone();
1181 let node_runtime = self.node_runtime.clone();
1182 let project_environment = self.project_environment.downgrade();
1183 let custom_command = self.custom_command.clone();
1184 let ignore_system_version = self.ignore_system_version;
1185 let root_dir: Arc<Path> = root_dir
1186 .map(|root_dir| Path::new(root_dir))
1187 .unwrap_or(paths::home_dir())
1188 .into();
1189
1190 cx.spawn(async move |cx| {
1191 let mut env = project_environment
1192 .update(cx, |project_environment, cx| {
1193 project_environment.local_directory_environment(
1194 &Shell::System,
1195 root_dir.clone(),
1196 cx,
1197 )
1198 })?
1199 .await
1200 .unwrap_or_default();
1201
1202 let mut command = if let Some(mut custom_command) = custom_command {
1203 env.extend(custom_command.env.unwrap_or_default());
1204 custom_command.env = Some(env);
1205 custom_command
1206 } else if !ignore_system_version
1207 && let Some(bin) =
1208 find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
1209 {
1210 AgentServerCommand {
1211 path: bin,
1212 args: Vec::new(),
1213 env: Some(env),
1214 }
1215 } else {
1216 let mut command = get_or_npm_install_builtin_agent(
1217 GEMINI_NAME.into(),
1218 "@google/gemini-cli".into(),
1219 "node_modules/@google/gemini-cli/dist/index.js".into(),
1220 if cfg!(windows) {
1221 // v0.8.x on Windows has a bug that causes the initialize request to hang forever
1222 Some("0.9.0".parse().unwrap())
1223 } else {
1224 Some("0.2.1".parse().unwrap())
1225 },
1226 status_tx,
1227 new_version_available_tx,
1228 fs,
1229 node_runtime,
1230 cx,
1231 )
1232 .await?;
1233 command.env = Some(env);
1234 command
1235 };
1236
1237 // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
1238 let login = task::SpawnInTerminal {
1239 command: Some(command.path.to_string_lossy().into_owned()),
1240 args: command.args.clone(),
1241 env: command.env.clone().unwrap_or_default(),
1242 label: "gemini /auth".into(),
1243 ..Default::default()
1244 };
1245
1246 command.env.get_or_insert_default().extend(extra_env);
1247 command.args.push("--experimental-acp".into());
1248 Ok((
1249 command,
1250 root_dir.to_string_lossy().into_owned(),
1251 Some(login),
1252 ))
1253 })
1254 }
1255
1256 fn as_any_mut(&mut self) -> &mut dyn Any {
1257 self
1258 }
1259}
1260
1261struct LocalClaudeCode {
1262 fs: Arc<dyn Fs>,
1263 node_runtime: NodeRuntime,
1264 project_environment: Entity<ProjectEnvironment>,
1265 custom_command: Option<AgentServerCommand>,
1266}
1267
1268impl ExternalAgentServer for LocalClaudeCode {
1269 fn get_command(
1270 &mut self,
1271 root_dir: Option<&str>,
1272 extra_env: HashMap<String, String>,
1273 status_tx: Option<watch::Sender<SharedString>>,
1274 new_version_available_tx: Option<watch::Sender<Option<String>>>,
1275 cx: &mut AsyncApp,
1276 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1277 let fs = self.fs.clone();
1278 let node_runtime = self.node_runtime.clone();
1279 let project_environment = self.project_environment.downgrade();
1280 let custom_command = self.custom_command.clone();
1281 let root_dir: Arc<Path> = root_dir
1282 .map(|root_dir| Path::new(root_dir))
1283 .unwrap_or(paths::home_dir())
1284 .into();
1285
1286 cx.spawn(async move |cx| {
1287 let mut env = project_environment
1288 .update(cx, |project_environment, cx| {
1289 project_environment.local_directory_environment(
1290 &Shell::System,
1291 root_dir.clone(),
1292 cx,
1293 )
1294 })?
1295 .await
1296 .unwrap_or_default();
1297 env.insert("ANTHROPIC_API_KEY".into(), "".into());
1298
1299 let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
1300 env.extend(custom_command.env.unwrap_or_default());
1301 custom_command.env = Some(env);
1302 (custom_command, None)
1303 } else {
1304 let mut command = get_or_npm_install_builtin_agent(
1305 "claude-code-acp".into(),
1306 "@zed-industries/claude-code-acp".into(),
1307 "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
1308 Some("0.5.2".parse().unwrap()),
1309 status_tx,
1310 new_version_available_tx,
1311 fs,
1312 node_runtime,
1313 cx,
1314 )
1315 .await?;
1316 command.env = Some(env);
1317 let login = command
1318 .args
1319 .first()
1320 .and_then(|path| {
1321 path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
1322 })
1323 .map(|path_prefix| task::SpawnInTerminal {
1324 command: Some(command.path.to_string_lossy().into_owned()),
1325 args: vec![
1326 Path::new(path_prefix)
1327 .join("@anthropic-ai/claude-agent-sdk/cli.js")
1328 .to_string_lossy()
1329 .to_string(),
1330 "/login".into(),
1331 ],
1332 env: command.env.clone().unwrap_or_default(),
1333 label: "claude /login".into(),
1334 ..Default::default()
1335 });
1336 (command, login)
1337 };
1338
1339 command.env.get_or_insert_default().extend(extra_env);
1340 Ok((
1341 command,
1342 root_dir.to_string_lossy().into_owned(),
1343 login_command,
1344 ))
1345 })
1346 }
1347
1348 fn as_any_mut(&mut self) -> &mut dyn Any {
1349 self
1350 }
1351}
1352
1353struct LocalCodex {
1354 fs: Arc<dyn Fs>,
1355 project_environment: Entity<ProjectEnvironment>,
1356 http_client: Arc<dyn HttpClient>,
1357 custom_command: Option<AgentServerCommand>,
1358 is_remote: bool,
1359}
1360
1361impl ExternalAgentServer for LocalCodex {
1362 fn get_command(
1363 &mut self,
1364 root_dir: Option<&str>,
1365 extra_env: HashMap<String, String>,
1366 status_tx: Option<watch::Sender<SharedString>>,
1367 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1368 cx: &mut AsyncApp,
1369 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1370 let fs = self.fs.clone();
1371 let project_environment = self.project_environment.downgrade();
1372 let http = self.http_client.clone();
1373 let custom_command = self.custom_command.clone();
1374 let root_dir: Arc<Path> = root_dir
1375 .map(|root_dir| Path::new(root_dir))
1376 .unwrap_or(paths::home_dir())
1377 .into();
1378 let is_remote = self.is_remote;
1379
1380 cx.spawn(async move |cx| {
1381 let mut env = project_environment
1382 .update(cx, |project_environment, cx| {
1383 project_environment.local_directory_environment(
1384 &Shell::System,
1385 root_dir.clone(),
1386 cx,
1387 )
1388 })?
1389 .await
1390 .unwrap_or_default();
1391 if is_remote {
1392 env.insert("NO_BROWSER".to_owned(), "1".to_owned());
1393 }
1394
1395 let mut command = if let Some(mut custom_command) = custom_command {
1396 env.extend(custom_command.env.unwrap_or_default());
1397 custom_command.env = Some(env);
1398 custom_command
1399 } else {
1400 let dir = paths::external_agents_dir().join(CODEX_NAME);
1401 fs.create_dir(&dir).await?;
1402
1403 // Find or install the latest Codex release (no update checks for now).
1404 let release = ::http_client::github::latest_github_release(
1405 CODEX_ACP_REPO,
1406 true,
1407 false,
1408 http.clone(),
1409 )
1410 .await
1411 .context("fetching Codex latest release")?;
1412
1413 let version_dir = dir.join(&release.tag_name);
1414 if !fs.is_dir(&version_dir).await {
1415 if let Some(mut status_tx) = status_tx {
1416 status_tx.send("Installing…".into()).ok();
1417 }
1418
1419 let tag = release.tag_name.clone();
1420 let version_number = tag.trim_start_matches('v');
1421 let asset_name = asset_name(version_number)
1422 .context("codex acp is not supported for this architecture")?;
1423 let asset = release
1424 .assets
1425 .into_iter()
1426 .find(|asset| asset.name == asset_name)
1427 .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
1428 // Strip "sha256:" prefix from digest if present (GitHub API format)
1429 let digest = asset
1430 .digest
1431 .as_deref()
1432 .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
1433 ::http_client::github_download::download_server_binary(
1434 &*http,
1435 &asset.browser_download_url,
1436 digest,
1437 &version_dir,
1438 if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1439 AssetKind::Zip
1440 } else {
1441 AssetKind::TarGz
1442 },
1443 )
1444 .await?;
1445
1446 // remove older versions
1447 util::fs::remove_matching(&dir, |entry| entry != version_dir).await;
1448 }
1449
1450 let bin_name = if cfg!(windows) {
1451 "codex-acp.exe"
1452 } else {
1453 "codex-acp"
1454 };
1455 let bin_path = version_dir.join(bin_name);
1456 anyhow::ensure!(
1457 fs.is_file(&bin_path).await,
1458 "Missing Codex binary at {} after installation",
1459 bin_path.to_string_lossy()
1460 );
1461
1462 let mut cmd = AgentServerCommand {
1463 path: bin_path,
1464 args: Vec::new(),
1465 env: None,
1466 };
1467 cmd.env = Some(env);
1468 cmd
1469 };
1470
1471 command.env.get_or_insert_default().extend(extra_env);
1472 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1473 })
1474 }
1475
1476 fn as_any_mut(&mut self) -> &mut dyn Any {
1477 self
1478 }
1479}
1480
1481pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
1482
1483fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
1484 let arch = if cfg!(target_arch = "x86_64") {
1485 "x86_64"
1486 } else if cfg!(target_arch = "aarch64") {
1487 "aarch64"
1488 } else {
1489 return None;
1490 };
1491
1492 let platform = if cfg!(target_os = "macos") {
1493 "apple-darwin"
1494 } else if cfg!(target_os = "windows") {
1495 "pc-windows-msvc"
1496 } else if cfg!(target_os = "linux") {
1497 "unknown-linux-gnu"
1498 } else {
1499 return None;
1500 };
1501
1502 // Only Windows x86_64 uses .zip in release assets
1503 let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1504 "zip"
1505 } else {
1506 "tar.gz"
1507 };
1508
1509 Some((arch, platform, ext))
1510}
1511
1512fn asset_name(version: &str) -> Option<String> {
1513 let (arch, platform, ext) = get_platform_info()?;
1514 Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
1515}
1516
1517struct LocalExtensionArchiveAgent {
1518 fs: Arc<dyn Fs>,
1519 http_client: Arc<dyn HttpClient>,
1520 node_runtime: NodeRuntime,
1521 project_environment: Entity<ProjectEnvironment>,
1522 extension_id: Arc<str>,
1523 agent_id: Arc<str>,
1524 targets: HashMap<String, extension::TargetConfig>,
1525 env: HashMap<String, String>,
1526}
1527
1528struct LocalCustomAgent {
1529 project_environment: Entity<ProjectEnvironment>,
1530 command: AgentServerCommand,
1531}
1532
1533impl ExternalAgentServer for LocalExtensionArchiveAgent {
1534 fn get_command(
1535 &mut self,
1536 root_dir: Option<&str>,
1537 extra_env: HashMap<String, String>,
1538 _status_tx: Option<watch::Sender<SharedString>>,
1539 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1540 cx: &mut AsyncApp,
1541 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1542 let fs = self.fs.clone();
1543 let http_client = self.http_client.clone();
1544 let node_runtime = self.node_runtime.clone();
1545 let project_environment = self.project_environment.downgrade();
1546 let extension_id = self.extension_id.clone();
1547 let agent_id = self.agent_id.clone();
1548 let targets = self.targets.clone();
1549 let base_env = self.env.clone();
1550
1551 let root_dir: Arc<Path> = root_dir
1552 .map(|root_dir| Path::new(root_dir))
1553 .unwrap_or(paths::home_dir())
1554 .into();
1555
1556 cx.spawn(async move |cx| {
1557 // Get project environment
1558 let mut env = project_environment
1559 .update(cx, |project_environment, cx| {
1560 project_environment.local_directory_environment(
1561 &Shell::System,
1562 root_dir.clone(),
1563 cx,
1564 )
1565 })?
1566 .await
1567 .unwrap_or_default();
1568
1569 // Merge manifest env and extra env
1570 env.extend(base_env);
1571 env.extend(extra_env);
1572
1573 let cache_key = format!("{}/{}", extension_id, agent_id);
1574 let dir = paths::external_agents_dir().join(&cache_key);
1575 fs.create_dir(&dir).await?;
1576
1577 // Determine platform key
1578 let os = if cfg!(target_os = "macos") {
1579 "darwin"
1580 } else if cfg!(target_os = "linux") {
1581 "linux"
1582 } else if cfg!(target_os = "windows") {
1583 "windows"
1584 } else {
1585 anyhow::bail!("unsupported OS");
1586 };
1587
1588 let arch = if cfg!(target_arch = "aarch64") {
1589 "aarch64"
1590 } else if cfg!(target_arch = "x86_64") {
1591 "x86_64"
1592 } else {
1593 anyhow::bail!("unsupported architecture");
1594 };
1595
1596 let platform_key = format!("{}-{}", os, arch);
1597 let target_config = targets.get(&platform_key).with_context(|| {
1598 format!(
1599 "no target specified for platform '{}'. Available platforms: {}",
1600 platform_key,
1601 targets
1602 .keys()
1603 .map(|k| k.as_str())
1604 .collect::<Vec<_>>()
1605 .join(", ")
1606 )
1607 })?;
1608
1609 let archive_url = &target_config.archive;
1610
1611 // Use URL as version identifier for caching
1612 // Hash the URL to get a stable directory name
1613 use std::collections::hash_map::DefaultHasher;
1614 use std::hash::{Hash, Hasher};
1615 let mut hasher = DefaultHasher::new();
1616 archive_url.hash(&mut hasher);
1617 let url_hash = hasher.finish();
1618 let version_dir = dir.join(format!("v_{:x}", url_hash));
1619
1620 if !fs.is_dir(&version_dir).await {
1621 // Determine SHA256 for verification
1622 let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1623 // Use provided SHA256
1624 Some(provided_sha.clone())
1625 } else if archive_url.starts_with("https://github.com/") {
1626 // Try to fetch SHA256 from GitHub API
1627 // Parse URL to extract repo and tag/file info
1628 // Format: https://github.com/owner/repo/releases/download/tag/file.zip
1629 if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
1630 let parts: Vec<&str> = caps.split('/').collect();
1631 if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
1632 let repo = format!("{}/{}", parts[0], parts[1]);
1633 let tag = parts[4];
1634 let filename = parts[5..].join("/");
1635
1636 // Try to get release info from GitHub
1637 if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1638 &repo,
1639 tag,
1640 http_client.clone(),
1641 )
1642 .await
1643 {
1644 // Find matching asset
1645 if let Some(asset) =
1646 release.assets.iter().find(|a| a.name == filename)
1647 {
1648 // Strip "sha256:" prefix if present
1649 asset.digest.as_ref().and_then(|d| {
1650 d.strip_prefix("sha256:")
1651 .map(|s| s.to_string())
1652 .or_else(|| Some(d.clone()))
1653 })
1654 } else {
1655 None
1656 }
1657 } else {
1658 None
1659 }
1660 } else {
1661 None
1662 }
1663 } else {
1664 None
1665 }
1666 } else {
1667 None
1668 };
1669
1670 // Determine archive type from URL
1671 let asset_kind = if archive_url.ends_with(".zip") {
1672 AssetKind::Zip
1673 } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
1674 AssetKind::TarGz
1675 } else {
1676 anyhow::bail!("unsupported archive type in URL: {}", archive_url);
1677 };
1678
1679 // Download and extract
1680 ::http_client::github_download::download_server_binary(
1681 &*http_client,
1682 archive_url,
1683 sha256.as_deref(),
1684 &version_dir,
1685 asset_kind,
1686 )
1687 .await?;
1688 }
1689
1690 // Validate and resolve cmd path
1691 let cmd = &target_config.cmd;
1692
1693 let cmd_path = if cmd == "node" {
1694 // Use Zed's managed Node.js runtime
1695 node_runtime.binary_path().await?
1696 } else {
1697 if cmd.contains("..") {
1698 anyhow::bail!("command path cannot contain '..': {}", cmd);
1699 }
1700
1701 if cmd.starts_with("./") || cmd.starts_with(".\\") {
1702 // Relative to extraction directory
1703 let cmd_path = version_dir.join(&cmd[2..]);
1704 anyhow::ensure!(
1705 fs.is_file(&cmd_path).await,
1706 "Missing command {} after extraction",
1707 cmd_path.to_string_lossy()
1708 );
1709 cmd_path
1710 } else {
1711 // On PATH
1712 anyhow::bail!("command must be relative (start with './'): {}", cmd);
1713 }
1714 };
1715
1716 let command = AgentServerCommand {
1717 path: cmd_path,
1718 args: target_config.args.clone(),
1719 env: Some(env),
1720 };
1721
1722 Ok((command, version_dir.to_string_lossy().into_owned(), None))
1723 })
1724 }
1725
1726 fn as_any_mut(&mut self) -> &mut dyn Any {
1727 self
1728 }
1729}
1730
1731impl ExternalAgentServer for LocalCustomAgent {
1732 fn get_command(
1733 &mut self,
1734 root_dir: Option<&str>,
1735 extra_env: HashMap<String, String>,
1736 _status_tx: Option<watch::Sender<SharedString>>,
1737 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1738 cx: &mut AsyncApp,
1739 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1740 let mut command = self.command.clone();
1741 let root_dir: Arc<Path> = root_dir
1742 .map(|root_dir| Path::new(root_dir))
1743 .unwrap_or(paths::home_dir())
1744 .into();
1745 let project_environment = self.project_environment.downgrade();
1746 cx.spawn(async move |cx| {
1747 let mut env = project_environment
1748 .update(cx, |project_environment, cx| {
1749 project_environment.local_directory_environment(
1750 &Shell::System,
1751 root_dir.clone(),
1752 cx,
1753 )
1754 })?
1755 .await
1756 .unwrap_or_default();
1757 env.extend(command.env.unwrap_or_default());
1758 env.extend(extra_env);
1759 command.env = Some(env);
1760 Ok((command, root_dir.to_string_lossy().into_owned(), None))
1761 })
1762 }
1763
1764 fn as_any_mut(&mut self) -> &mut dyn Any {
1765 self
1766 }
1767}
1768
1769pub const GEMINI_NAME: &'static str = "gemini";
1770pub const CLAUDE_CODE_NAME: &'static str = "claude";
1771pub const CODEX_NAME: &'static str = "codex";
1772
1773#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1774pub struct AllAgentServersSettings {
1775 pub gemini: Option<BuiltinAgentServerSettings>,
1776 pub claude: Option<BuiltinAgentServerSettings>,
1777 pub codex: Option<BuiltinAgentServerSettings>,
1778 pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1779}
1780#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1781pub struct BuiltinAgentServerSettings {
1782 pub path: Option<PathBuf>,
1783 pub args: Option<Vec<String>>,
1784 pub env: Option<HashMap<String, String>>,
1785 pub ignore_system_version: Option<bool>,
1786 pub default_mode: Option<String>,
1787 pub default_model: Option<String>,
1788}
1789
1790impl BuiltinAgentServerSettings {
1791 pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1792 self.path.map(|path| AgentServerCommand {
1793 path,
1794 args: self.args.unwrap_or_default(),
1795 env: self.env,
1796 })
1797 }
1798}
1799
1800impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1801 fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1802 BuiltinAgentServerSettings {
1803 path: value
1804 .path
1805 .map(|p| PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref())),
1806 args: value.args,
1807 env: value.env,
1808 ignore_system_version: value.ignore_system_version,
1809 default_mode: value.default_mode,
1810 default_model: value.default_model,
1811 }
1812 }
1813}
1814
1815impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1816 fn from(value: AgentServerCommand) -> Self {
1817 BuiltinAgentServerSettings {
1818 path: Some(value.path),
1819 args: Some(value.args),
1820 env: value.env,
1821 ..Default::default()
1822 }
1823 }
1824}
1825
1826#[derive(Clone, JsonSchema, Debug, PartialEq)]
1827pub enum CustomAgentServerSettings {
1828 Custom {
1829 command: AgentServerCommand,
1830 /// The default mode to use for this agent.
1831 ///
1832 /// Note: Not only all agents support modes.
1833 ///
1834 /// Default: None
1835 default_mode: Option<String>,
1836 /// The default model to use for this agent.
1837 ///
1838 /// This should be the model ID as reported by the agent.
1839 ///
1840 /// Default: None
1841 default_model: Option<String>,
1842 },
1843 Extension {
1844 /// The default mode to use for this agent.
1845 ///
1846 /// Note: Not only all agents support modes.
1847 ///
1848 /// Default: None
1849 default_mode: Option<String>,
1850 /// The default model to use for this agent.
1851 ///
1852 /// This should be the model ID as reported by the agent.
1853 ///
1854 /// Default: None
1855 default_model: Option<String>,
1856 },
1857}
1858
1859impl CustomAgentServerSettings {
1860 pub fn command(&self) -> Option<&AgentServerCommand> {
1861 match self {
1862 CustomAgentServerSettings::Custom { command, .. } => Some(command),
1863 CustomAgentServerSettings::Extension { .. } => None,
1864 }
1865 }
1866
1867 pub fn default_mode(&self) -> Option<&str> {
1868 match self {
1869 CustomAgentServerSettings::Custom { default_mode, .. }
1870 | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(),
1871 }
1872 }
1873
1874 pub fn default_model(&self) -> Option<&str> {
1875 match self {
1876 CustomAgentServerSettings::Custom { default_model, .. }
1877 | CustomAgentServerSettings::Extension { default_model, .. } => {
1878 default_model.as_deref()
1879 }
1880 }
1881 }
1882}
1883
1884impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1885 fn from(value: settings::CustomAgentServerSettings) -> Self {
1886 match value {
1887 settings::CustomAgentServerSettings::Custom {
1888 path,
1889 args,
1890 env,
1891 default_mode,
1892 default_model,
1893 } => CustomAgentServerSettings::Custom {
1894 command: AgentServerCommand {
1895 path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1896 args,
1897 env,
1898 },
1899 default_mode,
1900 default_model,
1901 },
1902 settings::CustomAgentServerSettings::Extension {
1903 default_mode,
1904 default_model,
1905 } => CustomAgentServerSettings::Extension {
1906 default_mode,
1907 default_model,
1908 },
1909 }
1910 }
1911}
1912
1913impl settings::Settings for AllAgentServersSettings {
1914 fn from_settings(content: &settings::SettingsContent) -> Self {
1915 let agent_settings = content.agent_servers.clone().unwrap();
1916 Self {
1917 gemini: agent_settings.gemini.map(Into::into),
1918 claude: agent_settings.claude.map(Into::into),
1919 codex: agent_settings.codex.map(Into::into),
1920 custom: agent_settings
1921 .custom
1922 .into_iter()
1923 .map(|(k, v)| (k, v.into()))
1924 .collect(),
1925 }
1926 }
1927}
1928
1929#[cfg(test)]
1930mod extension_agent_tests {
1931 use crate::worktree_store::WorktreeStore;
1932
1933 use super::*;
1934 use gpui::TestAppContext;
1935 use std::sync::Arc;
1936
1937 #[test]
1938 fn extension_agent_constructs_proper_display_names() {
1939 // Verify the display name format for extension-provided agents
1940 let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
1941 assert!(name1.0.contains(": "));
1942
1943 let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
1944 assert_eq!(name2.0, "MyExt: MyAgent");
1945
1946 // Non-extension agents shouldn't have the separator
1947 let custom = ExternalAgentServerName(SharedString::from("custom"));
1948 assert!(!custom.0.contains(": "));
1949 }
1950
1951 struct NoopExternalAgent;
1952
1953 impl ExternalAgentServer for NoopExternalAgent {
1954 fn get_command(
1955 &mut self,
1956 _root_dir: Option<&str>,
1957 _extra_env: HashMap<String, String>,
1958 _status_tx: Option<watch::Sender<SharedString>>,
1959 _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1960 _cx: &mut AsyncApp,
1961 ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1962 Task::ready(Ok((
1963 AgentServerCommand {
1964 path: PathBuf::from("noop"),
1965 args: Vec::new(),
1966 env: None,
1967 },
1968 "".to_string(),
1969 None,
1970 )))
1971 }
1972
1973 fn as_any_mut(&mut self) -> &mut dyn Any {
1974 self
1975 }
1976 }
1977
1978 #[test]
1979 fn sync_removes_only_extension_provided_agents() {
1980 let mut store = AgentServerStore {
1981 state: AgentServerStoreState::Collab,
1982 external_agents: HashMap::default(),
1983 agent_icons: HashMap::default(),
1984 };
1985
1986 // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
1987 store.external_agents.insert(
1988 ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
1989 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1990 );
1991 store.external_agents.insert(
1992 ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
1993 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1994 );
1995 store.external_agents.insert(
1996 ExternalAgentServerName(SharedString::from("custom-agent")),
1997 Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
1998 );
1999
2000 // Simulate removal phase
2001 let keys_to_remove: Vec<_> = store
2002 .external_agents
2003 .keys()
2004 .filter(|name| name.0.contains(": "))
2005 .cloned()
2006 .collect();
2007
2008 for key in keys_to_remove {
2009 store.external_agents.remove(&key);
2010 }
2011
2012 // Only custom-agent should remain
2013 assert_eq!(store.external_agents.len(), 1);
2014 assert!(
2015 store
2016 .external_agents
2017 .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
2018 );
2019 }
2020
2021 #[test]
2022 fn archive_launcher_constructs_with_all_fields() {
2023 use extension::AgentServerManifestEntry;
2024
2025 let mut env = HashMap::default();
2026 env.insert("GITHUB_TOKEN".into(), "secret".into());
2027
2028 let mut targets = HashMap::default();
2029 targets.insert(
2030 "darwin-aarch64".to_string(),
2031 extension::TargetConfig {
2032 archive:
2033 "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
2034 .into(),
2035 cmd: "./agent".into(),
2036 args: vec![],
2037 sha256: None,
2038 env: Default::default(),
2039 },
2040 );
2041
2042 let _entry = AgentServerManifestEntry {
2043 name: "GitHub Agent".into(),
2044 targets,
2045 env,
2046 icon: None,
2047 };
2048
2049 // Verify display name construction
2050 let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
2051 assert_eq!(expected_name.0, "GitHub Agent");
2052 }
2053
2054 #[gpui::test]
2055 async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
2056 let fs = fs::FakeFs::new(cx.background_executor.clone());
2057 let http_client = http_client::FakeHttpClient::with_404_response();
2058 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2059 let project_environment = cx.new(|cx| {
2060 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2061 });
2062
2063 let agent = LocalExtensionArchiveAgent {
2064 fs,
2065 http_client,
2066 node_runtime: node_runtime::NodeRuntime::unavailable(),
2067 project_environment,
2068 extension_id: Arc::from("my-extension"),
2069 agent_id: Arc::from("my-agent"),
2070 targets: {
2071 let mut map = HashMap::default();
2072 map.insert(
2073 "darwin-aarch64".to_string(),
2074 extension::TargetConfig {
2075 archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
2076 cmd: "./my-agent".into(),
2077 args: vec!["--serve".into()],
2078 sha256: None,
2079 env: Default::default(),
2080 },
2081 );
2082 map
2083 },
2084 env: {
2085 let mut map = HashMap::default();
2086 map.insert("PORT".into(), "8080".into());
2087 map
2088 },
2089 };
2090
2091 // Verify agent is properly constructed
2092 assert_eq!(agent.extension_id.as_ref(), "my-extension");
2093 assert_eq!(agent.agent_id.as_ref(), "my-agent");
2094 assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
2095 assert!(agent.targets.contains_key("darwin-aarch64"));
2096 }
2097
2098 #[test]
2099 fn sync_extension_agents_registers_archive_launcher() {
2100 use extension::AgentServerManifestEntry;
2101
2102 let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
2103 assert_eq!(expected_name.0, "Release Agent");
2104
2105 // Verify the manifest entry structure for archive-based installation
2106 let mut env = HashMap::default();
2107 env.insert("API_KEY".into(), "secret".into());
2108
2109 let mut targets = HashMap::default();
2110 targets.insert(
2111 "linux-x86_64".to_string(),
2112 extension::TargetConfig {
2113 archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
2114 cmd: "./release-agent".into(),
2115 args: vec!["serve".into()],
2116 sha256: None,
2117 env: Default::default(),
2118 },
2119 );
2120
2121 let manifest_entry = AgentServerManifestEntry {
2122 name: "Release Agent".into(),
2123 targets: targets.clone(),
2124 env,
2125 icon: None,
2126 };
2127
2128 // Verify target config is present
2129 assert!(manifest_entry.targets.contains_key("linux-x86_64"));
2130 let target = manifest_entry.targets.get("linux-x86_64").unwrap();
2131 assert_eq!(target.cmd, "./release-agent");
2132 }
2133
2134 #[gpui::test]
2135 async fn test_node_command_uses_managed_runtime(cx: &mut TestAppContext) {
2136 let fs = fs::FakeFs::new(cx.background_executor.clone());
2137 let http_client = http_client::FakeHttpClient::with_404_response();
2138 let node_runtime = NodeRuntime::unavailable();
2139 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2140 let project_environment = cx.new(|cx| {
2141 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2142 });
2143
2144 let agent = LocalExtensionArchiveAgent {
2145 fs: fs.clone(),
2146 http_client,
2147 node_runtime,
2148 project_environment,
2149 extension_id: Arc::from("node-extension"),
2150 agent_id: Arc::from("node-agent"),
2151 targets: {
2152 let mut map = HashMap::default();
2153 map.insert(
2154 "darwin-aarch64".to_string(),
2155 extension::TargetConfig {
2156 archive: "https://example.com/node-agent.zip".into(),
2157 cmd: "node".into(),
2158 args: vec!["index.js".into()],
2159 sha256: None,
2160 env: Default::default(),
2161 },
2162 );
2163 map
2164 },
2165 env: HashMap::default(),
2166 };
2167
2168 // Verify that when cmd is "node", it attempts to use the node runtime
2169 assert_eq!(agent.extension_id.as_ref(), "node-extension");
2170 assert_eq!(agent.agent_id.as_ref(), "node-agent");
2171
2172 let target = agent.targets.get("darwin-aarch64").unwrap();
2173 assert_eq!(target.cmd, "node");
2174 assert_eq!(target.args, vec!["index.js"]);
2175 }
2176
2177 #[gpui::test]
2178 async fn test_commands_run_in_extraction_directory(cx: &mut TestAppContext) {
2179 let fs = fs::FakeFs::new(cx.background_executor.clone());
2180 let http_client = http_client::FakeHttpClient::with_404_response();
2181 let node_runtime = NodeRuntime::unavailable();
2182 let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
2183 let project_environment = cx.new(|cx| {
2184 crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2185 });
2186
2187 let agent = LocalExtensionArchiveAgent {
2188 fs: fs.clone(),
2189 http_client,
2190 node_runtime,
2191 project_environment,
2192 extension_id: Arc::from("test-ext"),
2193 agent_id: Arc::from("test-agent"),
2194 targets: {
2195 let mut map = HashMap::default();
2196 map.insert(
2197 "darwin-aarch64".to_string(),
2198 extension::TargetConfig {
2199 archive: "https://example.com/test.zip".into(),
2200 cmd: "node".into(),
2201 args: vec![
2202 "server.js".into(),
2203 "--config".into(),
2204 "./config.json".into(),
2205 ],
2206 sha256: None,
2207 env: Default::default(),
2208 },
2209 );
2210 map
2211 },
2212 env: HashMap::default(),
2213 };
2214
2215 // Verify the agent is configured with relative paths in args
2216 let target = agent.targets.get("darwin-aarch64").unwrap();
2217 assert_eq!(target.args[0], "server.js");
2218 assert_eq!(target.args[2], "./config.json");
2219 // These relative paths will resolve relative to the extraction directory
2220 // when the command is executed
2221 }
2222
2223 #[test]
2224 fn test_tilde_expansion_in_settings() {
2225 let settings = settings::BuiltinAgentServerSettings {
2226 path: Some(PathBuf::from("~/bin/agent")),
2227 args: Some(vec!["--flag".into()]),
2228 env: None,
2229 ignore_system_version: None,
2230 default_mode: None,
2231 default_model: None,
2232 };
2233
2234 let BuiltinAgentServerSettings { path, .. } = settings.into();
2235
2236 let path = path.unwrap();
2237 assert!(
2238 !path.to_string_lossy().starts_with("~"),
2239 "Tilde should be expanded for builtin agent path"
2240 );
2241
2242 let settings = settings::CustomAgentServerSettings::Custom {
2243 path: PathBuf::from("~/custom/agent"),
2244 args: vec!["serve".into()],
2245 env: None,
2246 default_mode: None,
2247 default_model: None,
2248 };
2249
2250 let converted: CustomAgentServerSettings = settings.into();
2251 let CustomAgentServerSettings::Custom {
2252 command: AgentServerCommand { path, .. },
2253 ..
2254 } = converted
2255 else {
2256 panic!("Expected Custom variant");
2257 };
2258
2259 assert!(
2260 !path.to_string_lossy().starts_with("~"),
2261 "Tilde should be expanded for custom agent path"
2262 );
2263 }
2264}