Skip to main content

oxios_kernel/host_tools/
mod.rs

1//! Host Integrations subsystem (RFC-041).
2//!
3//! Three capabilities, all driven by a declarative registry:
4//! - **Discovery** ([`scanner::HostToolScanner`]) — enumerate host CLIs across PATH and
5//!   package-manager install roots; cross-platform; symlink-aware; TTL-cached.
6//! - **OAuth** (Phase 3) — device-code handshake with refresh + revoke.
7//! - **Provisioning** (Phase 4) — execute `SkillInstallSpec` as a privileged kernel op.
8//!
9//! The facade is [`HostToolsApi`], exposed on `KernelHandle` as `host_tools`.
10
11pub mod oauth;
12pub mod provisioner;
13pub mod registry;
14pub mod scanner;
15pub use oauth::{DeviceCodeResponse, OAuthBroker, PollOutcome, PollResponse};
16pub use provisioner::{InstallOutput, install as install_spec};
17pub use registry::{
18    CredentialResolver, CredentialStatus, Integration, IntegrationKind, IntegrationRegistry,
19};
20pub use scanner::{DetectedTool, HostProbe, HostToolScanner, RealProbe, ToolSource};
21
22use std::sync::Arc;
23
24/// Kernel facade for host-tool discovery + integration registry + OAuth
25/// (RFC-041). Owns the shared [`HostToolScanner`] (TTL cache), the loaded
26/// [`IntegrationRegistry`], and the [`OAuthBroker`].
27pub struct HostToolsApi {
28    scanner: Arc<HostToolScanner>,
29    registry: IntegrationRegistry,
30    oauth: OAuthBroker,
31}
32
33impl HostToolsApi {
34    /// Assemble with the real host probe, a 60s scan TTL, and the integration
35    /// registry loaded from the default paths.
36    pub fn new() -> Self {
37        let registry = Self::load_registry().unwrap_or_else(|e| {
38            tracing::warn!(error = %e, "failed to load integration registry; using empty");
39            IntegrationRegistry::default()
40        });
41        Self {
42            scanner: Arc::new(HostToolScanner::real()),
43            registry,
44            oauth: OAuthBroker::new(),
45        }
46    }
47
48    /// Assemble with an explicit scanner (tests). Registry defaults to empty.
49    pub fn with_scanner(scanner: Arc<HostToolScanner>) -> Self {
50        Self {
51            scanner,
52            registry: IntegrationRegistry::default(),
53            oauth: OAuthBroker::new(),
54        }
55    }
56
57    /// Resolve the integration registry. Defaults are **compiled in** via
58    /// `include_str!` (always present regardless of CWD/install layout), with
59    /// an optional filesystem override at `~/.oxios/share/default-integrations.toml`
60    /// and per-file user overrides layered on top from `~/.oxios/integrations.d/`.
61    fn load_registry() -> anyhow::Result<IntegrationRegistry> {
62        const EMBEDDED: &str = include_str!("../../share/default-integrations.toml");
63        let home = crate::config::expand_home("~/.oxios");
64        let fs_defaults = home.join("share/default-integrations.toml");
65        let defaults_text = if fs_defaults.exists() {
66            std::fs::read_to_string(&fs_defaults).unwrap_or_else(|_| EMBEDDED.to_string())
67        } else {
68            EMBEDDED.to_string()
69        };
70        let overrides = crate::config::expand_home("~/.oxios/integrations.d");
71        IntegrationRegistry::load_text(&defaults_text, &overrides)
72    }
73
74    /// Detect a single binary by name (cache-aware).
75    pub async fn detect(&self, name: &str) -> Option<DetectedTool> {
76        self.scanner.detect(name).await
77    }
78
79    /// Detect many names; returns only the ones found (cache-aware).
80    pub async fn detect_many(&self, names: &[String]) -> Vec<DetectedTool> {
81        self.scanner.detect_many(names).await
82    }
83
84    /// Invalidate the scan cache (force fresh detection on next call).
85    pub fn invalidate(&self) {
86        self.scanner.invalidate();
87    }
88
89    /// All integrations in the registry (stable order).
90    pub fn integrations(&self) -> Vec<&Integration> {
91        self.registry.all()
92    }
93
94    /// Look up one integration by id.
95    pub fn integration(&self, id: &str) -> Option<&Integration> {
96        self.registry.get(id)
97    }
98
99    /// CLI names the registry wants detected (drives `/api/host-tools`).
100    pub fn integration_cli_names(&self) -> Vec<String> {
101        self.registry.cli_names()
102    }
103
104    /// Credential status for one integration — calls the matching resolver
105    /// (H6: never pokes one env var). `None` if the id is unknown.
106    pub fn credential_status(&self, id: &str) -> Option<CredentialStatus> {
107        self.registry
108            .get(id)
109            .map(|i| CredentialStatus::resolve(&i.credential))
110    }
111
112    /// Provision an integration — runs its first applicable `SkillInstallSpec`
113    /// as a privileged kernel op (D8: not via ExecTool). User-triggered only;
114    /// the API layer gates consent + audit-logs.
115    pub async fn install(&self, id: &str) -> anyhow::Result<InstallOutput> {
116        let it = self
117            .registry
118            .get(id)
119            .ok_or_else(|| anyhow::anyhow!("integration '{id}' not found"))?;
120        anyhow::ensure!(
121            !it.install.is_empty(),
122            "integration '{id}' has no install specs"
123        );
124        install_spec(&it.install).await
125    }
126
127    /// Start an OAuth device-code flow for an integration. The `device_code`
128    /// stays daemon-side (H1); only the user-facing data is returned.
129    pub async fn oauth_start(&self, id: &str) -> anyhow::Result<DeviceCodeResponse> {
130        let it = self
131            .registry
132            .get(id)
133            .ok_or_else(|| anyhow::anyhow!("integration '{id}' not found"))?;
134        let (provider, store_key, scopes) = match &it.credential {
135            CredentialResolver::OAuth {
136                provider,
137                store_key,
138                scopes,
139            } => (provider.clone(), store_key.clone(), scopes.clone()),
140            other => anyhow::bail!("integration '{id}' is {:?}, not oauth", other),
141        };
142        self.oauth.start(&provider, &store_key, &scopes).await
143    }
144
145    /// Poll an OAuth flow by opaque handle (H1). Terminal outcomes drop the flow.
146    pub async fn oauth_poll(&self, handle: &str) -> anyhow::Result<PollResponse> {
147        self.oauth.poll(handle).await
148    }
149}
150
151impl Default for HostToolsApi {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl std::fmt::Debug for HostToolsApi {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("HostToolsApi").finish_non_exhaustive()
160    }
161}