skill_runtime/
instance.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use zeroize::Zeroizing;
6
7use crate::credentials::{parse_keyring_reference, CredentialStore};
8
9/// Configuration for a skill instance
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InstanceConfig {
12    /// Instance metadata
13    pub metadata: InstanceMetadata,
14
15    /// Configuration key-value pairs
16    pub config: HashMap<String, ConfigValue>,
17
18    /// Environment variables to pass to the skill
19    pub environment: HashMap<String, String>,
20
21    /// Capabilities granted to this instance
22    pub capabilities: Capabilities,
23}
24
25impl Default for InstanceConfig {
26    fn default() -> Self {
27        Self {
28            metadata: InstanceMetadata::default(),
29            config: HashMap::new(),
30            environment: HashMap::new(),
31            capabilities: Capabilities::default(),
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct InstanceMetadata {
38    pub skill_name: String,
39    pub skill_version: String,
40    pub instance_name: String,
41    pub created_at: chrono::DateTime<chrono::Utc>,
42    pub updated_at: chrono::DateTime<chrono::Utc>,
43}
44
45impl Default for InstanceMetadata {
46    fn default() -> Self {
47        let now = chrono::Utc::now();
48        Self {
49            skill_name: String::new(),
50            skill_version: String::new(),
51            instance_name: String::new(),
52            created_at: now,
53            updated_at: now,
54        }
55    }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ConfigValue {
60    pub value: String,
61    #[serde(default)]
62    pub secret: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Capabilities {
67    /// Allowed filesystem paths (outside of preopened dirs)
68    #[serde(default)]
69    pub allowed_paths: Vec<PathBuf>,
70
71    /// Network access permission
72    #[serde(default)]
73    pub network_access: bool,
74
75    /// Maximum concurrent requests
76    #[serde(default = "default_max_concurrent")]
77    pub max_concurrent_requests: usize,
78}
79
80fn default_max_concurrent() -> usize {
81    10
82}
83
84impl Default for Capabilities {
85    fn default() -> Self {
86        Self {
87            allowed_paths: Vec::new(),
88            network_access: false,
89            max_concurrent_requests: default_max_concurrent(),
90        }
91    }
92}
93
94impl InstanceConfig {
95    /// Load instance configuration from TOML file
96    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
97        let contents = std::fs::read_to_string(path.as_ref())
98            .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
99
100        let config: Self = toml::from_str(&contents)
101            .context("Failed to parse config file")?;
102
103        Ok(config)
104    }
105
106    /// Save instance configuration to TOML file
107    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
108        let contents = toml::to_string_pretty(self)
109            .context("Failed to serialize config")?;
110
111        std::fs::write(path.as_ref(), contents)
112            .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
113
114        Ok(())
115    }
116
117    /// Get a configuration value (non-secret values only)
118    /// For secret values, use get_secret_config which returns a Zeroizing string
119    pub fn get_config(&self, key: &str) -> Option<String> {
120        self.config.get(key).and_then(|v| {
121            if v.secret {
122                None // Secret values must be retrieved via get_secret_config
123            } else {
124                Some(v.value.clone())
125            }
126        })
127    }
128
129    /// Get a secret configuration value from keyring
130    /// Returns a Zeroizing string that clears memory on drop
131    pub fn get_secret_config(&self, key: &str) -> Result<Option<Zeroizing<String>>> {
132        if let Some(config_value) = self.config.get(key) {
133            if config_value.secret {
134                // Value is a keyring reference: "keyring://skill-engine/{skill}/{instance}/{key}"
135                let (skill, instance, secret_key) = parse_keyring_reference(&config_value.value)?;
136
137                let credential_store = CredentialStore::new();
138                let secret = credential_store.get_credential(&skill, &instance, &secret_key)?;
139
140                return Ok(Some(secret));
141            }
142        }
143        Ok(None)
144    }
145
146    /// Get all configuration including resolved secrets
147    /// Returns a HashMap with secret values resolved from keyring
148    /// IMPORTANT: Caller must ensure returned map is zeroed after use
149    pub fn get_all_config(&self) -> Result<HashMap<String, Zeroizing<String>>> {
150        let mut result = HashMap::new();
151
152        for (key, value) in &self.config {
153            if value.secret {
154                // Resolve from keyring
155                if let Some(secret) = self.get_secret_config(key)? {
156                    result.insert(key.clone(), secret);
157                }
158            } else {
159                // Plain value
160                result.insert(key.clone(), Zeroizing::new(value.value.clone()));
161            }
162        }
163
164        Ok(result)
165    }
166
167    /// Set a configuration value
168    pub fn set_config(&mut self, key: String, value: String, secret: bool) {
169        self.config.insert(key, ConfigValue { value, secret });
170        self.metadata.updated_at = chrono::Utc::now();
171    }
172
173    /// Get instance directory path
174    pub fn instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
175        let home = dirs::home_dir()
176            .context("Failed to get home directory")?;
177
178        Ok(home
179            .join(".skill-engine")
180            .join("instances")
181            .join(skill_name)
182            .join(instance_name))
183    }
184
185    /// Get config file path for an instance
186    pub fn config_path(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
187        Ok(Self::instance_dir(skill_name, instance_name)?.join("config.toml"))
188    }
189
190    /// Create instance directory structure
191    pub fn create_instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
192        let instance_dir = Self::instance_dir(skill_name, instance_name)?;
193        std::fs::create_dir_all(&instance_dir)
194            .with_context(|| format!("Failed to create instance directory: {}", instance_dir.display()))?;
195        Ok(instance_dir)
196    }
197}
198
199/// Manager for skill instances
200pub struct InstanceManager {
201    instances_root: PathBuf,
202    credential_store: CredentialStore,
203}
204
205impl InstanceManager {
206    /// Create a new instance manager
207    pub fn new() -> Result<Self> {
208        let home = dirs::home_dir()
209            .context("Failed to get home directory")?;
210
211        let instances_root = home.join(".skill-engine").join("instances");
212        std::fs::create_dir_all(&instances_root)?;
213
214        Ok(Self {
215            instances_root,
216            credential_store: CredentialStore::new(),
217        })
218    }
219
220    /// Create a new instance with configuration and secrets
221    pub fn create_instance(
222        &self,
223        skill_name: &str,
224        instance_name: &str,
225        config: InstanceConfig,
226        secrets: HashMap<String, String>,
227    ) -> Result<()> {
228        // Create instance directory
229        InstanceConfig::create_instance_dir(skill_name, instance_name)?;
230
231        // Store secrets in keyring and update config with references
232        let mut updated_config = config;
233        for (key, value) in secrets {
234            // Store in keyring
235            self.credential_store
236                .store_credential(skill_name, instance_name, &key, &value)?;
237
238            // Add keyring reference to config
239            let keyring_ref =
240                format!("keyring://skill-engine/{}/{}/{}", skill_name, instance_name, key);
241            updated_config.config.insert(
242                key,
243                ConfigValue {
244                    value: keyring_ref,
245                    secret: true,
246                },
247            );
248        }
249
250        // Save config to file
251        self.save_instance(skill_name, instance_name, &updated_config)?;
252
253        tracing::info!(
254            skill = %skill_name,
255            instance = %instance_name,
256            "Created instance"
257        );
258
259        Ok(())
260    }
261
262    /// List all instances for a skill
263    pub fn list_instances(&self, skill_name: &str) -> Result<Vec<String>> {
264        let skill_dir = self.instances_root.join(skill_name);
265
266        if !skill_dir.exists() {
267            return Ok(Vec::new());
268        }
269
270        let mut instances = Vec::new();
271
272        for entry in std::fs::read_dir(&skill_dir)? {
273            let entry = entry?;
274            if entry.file_type()?.is_dir() {
275                if let Some(name) = entry.file_name().to_str() {
276                    instances.push(name.to_string());
277                }
278            }
279        }
280
281        Ok(instances)
282    }
283
284    /// Load instance configuration
285    pub fn load_instance(&self, skill_name: &str, instance_name: &str) -> Result<InstanceConfig> {
286        let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
287        InstanceConfig::load(config_path)
288    }
289
290    /// Save instance configuration
291    pub fn save_instance(&self, skill_name: &str, instance_name: &str, config: &InstanceConfig) -> Result<()> {
292        let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
293        config.save(config_path)
294    }
295
296    /// Delete an instance and all associated credentials
297    pub fn delete_instance(&self, skill_name: &str, instance_name: &str) -> Result<()> {
298        // Load config to find all secret keys
299        if let Ok(config) = self.load_instance(skill_name, instance_name) {
300            // Delete all credentials from keyring
301            for (_key, value) in &config.config {
302                if value.secret {
303                    // Parse keyring reference and delete
304                    if let Ok((_, _, secret_key)) = parse_keyring_reference(&value.value) {
305                        let _ = self
306                            .credential_store
307                            .delete_credential(skill_name, instance_name, &secret_key);
308                    }
309                }
310            }
311        }
312
313        // Delete instance directory
314        let instance_dir = InstanceConfig::instance_dir(skill_name, instance_name)?;
315        if instance_dir.exists() {
316            std::fs::remove_dir_all(&instance_dir)
317                .with_context(|| format!("Failed to delete instance directory: {}", instance_dir.display()))?;
318        }
319
320        tracing::info!(
321            skill = %skill_name,
322            instance = %instance_name,
323            "Deleted instance and credentials"
324        );
325
326        Ok(())
327    }
328
329    /// Update a secret value for an instance
330    pub fn update_secret(
331        &self,
332        skill_name: &str,
333        instance_name: &str,
334        key: &str,
335        value: &str,
336    ) -> Result<()> {
337        self.credential_store
338            .store_credential(skill_name, instance_name, key, value)?;
339
340        tracing::debug!(
341            skill = %skill_name,
342            instance = %instance_name,
343            key = %key,
344            "Updated secret"
345        );
346
347        Ok(())
348    }
349}
350
351impl Default for InstanceManager {
352    fn default() -> Self {
353        Self::new().expect("Failed to create InstanceManager")
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use tempfile::TempDir;
361
362    #[test]
363    fn test_instance_config_serialization() {
364        let mut config = InstanceConfig::default();
365        config.metadata.skill_name = "test-skill".to_string();
366        config.metadata.instance_name = "test-instance".to_string();
367        config.set_config("key1".to_string(), "value1".to_string(), false);
368
369        let toml = toml::to_string(&config).unwrap();
370        let deserialized: InstanceConfig = toml::from_str(&toml).unwrap();
371
372        assert_eq!(deserialized.metadata.skill_name, "test-skill");
373        assert_eq!(deserialized.get_config("key1"), Some("value1".to_string()));
374    }
375
376    #[test]
377    fn test_config_value() {
378        let mut config = InstanceConfig::default();
379        config.set_config("test".to_string(), "value".to_string(), false);
380
381        assert_eq!(config.get_config("test"), Some("value".to_string()));
382        assert_eq!(config.get_config("nonexistent"), None);
383    }
384}