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