Skip to main content

vifu_runtime/
manifest.rs

1use std::collections::{BTreeMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::{AgentDefinition, EndpointDefinition, RuntimeError};
8
9pub const RUNTIME_MANIFEST_SCHEMA_VERSION: u32 = 1;
10
11const MAX_PROVIDERS: usize = 64;
12const MAX_AGENTS: usize = 1_024;
13const MAX_ENDPOINTS: usize = 2_048;
14const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
15const SENSITIVE_KEYS: &[&str] = &[
16    "apikey",
17    "authorization",
18    "credential",
19    "password",
20    "privatekey",
21    "secret",
22    "token",
23];
24
25/// A logical provider required by a runtime release.
26///
27/// Device credentials and filesystem paths are intentionally excluded. Hosts
28/// resolve this logical requirement to a local provider installation.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ProviderRequirement {
32    pub id: String,
33    pub provider_type: String,
34    #[serde(default)]
35    pub capabilities: Vec<String>,
36    #[serde(default)]
37    pub settings: Value,
38    #[serde(default)]
39    pub resources: BTreeMap<String, String>,
40}
41
42/// Portable runtime configuration shared by embedded and connected runtimes.
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RuntimeManifest {
46    pub schema_version: u32,
47    pub project_id: String,
48    #[serde(default)]
49    pub providers: Vec<ProviderRequirement>,
50    #[serde(default)]
51    pub agents: Vec<AgentDefinition>,
52    #[serde(default)]
53    pub endpoints: Vec<EndpointDefinition>,
54    #[serde(default)]
55    pub metadata: Value,
56}
57
58impl RuntimeManifest {
59    pub fn new(project_id: impl Into<String>) -> Self {
60        Self {
61            schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
62            project_id: project_id.into(),
63            providers: Vec::new(),
64            agents: Vec::new(),
65            endpoints: Vec::new(),
66            metadata: Value::Object(Default::default()),
67        }
68    }
69
70    pub fn from_json(bytes: &[u8]) -> Result<Self, RuntimeError> {
71        if bytes.len() > MAX_MANIFEST_BYTES {
72            return Err(RuntimeError::InvalidDefinition(
73                "runtime manifest is too large".to_string(),
74            ));
75        }
76        let manifest = serde_json::from_slice::<Self>(bytes)
77            .map_err(|error| RuntimeError::InvalidDefinition(error.to_string()))?;
78        manifest.validate()?;
79        Ok(manifest)
80    }
81
82    pub fn to_json(&self) -> Result<Vec<u8>, RuntimeError> {
83        self.validate()?;
84        serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
85    }
86
87    pub fn content_hash(&self) -> Result<String, RuntimeError> {
88        let bytes = self.to_json()?;
89        let digest = Sha256::digest(bytes);
90        let mut encoded = String::with_capacity(7 + digest.len() * 2);
91        encoded.push_str("sha256:");
92        for byte in digest {
93            use std::fmt::Write;
94            write!(&mut encoded, "{byte:02x}").map_err(|_error| RuntimeError::Internal)?;
95        }
96        Ok(encoded)
97    }
98
99    pub fn validate(&self) -> Result<(), RuntimeError> {
100        if self.schema_version != RUNTIME_MANIFEST_SCHEMA_VERSION {
101            return Err(RuntimeError::InvalidDefinition(format!(
102                "unsupported runtime manifest schema version {}",
103                self.schema_version
104            )));
105        }
106        validate_portable_identifier("project", &self.project_id)?;
107        validate_count("providers", self.providers.len(), MAX_PROVIDERS)?;
108        validate_count("agents", self.agents.len(), MAX_AGENTS)?;
109        validate_count("endpoints", self.endpoints.len(), MAX_ENDPOINTS)?;
110
111        let mut provider_ids = HashSet::with_capacity(self.providers.len());
112        for provider in &self.providers {
113            validate_portable_identifier("provider", &provider.id)?;
114            validate_portable_identifier("provider type", &provider.provider_type)?;
115            if !provider_ids.insert(provider.id.as_str()) {
116                return Err(duplicate("provider", &provider.id));
117            }
118            validate_capabilities(&provider.capabilities)?;
119            validate_portable_provider_value(&provider.settings, "settings")?;
120            for (name, reference) in &provider.resources {
121                validate_portable_identifier("resource", name)?;
122                validate_resource_reference(reference)?;
123            }
124        }
125
126        let mut agent_ids = HashSet::with_capacity(self.agents.len());
127        for agent in &self.agents {
128            validate_portable_identifier("agent", &agent.id)?;
129            validate_portable_identifier("provider", &agent.provider)?;
130            if agent.name.trim().is_empty() {
131                return Err(RuntimeError::InvalidDefinition(
132                    "agent name is required".to_string(),
133                ));
134            }
135            if !provider_ids.contains(agent.provider.as_str()) {
136                return Err(RuntimeError::ProviderNotFound(agent.provider.clone()));
137            }
138            if !agent_ids.insert(agent.id.as_str()) {
139                return Err(duplicate("agent", &agent.id));
140            }
141            validate_capabilities(&agent.capabilities)?;
142            validate_portable_value(&agent.metadata, "agent metadata")?;
143        }
144
145        let mut endpoint_names = HashSet::with_capacity(self.endpoints.len());
146        for endpoint in &self.endpoints {
147            validate_portable_identifier("endpoint", &endpoint.name)?;
148            validate_portable_identifier("agent", &endpoint.agent)?;
149            validate_portable_identifier("capability", &endpoint.capability)?;
150            if !agent_ids.contains(endpoint.agent.as_str()) {
151                return Err(RuntimeError::AgentNotFound(endpoint.agent.clone()));
152            }
153            if !endpoint_names.insert(endpoint.name.as_str()) {
154                return Err(duplicate("endpoint", &endpoint.name));
155            }
156            if !(1..=120_000).contains(&endpoint.timeout_ms) {
157                return Err(RuntimeError::InvalidDefinition(
158                    "endpoint timeout must be between 1 and 120000 milliseconds".to_string(),
159                ));
160            }
161            let agent = self
162                .agents
163                .iter()
164                .find(|agent| agent.id == endpoint.agent)
165                .ok_or_else(|| RuntimeError::AgentNotFound(endpoint.agent.clone()))?;
166            if !agent
167                .capabilities
168                .iter()
169                .any(|capability| capability == &endpoint.capability)
170            {
171                return Err(RuntimeError::CapabilityUnavailable {
172                    provider: agent.provider.clone(),
173                    capability: endpoint.capability.clone(),
174                });
175            }
176        }
177
178        validate_portable_value(&self.metadata, "manifest metadata")?;
179
180        if self.to_unchecked_json()?.len() > MAX_MANIFEST_BYTES {
181            return Err(RuntimeError::InvalidDefinition(
182                "runtime manifest is too large".to_string(),
183            ));
184        }
185        Ok(())
186    }
187
188    fn to_unchecked_json(&self) -> Result<Vec<u8>, RuntimeError> {
189        serde_json::to_vec(self).map_err(|error| RuntimeError::Snapshot(error.to_string()))
190    }
191}
192
193/// One immutable, content-addressed project runtime release.
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct RuntimeRelease {
197    pub version: u64,
198    pub content_hash: String,
199    pub manifest: RuntimeManifest,
200}
201
202impl RuntimeRelease {
203    pub fn new(version: u64, manifest: RuntimeManifest) -> Result<Self, RuntimeError> {
204        if version == 0 {
205            return Err(RuntimeError::InvalidDefinition(
206                "runtime release version must be greater than zero".to_string(),
207            ));
208        }
209        manifest.validate()?;
210        let content_hash = manifest.content_hash()?;
211        Ok(Self {
212            version,
213            content_hash,
214            manifest,
215        })
216    }
217
218    pub fn validate(&self) -> Result<(), RuntimeError> {
219        if self.version == 0 {
220            return Err(RuntimeError::InvalidDefinition(
221                "runtime release version must be greater than zero".to_string(),
222            ));
223        }
224        self.manifest.validate()?;
225        if self.content_hash != self.manifest.content_hash()? {
226            return Err(RuntimeError::InvalidDefinition(
227                "runtime release content hash does not match its manifest".to_string(),
228            ));
229        }
230        Ok(())
231    }
232}
233
234/// Local-only provider configuration kept outside portable releases.
235#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct LocalProviderBinding {
238    pub provider_id: String,
239    #[serde(default)]
240    pub configuration: Value,
241}
242
243/// Redacted invocation metadata waiting to be uploaded to an optional server.
244#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct RuntimeTraceRecord {
247    pub id: String,
248    pub project_id: String,
249    pub invocation_id: String,
250    pub endpoint: String,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub agent: Option<String>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub provider: Option<String>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub capability: Option<String>,
257    pub status: String,
258    pub duration_ms: u64,
259    pub created_at_ms: u64,
260}
261
262impl RuntimeTraceRecord {
263    pub fn validate(&self) -> Result<(), RuntimeError> {
264        validate_trace_identifier("trace", &self.id)?;
265        validate_portable_identifier("project", &self.project_id)?;
266        validate_trace_identifier("invocation", &self.invocation_id)?;
267        validate_portable_identifier("endpoint", &self.endpoint)?;
268        for (kind, value) in [
269            ("agent", self.agent.as_deref()),
270            ("provider", self.provider.as_deref()),
271            ("capability", self.capability.as_deref()),
272        ] {
273            if let Some(value) = value {
274                validate_portable_identifier(kind, value)?;
275            }
276        }
277        if !matches!(self.status.as_str(), "completed" | "cancelled" | "error") {
278            return Err(RuntimeError::InvalidDefinition(
279                "runtime trace status is invalid".to_string(),
280            ));
281        }
282        if self.duration_ms > 24 * 60 * 60 * 1_000 {
283            return Err(RuntimeError::InvalidDefinition(
284                "runtime trace duration is invalid".to_string(),
285            ));
286        }
287        if self.created_at_ms == 0 {
288            return Err(RuntimeError::InvalidDefinition(
289                "runtime trace timestamp is invalid".to_string(),
290            ));
291        }
292        Ok(())
293    }
294}
295
296fn validate_count(kind: &str, count: usize, maximum: usize) -> Result<(), RuntimeError> {
297    if count > maximum {
298        return Err(RuntimeError::InvalidDefinition(format!(
299            "runtime manifest contains too many {kind}"
300        )));
301    }
302    Ok(())
303}
304
305fn validate_capabilities(capabilities: &[String]) -> Result<(), RuntimeError> {
306    if capabilities.is_empty() {
307        return Err(RuntimeError::InvalidDefinition(
308            "at least one capability is required".to_string(),
309        ));
310    }
311    for capability in capabilities {
312        validate_portable_identifier("capability", capability)?;
313    }
314    Ok(())
315}
316
317fn validate_resource_reference(reference: &str) -> Result<(), RuntimeError> {
318    if reference.is_empty() || reference.len() > 512 {
319        return Err(RuntimeError::InvalidDefinition(
320            "resource reference must be between 1 and 512 bytes".to_string(),
321        ));
322    }
323    if is_absolute_or_file_path(reference) {
324        return Err(RuntimeError::InvalidDefinition(
325            "runtime manifests cannot contain filesystem paths".to_string(),
326        ));
327    }
328    Ok(())
329}
330
331fn validate_portable_provider_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
332    validate_portable_value(value, &format!("provider {path}"))
333}
334
335fn validate_portable_value(value: &Value, path: &str) -> Result<(), RuntimeError> {
336    match value {
337        Value::Object(object) => {
338            for (key, value) in object {
339                let normalized = key
340                    .chars()
341                    .filter(|character| character.is_ascii_alphanumeric())
342                    .flat_map(char::to_lowercase)
343                    .collect::<String>();
344                if SENSITIVE_KEYS
345                    .iter()
346                    .any(|sensitive| normalized == *sensitive)
347                {
348                    return Err(RuntimeError::InvalidDefinition(format!(
349                        "runtime manifest {path} contains a credential field"
350                    )));
351                }
352                validate_portable_value(value, path)?;
353            }
354        }
355        Value::Array(values) => {
356            for value in values {
357                validate_portable_value(value, path)?;
358            }
359        }
360        Value::String(value) if is_absolute_or_file_path(value) => {
361            return Err(RuntimeError::InvalidDefinition(format!(
362                "runtime manifest {path} contains a filesystem path"
363            )));
364        }
365        _ => {}
366    }
367    Ok(())
368}
369
370fn is_absolute_or_file_path(value: &str) -> bool {
371    value.starts_with('/')
372        || value.starts_with("file://")
373        || (value.len() >= 3
374            && value.as_bytes()[0].is_ascii_alphabetic()
375            && value.as_bytes()[1] == b':'
376            && matches!(value.as_bytes()[2], b'/' | b'\\'))
377}
378
379fn validate_portable_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
380    if value.is_empty()
381        || value.len() > 128
382        || !value
383            .bytes()
384            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
385    {
386        return Err(RuntimeError::InvalidDefinition(format!(
387            "{kind} must be a portable identifier"
388        )));
389    }
390    Ok(())
391}
392
393fn validate_trace_identifier(kind: &str, value: &str) -> Result<(), RuntimeError> {
394    if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
395        return Err(RuntimeError::InvalidDefinition(format!(
396            "{kind} must be a valid trace identifier"
397        )));
398    }
399    Ok(())
400}
401
402fn duplicate(kind: &str, id: &str) -> RuntimeError {
403    RuntimeError::InvalidDefinition(format!("duplicate {kind} {id}"))
404}
405
406#[cfg(test)]
407mod tests {
408    use serde_json::json;
409
410    use super::*;
411
412    fn manifest() -> RuntimeManifest {
413        RuntimeManifest {
414            schema_version: RUNTIME_MANIFEST_SCHEMA_VERSION,
415            project_id: "moon-train".to_string(),
416            providers: vec![ProviderRequirement {
417                id: "local-model".to_string(),
418                provider_type: "openai-compatible".to_string(),
419                capabilities: vec!["chat".to_string()],
420                settings: json!({ "model": "small-chat" }),
421                resources: BTreeMap::from([("model".to_string(), "model:small-chat".to_string())]),
422            }],
423            agents: vec![AgentDefinition {
424                id: "guide".to_string(),
425                name: "Guide".to_string(),
426                provider: "local-model".to_string(),
427                capabilities: vec!["chat".to_string()],
428                metadata: json!({ "persona": "A calm guide." }),
429            }],
430            endpoints: vec![EndpointDefinition {
431                name: "guide".to_string(),
432                agent: "guide".to_string(),
433                capability: "chat".to_string(),
434                timeout_ms: 30_000,
435            }],
436            metadata: json!({ "title": "Last Train to the Moon" }),
437        }
438    }
439
440    #[test]
441    fn content_hash_is_stable_for_the_same_manifest() {
442        let manifest = manifest();
443        assert_eq!(
444            manifest.content_hash().unwrap(),
445            manifest.content_hash().unwrap()
446        );
447    }
448
449    #[test]
450    fn provider_credentials_are_rejected() {
451        let mut manifest = manifest();
452        manifest.providers[0].settings = json!({ "apiKey": "not-portable" });
453        let error = manifest.validate().unwrap_err();
454        assert!(error.to_string().contains("credential field"));
455    }
456
457    #[test]
458    fn provider_filesystem_paths_are_rejected() {
459        let mut manifest = manifest();
460        manifest.providers[0]
461            .resources
462            .insert("model".to_string(), "/Users/example/model.gguf".to_string());
463        let error = manifest.validate().unwrap_err();
464        assert!(error.to_string().contains("filesystem paths"));
465    }
466
467    #[test]
468    fn metadata_credentials_are_rejected() {
469        let mut manifest = manifest();
470        manifest.agents[0].metadata = json!({ "credentials": { "token": "not-portable" } });
471        let error = manifest.validate().unwrap_err();
472        assert!(error.to_string().contains("credential field"));
473    }
474
475    #[test]
476    fn endpoint_timeouts_are_bounded() {
477        let mut manifest = manifest();
478        manifest.endpoints[0].timeout_ms = 0;
479        let error = manifest.validate().unwrap_err();
480        assert!(error.to_string().contains("endpoint timeout"));
481    }
482
483    #[test]
484    fn trace_records_reject_unknown_statuses() {
485        let trace = RuntimeTraceRecord {
486            id: "trace-1".to_string(),
487            project_id: "moon-train".to_string(),
488            invocation_id: "invocation-1".to_string(),
489            endpoint: "guide".to_string(),
490            agent: Some("guide".to_string()),
491            provider: Some("local-model".to_string()),
492            capability: Some("chat".to_string()),
493            status: "pending".to_string(),
494            duration_ms: 1,
495            created_at_ms: 1,
496        };
497        let error = trace.validate().unwrap_err();
498        assert!(error.to_string().contains("trace status"));
499    }
500}