Skip to main content

oxios_kernel/host_tools/
registry.rs

1//! Integration registry (RFC-041 Phase 2).
2//!
3//! Declarative catalog of host integrations — each entry binds a CLI to detect
4//! (optional), install spec(s) (Phase 4), and a credential descriptor. The
5//! registry is loaded from `share/default-integrations.toml` and merged with
6//! user overrides in `~/.oxios/integrations.d/*.toml` (whole-entry replace by
7//! `id`).
8//!
9//! Credential model (H6): a single `env_var` cannot serve both
10//! `CredentialStore::resolve` (provider, 6-source) and `resolve_secret`
11//! (non-provider, 3-source). So the descriptor names the store key, the env
12//! var, **and** which resolver applies. The status endpoint must call the
13//! matching resolver — never poke one env var.
14
15use std::collections::HashMap;
16use std::path::Path;
17
18use anyhow::{Context, Result};
19use serde::{Deserialize, Serialize};
20
21use crate::credential::{CredentialSource, CredentialStore};
22use crate::skill::SkillInstallSpec;
23
24// ─── Credential descriptor (fix H6) ──────────────────────────────────────────
25
26/// Which resolution path applies to a credential. Per D7 there is **no**
27/// `Provider` variant — LLM providers stay in `engine_api` and may only appear
28/// as read-only status cards via a separate UI path.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
30#[serde(tag = "resolver", rename_all = "lowercase")]
31pub enum CredentialResolver {
32    /// No credential needed (package managers).
33    #[default]
34    None,
35    /// Non-provider secret → `CredentialStore::resolve_secret(store_key, env_var)`.
36    /// 3-source: raw env var → oxios store → oxi-cli store.
37    Secret {
38        /// Key passed to `load_token`/`save_token` and `resolve_secret`'s `key`.
39        store_key: String,
40        /// Raw env var name checked first by `resolve_secret`.
41        env_var: String,
42    },
43    /// OAuth device-code → `TokenBundle` stored under `store_key` (Phase 3).
44    /// `provider` names a Rust `OAuthProvider` impl (D9); the TOML selects
45    /// *which* provider + scopes, Rust implements *how*.
46    OAuth {
47        store_key: String,
48        provider: String,
49        #[serde(default)]
50        scopes: Vec<String>,
51    },
52}
53
54/// A single registry entry.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Integration {
57    /// Stable identifier (URL path segment, skill `requires.integrations` ref).
58    pub id: String,
59    /// Human-readable label.
60    pub label: String,
61    /// Binary name to detect. `None` = credential-only (no CLI).
62    #[serde(default)]
63    pub cli: Option<String>,
64    /// Install instructions, reused verbatim from the skill system (Phase 4
65    /// executes them). Defaults to empty.
66    #[serde(default)]
67    pub install: Vec<SkillInstallSpec>,
68    /// Credential descriptor — which resolver applies + store key + env var.
69    #[serde(default)]
70    pub credential: CredentialResolver,
71}
72
73// ─── Install-spec TOML shim ──────────────────────────────────────────────────
74//
75// `SkillInstallSpec` derives Deserialize but its `kind` is a typed enum. The
76// registry TOML writes `kind = "brew"` etc., which maps cleanly. We deserialize
77// through this shim to keep the registry file self-describing and tolerant of
78// future InstallKind variants (Cargo/Bun/Pip — Phase 4).
79
80#[derive(Debug, Deserialize)]
81struct RawIntegration {
82    id: String,
83    label: String,
84    #[serde(default)]
85    cli: Option<String>,
86    #[serde(default)]
87    install: Vec<SkillInstallSpec>,
88    #[serde(default)]
89    credential: CredentialResolver,
90}
91
92impl From<RawIntegration> for Integration {
93    fn from(r: RawIntegration) -> Self {
94        Self {
95            id: r.id,
96            label: r.label,
97            cli: r.cli,
98            install: r.install,
99            credential: r.credential,
100        }
101    }
102}
103
104/// Top-level registry file: a list of integrations.
105#[derive(Debug, Deserialize)]
106struct RegistryFile {
107    #[serde(default, rename = "integration")]
108    integrations: Vec<RawIntegration>,
109}
110
111// ─── Registry ────────────────────────────────────────────────────────────────
112
113/// Loaded integration catalog, keyed by `id`. Built by merging the shipped
114/// defaults with user overrides (whole-entry replace by id).
115#[derive(Debug, Clone, Default)]
116pub struct IntegrationRegistry {
117    by_id: HashMap<String, Integration>,
118}
119
120impl IntegrationRegistry {
121    /// Parse a single TOML file's integrations.
122    fn parse_file(text: &str) -> Result<Vec<Integration>> {
123        let file: RegistryFile = toml::from_str(text).context("parsing integrations TOML")?;
124        Ok(file.integrations.into_iter().map(Into::into).collect())
125    }
126
127    /// Load defaults from an in-memory TOML string (compiled-in via
128    /// `include_str!`), then merge user overrides from `override_dir`.
129    /// This is the production path — always present regardless of CWD.
130    pub fn load_text(defaults_text: &str, override_dir: &Path) -> Result<Self> {
131        let mut by_id: HashMap<String, Integration> = HashMap::new();
132        for it in Self::parse_file(defaults_text)? {
133            by_id.insert(it.id.clone(), it);
134        }
135        Self::layer_overrides(&mut by_id, override_dir);
136        Ok(Self { by_id })
137    }
138
139    /// Load the shipped defaults from a file path, then merge user overrides.
140    /// Missing files are not errors. Used by tests.
141    pub fn load(defaults_path: &Path, override_dir: &Path) -> Result<Self> {
142        let mut by_id: HashMap<String, Integration> = HashMap::new();
143
144        if let Ok(text) = std::fs::read_to_string(defaults_path) {
145            for it in Self::parse_file(&text)? {
146                by_id.insert(it.id.clone(), it);
147            }
148        }
149
150        Self::layer_overrides(&mut by_id, override_dir);
151        Ok(Self { by_id })
152    }
153
154    /// Merge `override_dir/*.toml` into `by_id` (whole-entry replace by id).
155    fn layer_overrides(by_id: &mut HashMap<String, Integration>, override_dir: &Path) {
156        let Ok(entries) = std::fs::read_dir(override_dir) else {
157            return;
158        };
159        for entry in entries.flatten() {
160            let path = entry.path();
161            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
162                continue;
163            }
164            if let Ok(text) = std::fs::read_to_string(&path) {
165                match Self::parse_file(&text) {
166                    Ok(items) => {
167                        for it in items {
168                            by_id.insert(it.id.clone(), it);
169                        }
170                    }
171                    Err(e) => {
172                        tracing::warn!(path = %path.display(), error = %e, "skipping invalid integrations override");
173                    }
174                }
175            }
176        }
177    }
178
179    /// All integrations in stable (insertion-sorted-by-key) order.
180    pub fn all(&self) -> Vec<&Integration> {
181        let mut ids: Vec<&String> = self.by_id.keys().collect();
182        ids.sort();
183        ids.iter().filter_map(|id| self.by_id.get(*id)).collect()
184    }
185
186    /// Look up one integration by id.
187    pub fn get(&self, id: &str) -> Option<&Integration> {
188        self.by_id.get(id)
189    }
190
191    /// Names of CLIs this registry wants detected (non-`None` `cli` fields).
192    pub fn cli_names(&self) -> Vec<String> {
193        let mut names: Vec<String> = self.by_id.values().filter_map(|i| i.cli.clone()).collect();
194        names.sort();
195        names.dedup();
196        names
197    }
198}
199
200// ─── Credential status (H6: calls the matching resolver) ────────────────────
201
202/// Where a resolved credential came from (mirrors `CredentialSource` for JSON).
203#[derive(Debug, Clone, Serialize)]
204#[serde(rename_all = "camelCase")]
205pub struct CredentialStatus {
206    /// True when a usable credential exists (resolver found one).
207    pub configured: bool,
208    /// Source label: `env` | `auth_store` | `config` | `oauth` | `none`.
209    pub source: String,
210}
211
212impl CredentialStatus {
213    /// Run the matching resolver for a credential descriptor and report status.
214    ///
215    /// Per H6, this NEVER pokes a single env var — it calls the full chain:
216    /// - `None` → always unconfigured.
217    /// - `Secret` → `CredentialStore::resolve_secret(store_key, env_var)` (3-source).
218    /// - `OAuth` → checks for a stored `TokenBundle` under `store_key`.
219    pub fn resolve(cred: &CredentialResolver) -> Self {
220        match cred {
221            CredentialResolver::None => Self {
222                configured: false,
223                source: "none".into(),
224            },
225            CredentialResolver::Secret { store_key, env_var } => {
226                match CredentialStore::resolve_secret(store_key, env_var) {
227                    Some((_, src)) => Self {
228                        configured: true,
229                        source: source_label(&src),
230                    },
231                    None => Self {
232                        configured: false,
233                        source: "none".into(),
234                    },
235                }
236            }
237            CredentialResolver::OAuth { store_key, .. } => {
238                // Phase 3 populates TokenBundle via save_token; for now just
239                // check presence so the status is truthful today.
240                let present = oxi_sdk::load_token(store_key)
241                    .map(|t| t.is_some() && !t.unwrap().access_token.is_empty())
242                    .unwrap_or(false);
243                Self {
244                    configured: present,
245                    source: if present {
246                        "oauth".into()
247                    } else {
248                        "none".into()
249                    },
250                }
251            }
252        }
253    }
254}
255
256fn source_label(s: &CredentialSource) -> String {
257    match s {
258        CredentialSource::Config => "config",
259        CredentialSource::OxiAuthStore => "auth_store",
260        CredentialSource::EnvVar => "env",
261    }
262    .into()
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    const SAMPLE_TOML: &str = r#"
270[[integration]]
271id = "brew"
272label = "Homebrew"
273cli = "brew"
274credential = { resolver = "none" }
275
276[[integration]]
277id = "resend"
278label = "Resend"
279cli = "resend"
280install = [{ kind = "node", package = "resend" }]
281credential = { resolver = "secret", store_key = "resend", env_var = "RESEND_API_KEY" }
282
283[[integration]]
284id = "github"
285label = "GitHub CLI"
286cli = "gh"
287credential = { resolver = "oauth", store_key = "github", provider = "github", scopes = ["repo"] }
288"#;
289
290    #[test]
291    fn parses_integration_toml() {
292        let items = IntegrationRegistry::parse_file(SAMPLE_TOML).unwrap();
293        assert_eq!(items.len(), 3);
294
295        let brew = items.iter().find(|i| i.id == "brew").unwrap();
296        assert_eq!(brew.credential, CredentialResolver::None);
297
298        let resend = items.iter().find(|i| i.id == "resend").unwrap();
299        match &resend.credential {
300            CredentialResolver::Secret { store_key, env_var } => {
301                assert_eq!(store_key, "resend");
302                assert_eq!(env_var, "RESEND_API_KEY");
303            }
304            other => panic!("expected Secret, got {other:?}"),
305        }
306        assert_eq!(resend.install.len(), 1);
307
308        let gh = items.iter().find(|i| i.id == "github").unwrap();
309        match &gh.credential {
310            CredentialResolver::OAuth {
311                provider, scopes, ..
312            } => {
313                assert_eq!(provider, "github");
314                assert_eq!(scopes, &["repo"]);
315            }
316            other => panic!("expected OAuth, got {other:?}"),
317        }
318    }
319
320    #[test]
321    fn shipped_registry_parses() {
322        let text = include_str!("../../share/default-integrations.toml");
323        let items = IntegrationRegistry::parse_file(text).unwrap();
324        assert!(items.iter().any(|integration| integration.id == "github"));
325        assert!(items.iter().any(|integration| integration.id == "resend"));
326    }
327
328    #[test]
329    fn cli_names_dedups() {
330        let reg = IntegrationRegistry {
331            by_id: SAMPLE_TOML
332                .parse::<RegistryFile>()
333                .unwrap()
334                .integrations
335                .into_iter()
336                .map(Into::into)
337                .map(|i: Integration| (i.id.clone(), i))
338                .collect(),
339        };
340        let names = reg.cli_names();
341        assert_eq!(names, vec!["brew", "gh", "resend"]);
342    }
343
344    #[test]
345    fn none_resolver_is_unconfigured() {
346        let s = CredentialStatus::resolve(&CredentialResolver::None);
347        assert!(!s.configured);
348        assert_eq!(s.source, "none");
349    }
350}