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