Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:54:35.417Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

agent_server_store.rs

2534 lines · 93.1 KB · rust
1use std::{
2    any::Any,
3    path::{Path, PathBuf},
4    sync::{Arc, LazyLock},
5    time::Duration,
6};
7
8use anyhow::{Context as _, Result, bail};
9use collections::HashMap;
10use fs::{Fs, RemoveOptions};
11use futures::StreamExt;
12use gpui::{
13    AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
14    TaskExt,
15};
16use http_client::{HttpClient, github::AssetKind};
17use node_runtime::NodeRuntime;
18use percent_encoding::percent_decode_str;
19use remote::RemoteClient;
20use rpc::{AnyProtoClient, TypedEnvelope, proto};
21use schemars::JsonSchema;
22use semver::Version;
23use serde::{Deserialize, Serialize};
24use settings::{AgentConfigOptionValue, RegisterSetting, SettingsStore, update_settings_file};
25use sha2::{Digest, Sha256};
26use url::Url;
27use util::{ResultExt as _, debug_panic};
28
29use crate::ProjectEnvironment;
30use crate::agent_registry_store::{AgentRegistryStore, RegistryAgent, RegistryTargetConfig};
31use crate::harness_maintenance::{
32    NPX_RESOLVER, authorize_installed_harness, authorize_package_manager_launch,
33    authorize_version_fetch, now_ms as harness_now_ms, resolve_channel,
34};
35use omega_harness::{HarnessDistribution, MaintenanceAction};
36
37use crate::worktree_store::WorktreeStore;
38
39#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
40pub struct AgentServerCommand {
41    #[serde(rename = "command")]
42    pub path: PathBuf,
43    #[serde(default)]
44    pub args: Vec<String>,
45    pub env: Option<HashMap<String, String>>,
46}
47
48impl std::fmt::Debug for AgentServerCommand {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        let filtered_env = self.env.as_ref().map(|env| {
51            env.iter()
52                .map(|(k, v)| {
53                    (
54                        k,
55                        if util::redact::should_redact(k) {
56                            "[REDACTED]"
57                        } else {
58                            v
59                        },
60                    )
61                })
62                .collect::<Vec<_>>()
63        });
64
65        f.debug_struct("AgentServerCommand")
66            .field("path", &self.path)
67            .field("args", &self.args)
68            .field("env", &filtered_env)
69            .finish()
70    }
71}
72
73#[derive(
74    Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
75)]
76#[serde(transparent)]
77pub struct AgentId(pub SharedString);
78
79impl AgentId {
80    pub fn new(id: impl Into<SharedString>) -> Self {
81        AgentId(id.into())
82    }
83}
84
85impl std::fmt::Display for AgentId {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(f, "{}", self.0)
88    }
89}
90
91impl From<&'static str> for AgentId {
92    fn from(value: &'static str) -> Self {
93        AgentId(value.into())
94    }
95}
96
97impl From<AgentId> for SharedString {
98    fn from(value: AgentId) -> Self {
99        value.0
100    }
101}
102
103impl AsRef<str> for AgentId {
104    fn as_ref(&self) -> &str {
105        &self.0
106    }
107}
108
109impl std::borrow::Borrow<str> for AgentId {
110    fn borrow(&self) -> &str {
111        &self.0
112    }
113}
114
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
116pub enum ExternalAgentSource {
117    #[default]
118    Custom,
119    Registry,
120}
121
122/// What the front door needs in order to show one harness's maintenance state.
123///
124/// The store owns these facts and the settings page does not: the installed
125/// directory is derived from the version, the archive URL and its checksum, and
126/// re-deriving that anywhere else would let the page measure a different tree
127/// than the launch path gates.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub struct HarnessMaintenanceTarget {
130    /// The registry id, as the receipt log and the pin ledger name it.
131    pub harness_id: Arc<str>,
132    /// The version the registry currently advertises.
133    pub version: SharedString,
134    pub distribution: HarnessDistribution,
135    /// The exact tree the launch path measures, when there is one.
136    pub installed_dir: Option<PathBuf>,
137}
138
139pub trait ExternalAgentServer {
140    fn get_command(
141        &mut self,
142        extra_args: Vec<String>,
143        extra_env: HashMap<String, String>,
144        cx: &mut AsyncApp,
145    ) -> Task<Result<AgentServerCommand>>;
146
147    fn version(&self) -> Option<&SharedString> {
148        None
149    }
150
151    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
152        None
153    }
154
155    fn set_new_version_available_tx(&mut self, _tx: watch::Sender<Option<String>>) {}
156
157    fn take_loading_status_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
158        None
159    }
160
161    fn set_loading_status_tx(&mut self, _tx: watch::Sender<Option<String>>) {}
162
163    /// What the front door shows for this harness, or `None` when there is
164    /// nothing Omega maintains.
165    ///
166    /// `None` is the honest answer for an owner-named custom binary: Omega did
167    /// not choose it, does not update it, and owns no directory it lives in, so
168    /// there is no pin to offer and no provenance to claim.
169    fn maintenance_target(&self) -> Option<HarnessMaintenanceTarget> {
170        None
171    }
172
173    fn as_any(&self) -> &dyn Any;
174    fn as_any_mut(&mut self) -> &mut dyn Any;
175}
176
177enum AgentServerStoreState {
178    Local {
179        node_runtime: NodeRuntime,
180        fs: Arc<dyn Fs>,
181        project_environment: Entity<ProjectEnvironment>,
182        downstream_client: Option<(u64, AnyProtoClient)>,
183        settings: Option<AllAgentServersSettings>,
184        http_client: Arc<dyn HttpClient>,
185        _subscriptions: Vec<Subscription>,
186    },
187    Remote {
188        project_id: u64,
189        upstream_client: Entity<RemoteClient>,
190        worktree_store: Entity<WorktreeStore>,
191    },
192    Collab,
193}
194
195pub struct ExternalAgentEntry {
196    server: Box<dyn ExternalAgentServer>,
197    icon: Option<SharedString>,
198    display_name: Option<SharedString>,
199    pub source: ExternalAgentSource,
200}
201
202impl ExternalAgentEntry {
203    pub fn new(
204        server: Box<dyn ExternalAgentServer>,
205        source: ExternalAgentSource,
206        icon: Option<SharedString>,
207        display_name: Option<SharedString>,
208    ) -> Self {
209        Self {
210            server,
211            icon,
212            display_name,
213            source,
214        }
215    }
216}
217
218pub struct AgentServerStore {
219    state: AgentServerStoreState,
220    pub external_agents: HashMap<AgentId, ExternalAgentEntry>,
221}
222
223pub struct AgentServersUpdated;
224
225impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
226
227static EXTENSION_TO_REGISTRY_IDS: LazyLock<HashMap<&'static str, &'static str>> =
228    LazyLock::new(|| {
229        HashMap::from_iter([
230            ("opencode", "opencode"),
231            ("mistral-vibe", "mistral-vibe"),
232            ("auggie", "auggie"),
233            ("stakpak", "stakpak"),
234            ("codebuddy", "codebuddy-code"),
235            ("autohand-acp", "autohand"),
236            ("corust-agent", "corust-agent"),
237            ("factory-droid", "factory-droid"),
238            // Unmaintained
239            // ("qqcode", ""),
240        ])
241    });
242
243impl AgentServerStore {
244    pub fn migrate_agent_server_from_extensions(
245        &mut self,
246        id: Arc<str>,
247        fs: Arc<dyn Fs>,
248        cx: &mut Context<Self>,
249    ) {
250        let Some(registry_id) = EXTENSION_TO_REGISTRY_IDS.get(id.as_ref()) else {
251            return;
252        };
253
254        update_settings_file(fs, cx, move |settings, _| {
255            let agent_servers = settings.agent_servers.get_or_insert_default();
256            // Take the old settings
257            let settings = agent_servers.remove(id.as_ref());
258            // If they had both installed, just remove the extension settings, leave theirregistry settings alone
259            if agent_servers.contains_key(*registry_id) {
260                return;
261            }
262            // Insert the old settings, or write new ones so it is "installed" via the registry
263            agent_servers.insert(
264                registry_id.to_string(),
265                settings.unwrap_or_else(|| settings::CustomAgentServerSettings::Registry {
266                    default_mode: None,
267                    env: Default::default(),
268                    default_config_options: HashMap::default(),
269                    favorite_config_option_values: HashMap::default(),
270                }),
271            );
272        });
273    }
274
275    pub fn agent_icon(&self, id: &AgentId) -> Option<SharedString> {
276        self.external_agents
277            .get(id)
278            .and_then(|entry| entry.icon.clone())
279    }
280
281    pub fn agent_source(&self, name: &AgentId) -> Option<ExternalAgentSource> {
282        self.external_agents.get(name).map(|entry| entry.source)
283    }
284
285    /// What the front door needs to show one agent's maintenance state.
286    ///
287    /// `None` for an agent Omega does not maintain. The settings page renders
288    /// nothing rather than an empty maintenance section, because a section that
289    /// says nothing about a custom binary reads as a claim that there was
290    /// nothing to say.
291    pub fn maintenance_target(&self, name: &AgentId) -> Option<HarnessMaintenanceTarget> {
292        self.external_agents
293            .get(name)
294            .and_then(|entry| entry.server.maintenance_target())
295    }
296}
297
298impl AgentServerStore {
299    pub fn agent_display_name(&self, name: &AgentId) -> Option<SharedString> {
300        self.external_agents
301            .get(name)
302            .and_then(|entry| entry.display_name.clone())
303    }
304
305    pub fn init_remote(session: &AnyProtoClient) {
306        session.add_entity_message_handler(Self::handle_external_agents_updated);
307        session.add_entity_message_handler(Self::handle_loading_status_updated);
308        session.add_entity_message_handler(Self::handle_new_version_available);
309    }
310
311    pub fn init_headless(session: &AnyProtoClient) {
312        session.add_entity_request_handler(Self::handle_get_agent_server_command);
313    }
314
315    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
316        let AgentServerStoreState::Local {
317            settings: old_settings,
318            ..
319        } = &mut self.state
320        else {
321            debug_panic!(
322                "should not be subscribed to agent server settings changes in non-local project"
323            );
324            return;
325        };
326
327        let new_settings = cx
328            .global::<SettingsStore>()
329            .get::<AllAgentServersSettings>(None)
330            .clone();
331        if Some(&new_settings) == old_settings.as_ref() {
332            return;
333        }
334
335        self.reregister_agents(cx);
336    }
337
338    fn reregister_agents(&mut self, cx: &mut Context<Self>) {
339        let AgentServerStoreState::Local {
340            node_runtime,
341            fs,
342            project_environment,
343            downstream_client,
344            settings: old_settings,
345            http_client,
346            ..
347        } = &mut self.state
348        else {
349            debug_panic!("Non-local projects should never attempt to reregister. This is a bug!");
350
351            return;
352        };
353
354        let new_settings = cx
355            .global::<SettingsStore>()
356            .get::<AllAgentServersSettings>(None)
357            .clone();
358
359        // If we don't have agents from the registry loaded yet, trigger a
360        // refresh, which will cause this function to be called again
361        let registry_store = AgentRegistryStore::try_global(cx);
362        if new_settings.has_registry_agents()
363            && let Some(registry) = registry_store.as_ref()
364        {
365            registry.update(cx, |registry, cx| registry.refresh_if_stale(cx));
366        }
367
368        let registry_agents_by_id = registry_store
369            .as_ref()
370            .map(|store| {
371                store
372                    .read(cx)
373                    .agents()
374                    .iter()
375                    .cloned()
376                    .map(|agent| (agent.id().to_string(), agent))
377                    .collect::<HashMap<_, _>>()
378            })
379            .unwrap_or_default();
380
381        // Drain the existing versioned agents, extracting reconnect state
382        // from any active connection so we can preserve it or trigger a
383        // reconnect when the version changes.
384        let mut old_versioned_agents: HashMap<
385            AgentId,
386            (
387                SharedString,
388                Option<watch::Sender<Option<String>>>,
389                Option<watch::Sender<Option<String>>>,
390            ),
391        > = HashMap::default();
392        for (name, mut entry) in self.external_agents.drain() {
393            if let Some(version) = entry.server.version().cloned() {
394                let new_version_available_tx = entry.server.take_new_version_available_tx();
395                let loading_status_tx = entry.server.take_loading_status_tx();
396                if new_version_available_tx.is_some() || loading_status_tx.is_some() {
397                    old_versioned_agents
398                        .insert(name, (version, new_version_available_tx, loading_status_tx));
399                }
400            }
401        }
402
403        for (name, settings) in new_settings.iter() {
404            match settings {
405                CustomAgentServerSettings::Custom { command, .. } => {
406                    let agent_name = AgentId(name.clone().into());
407                    self.external_agents.insert(
408                        agent_name.clone(),
409                        ExternalAgentEntry::new(
410                            Box::new(LocalCustomAgent {
411                                command: command.clone(),
412                                project_environment: project_environment.clone(),
413                            }) as Box<dyn ExternalAgentServer>,
414                            ExternalAgentSource::Custom,
415                            None,
416                            None,
417                        ),
418                    );
419                }
420                CustomAgentServerSettings::Registry { env, .. } => {
421                    let Some(agent) = registry_agents_by_id.get(name) else {
422                        if registry_store.is_some() {
423                            log::debug!("Registry agent '{}' not found in ACP registry", name);
424                        }
425                        continue;
426                    };
427
428                    let agent_name = AgentId(name.clone().into());
429                    match agent {
430                        RegistryAgent::Binary(agent) => {
431                            if !agent.supports_current_platform {
432                                log::warn!(
433                                    "Registry agent '{}' has no compatible binary for this platform",
434                                    name
435                                );
436                                continue;
437                            }
438
439                            self.external_agents.insert(
440                                agent_name.clone(),
441                                ExternalAgentEntry::new(
442                                    Box::new(LocalRegistryArchiveAgent {
443                                        fs: fs.clone(),
444                                        http_client: http_client.clone(),
445                                        node_runtime: node_runtime.clone(),
446                                        project_environment: project_environment.clone(),
447                                        installation_dir: paths::external_agents_dir()
448                                            .join("registry")
449                                            .join(sanitize_path_component(name)),
450                                        registry_id: Arc::from(name.as_str()),
451                                        version: agent.metadata.version.clone(),
452                                        targets: agent.targets.clone(),
453                                        env: env.clone(),
454                                        new_version_available_tx: None,
455                                        loading_status_tx: None,
456                                    })
457                                        as Box<dyn ExternalAgentServer>,
458                                    ExternalAgentSource::Registry,
459                                    agent.metadata.icon_path.clone(),
460                                    Some(agent.metadata.name.clone()),
461                                ),
462                            );
463                        }
464                        RegistryAgent::Npx(agent) => {
465                            self.external_agents.insert(
466                                agent_name.clone(),
467                                ExternalAgentEntry::new(
468                                    Box::new(LocalRegistryNpxAgent {
469                                        fs: fs.clone(),
470                                        node_runtime: node_runtime.clone(),
471                                        project_environment: project_environment.clone(),
472                                        registry_id: Arc::from(name.as_str()),
473                                        version: agent.metadata.version.clone(),
474                                        package: agent.package.clone(),
475                                        args: agent.args.clone(),
476                                        distribution_env: agent.env.clone(),
477                                        settings_env: env.clone(),
478                                        new_version_available_tx: None,
479                                    })
480                                        as Box<dyn ExternalAgentServer>,
481                                    ExternalAgentSource::Registry,
482                                    agent.metadata.icon_path.clone(),
483                                    Some(agent.metadata.name.clone()),
484                                ),
485                            );
486                        }
487                    }
488                }
489            }
490        }
491
492        // For each rebuilt versioned agent, compare the version. If it
493        // changed, notify the active connection to reconnect. Otherwise,
494        // transfer the channel to the new entry so future updates can use it.
495        for (name, entry) in &mut self.external_agents {
496            let Some((old_version, new_version_available_tx, loading_status_tx)) =
497                old_versioned_agents.remove(name)
498            else {
499                continue;
500            };
501            let Some(new_version) = entry.server.version() else {
502                continue;
503            };
504
505            if new_version != &old_version {
506                if let Some(tx) = new_version_available_tx {
507                    // omega#81. Resolving the channel is its own maintenance
508                    // action, and it happens here — no bytes move and nothing
509                    // is about to launch. A harness frozen at another version
510                    // must not be *offered* the one the registry now names: the
511                    // offer would lead to a refusal on the next launch, which
512                    // is the front door promising what the gate then takes
513                    // back. The refusal is recorded, because an update that
514                    // never starts leaves no other trace it was considered.
515                    let fs = fs.clone();
516                    let agent_id = name.clone();
517                    let version = new_version.to_string();
518                    cx.spawn(async move |this, cx| {
519                        let refusal = resolve_channel(
520                            fs,
521                            agent_id.0.as_ref(),
522                            version.as_str(),
523                            harness_now_ms(),
524                        )
525                        .await;
526                        let mut tx = tx;
527                        if refusal.is_none() {
528                            tx.send(Some(version)).ok();
529                            return;
530                        }
531                        // Hand the channel back rather than dropping it. A
532                        // dropped sender would close the announcement path for
533                        // the life of the connection, so removing the pin would
534                        // not bring the offer back.
535                        this.update(cx, |store, _| {
536                            if let Some(entry) = store.external_agents.get_mut(&agent_id) {
537                                entry.server.set_new_version_available_tx(tx);
538                            }
539                        })
540                        .ok();
541                    })
542                    .detach();
543                }
544            } else {
545                if let Some(tx) = new_version_available_tx {
546                    entry.server.set_new_version_available_tx(tx);
547                }
548                if let Some(tx) = loading_status_tx {
549                    entry.server.set_loading_status_tx(tx);
550                }
551            }
552        }
553
554        *old_settings = Some(new_settings);
555
556        if let Some((project_id, downstream_client)) = downstream_client {
557            downstream_client
558                .send(proto::ExternalAgentsUpdated {
559                    project_id: *project_id,
560                    names: self
561                        .external_agents
562                        .keys()
563                        .map(|name| name.to_string())
564                        .collect(),
565                })
566                .log_err();
567        }
568        cx.emit(AgentServersUpdated);
569    }
570
571    pub fn node_runtime(&self) -> Option<NodeRuntime> {
572        match &self.state {
573            AgentServerStoreState::Local { node_runtime, .. } => Some(node_runtime.clone()),
574            _ => None,
575        }
576    }
577
578    pub fn local(
579        node_runtime: NodeRuntime,
580        fs: Arc<dyn Fs>,
581        project_environment: Entity<ProjectEnvironment>,
582        http_client: Arc<dyn HttpClient>,
583        cx: &mut Context<Self>,
584    ) -> Self {
585        let mut subscriptions = vec![cx.observe_global::<SettingsStore>(|this, cx| {
586            this.agent_servers_settings_changed(cx);
587        })];
588        if let Some(registry_store) = AgentRegistryStore::try_global(cx) {
589            subscriptions.push(cx.observe(&registry_store, |this, _, cx| {
590                this.reregister_agents(cx);
591            }));
592        }
593        let mut this = Self {
594            state: AgentServerStoreState::Local {
595                node_runtime,
596                fs,
597                project_environment,
598                http_client,
599                downstream_client: None,
600                settings: None,
601                _subscriptions: subscriptions,
602            },
603            external_agents: HashMap::default(),
604        };
605        this.agent_servers_settings_changed(cx);
606        this
607    }
608
609    pub(crate) fn remote(
610        project_id: u64,
611        upstream_client: Entity<RemoteClient>,
612        worktree_store: Entity<WorktreeStore>,
613    ) -> Self {
614        Self {
615            state: AgentServerStoreState::Remote {
616                project_id,
617                upstream_client,
618                worktree_store,
619            },
620            external_agents: HashMap::default(),
621        }
622    }
623
624    pub fn collab() -> Self {
625        Self {
626            state: AgentServerStoreState::Collab,
627            external_agents: HashMap::default(),
628        }
629    }
630
631    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
632        match &mut self.state {
633            AgentServerStoreState::Local {
634                downstream_client, ..
635            } => {
636                *downstream_client = Some((project_id, client.clone()));
637                // Send the current list of external agents downstream, but only after a delay,
638                // to avoid having the message arrive before the downstream project's agent server store
639                // sets up its handlers.
640                cx.spawn(async move |this, cx| {
641                    cx.background_executor().timer(Duration::from_secs(1)).await;
642                    let names = this.update(cx, |this, _| {
643                        this.external_agents()
644                            .map(|name| name.to_string())
645                            .collect()
646                    })?;
647                    client
648                        .send(proto::ExternalAgentsUpdated { project_id, names })
649                        .log_err();
650                    anyhow::Ok(())
651                })
652                .detach();
653            }
654            AgentServerStoreState::Remote { .. } => {
655                debug_panic!(
656                    "external agents over collab not implemented, remote project should not be shared"
657                );
658            }
659            AgentServerStoreState::Collab => {
660                debug_panic!("external agents over collab not implemented, should not be shared");
661            }
662        }
663    }
664
665    pub fn get_external_agent(
666        &mut self,
667        name: &AgentId,
668    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
669        self.external_agents
670            .get_mut(name)
671            .map(|entry| entry.server.as_mut())
672    }
673
674    pub fn no_browser(&self) -> bool {
675        match &self.state {
676            AgentServerStoreState::Local {
677                downstream_client, ..
678            } => downstream_client
679                .as_ref()
680                .is_some_and(|(_, client)| !client.has_wsl_interop()),
681            _ => false,
682        }
683    }
684
685    pub fn has_external_agents(&self) -> bool {
686        !self.external_agents.is_empty()
687    }
688
689    pub fn external_agents(&self) -> impl Iterator<Item = &AgentId> {
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 = 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 no_browser = this.no_browser();
708                let agent = this
709                    .external_agents
710                    .get_mut(&*envelope.payload.name)
711                    .map(|entry| entry.server.as_mut())
712                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
713                let new_version_available_tx =
714                    downstream_client
715                        .clone()
716                        .map(|(project_id, downstream_client)| {
717                            let (new_version_available_tx, mut new_version_available_rx) =
718                                watch::channel(None);
719                            cx.spawn({
720                                let name = envelope.payload.name.clone();
721                                async move |_, _| {
722                                    if let Some(version) =
723                                        new_version_available_rx.recv().await.ok().flatten()
724                                    {
725                                        downstream_client.send(
726                                            proto::NewExternalAgentVersionAvailable {
727                                                project_id,
728                                                name: name.clone(),
729                                                version,
730                                            },
731                                        )?;
732                                    }
733                                    anyhow::Ok(())
734                                }
735                            })
736                            .detach_and_log_err(cx);
737                            new_version_available_tx
738                        });
739                let loading_status_tx =
740                    downstream_client
741                        .clone()
742                        .map(|(project_id, downstream_client)| {
743                            let (loading_status_tx, mut loading_status_rx) = watch::channel(None);
744                            cx.spawn({
745                                let name = envelope.payload.name.clone();
746                                async move |_, _| {
747                                    while let Ok(status) = loading_status_rx.recv().await {
748                                        downstream_client.send(
749                                            proto::ExternalAgentLoadingStatusUpdated {
750                                                project_id,
751                                                name: name.clone(),
752                                                status,
753                                            },
754                                        )?;
755                                    }
756                                    anyhow::Ok(())
757                                }
758                            })
759                            .detach_and_log_err(cx);
760                            loading_status_tx
761                        });
762                let mut extra_env = HashMap::default();
763                if no_browser {
764                    extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
765                }
766                if let Some(new_version_available_tx) = new_version_available_tx {
767                    agent.set_new_version_available_tx(new_version_available_tx);
768                }
769                if let Some(loading_status_tx) = loading_status_tx {
770                    agent.set_loading_status_tx(loading_status_tx);
771                }
772                anyhow::Ok(agent.get_command(vec![], extra_env, &mut cx.to_async()))
773            })?
774            .await?;
775        Ok(proto::AgentServerCommand {
776            path: command.path.to_string_lossy().into_owned(),
777            args: command.args,
778            env: command
779                .env
780                .map(|env| env.into_iter().collect())
781                .unwrap_or_default(),
782            root_dir: envelope
783                .payload
784                .root_dir
785                .unwrap_or_else(|| paths::home_dir().to_string_lossy().to_string()),
786            login: None,
787        })
788    }
789
790    async fn handle_external_agents_updated(
791        this: Entity<Self>,
792        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
793        mut cx: AsyncApp,
794    ) -> Result<()> {
795        this.update(&mut cx, |this, cx| {
796            let AgentServerStoreState::Remote {
797                project_id,
798                upstream_client,
799                worktree_store,
800            } = &this.state
801            else {
802                debug_panic!(
803                    "handle_external_agents_updated should not be called for a non-remote project"
804                );
805                bail!("unexpected ExternalAgentsUpdated message")
806            };
807
808            let mut previous_entries = std::mem::take(&mut this.external_agents);
809            let mut new_version_available_txs = HashMap::default();
810            let mut loading_status_txs = HashMap::default();
811            let mut metadata = HashMap::default();
812
813            for (name, mut entry) in previous_entries.drain() {
814                if let Some(tx) = entry.server.take_new_version_available_tx() {
815                    new_version_available_txs.insert(name.clone(), tx);
816                }
817                if let Some(tx) = entry.server.take_loading_status_tx() {
818                    loading_status_txs.insert(name.clone(), tx);
819                }
820
821                metadata.insert(name, (entry.icon, entry.display_name, entry.source));
822            }
823
824            this.external_agents = envelope
825                .payload
826                .names
827                .into_iter()
828                .map(|name| {
829                    let agent_id = AgentId(name.into());
830                    let (icon, display_name, source) = metadata
831                        .remove(&agent_id)
832                        .or_else(|| {
833                            AgentRegistryStore::try_global(cx)
834                                .and_then(|store| store.read(cx).agent(&agent_id))
835                                .map(|s| {
836                                    (
837                                        s.icon_path().cloned(),
838                                        Some(s.name().clone()),
839                                        ExternalAgentSource::Registry,
840                                    )
841                                })
842                        })
843                        .unwrap_or((None, None, ExternalAgentSource::default()));
844                    let agent = RemoteExternalAgentServer {
845                        project_id: *project_id,
846                        upstream_client: upstream_client.clone(),
847                        worktree_store: worktree_store.clone(),
848                        name: agent_id.clone(),
849                        new_version_available_tx: new_version_available_txs.remove(&agent_id),
850                        loading_status_tx: loading_status_txs.remove(&agent_id),
851                    };
852                    (
853                        agent_id,
854                        ExternalAgentEntry::new(
855                            Box::new(agent) as Box<dyn ExternalAgentServer>,
856                            source,
857                            icon,
858                            display_name,
859                        ),
860                    )
861                })
862                .collect();
863            cx.emit(AgentServersUpdated);
864            Ok(())
865        })
866    }
867
868    async fn handle_loading_status_updated(
869        this: Entity<Self>,
870        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
871        mut cx: AsyncApp,
872    ) -> Result<()> {
873        this.update(&mut cx, |this, _| {
874            if let Some(entry) = this.external_agents.get_mut(&*envelope.payload.name)
875                && let Some(mut tx) = entry.server.take_loading_status_tx()
876            {
877                tx.send(envelope.payload.status).ok();
878                entry.server.set_loading_status_tx(tx);
879            }
880        });
881        Ok(())
882    }
883
884    async fn handle_new_version_available(
885        this: Entity<Self>,
886        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
887        mut cx: AsyncApp,
888    ) -> Result<()> {
889        this.update(&mut cx, |this, _| {
890            if let Some(entry) = this.external_agents.get_mut(&*envelope.payload.name)
891                && let Some(mut tx) = entry.server.take_new_version_available_tx()
892            {
893                tx.send(Some(envelope.payload.version)).ok();
894                entry.server.set_new_version_available_tx(tx);
895            }
896        });
897        Ok(())
898    }
899}
900
901struct RemoteExternalAgentServer {
902    project_id: u64,
903    upstream_client: Entity<RemoteClient>,
904    worktree_store: Entity<WorktreeStore>,
905    name: AgentId,
906    new_version_available_tx: Option<watch::Sender<Option<String>>>,
907    loading_status_tx: Option<watch::Sender<Option<String>>>,
908}
909
910impl ExternalAgentServer for RemoteExternalAgentServer {
911    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
912        self.new_version_available_tx.take()
913    }
914
915    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
916        self.new_version_available_tx = Some(tx);
917    }
918
919    fn take_loading_status_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
920        self.loading_status_tx.take()
921    }
922
923    fn set_loading_status_tx(&mut self, tx: watch::Sender<Option<String>>) {
924        self.loading_status_tx = Some(tx);
925    }
926
927    fn get_command(
928        &mut self,
929        extra_args: Vec<String>,
930        extra_env: HashMap<String, String>,
931        cx: &mut AsyncApp,
932    ) -> Task<Result<AgentServerCommand>> {
933        let project_id = self.project_id;
934        let name = self.name.to_string();
935        let upstream_client = self.upstream_client.downgrade();
936        let worktree_store = self.worktree_store.clone();
937        cx.spawn(async move |cx| {
938            let root_dir = worktree_store.read_with(cx, |worktree_store, cx| {
939                crate::Project::default_visible_worktree_paths(worktree_store, cx)
940                    .into_iter()
941                    .next()
942                    .map(|path| path.display().to_string())
943            });
944
945            let mut response = upstream_client
946                .update(cx, |upstream_client, _| {
947                    upstream_client
948                        .proto_client()
949                        .request(proto::GetAgentServerCommand {
950                            project_id,
951                            name,
952                            root_dir,
953                        })
954                })?
955                .await?;
956            response.args.extend(extra_args);
957            response.env.extend(extra_env);
958
959            Ok(AgentServerCommand {
960                path: response.path.into(),
961                args: response.args,
962                env: Some(response.env.into_iter().collect()),
963            })
964        })
965    }
966
967    fn as_any(&self) -> &dyn Any {
968        self
969    }
970
971    fn as_any_mut(&mut self) -> &mut dyn Any {
972        self
973    }
974}
975
976#[derive(Debug, PartialEq, Eq)]
977enum RegistryArchiveKind {
978    Archive(AssetKind),
979    /// The archive URL points directly at an executable, per the ACP registry
980    /// schema: "URL to download archive (.zip, .tar.gz, .tgz, .tar.bz2, .tbz2,
981    /// or raw binary)".
982    RawBinary {
983        file_name: String,
984    },
985}
986
987fn registry_archive_kind_for_url(archive_url: &str) -> Result<RegistryArchiveKind> {
988    const UNSUPPORTED_SUFFIXES: &[&str] = &[
989        // Installer formats explicitly rejected by the registry schema.
990        ".dmg",
991        ".pkg",
992        ".deb",
993        ".rpm",
994        ".msi",
995        ".appimage",
996        // Archive formats we cannot extract; treating them as raw binaries
997        // would produce a broken install.
998        ".tar.xz",
999        ".txz",
1000        ".tar",
1001        ".gz",
1002        ".bz2",
1003        ".xz",
1004        ".7z",
1005    ];
1006
1007    let archive_path = Url::parse(archive_url)
1008        .ok()
1009        .map(|url| url.path().to_string())
1010        .unwrap_or_else(|| archive_url.to_string());
1011    let lowercase_path = archive_path.to_lowercase();
1012
1013    if lowercase_path.ends_with(".zip") {
1014        Ok(RegistryArchiveKind::Archive(AssetKind::Zip))
1015    } else if lowercase_path.ends_with(".tar.gz") || lowercase_path.ends_with(".tgz") {
1016        Ok(RegistryArchiveKind::Archive(AssetKind::TarGz))
1017    } else if lowercase_path.ends_with(".tar.bz2") || lowercase_path.ends_with(".tbz2") {
1018        Ok(RegistryArchiveKind::Archive(AssetKind::TarBz2))
1019    } else if let Some(suffix) = UNSUPPORTED_SUFFIXES
1020        .iter()
1021        .find(|suffix| lowercase_path.ends_with(*suffix))
1022    {
1023        bail!("unsupported archive type {suffix} in URL: {archive_url}");
1024    } else {
1025        let file_name = raw_binary_file_name(&archive_path)
1026            .with_context(|| format!("determining binary file name from URL: {archive_url}"))?;
1027        Ok(RegistryArchiveKind::RawBinary { file_name })
1028    }
1029}
1030
1031fn raw_binary_file_name(archive_path: &str) -> Result<String> {
1032    let last_segment = archive_path
1033        .rsplit('/')
1034        .next()
1035        .filter(|segment| !segment.is_empty())
1036        .context("URL has no file name")?;
1037    let file_name = percent_decode_str(last_segment)
1038        .decode_utf8()
1039        .context("file name is not valid UTF-8")?
1040        .into_owned();
1041    anyhow::ensure!(
1042        !file_name.is_empty()
1043            && file_name != "."
1044            && file_name != ".."
1045            && !file_name.contains(['/', '\\'])
1046            && !file_name.contains('\0'),
1047        "invalid binary file name: {file_name}"
1048    );
1049    Ok(file_name)
1050}
1051
1052struct GithubReleaseArchive {
1053    repo_name_with_owner: String,
1054    tag: String,
1055    asset_name: String,
1056}
1057
1058fn github_release_archive_from_url(archive_url: &str) -> Option<GithubReleaseArchive> {
1059    fn decode_path_segment(segment: &str) -> Option<String> {
1060        percent_decode_str(segment)
1061            .decode_utf8()
1062            .ok()
1063            .map(|segment| segment.into_owned())
1064    }
1065
1066    let url = Url::parse(archive_url).ok()?;
1067    if url.scheme() != "https" || url.host_str()? != "github.com" {
1068        return None;
1069    }
1070
1071    let segments = url.path_segments()?.collect::<Vec<_>>();
1072    if segments.len() < 6 || segments[2] != "releases" || segments[3] != "download" {
1073        return None;
1074    }
1075
1076    Some(GithubReleaseArchive {
1077        repo_name_with_owner: format!("{}/{}", segments[0], segments[1]),
1078        tag: decode_path_segment(segments[4])?,
1079        asset_name: segments[5..]
1080            .iter()
1081            .map(|segment| decode_path_segment(segment))
1082            .collect::<Option<Vec<_>>>()?
1083            .join("/"),
1084    })
1085}
1086
1087fn sanitize_path_component(input: &str) -> String {
1088    let sanitized = input
1089        .chars()
1090        .map(|character| match character {
1091            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
1092            _ => '-',
1093        })
1094        .collect::<String>();
1095
1096    if sanitized.is_empty() {
1097        "unknown".to_string()
1098    } else {
1099        sanitized
1100    }
1101}
1102
1103fn versioned_archive_cache_dir(
1104    base_dir: &Path,
1105    version: Option<&str>,
1106    archive_url: &str,
1107    sha256: Option<&str>,
1108) -> PathBuf {
1109    let version = version.unwrap_or_default();
1110    let sanitized_version = sanitize_path_component(version);
1111
1112    let mut version_hasher = Sha256::new();
1113    version_hasher.update(version.as_bytes());
1114    let version_hash = format!("{:x}", version_hasher.finalize());
1115
1116    let mut archive_hasher = Sha256::new();
1117    archive_hasher.update(archive_url.as_bytes());
1118    if let Some(sha256) = sha256 {
1119        archive_hasher.update(b"\0sha256:");
1120        archive_hasher.update(sha256.to_ascii_lowercase().as_bytes());
1121    }
1122    let archive_hash = format!("{:x}", archive_hasher.finalize());
1123
1124    base_dir.join(format!(
1125        "v_{sanitized_version}_{}_{}",
1126        &version_hash[..16],
1127        &archive_hash[..16],
1128    ))
1129}
1130
1131// The `v_` prefix here must stay in sync with `versioned_archive_cache_dir`,
1132// so we only ever remove directories that we created ourselves.
1133const VERSIONED_ARCHIVE_CACHE_DIR_PREFIX: &str = "v_";
1134
1135async fn remove_stale_versioned_archive_cache_dirs(
1136    fs: Arc<dyn Fs>,
1137    base_dir: &Path,
1138    current_version_dir: &Path,
1139) -> Result<()> {
1140    let Some(current_dir_name) = current_version_dir.file_name() else {
1141        return Ok(());
1142    };
1143
1144    let current_mtime = fs
1145        .metadata(current_version_dir)
1146        .await
1147        .with_context(|| format!("reading metadata for {current_version_dir:?}"))?
1148        .with_context(|| format!("missing metadata for {current_version_dir:?}"))?
1149        .mtime;
1150
1151    let mut entries = fs
1152        .read_dir(base_dir)
1153        .await
1154        .with_context(|| format!("reading archive cache directory {base_dir:?}"))?;
1155
1156    while let Some(entry) = entries.next().await {
1157        let entry = entry.with_context(|| format!("reading entry in {base_dir:?}"))?;
1158        let Some(entry_name) = entry.file_name() else {
1159            continue;
1160        };
1161
1162        if entry_name == current_dir_name
1163            || !entry_name
1164                .to_string_lossy()
1165                .starts_with(VERSIONED_ARCHIVE_CACHE_DIR_PREFIX)
1166        {
1167            continue;
1168        }
1169
1170        let Some(entry_metadata) = fs.metadata(&entry).await.log_err().flatten() else {
1171            continue;
1172        };
1173        if !entry_metadata.is_dir {
1174            continue;
1175        }
1176        // Only remove directories that predate the current version's directory.
1177        // This avoids racing with a concurrent extraction of a different version
1178        // that finished after we cached the current version's mtime.
1179        if !current_mtime.bad_is_greater_than(entry_metadata.mtime) {
1180            continue;
1181        }
1182
1183        fs.remove_dir(
1184            &entry,
1185            RemoveOptions {
1186                recursive: true,
1187                ignore_if_not_exists: true,
1188            },
1189        )
1190        .await
1191        .with_context(|| format!("removing stale archive cache directory {entry:?}"))?;
1192    }
1193
1194    Ok(())
1195}
1196
1197/// The `<os>-<arch>` key the registry document's `targets` map is keyed by.
1198///
1199/// `None` on a platform the build does not name, which is the same condition
1200/// `get_command` bails on. The front door then shows no installed tree rather
1201/// than guessing at one.
1202fn current_registry_platform_key() -> Option<String> {
1203    let os = if cfg!(target_os = "macos") {
1204        "darwin"
1205    } else if cfg!(target_os = "linux") {
1206        "linux"
1207    } else if cfg!(target_os = "windows") {
1208        "windows"
1209    } else {
1210        return None;
1211    };
1212    let arch = if cfg!(target_arch = "aarch64") {
1213        "aarch64"
1214    } else if cfg!(target_arch = "x86_64") {
1215        "x86_64"
1216    } else {
1217        return None;
1218    };
1219    Some(format!("{os}-{arch}"))
1220}
1221
1222struct LocalRegistryArchiveAgent {
1223    fs: Arc<dyn Fs>,
1224    http_client: Arc<dyn HttpClient>,
1225    node_runtime: NodeRuntime,
1226    project_environment: Entity<ProjectEnvironment>,
1227    installation_dir: PathBuf,
1228    /// The registry id, kept so maintenance receipts name the harness the way
1229    /// the store and the settings file name it. `installation_dir` already
1230    /// encodes it, but only after `sanitize_path_component`, and a receipt that
1231    /// named a sanitized path component would not join up with anything.
1232    registry_id: Arc<str>,
1233    version: SharedString,
1234    targets: HashMap<String, RegistryTargetConfig>,
1235    env: HashMap<String, String>,
1236    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1237    loading_status_tx: Option<watch::Sender<Option<String>>>,
1238}
1239
1240impl LocalRegistryArchiveAgent {
1241    /// The tree `get_command` measures, derived the same way it derives it.
1242    ///
1243    /// One function would be better than two call sites agreeing; the launch
1244    /// path computes this inside an async block that also resolves the
1245    /// environment, so the shared thing here is
1246    /// [`versioned_archive_cache_dir`] and the platform key, not the whole
1247    /// expression. `the_front_door_measures_the_tree_the_launch_path_gates` in
1248    /// `crates/omega_deltas` fails if the two stop agreeing.
1249    fn installed_version_dir(&self) -> Option<PathBuf> {
1250        let target_config = self.targets.get(&current_registry_platform_key()?)?;
1251        Some(versioned_archive_cache_dir(
1252            &self.installation_dir,
1253            Some(self.version.as_ref()),
1254            &target_config.archive,
1255            target_config.sha256.as_deref(),
1256        ))
1257    }
1258}
1259
1260impl ExternalAgentServer for LocalRegistryArchiveAgent {
1261    fn version(&self) -> Option<&SharedString> {
1262        Some(&self.version)
1263    }
1264
1265    fn maintenance_target(&self) -> Option<HarnessMaintenanceTarget> {
1266        Some(HarnessMaintenanceTarget {
1267            harness_id: self.registry_id.clone(),
1268            version: self.version.clone(),
1269            distribution: HarnessDistribution::OwnedTree,
1270            installed_dir: self.installed_version_dir(),
1271        })
1272    }
1273
1274    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1275        self.new_version_available_tx.take()
1276    }
1277
1278    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
1279        self.new_version_available_tx = Some(tx);
1280    }
1281
1282    fn take_loading_status_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1283        self.loading_status_tx.take()
1284    }
1285
1286    fn set_loading_status_tx(&mut self, tx: watch::Sender<Option<String>>) {
1287        self.loading_status_tx = Some(tx);
1288    }
1289
1290    fn get_command(
1291        &mut self,
1292        extra_args: Vec<String>,
1293        extra_env: HashMap<String, String>,
1294        cx: &mut AsyncApp,
1295    ) -> Task<Result<AgentServerCommand>> {
1296        let fs = self.fs.clone();
1297        let http_client = self.http_client.clone();
1298        let node_runtime = self.node_runtime.clone();
1299        let project_environment = self.project_environment.downgrade();
1300        let installation_dir = self.installation_dir.clone();
1301        let registry_id = self.registry_id.clone();
1302        let targets = self.targets.clone();
1303        let settings_env = self.env.clone();
1304        let version = self.version.clone();
1305        let loading_status_tx = self.loading_status_tx.take();
1306
1307        cx.spawn(async move |cx| {
1308            let mut env = project_environment
1309                .update(cx, |project_environment, cx| {
1310                    project_environment.default_environment(cx)
1311                })?
1312                .await
1313                .unwrap_or_default();
1314
1315            let dir = installation_dir;
1316            fs.create_dir(&dir).await?;
1317
1318            let os = if cfg!(target_os = "macos") {
1319                "darwin"
1320            } else if cfg!(target_os = "linux") {
1321                "linux"
1322            } else if cfg!(target_os = "windows") {
1323                "windows"
1324            } else {
1325                anyhow::bail!("unsupported OS");
1326            };
1327
1328            let arch = if cfg!(target_arch = "aarch64") {
1329                "aarch64"
1330            } else if cfg!(target_arch = "x86_64") {
1331                "x86_64"
1332            } else {
1333                anyhow::bail!("unsupported architecture");
1334            };
1335
1336            let platform_key = format!("{}-{}", os, arch);
1337            let target_config = targets.get(&platform_key).with_context(|| {
1338                format!(
1339                    "no target specified for platform '{}'. Available platforms: {}",
1340                    platform_key,
1341                    targets
1342                        .keys()
1343                        .map(|k| k.as_str())
1344                        .collect::<Vec<_>>()
1345                        .join(", ")
1346                )
1347            })?;
1348
1349            env.extend(target_config.env.clone());
1350            env.extend(extra_env);
1351            env.extend(settings_env);
1352
1353            let archive_url = &target_config.archive;
1354            let version_dir = versioned_archive_cache_dir(
1355                &dir,
1356                Some(version.as_ref()),
1357                archive_url,
1358                target_config.sha256.as_deref(),
1359            );
1360
1361            let already_installed = fs.is_dir(&version_dir).await;
1362            if !already_installed {
1363                // omega#81. The pin is consulted before any bytes move, so a
1364                // frozen harness does not silently download the release the
1365                // registry now advertises. This is a prefilter: the authority
1366                // is the measured check below, which runs whether or not
1367                // anything was downloaded.
1368                authorize_version_fetch(
1369                    fs.clone(),
1370                    registry_id.as_ref(),
1371                    version.as_ref(),
1372                    harness_now_ms(),
1373                )
1374                .await?;
1375
1376                let mut loading_status_tx = loading_status_tx;
1377                if let Some(tx) = loading_status_tx.as_mut() {
1378                    tx.send(Some(format!("Installing {}…", version.as_ref())))
1379                        .ok();
1380                }
1381
1382                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
1383                    Some(provided_sha.clone())
1384                } else if let Some(github_archive) = github_release_archive_from_url(archive_url) {
1385                    if let Ok(release) = ::http_client::github::get_release_by_tag_name(
1386                        &github_archive.repo_name_with_owner,
1387                        &github_archive.tag,
1388                        http_client.clone(),
1389                    )
1390                    .await
1391                    {
1392                        if let Some(asset) = release
1393                            .assets
1394                            .iter()
1395                            .find(|a| a.name == github_archive.asset_name)
1396                        {
1397                            asset.digest.as_ref().and_then(|d| {
1398                                d.strip_prefix("sha256:")
1399                                    .map(|s| s.to_string())
1400                                    .or_else(|| Some(d.clone()))
1401                            })
1402                        } else {
1403                            None
1404                        }
1405                    } else {
1406                        None
1407                    }
1408                } else {
1409                    None
1410                };
1411
1412                match registry_archive_kind_for_url(archive_url)? {
1413                    RegistryArchiveKind::Archive(asset_kind) => {
1414                        ::http_client::github_download::download_server_binary(
1415                            &*http_client,
1416                            archive_url,
1417                            sha256.as_deref(),
1418                            &version_dir,
1419                            asset_kind,
1420                        )
1421                        .await?;
1422                    }
1423                    RegistryArchiveKind::RawBinary { file_name } => {
1424                        ::http_client::github_download::download_server_raw_binary(
1425                            &*http_client,
1426                            archive_url,
1427                            sha256.as_deref(),
1428                            &version_dir,
1429                            &file_name,
1430                        )
1431                        .await?;
1432                    }
1433                }
1434            }
1435
1436            let cmd = &target_config.cmd;
1437
1438            let cmd_path = if cmd == "node" {
1439                node_runtime.binary_path().await?
1440            } else {
1441                if cmd.contains("..") {
1442                    anyhow::bail!("command path cannot contain '..': {}", cmd);
1443                }
1444
1445                if cmd.starts_with("./") || cmd.starts_with(".\\") {
1446                    let cmd_path = version_dir.join(&cmd[2..]);
1447                    anyhow::ensure!(
1448                        fs.is_file(&cmd_path).await,
1449                        "Missing command {} after extraction",
1450                        cmd_path.to_string_lossy()
1451                    );
1452                    cmd_path
1453                } else {
1454                    anyhow::bail!("command must be relative (start with './'): {}", cmd);
1455                }
1456            };
1457
1458            cx.background_spawn({
1459                let fs = fs.clone();
1460                let dir = dir.clone();
1461                let version_dir = version_dir.clone();
1462                async move {
1463                    remove_stale_versioned_archive_cache_dirs(fs, &dir, &version_dir)
1464                        .await
1465                        .log_err();
1466                }
1467            })
1468            .detach();
1469
1470            // omega#81, the enforcement point. The installed tree is hashed
1471            // here, on every launch, and the command is only handed back if the
1472            // owner's pins admit that hash. Measuring at install time and
1473            // trusting it afterwards would leave the window this closes: the
1474            // bytes can be replaced after the install receipt is written, and
1475            // this harness is about to run with the thread's tool permissions.
1476            //
1477            // The receipt is written by this call, permitted or refused.
1478            authorize_installed_harness(
1479                fs.clone(),
1480                registry_id.as_ref(),
1481                version.as_ref(),
1482                &version_dir,
1483                if already_installed {
1484                    MaintenanceAction::Verify
1485                } else {
1486                    MaintenanceAction::Install
1487                },
1488                harness_now_ms(),
1489            )
1490            .await?;
1491
1492            let mut args = target_config.args.clone();
1493            args.extend(extra_args);
1494
1495            let command = AgentServerCommand {
1496                path: cmd_path,
1497                args,
1498                env: Some(env),
1499            };
1500
1501            Ok(command)
1502        })
1503    }
1504
1505    fn as_any(&self) -> &dyn Any {
1506        self
1507    }
1508
1509    fn as_any_mut(&mut self) -> &mut dyn Any {
1510        self
1511    }
1512}
1513
1514struct LocalRegistryNpxAgent {
1515    fs: Arc<dyn Fs>,
1516    node_runtime: NodeRuntime,
1517    project_environment: Entity<ProjectEnvironment>,
1518    registry_id: Arc<str>,
1519    version: SharedString,
1520    package: SharedString,
1521    args: Vec<String>,
1522    distribution_env: HashMap<String, String>,
1523    settings_env: HashMap<String, String>,
1524    new_version_available_tx: Option<watch::Sender<Option<String>>>,
1525}
1526
1527impl ExternalAgentServer for LocalRegistryNpxAgent {
1528    fn version(&self) -> Option<&SharedString> {
1529        Some(&self.version)
1530    }
1531
1532    fn maintenance_target(&self) -> Option<HarnessMaintenanceTarget> {
1533        Some(HarnessMaintenanceTarget {
1534            harness_id: self.registry_id.clone(),
1535            version: self.version.clone(),
1536            distribution: HarnessDistribution::PackageManager {
1537                resolver: NPX_RESOLVER.to_string(),
1538            },
1539            // There is no tree Omega owns. `npm exec` resolves the package into
1540            // its own cache at launch, so there is nothing here to measure and
1541            // no directory to name — which is the fact the front door shows,
1542            // rather than an absence it leaves the owner to infer.
1543            installed_dir: None,
1544        })
1545    }
1546
1547    fn take_new_version_available_tx(&mut self) -> Option<watch::Sender<Option<String>>> {
1548        self.new_version_available_tx.take()
1549    }
1550
1551    fn set_new_version_available_tx(&mut self, tx: watch::Sender<Option<String>>) {
1552        self.new_version_available_tx = Some(tx);
1553    }
1554
1555    fn get_command(
1556        &mut self,
1557        extra_args: Vec<String>,
1558        extra_env: HashMap<String, String>,
1559        cx: &mut AsyncApp,
1560    ) -> Task<Result<AgentServerCommand>> {
1561        let fs = self.fs.clone();
1562        let node_runtime = self.node_runtime.clone();
1563        let project_environment = self.project_environment.downgrade();
1564        let registry_id = self.registry_id.clone();
1565        let package = bounded_npm_package_spec(&self.package);
1566        let args = self.args.clone();
1567        let distribution_env = self.distribution_env.clone();
1568        let settings_env = self.settings_env.clone();
1569
1570        cx.spawn(async move |cx| {
1571            // omega#81. The npx path has no tree to hash, so the measured gate
1572            // cannot run here. What can run is the pin: a harness the owner
1573            // froze does not launch through a resolver that would pick its own
1574            // version. Before this, pinning an npx harness did nothing at all.
1575            authorize_package_manager_launch(
1576                fs.clone(),
1577                registry_id.as_ref(),
1578                NPX_RESOLVER,
1579                harness_now_ms(),
1580            )
1581            .await?;
1582
1583            let mut env = project_environment
1584                .update(cx, |project_environment, cx| {
1585                    project_environment.default_environment(cx)
1586                })?
1587                .await
1588                .unwrap_or_default();
1589
1590            let prefix_dir = paths::external_agents_dir()
1591                .join("registry")
1592                .join("npx")
1593                .join(sanitize_path_component(&registry_id));
1594            fs.create_dir(&prefix_dir).await?;
1595
1596            let mut exec_args = vec!["--yes".to_string(), "--".to_string(), package];
1597            exec_args.extend(args);
1598
1599            let npm_command = node_runtime
1600                .npm_command(
1601                    Some(&prefix_dir),
1602                    "exec",
1603                    &exec_args.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
1604                )
1605                .await?;
1606
1607            env.extend(npm_command.env);
1608            env.extend(distribution_env);
1609            env.extend(extra_env);
1610            env.extend(settings_env);
1611
1612            let mut args = npm_command.args;
1613            args.extend(extra_args);
1614
1615            let command = AgentServerCommand {
1616                path: npm_command.path,
1617                args,
1618                env: Some(env),
1619            };
1620
1621            Ok(command)
1622        })
1623    }
1624
1625    fn as_any(&self) -> &dyn Any {
1626        self
1627    }
1628
1629    fn as_any_mut(&mut self) -> &mut dyn Any {
1630        self
1631    }
1632}
1633
1634/// People are using min-release-age more frequently. Which means a fresh registry will likely have
1635/// new package versions than the user can install.
1636/// We set the version to now be a ceiling and not an exact pin instead. This allows npm to resolve
1637/// the latest version it can find that satisfies the constraint. npm seems to check regularly enough
1638/// that new versions are available. This does have a few downsides:
1639/// - The user might have an older cached version of the package that satisfies the constraint, until
1640///   npm checks for updates again.
1641/// - The registry args/env may not be valid for the resolved version.
1642///
1643/// This is a best-effort attempt to install a version that works without overriding the user's
1644/// security settings, as the args don't change often. The registry will need to support this better
1645/// at some point, but until then, this is a best-effort workaround that hopefully solves the issue
1646/// for most users.
1647///
1648/// We use npm's hyphen-range syntax (`0.0.0 - <version>`, equivalent to `<=<version>`) instead of
1649/// the more compact `<=<version>` form because on Windows, `npm` is `npm.cmd` (a batch file run by
1650/// cmd.exe), and the quotes our shell builder emits are PowerShell string-literal syntax that PS
1651/// strips during parsing. PS only re-adds CRT-style transport quotes around native command args
1652/// containing whitespace, so `package@<=0.25.3` reaches cmd.exe bare and the unquoted `<` is
1653/// interpreted as input redirection. See zed-industries/zed#55921.
1654fn bounded_npm_package_spec(package_spec: &str) -> String {
1655    let Some((package_name, version)) = package_spec.rsplit_once('@') else {
1656        return package_spec.to_string();
1657    };
1658    if package_name.is_empty() || Version::parse(version).is_err() {
1659        return package_spec.to_string();
1660    }
1661
1662    format!("{package_name}@0.0.0 - {version}")
1663}
1664
1665struct LocalCustomAgent {
1666    project_environment: Entity<ProjectEnvironment>,
1667    command: AgentServerCommand,
1668}
1669
1670impl ExternalAgentServer for LocalCustomAgent {
1671    fn get_command(
1672        &mut self,
1673        extra_args: Vec<String>,
1674        extra_env: HashMap<String, String>,
1675        cx: &mut AsyncApp,
1676    ) -> Task<Result<AgentServerCommand>> {
1677        let mut command = self.command.clone();
1678        let project_environment = self.project_environment.downgrade();
1679        cx.spawn(async move |cx| {
1680            let mut env = project_environment
1681                .update(cx, |project_environment, cx| {
1682                    project_environment.default_environment(cx)
1683                })?
1684                .await
1685                .unwrap_or_default();
1686            env.extend(command.env.unwrap_or_default());
1687            env.extend(extra_env);
1688            command.env = Some(env);
1689            command.args.extend(extra_args);
1690            Ok(command)
1691        })
1692    }
1693
1694    fn as_any(&self) -> &dyn Any {
1695        self
1696    }
1697
1698    fn as_any_mut(&mut self) -> &mut dyn Any {
1699        self
1700    }
1701}
1702
1703#[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)]
1704pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
1705
1706impl std::ops::Deref for AllAgentServersSettings {
1707    type Target = HashMap<String, CustomAgentServerSettings>;
1708
1709    fn deref(&self) -> &Self::Target {
1710        &self.0
1711    }
1712}
1713
1714impl std::ops::DerefMut for AllAgentServersSettings {
1715    fn deref_mut(&mut self) -> &mut Self::Target {
1716        &mut self.0
1717    }
1718}
1719
1720impl AllAgentServersSettings {
1721    pub fn has_registry_agents(&self) -> bool {
1722        self.values()
1723            .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. }))
1724    }
1725}
1726
1727#[derive(Clone, JsonSchema, Debug, PartialEq)]
1728pub enum CustomAgentServerSettings {
1729    Custom {
1730        command: AgentServerCommand,
1731        /// The default mode to use for this agent.
1732        ///
1733        /// Note: Not only all agents support modes.
1734        ///
1735        /// Default: None
1736        default_mode: Option<String>,
1737        /// Default values for session config options.
1738        ///
1739        /// This is a map from config option ID to the default value for that option.
1740        ///
1741        /// Default: {}
1742        default_config_options: HashMap<String, AgentConfigOptionValue>,
1743        /// Favorited values for session config options.
1744        ///
1745        /// This is a map from config option ID to a list of favorited value IDs.
1746        ///
1747        /// Default: {}
1748        favorite_config_option_values: HashMap<String, Vec<String>>,
1749    },
1750    Registry {
1751        /// Additional environment variables to pass to the agent.
1752        ///
1753        /// Default: {}
1754        env: HashMap<String, String>,
1755        /// The default mode to use for this agent.
1756        ///
1757        /// Note: Not only all agents support modes.
1758        ///
1759        /// Default: None
1760        default_mode: Option<String>,
1761        /// Default values for session config options.
1762        ///
1763        /// This is a map from config option ID to the default value for that option.
1764        ///
1765        /// Default: {}
1766        default_config_options: HashMap<String, AgentConfigOptionValue>,
1767        /// Favorited values for session config options.
1768        ///
1769        /// This is a map from config option ID to a list of favorited value IDs.
1770        ///
1771        /// Default: {}
1772        favorite_config_option_values: HashMap<String, Vec<String>>,
1773    },
1774}
1775
1776impl CustomAgentServerSettings {
1777    pub fn command(&self) -> Option<&AgentServerCommand> {
1778        match self {
1779            CustomAgentServerSettings::Custom { command, .. } => Some(command),
1780            CustomAgentServerSettings::Registry { .. } => None,
1781        }
1782    }
1783
1784    pub fn default_mode(&self) -> Option<&str> {
1785        match self {
1786            CustomAgentServerSettings::Custom { default_mode, .. }
1787            | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(),
1788        }
1789    }
1790
1791    pub fn default_config_option(&self, config_id: &str) -> Option<&AgentConfigOptionValue> {
1792        match self {
1793            CustomAgentServerSettings::Custom {
1794                default_config_options,
1795                ..
1796            }
1797            | CustomAgentServerSettings::Registry {
1798                default_config_options,
1799                ..
1800            } => default_config_options.get(config_id),
1801        }
1802    }
1803
1804    pub fn favorite_config_option_values(&self, config_id: &str) -> Option<&[String]> {
1805        match self {
1806            CustomAgentServerSettings::Custom {
1807                favorite_config_option_values,
1808                ..
1809            }
1810            | CustomAgentServerSettings::Registry {
1811                favorite_config_option_values,
1812                ..
1813            } => favorite_config_option_values
1814                .get(config_id)
1815                .map(|v| v.as_slice()),
1816        }
1817    }
1818}
1819
1820impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1821    fn from(value: settings::CustomAgentServerSettings) -> Self {
1822        match value {
1823            settings::CustomAgentServerSettings::Custom {
1824                path,
1825                args,
1826                env,
1827                default_mode,
1828                default_config_options,
1829                favorite_config_option_values,
1830            } => CustomAgentServerSettings::Custom {
1831                command: AgentServerCommand {
1832                    path: PathBuf::from(shellexpand::tilde(&path.to_string_lossy()).as_ref()),
1833                    args,
1834                    env: Some(env),
1835                },
1836                default_mode,
1837                default_config_options,
1838                favorite_config_option_values,
1839            },
1840            settings::CustomAgentServerSettings::Registry {
1841                env,
1842                default_mode,
1843                default_config_options,
1844                favorite_config_option_values,
1845            } => CustomAgentServerSettings::Registry {
1846                env,
1847                default_mode,
1848                default_config_options,
1849                favorite_config_option_values,
1850            },
1851        }
1852    }
1853}
1854
1855impl settings::Settings for AllAgentServersSettings {
1856    fn from_settings(content: &settings::SettingsContent) -> Self {
1857        let agent_settings = content.agent_servers.clone().unwrap();
1858        Self(
1859            agent_settings
1860                .0
1861                .into_iter()
1862                .map(|(k, v)| {
1863                    (
1864                        EXTENSION_TO_REGISTRY_IDS
1865                            .get(&k.as_str())
1866                            .map(|v| v.to_string())
1867                            .unwrap_or(k),
1868                        v.into(),
1869                    )
1870                })
1871                .collect(),
1872        )
1873    }
1874}
1875
1876#[cfg(test)]
1877mod tests {
1878    use super::*;
1879    use crate::agent_registry_store::{
1880        AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent,
1881    };
1882    use crate::worktree_store::{WorktreeIdCounter, WorktreeStore};
1883    use gpui::TestAppContext;
1884    #[cfg(feature = "test-support")]
1885    use http_client::{AsyncBody, FakeHttpClient, Response};
1886    use node_runtime::NodeRuntime;
1887    use settings::Settings as _;
1888
1889    #[cfg(feature = "test-support")]
1890    const TEST_ARCHIVE_URL: &str = "https://example.test/agent";
1891
1892    #[cfg(feature = "test-support")]
1893    fn static_http_client(body: Vec<u8>) -> Arc<dyn HttpClient> {
1894        FakeHttpClient::create(move |_| {
1895            let body = body.clone();
1896            async move {
1897                Ok(Response::builder()
1898                    .status(200)
1899                    .body(AsyncBody::from(body))?)
1900            }
1901        })
1902    }
1903
1904    #[cfg(feature = "test-support")]
1905    fn make_registry_archive_agent(
1906        cx: &mut TestAppContext,
1907        installation_dir: PathBuf,
1908        http_client: Arc<dyn HttpClient>,
1909        sha256: Option<String>,
1910    ) -> LocalRegistryArchiveAgent {
1911        let fs: Arc<dyn Fs> = Arc::new(fs::RealFs::new(None, cx.executor()));
1912        let target = RegistryTargetConfig {
1913            archive: TEST_ARCHIVE_URL.to_string(),
1914            cmd: "./agent".to_string(),
1915            args: Vec::new(),
1916            sha256,
1917            env: HashMap::default(),
1918        };
1919        let targets = [
1920            "darwin-aarch64",
1921            "darwin-x86_64",
1922            "linux-aarch64",
1923            "linux-x86_64",
1924            "windows-aarch64",
1925            "windows-x86_64",
1926        ]
1927        .into_iter()
1928        .map(|platform| (platform.to_string(), target.clone()))
1929        .collect();
1930
1931        cx.update(|cx| {
1932            let worktree_store =
1933                cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
1934            let project_environment = cx.new(|cx| {
1935                crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
1936            });
1937
1938            LocalRegistryArchiveAgent {
1939                fs,
1940                http_client,
1941                node_runtime: NodeRuntime::unavailable(),
1942                project_environment,
1943                installation_dir,
1944                registry_id: Arc::from("test-agent"),
1945                version: "1.0.0".into(),
1946                targets,
1947                env: HashMap::default(),
1948                new_version_available_tx: None,
1949                loading_status_tx: None,
1950            }
1951        })
1952    }
1953
1954    fn make_npx_agent(id: &str, version: &str) -> RegistryAgent {
1955        let id = SharedString::from(id.to_string());
1956        RegistryAgent::Npx(RegistryNpxAgent {
1957            metadata: RegistryAgentMetadata {
1958                id: AgentId::new(id.clone()),
1959                name: id.clone(),
1960                description: SharedString::from(""),
1961                version: SharedString::from(version.to_string()),
1962                repository: None,
1963                website: None,
1964                icon_path: None,
1965            },
1966            package: id,
1967            args: Vec::new(),
1968            env: HashMap::default(),
1969        })
1970    }
1971
1972    fn init_test_settings(cx: &mut TestAppContext) {
1973        cx.update(|cx| {
1974            let settings_store = SettingsStore::test(cx);
1975            cx.set_global(settings_store);
1976        });
1977    }
1978
1979    fn init_registry(
1980        cx: &mut TestAppContext,
1981        agents: Vec<RegistryAgent>,
1982    ) -> gpui::Entity<AgentRegistryStore> {
1983        cx.update(|cx| AgentRegistryStore::init_test_global(cx, agents))
1984    }
1985
1986    fn set_registry_settings(cx: &mut TestAppContext, agent_names: &[&str]) {
1987        cx.update(|cx| {
1988            AllAgentServersSettings::override_global(
1989                AllAgentServersSettings(
1990                    agent_names
1991                        .iter()
1992                        .map(|name| {
1993                            (
1994                                name.to_string(),
1995                                settings::CustomAgentServerSettings::Registry {
1996                                    env: HashMap::default(),
1997                                    default_mode: None,
1998                                    default_config_options: HashMap::default(),
1999                                    favorite_config_option_values: HashMap::default(),
2000                                }
2001                                .into(),
2002                            )
2003                        })
2004                        .collect(),
2005                ),
2006                cx,
2007            );
2008        });
2009    }
2010
2011    fn create_agent_server_store(cx: &mut TestAppContext) -> gpui::Entity<AgentServerStore> {
2012        cx.update(|cx| {
2013            let fs: Arc<dyn Fs> = fs::FakeFs::new(cx.background_executor().clone());
2014            let worktree_store =
2015                cx.new(|cx| WorktreeStore::local(false, fs.clone(), WorktreeIdCounter::get(cx)));
2016            let project_environment = cx.new(|cx| {
2017                crate::ProjectEnvironment::new(None, worktree_store.downgrade(), None, false, cx)
2018            });
2019            let http_client = http_client::FakeHttpClient::with_404_response();
2020
2021            cx.new(|cx| {
2022                AgentServerStore::local(
2023                    NodeRuntime::unavailable(),
2024                    fs,
2025                    project_environment,
2026                    http_client,
2027                    cx,
2028                )
2029            })
2030        })
2031    }
2032
2033    #[test]
2034    fn builds_bounded_npm_package_specs() {
2035        assert_eq!(
2036            bounded_npm_package_spec("agent-package@1.2.3"),
2037            "agent-package@0.0.0 - 1.2.3"
2038        );
2039        assert_eq!(
2040            bounded_npm_package_spec("@scope/agent-package@1.2.3-beta.1"),
2041            "@scope/agent-package@0.0.0 - 1.2.3-beta.1"
2042        );
2043        assert_eq!(
2044            bounded_npm_package_spec("@scope/agent-package"),
2045            "@scope/agent-package"
2046        );
2047        assert_eq!(
2048            bounded_npm_package_spec("agent-package@latest"),
2049            "agent-package@latest"
2050        );
2051    }
2052
2053    #[test]
2054    fn detects_supported_archive_suffixes() {
2055        assert!(matches!(
2056            registry_archive_kind_for_url("https://example.com/agent.zip"),
2057            Ok(RegistryArchiveKind::Archive(AssetKind::Zip))
2058        ));
2059        assert!(matches!(
2060            registry_archive_kind_for_url("https://example.com/agent.zip?download=1"),
2061            Ok(RegistryArchiveKind::Archive(AssetKind::Zip))
2062        ));
2063        assert!(matches!(
2064            registry_archive_kind_for_url("https://example.com/agent.tar.gz"),
2065            Ok(RegistryArchiveKind::Archive(AssetKind::TarGz))
2066        ));
2067        assert!(matches!(
2068            registry_archive_kind_for_url("https://example.com/agent.tar.gz?download=1#latest"),
2069            Ok(RegistryArchiveKind::Archive(AssetKind::TarGz))
2070        ));
2071        assert!(matches!(
2072            registry_archive_kind_for_url("https://example.com/agent.tgz"),
2073            Ok(RegistryArchiveKind::Archive(AssetKind::TarGz))
2074        ));
2075        assert!(matches!(
2076            registry_archive_kind_for_url("https://example.com/agent.tgz#download"),
2077            Ok(RegistryArchiveKind::Archive(AssetKind::TarGz))
2078        ));
2079        assert!(matches!(
2080            registry_archive_kind_for_url("https://example.com/agent.tar.bz2"),
2081            Ok(RegistryArchiveKind::Archive(AssetKind::TarBz2))
2082        ));
2083        assert!(matches!(
2084            registry_archive_kind_for_url("https://example.com/agent.tar.bz2?download=1"),
2085            Ok(RegistryArchiveKind::Archive(AssetKind::TarBz2))
2086        ));
2087        assert!(matches!(
2088            registry_archive_kind_for_url("https://example.com/agent.tbz2"),
2089            Ok(RegistryArchiveKind::Archive(AssetKind::TarBz2))
2090        ));
2091        assert!(matches!(
2092            registry_archive_kind_for_url("https://example.com/agent.tbz2#download"),
2093            Ok(RegistryArchiveKind::Archive(AssetKind::TarBz2))
2094        ));
2095        assert!(matches!(
2096            registry_archive_kind_for_url("https://example.com/agent.ZIP"),
2097            Ok(RegistryArchiveKind::Archive(AssetKind::Zip))
2098        ));
2099    }
2100
2101    #[test]
2102    fn detects_raw_binary_archive_urls() {
2103        assert_eq!(
2104            registry_archive_kind_for_url("https://x.ai/cli/grok-0.2.20-macos-aarch64").unwrap(),
2105            RegistryArchiveKind::RawBinary {
2106                file_name: "grok-0.2.20-macos-aarch64".to_string()
2107            },
2108        );
2109        assert_eq!(
2110            registry_archive_kind_for_url("https://x.ai/cli/grok-0.2.20-windows-x86_64.exe")
2111                .unwrap(),
2112            RegistryArchiveKind::RawBinary {
2113                file_name: "grok-0.2.20-windows-x86_64.exe".to_string()
2114            },
2115        );
2116        assert_eq!(
2117            registry_archive_kind_for_url("https://example.com/agent-binary?download=1#latest")
2118                .unwrap(),
2119            RegistryArchiveKind::RawBinary {
2120                file_name: "agent-binary".to_string()
2121            },
2122        );
2123        assert_eq!(
2124            registry_archive_kind_for_url("https://example.com/agent%20binary").unwrap(),
2125            RegistryArchiveKind::RawBinary {
2126                file_name: "agent binary".to_string()
2127            },
2128        );
2129        // No file name to install the binary as.
2130        assert!(registry_archive_kind_for_url("https://example.com/").is_err());
2131        // Percent-decoding must not allow path traversal in the file name.
2132        assert!(registry_archive_kind_for_url("https://example.com/a%2F..%2Fevil").is_err());
2133        assert!(registry_archive_kind_for_url("https://example.com/%2E%2E").is_err());
2134    }
2135
2136    #[test]
2137    fn parses_github_release_archive_urls() {
2138        let github_archive = github_release_archive_from_url(
2139            "https://github.com/owner/repo/releases/download/release%2F2.3.5/agent.tar.bz2?download=1",
2140        )
2141        .unwrap();
2142
2143        assert_eq!(github_archive.repo_name_with_owner, "owner/repo");
2144        assert_eq!(github_archive.tag, "release/2.3.5");
2145        assert_eq!(github_archive.asset_name, "agent.tar.bz2");
2146    }
2147
2148    #[test]
2149    fn rejects_unsupported_archive_suffixes() {
2150        let error = registry_archive_kind_for_url("https://example.com/agent.tar.xz")
2151            .err()
2152            .map(|error| error.to_string());
2153
2154        assert_eq!(
2155            error,
2156            Some(
2157                "unsupported archive type .tar.xz in URL: https://example.com/agent.tar.xz"
2158                    .to_string()
2159            ),
2160        );
2161
2162        for installer_url in [
2163            "https://example.com/agent.dmg",
2164            "https://example.com/agent.pkg",
2165            "https://example.com/agent.deb",
2166            "https://example.com/agent.rpm",
2167            "https://example.com/agent.msi",
2168            "https://example.com/agent.AppImage",
2169        ] {
2170            assert!(
2171                registry_archive_kind_for_url(installer_url).is_err(),
2172                "expected {installer_url} to be rejected"
2173            );
2174        }
2175    }
2176
2177    #[test]
2178    fn versioned_archive_cache_dir_includes_artifact_identity() {
2179        let slash_version_dir = versioned_archive_cache_dir(
2180            Path::new("/tmp/agents"),
2181            Some("release/2.3.5"),
2182            "https://example.com/agent.zip",
2183            None,
2184        );
2185        let colon_version_dir = versioned_archive_cache_dir(
2186            Path::new("/tmp/agents"),
2187            Some("release:2.3.5"),
2188            "https://example.com/agent.zip",
2189            None,
2190        );
2191        let file_name = slash_version_dir
2192            .file_name()
2193            .and_then(|name| name.to_str())
2194            .expect("cache directory should have a file name");
2195
2196        assert!(file_name.starts_with("v_release-2.3.5_"));
2197        assert_ne!(slash_version_dir, colon_version_dir);
2198
2199        let lowercase_checksum_dir = versioned_archive_cache_dir(
2200            Path::new("/tmp/agents"),
2201            Some("release/2.3.5"),
2202            "https://example.com/agent.zip",
2203            Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2204        );
2205        let uppercase_checksum_dir = versioned_archive_cache_dir(
2206            Path::new("/tmp/agents"),
2207            Some("release/2.3.5"),
2208            "https://example.com/agent.zip",
2209            Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
2210        );
2211        let changed_checksum_dir = versioned_archive_cache_dir(
2212            Path::new("/tmp/agents"),
2213            Some("release/2.3.5"),
2214            "https://example.com/agent.zip",
2215            Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2216        );
2217
2218        assert_ne!(slash_version_dir, lowercase_checksum_dir);
2219        assert_eq!(lowercase_checksum_dir, uppercase_checksum_dir);
2220        assert_ne!(lowercase_checksum_dir, changed_checksum_dir);
2221    }
2222
2223    #[cfg(feature = "test-support")]
2224    #[gpui::test]
2225    async fn registry_raw_binary_checksum_invalidates_unverified_cache_and_blocks_mismatch(
2226        cx: &mut TestAppContext,
2227    ) {
2228        init_test_settings(cx);
2229        cx.executor().allow_parking();
2230        let temp_dir = tempfile::tempdir().unwrap();
2231        let installation_dir = temp_dir.path().join("agent");
2232        let old_version_dir =
2233            versioned_archive_cache_dir(&installation_dir, Some("1.0.0"), TEST_ARCHIVE_URL, None);
2234        std::fs::create_dir_all(&old_version_dir).unwrap();
2235        std::fs::write(old_version_dir.join("agent"), b"unverified agent").unwrap();
2236
2237        let expected_sha256 = "0000000000000000000000000000000000000000000000000000000000000000";
2238        let http_client = static_http_client(b"unexpected agent".to_vec());
2239        let mut agent = make_registry_archive_agent(
2240            cx,
2241            installation_dir.clone(),
2242            http_client,
2243            Some(expected_sha256.to_string()),
2244        );
2245        let get_command =
2246            cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async()));
2247
2248        let error = get_command.await.unwrap_err();
2249        assert!(
2250            error.to_string().contains("SHA-256 mismatch"),
2251            "unexpected error: {error:#}"
2252        );
2253        assert!(old_version_dir.exists());
2254        assert!(
2255            !versioned_archive_cache_dir(
2256                &installation_dir,
2257                Some("1.0.0"),
2258                TEST_ARCHIVE_URL,
2259                Some(expected_sha256),
2260            )
2261            .exists()
2262        );
2263    }
2264
2265    #[cfg(feature = "test-support")]
2266    #[gpui::test]
2267    async fn registry_raw_binary_with_checksum_installs(cx: &mut TestAppContext) {
2268        init_test_settings(cx);
2269        cx.executor().allow_parking();
2270        let temp_dir = tempfile::tempdir().unwrap();
2271        let installation_dir = temp_dir.path().join("agent");
2272        let contents = b"verified agent";
2273        let expected_sha256 = format!("{:X}", Sha256::digest(contents));
2274        let http_client = static_http_client(contents.to_vec());
2275        let mut agent = make_registry_archive_agent(
2276            cx,
2277            installation_dir.clone(),
2278            http_client,
2279            Some(expected_sha256.clone()),
2280        );
2281        let get_command =
2282            cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async()));
2283
2284        let command = get_command.await.unwrap();
2285        cx.run_until_parked();
2286        assert_eq!(
2287            command.path,
2288            versioned_archive_cache_dir(
2289                &installation_dir,
2290                Some("1.0.0"),
2291                TEST_ARCHIVE_URL,
2292                Some(&expected_sha256),
2293            )
2294            .join("agent")
2295        );
2296        assert_eq!(std::fs::read(command.path).unwrap(), contents);
2297    }
2298
2299    #[cfg(feature = "test-support")]
2300    #[gpui::test]
2301    async fn registry_raw_binary_without_checksum_installs(cx: &mut TestAppContext) {
2302        init_test_settings(cx);
2303        cx.executor().allow_parking();
2304        let temp_dir = tempfile::tempdir().unwrap();
2305        let installation_dir = temp_dir.path().join("agent");
2306        let contents = b"unchecked agent";
2307        let http_client = static_http_client(contents.to_vec());
2308        let mut agent =
2309            make_registry_archive_agent(cx, installation_dir.clone(), http_client, None);
2310        let get_command =
2311            cx.update(|cx| agent.get_command(Vec::new(), HashMap::default(), &mut cx.to_async()));
2312
2313        let command = get_command.await.unwrap();
2314        cx.run_until_parked();
2315        assert_eq!(
2316            command.path,
2317            versioned_archive_cache_dir(&installation_dir, Some("1.0.0"), TEST_ARCHIVE_URL, None,)
2318                .join("agent")
2319        );
2320        assert_eq!(std::fs::read(command.path).unwrap(), contents);
2321    }
2322
2323    #[gpui::test]
2324    async fn test_remove_stale_versioned_archive_cache_dirs(cx: &mut TestAppContext) {
2325        let fs = fs::FakeFs::new(cx.executor());
2326        let base_dir = Path::new("/cache");
2327
2328        // FakeFs increments mtime on every create, so creation order is
2329        // ascending mtime: v_old_1 < v_old_2 < other < v_not_a_dir < v_current < v_newer.
2330        fs.insert_tree(
2331            base_dir,
2332            serde_json::json!({
2333                "v_old_1": {},
2334                "v_old_2": {},
2335                "other": {},
2336            }),
2337        )
2338        .await;
2339        fs.insert_file(base_dir.join("v_not_a_dir"), b"keep me".to_vec())
2340            .await;
2341        let current_version_dir = base_dir.join("v_current");
2342        fs.create_dir(&current_version_dir).await.unwrap();
2343        // Sibling that "finished extracting" after the current dir was cached.
2344        fs.create_dir(&base_dir.join("v_newer")).await.unwrap();
2345
2346        remove_stale_versioned_archive_cache_dirs(
2347            fs.clone() as Arc<dyn Fs>,
2348            base_dir,
2349            &current_version_dir,
2350        )
2351        .await
2352        .unwrap();
2353
2354        let mut remaining = fs
2355            .read_dir(base_dir)
2356            .await
2357            .unwrap()
2358            .filter_map(|entry| async move { entry.ok() })
2359            .map(|path| {
2360                path.file_name()
2361                    .expect("entry has a name")
2362                    .to_string_lossy()
2363                    .into_owned()
2364            })
2365            .collect::<Vec<_>>()
2366            .await;
2367        remaining.sort();
2368
2369        assert_eq!(
2370            remaining,
2371            vec![
2372                "other".to_string(),
2373                "v_current".to_string(),
2374                "v_newer".to_string(),
2375                "v_not_a_dir".to_string(),
2376            ]
2377        );
2378    }
2379
2380    #[gpui::test]
2381    fn test_version_change_sends_notification(cx: &mut TestAppContext) {
2382        init_test_settings(cx);
2383        let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2384        set_registry_settings(cx, &["test-agent"]);
2385        let store = create_agent_server_store(cx);
2386
2387        // Verify the agent was registered with version 1.0.0.
2388        store.read_with(cx, |store, _| {
2389            let entry = store
2390                .external_agents
2391                .get(&AgentId::new("test-agent"))
2392                .expect("agent should be registered");
2393            assert_eq!(
2394                entry.server.version().map(|v| v.to_string()),
2395                Some("1.0.0".to_string())
2396            );
2397        });
2398
2399        // Set up a watch channel and store the tx on the agent.
2400        let (tx, mut rx) = watch::channel::<Option<String>>(None);
2401        store.update(cx, |store, _| {
2402            let entry = store
2403                .external_agents
2404                .get_mut(&AgentId::new("test-agent"))
2405                .expect("agent should be registered");
2406            entry.server.set_new_version_available_tx(tx);
2407        });
2408
2409        // Update the registry to version 2.0.0.
2410        registry.update(cx, |store, cx| {
2411            store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx);
2412        });
2413        cx.run_until_parked();
2414
2415        // The watch channel should have received the new version.
2416        assert_eq!(rx.borrow().as_deref(), Some("2.0.0"));
2417    }
2418
2419    #[gpui::test]
2420    fn test_same_version_preserves_tx(cx: &mut TestAppContext) {
2421        init_test_settings(cx);
2422        let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2423        set_registry_settings(cx, &["test-agent"]);
2424        let store = create_agent_server_store(cx);
2425
2426        let (tx, mut rx) = watch::channel::<Option<String>>(None);
2427        store.update(cx, |store, _| {
2428            let entry = store
2429                .external_agents
2430                .get_mut(&AgentId::new("test-agent"))
2431                .expect("agent should be registered");
2432            entry.server.set_new_version_available_tx(tx);
2433        });
2434
2435        // "Refresh" the registry with the same version.
2436        registry.update(cx, |store, cx| {
2437            store.set_agents(vec![make_npx_agent("test-agent", "1.0.0")], cx);
2438        });
2439        cx.run_until_parked();
2440
2441        // No notification should have been sent.
2442        assert_eq!(rx.borrow().as_deref(), None);
2443
2444        // The tx should have been transferred to the rebuilt agent entry.
2445        store.update(cx, |store, _| {
2446            let entry = store
2447                .external_agents
2448                .get_mut(&AgentId::new("test-agent"))
2449                .expect("agent should be registered");
2450            assert!(
2451                entry.server.take_new_version_available_tx().is_some(),
2452                "tx should have been transferred to the rebuilt agent"
2453            );
2454        });
2455    }
2456
2457    #[gpui::test]
2458    fn test_no_tx_stored_does_not_panic_on_version_change(cx: &mut TestAppContext) {
2459        init_test_settings(cx);
2460        let registry = init_registry(cx, vec![make_npx_agent("test-agent", "1.0.0")]);
2461        set_registry_settings(cx, &["test-agent"]);
2462        let _store = create_agent_server_store(cx);
2463
2464        // Update the registry without having stored any tx — should not panic.
2465        registry.update(cx, |store, cx| {
2466            store.set_agents(vec![make_npx_agent("test-agent", "2.0.0")], cx);
2467        });
2468        cx.run_until_parked();
2469    }
2470
2471    #[gpui::test]
2472    fn test_multiple_agents_independent_notifications(cx: &mut TestAppContext) {
2473        init_test_settings(cx);
2474        let registry = init_registry(
2475            cx,
2476            vec![
2477                make_npx_agent("agent-a", "1.0.0"),
2478                make_npx_agent("agent-b", "3.0.0"),
2479            ],
2480        );
2481        set_registry_settings(cx, &["agent-a", "agent-b"]);
2482        let store = create_agent_server_store(cx);
2483
2484        let (tx_a, mut rx_a) = watch::channel::<Option<String>>(None);
2485        let (tx_b, mut rx_b) = watch::channel::<Option<String>>(None);
2486        store.update(cx, |store, _| {
2487            store
2488                .external_agents
2489                .get_mut(&AgentId::new("agent-a"))
2490                .expect("agent-a should be registered")
2491                .server
2492                .set_new_version_available_tx(tx_a);
2493            store
2494                .external_agents
2495                .get_mut(&AgentId::new("agent-b"))
2496                .expect("agent-b should be registered")
2497                .server
2498                .set_new_version_available_tx(tx_b);
2499        });
2500
2501        // Update only agent-a to a new version; agent-b stays the same.
2502        registry.update(cx, |store, cx| {
2503            store.set_agents(
2504                vec![
2505                    make_npx_agent("agent-a", "2.0.0"),
2506                    make_npx_agent("agent-b", "3.0.0"),
2507                ],
2508                cx,
2509            );
2510        });
2511        cx.run_until_parked();
2512
2513        // agent-a should have received a notification.
2514        assert_eq!(rx_a.borrow().as_deref(), Some("2.0.0"));
2515
2516        // agent-b should NOT have received a notification.
2517        assert_eq!(rx_b.borrow().as_deref(), None);
2518
2519        // agent-b's tx should have been transferred.
2520        store.update(cx, |store, _| {
2521            assert!(
2522                store
2523                    .external_agents
2524                    .get_mut(&AgentId::new("agent-b"))
2525                    .expect("agent-b should be registered")
2526                    .server
2527                    .take_new_version_available_tx()
2528                    .is_some(),
2529                "agent-b tx should have been transferred"
2530            );
2531        });
2532    }
2533}
2534
Served at tenant.openagents/omega Member data and write actions are omitted.