Skip to repository content2534 lines · 93.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:44:02.965Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
agent_server_store.rs
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(®istry_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(¤t_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(®istry_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(¤t_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 ¤t_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