Skip to main content

oxios_kernel/
host_tools.rs

1//! Host tool validation for Oxios.
2//!
3//! Checks that required and optional host tools (git, gh, osascript, etc.) are available.
4//! These are macOS tools that agents can call via ExecTool.
5
6use std::collections::HashMap;
7use std::process::Command;
8
9/// Validates that required host tools are available.
10///
11/// Implements the "minimal container, host dependency" philosophy.
12/// The container ships only essential tools; additional capabilities
13/// must be provided by the host system.
14pub struct HostToolValidator {
15    /// Required tools that MUST be on the host
16    required: Vec<String>,
17    /// Optional tools that MAY be on the host
18    optional: Vec<String>,
19}
20
21impl Clone for HostToolValidator {
22    fn clone(&self) -> Self {
23        Self {
24            required: self.required.clone(),
25            optional: self.optional.clone(),
26        }
27    }
28}
29
30impl HostToolValidator {
31    /// Create a new validator with the specified tool requirements
32    pub fn new(required: Vec<String>, optional: Vec<String>) -> Self {
33        Self { required, optional }
34    }
35
36    /// Check if all required tools are available on the host
37    ///
38    /// Returns a list of missing required tools. Empty list means all good.
39    pub fn validate_required(&self) -> Vec<String> {
40        self.required
41            .iter()
42            .filter(|tool| !Self::is_tool_available(tool))
43            .cloned()
44            .collect()
45    }
46
47    /// Check which optional tools are available
48    ///
49    /// Returns a map of tool name → availability status.
50    pub fn check_optional(&self) -> HashMap<String, bool> {
51        self.optional
52            .iter()
53            .map(|tool| (tool.clone(), Self::is_tool_available(tool)))
54            .collect()
55    }
56
57    /// Check all required and optional tools at once
58    ///
59    /// Returns a comprehensive status report.
60    pub fn full_check(&self) -> HostToolStatus {
61        let missing_required = self.validate_required();
62        let optional_available = self.check_optional();
63
64        HostToolStatus {
65            all_required_present: missing_required.is_empty(),
66            missing_required,
67            optional_available,
68        }
69    }
70
71    /// Check if a specific tool is available on the host
72    pub fn is_tool_available(tool: &str) -> bool {
73        Self::check_command(tool, &["--version"])
74            || Self::check_command(tool, &["-v"])
75            || Self::check_command(tool, &["version"])
76    }
77
78    /// Check if a command exists and returns successfully
79    fn check_command(cmd: &str, args: &[&str]) -> bool {
80        Command::new(cmd)
81            .args(args)
82            .output()
83            .map(|output| output.status.success())
84            .unwrap_or(false)
85    }
86
87    /// Get the list of required tools
88    pub fn required_tools(&self) -> &[String] {
89        &self.required
90    }
91
92    /// Get the list of optional tools
93    pub fn optional_tools(&self) -> &[String] {
94        &self.optional
95    }
96}
97
98/// Result of a full host tool status check
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
100pub struct HostToolStatus {
101    /// Whether all required tools are present
102    pub all_required_present: bool,
103    /// List of missing required tools
104    pub missing_required: Vec<String>,
105    /// Map of optional tool → availability
106    pub optional_available: HashMap<String, bool>,
107}
108
109/// Common host tools that Oxios uses
110pub mod common {
111    /// Required tools that should be on every host
112    pub const REQUIRED: &[&str] = &["git"];
113
114    /// Optional tools that enhance functionality
115    pub const OPTIONAL: &[&str] = &[
116        "gh",        // GitHub CLI
117        "remindctl", // Reminders CLI
118        "shortcuts", // macOS Shortcuts
119        "osascript", // AppleScript execution
120        "open",      // Open files/URLs
121        "jq",        // JSON processing
122        "curl",      // HTTP client
123        "ripgrep",   // Better grep
124        "sqlite3",   // SQLite CLI
125    ];
126
127    /// Tools pre-installed in the minimal container
128    pub const CONTAINER_MINIMAL: &[&str] =
129        &["bash", "python3", "git", "curl", "jq", "ripgrep", "sqlite3"];
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // --- HostToolValidator tests ---
137
138    #[test]
139    fn test_validate_required_all_present() {
140        // Use tools that should exist on most systems.
141        let validator = HostToolValidator::new(vec!["echo".to_string()], Vec::new());
142
143        let missing = validator.validate_required();
144        assert!(missing.is_empty());
145    }
146
147    #[test]
148    fn test_validate_required_missing() {
149        let validator = HostToolValidator::new(
150            vec!["definitely-not-a-real-tool-12345".to_string()],
151            Vec::new(),
152        );
153
154        let missing = validator.validate_required();
155        assert_eq!(missing.len(), 1);
156        assert_eq!(missing[0], "definitely-not-a-real-tool-12345");
157    }
158
159    #[test]
160    fn test_validate_required_multiple_missing() {
161        let validator = HostToolValidator::new(
162            vec!["not-real-1".to_string(), "not-real-2".to_string()],
163            Vec::new(),
164        );
165
166        let missing = validator.validate_required();
167        assert_eq!(missing.len(), 2);
168    }
169
170    #[test]
171    fn test_check_optional() {
172        let validator = HostToolValidator::new(
173            Vec::new(),
174            vec!["echo".to_string(), "definitely-not-real".to_string()],
175        );
176
177        let results = validator.check_optional();
178        assert_eq!(results.len(), 2);
179        assert!(results["echo"]);
180        assert!(!results["definitely-not-real"]);
181    }
182
183    #[test]
184    fn test_is_tool_available() {
185        // These should exist on most Unix-like systems.
186        assert!(HostToolValidator::is_tool_available("echo"));
187        assert!(HostToolValidator::is_tool_available("ls"));
188        assert!(HostToolValidator::is_tool_available("cat"));
189    }
190
191    #[test]
192    fn test_is_tool_available_not_found() {
193        assert!(!HostToolValidator::is_tool_available(
194            "this-tool-definitely-does-not-exist-abc123"
195        ));
196    }
197
198    #[test]
199    fn test_full_check() {
200        let validator = HostToolValidator::new(vec!["echo".to_string()], vec!["cat".to_string()]);
201
202        let status = validator.full_check();
203        assert!(status.all_required_present);
204        assert!(status.missing_required.is_empty());
205        assert!(status.optional_available["cat"]);
206    }
207
208    #[test]
209    fn test_full_check_missing_required() {
210        let validator = HostToolValidator::new(
211            vec!["echo".to_string(), "not-real-xyz".to_string()],
212            Vec::new(),
213        );
214
215        let status = validator.full_check();
216        assert!(!status.all_required_present);
217        assert_eq!(status.missing_required.len(), 1);
218    }
219
220    #[test]
221    fn test_required_tools_accessors() {
222        let validator = HostToolValidator::new(
223            vec!["git".to_string(), "gh".to_string()],
224            vec!["jq".to_string()],
225        );
226
227        assert_eq!(validator.required_tools(), &["git", "gh"]);
228        assert_eq!(validator.optional_tools(), &["jq"]);
229    }
230
231    // --- common module constants ---
232
233    #[test]
234    fn test_common_tools_constants() {
235        assert!(!common::REQUIRED.is_empty());
236        assert!(common::REQUIRED.contains(&"git"));
237
238        assert!(!common::OPTIONAL.is_empty());
239        assert!(common::OPTIONAL.contains(&"gh"));
240        assert!(common::OPTIONAL.contains(&"jq"));
241        assert!(common::OPTIONAL.contains(&"curl"));
242
243        assert!(!common::CONTAINER_MINIMAL.is_empty());
244        assert!(common::CONTAINER_MINIMAL.contains(&"bash"));
245        assert!(common::CONTAINER_MINIMAL.contains(&"git"));
246    }
247
248    // --- HostToolStatus ---
249
250    #[test]
251    fn test_host_tool_status_serialization() {
252        let status = HostToolStatus {
253            all_required_present: true,
254            missing_required: vec!["git".to_string()],
255            optional_available: HashMap::from([
256                ("jq".to_string(), true),
257                ("curl".to_string(), false),
258            ]),
259        };
260
261        let json = serde_json::to_string(&status).unwrap();
262        assert!(json.contains("all_required_present"));
263        assert!(json.contains("missing_required"));
264        assert!(json.contains("optional_available"));
265
266        // Deserialize back.
267        let loaded: HostToolStatus = serde_json::from_str(&json).unwrap();
268        assert!(loaded.all_required_present);
269        assert_eq!(loaded.missing_required.len(), 1);
270    }
271}