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