Skip to main content

packc/
config.rs

1use crate::path_safety::normalize_under_root;
2use anyhow::{Context, Result, bail};
3use greentic_types::pack_manifest::ExtensionInline;
4use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderDecl, ProviderExtensionInline};
5use greentic_types::{
6    ComponentCapabilities, ComponentProfiles, ExtensionRef, FlowKind, ResourceHints,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13const PROVIDER_RUNTIME_WORLD: &str = "greentic:provider/schema-core@1.0.0";
14const LEGACY_PROVIDER_EXTENSION_KIND: &str = "greentic.ext.provider";
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
17#[non_exhaustive]
18pub struct PackConfig {
19    pub pack_id: String,
20    pub version: String,
21    pub kind: String,
22    pub publisher: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub name: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub display_name: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub bootstrap: Option<BootstrapConfig>,
29    #[serde(default)]
30    pub components: Vec<ComponentConfig>,
31    #[serde(default)]
32    pub dependencies: Vec<DependencyConfig>,
33    #[serde(default)]
34    pub flows: Vec<FlowConfig>,
35    #[serde(default)]
36    pub assets: Vec<AssetConfig>,
37    #[serde(
38        default,
39        skip_serializing_if = "Option::is_none",
40        deserialize_with = "deserialize_extensions"
41    )]
42    pub extensions: Option<BTreeMap<String, ExtensionRef>>,
43    /// Per-agent config blobs to embed in the pack manifest.
44    ///
45    /// Keyed by `agent_id`; values are opaque JSON blobs produced by the
46    /// designer (e.g. from a `greentic_aw_runtime::AgentConfig`).  packc does
47    /// NOT interpret these — they are passed through verbatim into
48    /// `greentic_types::PackManifest::agents`.
49    ///
50    /// In `pack.yaml`:
51    ///
52    /// ```yaml
53    /// agents:
54    ///   greeter:
55    ///     agent_id: greeter
56    ///     system_prompt: "You are a helpful greeter."
57    ///     tools: []
58    ///     llm:
59    ///       provider: openai
60    ///       model: gpt-4o-mini
61    /// ```
62    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
63    pub agents: BTreeMap<String, serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
67struct RawExtensionRef {
68    pub kind: String,
69    pub version: String,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub digest: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub location: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub inline: Option<JsonValue>,
76}
77
78#[derive(Debug, Clone, Deserialize, Serialize)]
79pub struct ComponentConfig {
80    pub id: String,
81    pub version: String,
82    pub world: String,
83    #[serde(default)]
84    pub supports: Vec<FlowKindLabel>,
85    pub profiles: ComponentProfiles,
86    pub capabilities: ComponentCapabilities,
87    pub wasm: PathBuf,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub operations: Vec<ComponentOperationConfig>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub config_schema: Option<JsonValue>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub resources: Option<ResourceHints>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub configurators: Option<ComponentConfiguratorConfig>,
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct ComponentOperationConfig {
100    pub name: String,
101    pub input_schema: JsonValue,
102    pub output_schema: JsonValue,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106pub struct FlowConfig {
107    pub id: String,
108    pub file: PathBuf,
109    #[serde(default)]
110    pub tags: Vec<String>,
111    #[serde(default)]
112    pub entrypoints: Vec<String>,
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct DependencyConfig {
117    pub alias: String,
118    pub pack_id: String,
119    pub version_req: String,
120    #[serde(default)]
121    pub required_capabilities: Vec<String>,
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct AssetConfig {
126    pub path: PathBuf,
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize)]
130pub struct BootstrapConfig {
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub install_flow: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub upgrade_flow: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub installer_component: Option<String>,
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize)]
140pub struct ComponentConfiguratorConfig {
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub basic: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub full: Option<String>,
145}
146
147#[derive(Debug, Clone, Deserialize, Serialize)]
148#[serde(rename_all = "lowercase")]
149pub enum FlowKindLabel {
150    Messaging,
151    Event,
152    #[serde(
153        rename = "componentconfig",
154        alias = "component-config",
155        alias = "component_config"
156    )]
157    ComponentConfig,
158    Job,
159    Http,
160}
161
162impl FlowKindLabel {
163    pub fn to_kind(&self) -> FlowKind {
164        match self {
165            FlowKindLabel::Messaging => FlowKind::Messaging,
166            FlowKindLabel::Event => FlowKind::Event,
167            FlowKindLabel::ComponentConfig => FlowKind::ComponentConfig,
168            FlowKindLabel::Job => FlowKind::Job,
169            FlowKindLabel::Http => FlowKind::Http,
170        }
171    }
172}
173
174fn deserialize_extensions<'de, D>(
175    deserializer: D,
176) -> std::result::Result<Option<BTreeMap<String, ExtensionRef>>, D::Error>
177where
178    D: serde::Deserializer<'de>,
179{
180    let raw = Option::<BTreeMap<String, RawExtensionRef>>::deserialize(deserializer)?;
181    raw.map(convert_extensions)
182        .transpose()
183        .map_err(serde::de::Error::custom)
184}
185
186fn convert_extensions(
187    raw: BTreeMap<String, RawExtensionRef>,
188) -> Result<BTreeMap<String, ExtensionRef>> {
189    raw.into_iter()
190        .map(|(key, value)| Ok((key, convert_extension_ref(value)?)))
191        .collect()
192}
193
194fn convert_extension_ref(raw: RawExtensionRef) -> Result<ExtensionRef> {
195    let inline = raw
196        .inline
197        .map(|value| convert_extension_inline(&raw.kind, value))
198        .transpose()?;
199    Ok(ExtensionRef {
200        kind: raw.kind,
201        version: raw.version,
202        digest: raw.digest,
203        location: raw.location,
204        inline,
205    })
206}
207
208fn convert_extension_inline(kind: &str, value: JsonValue) -> Result<ExtensionInline> {
209    if kind == PROVIDER_EXTENSION_ID || kind == LEGACY_PROVIDER_EXTENSION_KIND {
210        let provider = serde_json::from_value::<ProviderExtensionInline>(value.clone())
211            .with_context(|| {
212                format!("extensions[{kind}].inline is not a valid provider extension")
213            })?;
214        return Ok(ExtensionInline::Provider(provider));
215    }
216    Ok(ExtensionInline::Other(value))
217}
218
219pub fn load_pack_config(root: &Path) -> Result<PackConfig> {
220    let manifest_path = normalize_under_root(root, Path::new("pack.yaml"))?;
221    let contents = std::fs::read_to_string(&manifest_path)
222        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
223    let mut cfg: PackConfig = serde_yaml_bw::from_str(&contents)
224        .with_context(|| format!("{} is not a valid pack.yaml", manifest_path.display()))?;
225
226    // Normalize relative paths to be under the pack root so downstream logic can treat them as absolute.
227    for component in cfg.components.iter_mut() {
228        component.wasm = normalize_under_root(root, &component.wasm)?;
229    }
230    for flow in cfg.flows.iter_mut() {
231        flow.file = normalize_under_root(root, &flow.file)?;
232    }
233    for asset in cfg.assets.iter_mut() {
234        asset.path = normalize_under_root(root, &asset.path)?;
235    }
236
237    validate_extensions(cfg.extensions.as_ref(), strict_extensions())?;
238
239    Ok(cfg)
240}
241
242fn strict_extensions() -> bool {
243    matches!(
244        std::env::var("GREENTIC_PACK_STRICT_EXTENSIONS")
245            .unwrap_or_default()
246            .as_str(),
247        "1" | "true" | "TRUE"
248    )
249}
250
251fn validate_extensions(
252    extensions: Option<&BTreeMap<String, ExtensionRef>>,
253    strict: bool,
254) -> Result<()> {
255    let Some(exts) = extensions else {
256        return Ok(());
257    };
258
259    for (key, ext) in exts {
260        if ext.kind.trim().is_empty() {
261            bail!("extensions[{key}] kind must not be empty");
262        }
263        if ext.version.trim().is_empty() {
264            bail!("extensions[{key}] version must not be empty");
265        }
266        if ext.kind != *key {
267            bail!(
268                "extensions[{key}] kind `{}` must match the extension key",
269                ext.kind
270            );
271        }
272        if strict && let Some(location) = ext.location.as_deref() {
273            let digest_missing = ext
274                .digest
275                .as_ref()
276                .map(|d| d.trim().is_empty())
277                .unwrap_or(true);
278            if digest_missing {
279                bail!("extensions[{key}] location requires digest in strict mode");
280            }
281            let allowed = location.starts_with("oci://")
282                || location.starts_with("file://")
283                || location.starts_with("https://");
284            if !allowed {
285                bail!(
286                    "extensions[{key}] location `{location}` uses an unsupported scheme; allowed: oci://, file://, https://"
287                );
288            }
289        }
290
291        if ext.kind == PROVIDER_EXTENSION_ID || ext.kind == LEGACY_PROVIDER_EXTENSION_KIND {
292            validate_provider_extension(key, ext)?;
293        }
294    }
295
296    Ok(())
297}
298
299fn validate_provider_extension(key: &str, ext: &ExtensionRef) -> Result<()> {
300    let inline = ext
301        .inline
302        .as_ref()
303        .ok_or_else(|| anyhow::anyhow!("extensions[{key}] inline payload is required"))?;
304    let providers = match inline {
305        ExtensionInline::Provider(value) => value.providers.clone(),
306        ExtensionInline::Other(value) => {
307            serde_json::from_value::<ProviderExtensionInline>(value.clone())
308                .with_context(|| {
309                    format!("extensions[{key}].inline is not a valid provider extension")
310                })?
311                .providers
312        }
313    };
314    if providers.is_empty() {
315        bail!("extensions[{key}].inline.providers must not be empty");
316    }
317
318    for (idx, provider) in providers.iter().enumerate() {
319        validate_provider_decl(provider, key, idx)?;
320    }
321
322    Ok(())
323}
324
325fn validate_provider_decl(provider: &ProviderDecl, key: &str, idx: usize) -> Result<()> {
326    if provider.provider_type.trim().is_empty() {
327        bail!("extensions[{key}].inline.providers[{idx}].provider_type must not be empty");
328    }
329    if provider.config_schema_ref.trim().is_empty() {
330        bail!("extensions[{key}].inline.providers[{idx}].config_schema_ref must not be empty");
331    }
332    if provider.runtime.world != PROVIDER_RUNTIME_WORLD {
333        bail!(
334            "extensions[{key}].inline.providers[{idx}].runtime.world must be `{}`",
335            PROVIDER_RUNTIME_WORLD
336        );
337    }
338    if provider.runtime.component_ref.trim().is_empty() || provider.runtime.export.trim().is_empty()
339    {
340        bail!(
341            "extensions[{key}].inline.providers[{idx}].runtime component_ref/export must not be empty"
342        );
343    }
344    validate_string_vec(&provider.capabilities, "capabilities", key, idx)?;
345    validate_string_vec(&provider.ops, "ops", key, idx)?;
346    Ok(())
347}
348
349fn validate_string_vec(entries: &[String], field: &str, key: &str, idx: usize) -> Result<()> {
350    if entries.is_empty() {
351        bail!("extensions[{key}].inline.providers[{idx}].{field} must not be empty");
352    }
353    for (entry_idx, entry) in entries.iter().enumerate() {
354        if entry.trim().is_empty() {
355            bail!(
356                "extensions[{key}].inline.providers[{idx}].{field}[{entry_idx}] must be a non-empty string"
357            );
358        }
359    }
360    Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use serde_json::json;
367
368    fn provider_extension_inline() -> JsonValue {
369        json!({
370            "providers": [
371                {
372                    "provider_type": "messaging.telegram.bot",
373                    "capabilities": ["send", "receive"],
374                    "ops": ["send", "reply"],
375                    "config_schema_ref": "schemas/messaging/telegram/config.schema.json",
376                    "state_schema_ref": "schemas/messaging/telegram/state.schema.json",
377                    "runtime": {
378                        "component_ref": "telegram-provider",
379                        "export": "provider",
380                        "world": PROVIDER_RUNTIME_WORLD
381                    },
382                    "docs_ref": "schemas/messaging/telegram/README.md"
383                }
384            ]
385        })
386    }
387
388    #[test]
389    fn provider_extension_validates() {
390        let mut extensions = BTreeMap::new();
391        extensions.insert(
392            PROVIDER_EXTENSION_ID.to_string(),
393            ExtensionRef {
394                kind: PROVIDER_EXTENSION_ID.into(),
395                version: "1.0.0".into(),
396                digest: Some("sha256:abc123".into()),
397                location: None,
398                inline: Some(
399                    serde_json::from_value(provider_extension_inline()).expect("inline parse"),
400                ),
401            },
402        );
403        validate_extensions(Some(&extensions), false).expect("provider extension should validate");
404    }
405
406    #[test]
407    fn provider_extension_missing_required_fields_fails() {
408        let mut extensions = BTreeMap::new();
409        extensions.insert(
410            PROVIDER_EXTENSION_ID.to_string(),
411            ExtensionRef {
412                kind: PROVIDER_EXTENSION_ID.into(),
413                version: "1.0.0".into(),
414                digest: None,
415                location: None,
416                inline: Some(
417                    serde_json::from_value(json!({
418                        "providers": [{
419                            "provider_type": "",
420                            "capabilities": [],
421                            "ops": ["send"],
422                            "config_schema_ref": "",
423                            "state_schema_ref": "schemas/state.json",
424                            "runtime": {
425                                "component_ref": "",
426                                "export": "",
427                                "world": "greentic:provider/schema-core@1.0.0"
428                            }
429                        }]
430                    }))
431                    .expect("inline parse"),
432                ),
433            },
434        );
435        assert!(
436            validate_extensions(Some(&extensions), false).is_err(),
437            "missing fields should fail validation"
438        );
439    }
440
441    #[test]
442    fn strict_mode_requires_digest_for_remote_extension() {
443        let mut extensions = BTreeMap::new();
444        extensions.insert(
445            "greentic.ext.provider".to_string(),
446            ExtensionRef {
447                kind: PROVIDER_EXTENSION_ID.into(),
448                version: "1.0.0".into(),
449                digest: None,
450                location: Some("oci://registry/extensions/provider".into()),
451                inline: None,
452            },
453        );
454        assert!(
455            validate_extensions(Some(&extensions), true).is_err(),
456            "strict mode should require digest when location is set"
457        );
458    }
459
460    #[test]
461    fn unknown_extensions_are_allowed() {
462        let mut extensions = BTreeMap::new();
463        extensions.insert(
464            "acme.ext.logging".to_string(),
465            ExtensionRef {
466                kind: "acme.ext.logging".into(),
467                version: "0.1.0".into(),
468                digest: None,
469                location: None,
470                inline: None,
471            },
472        );
473        validate_extensions(Some(&extensions), false).expect("unknown extensions should pass");
474    }
475
476    #[test]
477    fn pack_config_preserves_unknown_inline_extension_payload() {
478        let cfg: PackConfig = serde_yaml_bw::from_str(
479            r#"pack_id: dev.local.static-routes
480version: 0.1.0
481kind: application
482publisher: Test
483extensions:
484  greentic.static-routes.v1:
485    kind: greentic.static-routes.v1
486    version: 0.4.37
487    inline:
488      version: 1
489      routes:
490        - id: webchat-gui
491          public_path: /v1/web/webchat/{tenant}
492          source_root: assets/webchat-gui
493          scope:
494            tenant: true
495            team: false
496          index_file: index.html
497          spa_fallback: index.html
498"#,
499        )
500        .expect("deserialize pack config");
501
502        let ext = cfg
503            .extensions
504            .as_ref()
505            .and_then(|extensions| extensions.get("greentic.static-routes.v1"))
506            .expect("static routes extension present");
507        assert_eq!(ext.version, "0.4.37");
508
509        let inline = match ext.inline.as_ref() {
510            Some(ExtensionInline::Other(value)) => value,
511            other => panic!("unexpected inline payload: {other:?}"),
512        };
513        assert_eq!(inline.get("version"), Some(&json!(1)));
514        assert_eq!(
515            inline
516                .get("routes")
517                .and_then(JsonValue::as_array)
518                .map(Vec::len),
519            Some(1)
520        );
521    }
522}