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