Skip to main content

greentic_runner_host/
config.rs

1use crate::gtbind::PackBinding;
2use crate::gtbind::TenantBindings;
3use crate::oauth::OAuthBrokerConfig;
4use crate::runner::mocks::MocksConfig;
5use crate::trace::TraceConfig;
6use crate::validate::ValidationConfig;
7use anyhow::{Context, Result};
8use parking_lot::RwLock;
9use serde::Deserialize;
10use serde_json::Value;
11use serde_yaml_bw as serde_yaml;
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::str::FromStr;
16use std::sync::Arc;
17
18#[derive(Debug, Clone)]
19pub struct HostConfig {
20    pub tenant: String,
21    pub bindings_path: PathBuf,
22    pub flow_type_bindings: HashMap<String, FlowBinding>,
23    pub rate_limits: RateLimits,
24    pub retry: FlowRetryConfig,
25    pub http_enabled: bool,
26    pub secrets_policy: SecretsPolicy,
27    pub state_store_policy: StateStorePolicy,
28    pub webhook_policy: WebhookPolicy,
29    pub timers: Vec<TimerBinding>,
30    pub oauth: Option<OAuthConfig>,
31    pub mocks: Option<MocksConfig>,
32    pub pack_bindings: Vec<PackBinding>,
33    pub env_passthrough: Vec<String>,
34    pub trace: TraceConfig,
35    pub validation: ValidationConfig,
36    pub operator_policy: OperatorPolicy,
37    pub fast2flow: Fast2FlowRoutingConfig,
38    /// Operator-declared Digital Worker agent configs, keyed by `agent_id`.
39    /// Sourced from the `agents:` section of the bindings YAML and consumed
40    /// by the production `ConfigProvider` (Task 4.3).
41    /// Only populated when the `agentic-worker` feature is enabled.
42    #[cfg(feature = "agentic-worker")]
43    pub agents: HashMap<String, greentic_aw_runtime::AgentConfig>,
44    /// Operator/producer-declared agent-graph configs, keyed by `graph_id`.
45    /// The pack loader (Task 8) reads `agent-graph.json` sidecars directly, but
46    /// this map is the contract that external producers (e.g. greentic-start's
47    /// env-file path, the admin registry hand-off) populate to supply graphs
48    /// that do not ship as a pack sidecar. Merged with pack-sidecar graphs at
49    /// runtime construction (producer entries win on `graph_id` collision).
50    /// Only populated when the `agentic-worker` feature is enabled.
51    #[cfg(feature = "agentic-worker")]
52    pub graphs: HashMap<String, greentic_aw_runtime::graph::GraphConfig>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct BindingsFile {
57    pub tenant: String,
58    #[serde(default)]
59    pub flow_type_bindings: HashMap<String, FlowBinding>,
60    #[serde(default)]
61    pub rate_limits: RateLimits,
62    #[serde(default)]
63    pub retry: FlowRetryConfig,
64    #[serde(default)]
65    pub timers: Vec<TimerBinding>,
66    #[serde(default)]
67    pub oauth: Option<OAuthConfig>,
68    #[serde(default)]
69    pub mocks: Option<MocksConfig>,
70    #[serde(default)]
71    pub state_store: StateStorePolicy,
72    #[serde(default)]
73    pub operator: OperatorPolicyConfig,
74    #[serde(default)]
75    pub fast2flow: Fast2FlowRoutingConfig,
76    /// Digital Worker agent configs keyed by `agent_id`. The whole section is
77    /// optional; each value must be a complete `AgentConfig` (all `limits`
78    /// fields are required — `AgentLimits` has no serde defaults).
79    /// Only present when the `agentic-worker` feature is enabled.
80    #[cfg(feature = "agentic-worker")]
81    #[serde(default)]
82    pub agents: HashMap<String, greentic_aw_runtime::AgentConfig>,
83    /// Operator-declared agent-graph configs keyed by `graph_id`. The whole
84    /// section is optional; each value must be a complete `GraphConfig`
85    /// (camelCase fields, same wire shape as the `agent-graph.json` sidecar).
86    /// Only present when the `agentic-worker` feature is enabled.
87    #[cfg(feature = "agentic-worker")]
88    #[serde(default)]
89    pub graphs: HashMap<String, greentic_aw_runtime::graph::GraphConfig>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
93pub struct FlowBinding {
94    pub adapter: String,
95    #[serde(default)]
96    pub config: serde_yaml::Value,
97    #[serde(default)]
98    pub secrets: Vec<String>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102pub struct RateLimits {
103    #[serde(default = "default_messaging_qps")]
104    pub messaging_send_qps: u32,
105    #[serde(default = "default_messaging_burst")]
106    pub messaging_burst: u32,
107}
108
109#[derive(Debug, Clone)]
110pub struct SecretsPolicy {
111    /// Secret names allowed by the bindings file.
112    binding_allowed: HashSet<String>,
113    /// Secret names discovered at flow-load time from node configs that
114    /// reference secrets via fields ending in `_secret` (e.g.
115    /// `api_key_secret: "llm-api-key"`). Shared across all clones so that
116    /// flow loading can register names after policy construction.
117    flow_discovered: Arc<RwLock<HashSet<String>>>,
118    allow_all: bool,
119}
120
121#[derive(Debug, Clone, Deserialize, Default)]
122pub struct OperatorPolicyConfig {
123    #[serde(default)]
124    pub allowed_providers: Vec<String>,
125    #[serde(default)]
126    pub allowed_ops: HashMap<String, Vec<String>>,
127}
128
129#[derive(Debug, Clone)]
130pub struct OperatorPolicy {
131    allow_all: bool,
132    allowed_providers: HashSet<String>,
133    allowed_ops: HashMap<String, HashSet<String>>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
137pub struct Fast2FlowRoutingConfig {
138    #[serde(default)]
139    pub enabled: bool,
140    #[serde(default = "default_fast2flow_component_ref")]
141    pub component_ref: String,
142    #[serde(default = "default_fast2flow_operation")]
143    pub operation: String,
144    #[serde(default)]
145    pub scope: Option<String>,
146    #[serde(default)]
147    pub registry_path: String,
148    #[serde(default)]
149    pub indexes_path: String,
150    #[serde(default = "default_fast2flow_time_budget_ms")]
151    pub time_budget_ms: u64,
152}
153
154impl Default for Fast2FlowRoutingConfig {
155    fn default() -> Self {
156        Self {
157            enabled: false,
158            component_ref: default_fast2flow_component_ref(),
159            operation: default_fast2flow_operation(),
160            scope: None,
161            registry_path: String::new(),
162            indexes_path: String::new(),
163            time_budget_ms: default_fast2flow_time_budget_ms(),
164        }
165    }
166}
167
168fn default_fast2flow_component_ref() -> String {
169    "fast2flow-routing".to_owned()
170}
171
172fn default_fast2flow_operation() -> String {
173    "route".to_owned()
174}
175
176fn default_fast2flow_time_budget_ms() -> u64 {
177    250
178}
179
180#[derive(Debug, Clone, Deserialize)]
181pub struct FlowRetryConfig {
182    #[serde(default = "default_retry_attempts")]
183    pub max_attempts: u32,
184    #[serde(default = "default_retry_base_delay_ms")]
185    pub base_delay_ms: u64,
186}
187
188#[derive(Debug, Clone, Default)]
189pub struct WebhookPolicy {
190    allow_paths: Vec<String>,
191    deny_paths: Vec<String>,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195pub struct StateStorePolicy {
196    #[serde(default = "default_state_store_allow")]
197    pub allow: bool,
198}
199
200#[derive(Debug, Clone, Deserialize)]
201pub struct WebhookBindingConfig {
202    #[serde(default)]
203    pub allow_paths: Vec<String>,
204    #[serde(default)]
205    pub deny_paths: Vec<String>,
206}
207
208#[derive(Debug, Clone, Deserialize)]
209pub struct TimerBinding {
210    pub flow_id: String,
211    pub cron: String,
212    #[serde(default)]
213    pub schedule_id: Option<String>,
214}
215
216#[derive(Debug, Clone, Deserialize)]
217pub struct OAuthConfig {
218    pub http_base_url: String,
219    pub nats_url: String,
220    pub provider: String,
221    #[serde(default)]
222    pub env: Option<String>,
223    #[serde(default)]
224    pub team: Option<String>,
225    #[serde(default)]
226    pub shared_secret: Option<String>,
227}
228
229impl HostConfig {
230    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
231        let path = path.as_ref();
232        let content = fs::read_to_string(path)
233            .with_context(|| format!("failed to read bindings file {path:?}"))?;
234        let bindings: BindingsFile = serde_yaml::from_str(&content)
235            .with_context(|| format!("failed to parse bindings file {path:?}"))?;
236
237        let secrets_policy = SecretsPolicy::from_bindings(&bindings);
238        let http_enabled = bindings.flow_type_bindings.contains_key("messaging");
239        let webhook_policy = bindings
240            .flow_type_bindings
241            .get("webhook")
242            .and_then(|binding| {
243                serde_yaml::from_value::<WebhookBindingConfig>(binding.config.clone())
244                    .map(WebhookPolicy::from)
245                    .map_err(|err| {
246                        tracing::warn!(error = %err, "failed to parse webhook binding config");
247                        err
248                    })
249                    .ok()
250            })
251            .unwrap_or_default();
252
253        Ok(Self {
254            tenant: bindings.tenant.clone(),
255            bindings_path: path.to_path_buf(),
256            flow_type_bindings: bindings.flow_type_bindings.clone(),
257            rate_limits: bindings.rate_limits.clone(),
258            retry: bindings.retry.clone(),
259            http_enabled,
260            secrets_policy,
261            state_store_policy: bindings.state_store.clone(),
262            webhook_policy,
263            timers: bindings.timers.clone(),
264            oauth: bindings.oauth.clone(),
265            mocks: bindings.mocks.clone(),
266            pack_bindings: Vec::new(),
267            env_passthrough: Vec::new(),
268            trace: TraceConfig::from_env(),
269            validation: ValidationConfig::from_env(),
270            operator_policy: OperatorPolicy::from_config(bindings.operator.clone()),
271            fast2flow: bindings.fast2flow.clone(),
272            #[cfg(feature = "agentic-worker")]
273            agents: bindings.agents.clone(),
274            #[cfg(feature = "agentic-worker")]
275            graphs: bindings.graphs.clone(),
276        })
277    }
278
279    pub fn from_gtbind(bindings: TenantBindings) -> Self {
280        Self {
281            tenant: bindings.tenant,
282            bindings_path: PathBuf::from("<gtbind>"),
283            flow_type_bindings: HashMap::new(),
284            rate_limits: RateLimits::default(),
285            retry: FlowRetryConfig::default(),
286            // GTBind-backed embedded hosts do not populate flow_type_bindings,
287            // so the YAML-path heuristic cannot be used here. Keep outbound
288            // host HTTP enabled so components like component-llm-openai can
289            // reach their configured providers.
290            http_enabled: true,
291            secrets_policy: SecretsPolicy::allow_all(),
292            state_store_policy: StateStorePolicy::default(),
293            webhook_policy: WebhookPolicy::default(),
294            timers: Vec::new(),
295            oauth: None,
296            mocks: None,
297            pack_bindings: bindings.packs,
298            env_passthrough: bindings.env_passthrough,
299            trace: TraceConfig::from_env(),
300            validation: ValidationConfig::from_env(),
301            operator_policy: OperatorPolicy::allow_all(),
302            fast2flow: Fast2FlowRoutingConfig::default(),
303            // TODO(phase-4): TenantBindings (gtbind) has no agents section yet.
304            // When embedded gtbind hosts need Digital Worker agents, extend
305            // TenantBindings to carry them and populate this map here.
306            #[cfg(feature = "agentic-worker")]
307            agents: HashMap::new(),
308            // TODO(phase-4): likewise, gtbind carries no agent-graph section;
309            // pack sidecars remain the local source of graphs for now.
310            #[cfg(feature = "agentic-worker")]
311            graphs: HashMap::new(),
312        }
313    }
314
315    pub fn messaging_binding(&self) -> Option<&FlowBinding> {
316        self.flow_type_bindings.get("messaging")
317    }
318
319    pub fn retry_config(&self) -> FlowRetryConfig {
320        self.retry.clone()
321    }
322
323    pub fn oauth_broker_config(&self) -> Option<OAuthBrokerConfig> {
324        let env_secret = std::env::var("GREENTIC_OAUTH_BROKER_SHARED_SECRET").ok();
325        self.oauth_broker_config_with_env(env_secret.as_deref())
326    }
327
328    /// Internal builder that accepts the env-var value explicitly so that
329    /// tests can exercise the priority logic without mutating the process
330    /// environment (which is unsafe in Rust 2024).
331    fn oauth_broker_config_with_env(&self, env_secret: Option<&str>) -> Option<OAuthBrokerConfig> {
332        let oauth = self.oauth.as_ref()?;
333        let mut cfg = OAuthBrokerConfig::new(&oauth.http_base_url, &oauth.nats_url);
334        if !oauth.provider.is_empty() {
335            cfg.default_provider = Some(oauth.provider.clone());
336        }
337        if let Some(team) = &oauth.team
338            && !team.is_empty()
339        {
340            cfg.team = Some(team.clone());
341        }
342        // Prefer env var; fall back to yaml field. Never log the value.
343        cfg.shared_secret = env_secret
344            .map(str::to_owned)
345            .or_else(|| oauth.shared_secret.clone());
346        Some(cfg)
347    }
348
349    /// Derive a tenant context for the current configuration. This is used when
350    /// evaluating scoped requirements (e.g. secrets).
351    pub fn tenant_ctx(&self) -> greentic_types::TenantCtx {
352        let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
353        let env_id = greentic_types::EnvId::from_str(&env)
354            .unwrap_or_else(|_| greentic_types::EnvId::new("local").expect("local env id"));
355        let tenant_id = greentic_types::TenantId::from_str(&self.tenant)
356            .unwrap_or_else(|_| greentic_types::TenantId::new("local").expect("tenant id"));
357        greentic_types::TenantCtx::new(env_id, tenant_id)
358    }
359}
360
361impl SecretsPolicy {
362    fn from_bindings(bindings: &BindingsFile) -> Self {
363        let binding_allowed = bindings
364            .flow_type_bindings
365            .values()
366            .flat_map(|binding| binding.secrets.iter().cloned())
367            .collect::<HashSet<_>>();
368        Self {
369            binding_allowed,
370            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
371            allow_all: false,
372        }
373    }
374
375    pub fn is_allowed(&self, key: &str) -> bool {
376        if self.allow_all || self.binding_allowed.contains(key) {
377            return true;
378        }
379        self.flow_discovered.read().contains(key)
380    }
381
382    pub fn allow_all() -> Self {
383        Self {
384            binding_allowed: HashSet::new(),
385            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
386            allow_all: true,
387        }
388    }
389
390    /// Register a secret name discovered while loading a flow's node config.
391    ///
392    /// Components are gated by [`is_allowed`]; the bindings file is the
393    /// authoritative source, but flows that reference secrets directly via
394    /// node config fields (`api_key_secret: "llm-api-key"`) would otherwise
395    /// have their lookups denied even when the secret is provisioned. Calling
396    /// this from the pack flow loader closes that gap without changing the
397    /// component manifest contract.
398    pub fn register_flow_secret(&self, name: &str) {
399        if name.is_empty() {
400            return;
401        }
402        self.flow_discovered.write().insert(name.to_string());
403    }
404
405    /// Walk a JSON value (typically a flow node's config block) and register
406    /// any string-valued field whose key ends in `_secret`. Recurses through
407    /// nested objects and arrays.
408    pub fn register_flow_secret_refs(&self, value: &Value) {
409        match value {
410            Value::Object(map) => {
411                for (key, val) in map {
412                    if key.ends_with("_secret")
413                        && let Value::String(name) = val
414                    {
415                        self.register_flow_secret(name);
416                    } else {
417                        self.register_flow_secret_refs(val);
418                    }
419                }
420            }
421            Value::Array(items) => {
422                for item in items {
423                    self.register_flow_secret_refs(item);
424                }
425            }
426            _ => {}
427        }
428    }
429}
430
431impl OperatorPolicy {
432    pub fn from_config(config: OperatorPolicyConfig) -> Self {
433        let allowed_providers = config.allowed_providers.into_iter().collect::<HashSet<_>>();
434        let allowed_ops = config
435            .allowed_ops
436            .into_iter()
437            .map(|(provider, ops)| (provider, ops.into_iter().collect::<HashSet<_>>()))
438            .collect::<HashMap<_, _>>();
439        let allow_all = allowed_providers.is_empty() && allowed_ops.is_empty();
440        Self {
441            allow_all,
442            allowed_providers,
443            allowed_ops,
444        }
445    }
446
447    pub fn allow_all() -> Self {
448        Self {
449            allow_all: true,
450            allowed_providers: HashSet::new(),
451            allowed_ops: HashMap::new(),
452        }
453    }
454
455    pub fn allows_provider(&self, provider_id: Option<&str>, provider_type: &str) -> bool {
456        if self.allow_all {
457            return true;
458        }
459        provider_id
460            .map(|id| self.allowed_providers.contains(id))
461            .unwrap_or(false)
462            || self.allowed_providers.contains(provider_type)
463    }
464
465    pub fn allows_op(&self, provider_id: Option<&str>, provider_type: &str, op_id: &str) -> bool {
466        if self.allow_all {
467            return true;
468        }
469        if let Some(ops) = provider_id.and_then(|id| self.allowed_ops.get(id)) {
470            return ops.contains(op_id);
471        }
472        if let Some(ops) = self.allowed_ops.get(provider_type) {
473            return ops.contains(op_id);
474        }
475        self.allows_provider(provider_id, provider_type)
476    }
477}
478
479impl Default for RateLimits {
480    fn default() -> Self {
481        Self {
482            messaging_send_qps: default_messaging_qps(),
483            messaging_burst: default_messaging_burst(),
484        }
485    }
486}
487
488impl Default for StateStorePolicy {
489    fn default() -> Self {
490        Self {
491            allow: default_state_store_allow(),
492        }
493    }
494}
495
496fn default_messaging_qps() -> u32 {
497    10
498}
499
500fn default_messaging_burst() -> u32 {
501    20
502}
503
504fn default_state_store_allow() -> bool {
505    true
506}
507
508impl From<WebhookBindingConfig> for WebhookPolicy {
509    fn from(value: WebhookBindingConfig) -> Self {
510        Self {
511            allow_paths: value.allow_paths,
512            deny_paths: value.deny_paths,
513        }
514    }
515}
516
517impl WebhookPolicy {
518    pub fn is_allowed(&self, path: &str) -> bool {
519        if self
520            .deny_paths
521            .iter()
522            .any(|prefix| path.starts_with(prefix))
523        {
524            return false;
525        }
526
527        if self.allow_paths.is_empty() {
528            return true;
529        }
530
531        self.allow_paths
532            .iter()
533            .any(|prefix| path.starts_with(prefix))
534    }
535}
536
537impl TimerBinding {
538    pub fn schedule_id(&self) -> &str {
539        self.schedule_id.as_deref().unwrap_or(self.flow_id.as_str())
540    }
541}
542
543impl Default for FlowRetryConfig {
544    fn default() -> Self {
545        Self {
546            max_attempts: default_retry_attempts(),
547            base_delay_ms: default_retry_base_delay_ms(),
548        }
549    }
550}
551
552#[cfg(test)]
553mod operator_policy_tests {
554    use super::{OperatorPolicy, OperatorPolicyConfig};
555    use std::collections::HashMap;
556
557    #[test]
558    fn policy_allows_configured_provider_op() {
559        let mut allowed_ops = HashMap::new();
560        allowed_ops.insert("provider.allowed".into(), vec!["op1".into(), "op2".into()]);
561        let config = OperatorPolicyConfig {
562            allowed_providers: vec!["provider.allowed".into()],
563            allowed_ops,
564        };
565        let policy = OperatorPolicy::from_config(config);
566        assert!(policy.allows_provider(Some("provider.allowed"), "provider.allowed"));
567        assert!(policy.allows_op(Some("provider.allowed"), "provider.allowed", "op1"));
568        assert!(!policy.allows_op(Some("provider.allowed"), "provider.allowed", "other"));
569        assert!(!policy.allows_provider(Some("provider.denied"), "provider.denied"));
570    }
571
572    #[test]
573    fn policy_allow_all_defaults_true() {
574        let policy = OperatorPolicy::allow_all();
575        assert!(policy.allows_provider(None, "any"));
576        assert!(policy.allows_op(None, "any", "op"));
577    }
578}
579
580fn default_retry_attempts() -> u32 {
581    3
582}
583
584fn default_retry_base_delay_ms() -> u64 {
585    250
586}
587
588#[cfg(test)]
589#[allow(clippy::items_after_test_module)]
590mod tests {
591    use super::*;
592    use crate::gtbind::{PackBinding, TenantBindings};
593    use std::collections::HashMap;
594    use std::path::PathBuf;
595
596    fn host_config_with_oauth(oauth: Option<OAuthConfig>) -> HostConfig {
597        HostConfig {
598            tenant: "tenant-a".to_string(),
599            bindings_path: PathBuf::from("/tmp/bindings.yaml"),
600            flow_type_bindings: HashMap::new(),
601            rate_limits: RateLimits::default(),
602            retry: FlowRetryConfig::default(),
603            http_enabled: false,
604            secrets_policy: SecretsPolicy::allow_all(),
605            state_store_policy: StateStorePolicy::default(),
606            webhook_policy: WebhookPolicy::default(),
607            timers: Vec::new(),
608            oauth,
609            mocks: None,
610            pack_bindings: Vec::new(),
611            env_passthrough: Vec::new(),
612            trace: TraceConfig::from_env(),
613            validation: ValidationConfig::from_env(),
614            operator_policy: OperatorPolicy::allow_all(),
615            fast2flow: Fast2FlowRoutingConfig::default(),
616            #[cfg(feature = "agentic-worker")]
617            agents: HashMap::new(),
618            #[cfg(feature = "agentic-worker")]
619            graphs: HashMap::new(),
620        }
621    }
622
623    #[cfg(feature = "agentic-worker")]
624    #[test]
625    fn load_from_path_parses_agents_section() {
626        // All seven `limits` fields are required: AgentLimits has no serde
627        // defaults, so operator-authored YAML must spell each one out.
628        let yaml = r#"
629tenant: acme
630agents:
631  greeter:
632    agent_id: greeter
633    system_prompt: "You are a greeter."
634    tools: []
635    llm:
636      provider: openai
637      model: gpt-4o-mini
638    limits:
639      max_iter: 8
640      timeout: 60
641      max_history_turns: 20
642      llm_retry_attempts: 3
643      llm_retry_backoff: 250
644      provider_failure_message: null
645      daily_token_cap_per_tenant: null
646"#;
647        let dir = tempfile::tempdir().unwrap();
648        let path = dir.path().join("bindings.yaml");
649        std::fs::write(&path, yaml).unwrap();
650        let cfg = HostConfig::load_from_path(&path).unwrap();
651        assert!(cfg.agents.contains_key("greeter"));
652        let greeter = &cfg.agents["greeter"];
653        assert_eq!(greeter.system_prompt, "You are a greeter.");
654        assert_eq!(greeter.limits.max_iter, 8);
655        // duration_secs / duration_ms custom serde maps the integers above.
656        assert_eq!(greeter.limits.timeout, std::time::Duration::from_secs(60));
657        assert_eq!(
658            greeter.limits.llm_retry_backoff,
659            std::time::Duration::from_millis(250)
660        );
661    }
662
663    #[cfg(feature = "agentic-worker")]
664    #[test]
665    fn load_from_path_omitted_agents_section_yields_empty_map() {
666        let yaml = "tenant: acme\n";
667        let dir = tempfile::tempdir().unwrap();
668        let path = dir.path().join("bindings.yaml");
669        std::fs::write(&path, yaml).unwrap();
670        let cfg = HostConfig::load_from_path(&path).unwrap();
671        assert!(cfg.agents.is_empty());
672    }
673
674    #[test]
675    fn host_config_loads_fast2flow_routing_block() {
676        let temp = tempfile::TempDir::new().expect("tempdir");
677        let path = temp.path().join("bindings.yaml");
678        std::fs::write(
679            &path,
680            r#"
681tenant: demo
682fast2flow:
683  enabled: true
684  component_ref: router.fast2flow
685  operation: handle-hook
686  scope: tenant-a
687  registry_path: /mnt/registry
688  indexes_path: /mnt/indexes
689  time_budget_ms: 750
690"#,
691        )
692        .expect("write bindings");
693
694        let cfg = HostConfig::load_from_path(&path).expect("load config");
695
696        assert!(cfg.fast2flow.enabled);
697        assert_eq!(cfg.fast2flow.component_ref, "router.fast2flow");
698        assert_eq!(cfg.fast2flow.operation, "handle-hook");
699        assert_eq!(cfg.fast2flow.scope.as_deref(), Some("tenant-a"));
700        assert_eq!(cfg.fast2flow.registry_path, "/mnt/registry");
701        assert_eq!(cfg.fast2flow.indexes_path, "/mnt/indexes");
702        assert_eq!(cfg.fast2flow.time_budget_ms, 750);
703    }
704
705    #[test]
706    fn secrets_policy_register_flow_secret_refs_walks_nested_objects() {
707        let policy = SecretsPolicy {
708            binding_allowed: HashSet::new(),
709            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
710            allow_all: false,
711        };
712
713        // Bare bindings deny anything by default.
714        assert!(!policy.is_allowed("llm-api-key"));
715
716        // Simulate a flow node config block carrying both a top-level
717        // *_secret reference and a nested one inside an arbitrary subtree.
718        let node_config = serde_json::json!({
719            "api_key_secret": "llm-api-key",
720            "provider": "openai",
721            "fallback": {
722                "secondary_api_key_secret": "openrouter-key",
723                "model": "gpt-4o",
724            },
725            "list": [
726                { "tertiary_secret": "another-key" },
727                { "non_secret_field": "ignored" },
728            ],
729            "ignored_field": ""
730        });
731
732        policy.register_flow_secret_refs(&node_config);
733
734        assert!(policy.is_allowed("llm-api-key"));
735        assert!(policy.is_allowed("openrouter-key"));
736        assert!(policy.is_allowed("another-key"));
737        assert!(!policy.is_allowed("non_secret_field"));
738        assert!(!policy.is_allowed("ignored"));
739    }
740
741    #[test]
742    fn secrets_policy_ignores_empty_or_non_string_secret_values() {
743        let policy = SecretsPolicy {
744            binding_allowed: HashSet::new(),
745            flow_discovered: Arc::new(RwLock::new(HashSet::new())),
746            allow_all: false,
747        };
748
749        let node_config = serde_json::json!({
750            "api_key_secret": "",
751            "fallback_secret": null,
752            "numeric_secret": 42,
753            "real_secret": "good-key",
754        });
755
756        policy.register_flow_secret_refs(&node_config);
757
758        assert!(policy.is_allowed("good-key"));
759        assert!(!policy.is_allowed(""));
760    }
761
762    #[test]
763    fn oauth_broker_config_absent_without_block() {
764        let cfg = host_config_with_oauth(None);
765        assert!(cfg.oauth_broker_config().is_none());
766    }
767
768    #[test]
769    fn oauth_broker_config_maps_fields() {
770        let cfg = host_config_with_oauth(Some(OAuthConfig {
771            http_base_url: "https://oauth.example/".into(),
772            nats_url: "nats://broker:4222".into(),
773            provider: "demo".into(),
774            env: None,
775            team: Some("ops".into()),
776            shared_secret: None,
777        }));
778        // Use `_with_env(None)` so the test is not perturbed by a set
779        // GREENTIC_OAUTH_BROKER_SHARED_SECRET environment variable.
780        let broker = cfg
781            .oauth_broker_config_with_env(None)
782            .expect("missing broker config");
783        assert_eq!(broker.http_base_url, "https://oauth.example/");
784        assert_eq!(broker.nats_url, "nats://broker:4222");
785        assert_eq!(broker.default_provider.as_deref(), Some("demo"));
786        assert_eq!(broker.team.as_deref(), Some("ops"));
787        assert!(broker.shared_secret.is_none());
788    }
789
790    #[test]
791    fn oauth_broker_config_maps_shared_secret_from_yaml() {
792        let cfg = host_config_with_oauth(Some(OAuthConfig {
793            http_base_url: "https://oauth.example/".into(),
794            nats_url: "nats://broker:4222".into(),
795            provider: "demo".into(),
796            env: None,
797            team: None,
798            shared_secret: Some("yaml-secret".into()),
799        }));
800        // Pass None as the env var to exercise the yaml-field fallback path.
801        let broker = cfg
802            .oauth_broker_config_with_env(None)
803            .expect("missing broker config");
804        assert_eq!(broker.shared_secret.as_deref(), Some("yaml-secret"));
805    }
806
807    #[test]
808    fn oauth_broker_config_env_overrides_yaml_shared_secret() {
809        let cfg = host_config_with_oauth(Some(OAuthConfig {
810            http_base_url: "https://oauth.example/".into(),
811            nats_url: "nats://broker:4222".into(),
812            provider: "demo".into(),
813            env: None,
814            team: None,
815            shared_secret: Some("yaml-secret".into()),
816        }));
817        let broker = cfg
818            .oauth_broker_config_with_env(Some("env-secret"))
819            .expect("missing broker config");
820        assert_eq!(broker.shared_secret.as_deref(), Some("env-secret"));
821    }
822
823    #[test]
824    fn oauth_broker_config_env_provides_secret_when_yaml_absent() {
825        let cfg = host_config_with_oauth(Some(OAuthConfig {
826            http_base_url: "https://oauth.example/".into(),
827            nats_url: "nats://broker:4222".into(),
828            provider: "demo".into(),
829            env: None,
830            team: None,
831            shared_secret: None,
832        }));
833        let broker = cfg
834            .oauth_broker_config_with_env(Some("env-only-secret"))
835            .expect("missing broker config");
836        assert_eq!(broker.shared_secret.as_deref(), Some("env-only-secret"));
837    }
838
839    #[test]
840    fn gtbind_configs_enable_outbound_http() {
841        let cfg = HostConfig::from_gtbind(TenantBindings {
842            tenant: "demo".into(),
843            packs: vec![PackBinding {
844                pack_id: "deep-research-demo".into(),
845                pack_ref: "deep-research-demo@0.1.0".into(),
846                pack_locator: None,
847                flows: vec!["main".into()],
848            }],
849            env_passthrough: Vec::new(),
850        });
851
852        assert!(
853            cfg.http_enabled,
854            "gtbind-backed tenants should allow outbound component HTTP"
855        );
856    }
857}