Skip to main content

robonix_executor/
config.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Executor config — same three-source resolution as pilot:
5//   compiled defaults < YAML at $ROBONIX_CONFIG_PATH < CLI flags / env.
6
7use anyhow::{Context, Result, bail};
8use clap::Parser;
9use serde::Deserialize;
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13pub const DEFAULT_EXECUTOR_PROVIDER_ID: &str = "executor";
14pub const EXECUTOR_NAMESPACE: &str = "robonix/system/executor";
15pub const DEFAULT_ATLAS_ENDPOINT: &str = "127.0.0.1:50051";
16pub const DEFAULT_LISTEN: &str = "127.0.0.1:50061";
17
18#[derive(Debug, Clone)]
19pub struct ExecutorConfig {
20    pub atlas_endpoint: String,
21    pub listen: String,
22    pub id: String,
23    pub verification: Vec<VerificationRule>,
24}
25
26/// Route one completed capability call to a verifier provider.
27#[derive(Debug, Clone, Deserialize, PartialEq)]
28pub struct VerificationRule {
29    pub target_contract_id: String,
30    #[serde(default)]
31    pub target_provider_id: Option<String>,
32    pub verifier_provider_id: String,
33    #[serde(default = "empty_json_object")]
34    pub verifier_args: serde_json::Value,
35}
36
37fn empty_json_object() -> serde_json::Value {
38    serde_json::json!({})
39}
40
41#[derive(Parser, Debug)]
42#[command(
43    name = "robonix-executor",
44    about = "Robonix Executor — tool-call dispatch runtime"
45)]
46pub struct Args {
47    /// Atlas control-plane endpoint. Also reads `ROBONIX_ATLAS` (the var rbnx /
48    /// the Python API / liaison use) as an alias; see `env_atlas`.
49    #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
50    pub atlas: Option<String>,
51
52    /// Address the SystemExecutor gRPC service binds to.
53    #[arg(long, env = "ROBONIX_EXECUTOR_LISTEN")]
54    pub listen: Option<String>,
55
56    /// Override executor's id (singleton; rarely needed).
57    #[arg(long, env = "ROBONIX_EXECUTOR_PROVIDER_ID")]
58    pub id: Option<String>,
59
60    /// Optional YAML config file (rbnx writes this; CLI/env still override).
61    #[arg(long, env = "ROBONIX_CONFIG_PATH")]
62    pub config: Option<PathBuf>,
63
64    /// Log level for this component (`debug`/`info`/`warn`/`error`). Sets the
65    /// scribe log-file floor; falls back to `SCRIBE_FILE_LEVEL` / `info`.
66    /// Normally arrives inside `--config-json`, not as a standalone flag.
67    #[arg(long)]
68    pub log: Option<String>,
69
70    /// The component's `system.executor` manifest block, serialized to JSON by
71    /// rbnx and passed as one arg (`--config-json '{…}'`). Parsed by the binary
72    /// itself — see `robonix_scribe::init_from_config`, which reads the `log`
73    /// key from it so the manifest's per-component level reaches the log.
74    #[arg(long)]
75    pub config_json: Option<String>,
76}
77
78#[derive(Default, Deserialize)]
79struct FileConfig {
80    #[serde(default)]
81    atlas_endpoint: Option<String>,
82    #[serde(default)]
83    listen: Option<String>,
84    #[serde(default)]
85    id: Option<String>,
86    #[serde(default)]
87    verification: Option<Vec<VerificationRule>>,
88}
89
90#[derive(Default, Deserialize)]
91struct ManifestConfig {
92    #[serde(default)]
93    verification: Option<Vec<VerificationRule>>,
94}
95
96impl ExecutorConfig {
97    /// Resolve connection settings from the existing CLI/env/YAML sources and
98    /// verification rules from the manifest block, falling back to YAML.
99    pub fn resolve(args: Args) -> Result<Self> {
100        let file_cfg: FileConfig = match &args.config {
101            Some(path) => load_yaml(path)?,
102            None => FileConfig::default(),
103        };
104        let manifest_cfg: ManifestConfig = match args.config_json.as_deref() {
105            Some(raw) => serde_json::from_str(raw).context("parse Executor --config-json")?,
106            None => ManifestConfig::default(),
107        };
108        let verification = validate_verification_rules(
109            manifest_cfg
110                .verification
111                .or(file_cfg.verification)
112                .unwrap_or_default(),
113        )?;
114        Ok(Self {
115            atlas_endpoint: args
116                .atlas
117                .or_else(env_atlas)
118                .or(file_cfg.atlas_endpoint)
119                .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string()),
120            listen: args
121                .listen
122                .or(file_cfg.listen)
123                .unwrap_or_else(|| DEFAULT_LISTEN.to_string()),
124            id: args
125                .id
126                .or(file_cfg.id)
127                .unwrap_or_else(|| DEFAULT_EXECUTOR_PROVIDER_ID.to_string()),
128            verification,
129        })
130    }
131}
132
133/// Normalize identifiers and reject ambiguous rules before Executor starts.
134fn validate_verification_rules(rules: Vec<VerificationRule>) -> Result<Vec<VerificationRule>> {
135    let mut seen = HashSet::new();
136    let mut normalized = Vec::with_capacity(rules.len());
137    for mut rule in rules {
138        rule.target_contract_id = rule.target_contract_id.trim().to_string();
139        rule.verifier_provider_id = rule.verifier_provider_id.trim().to_string();
140        rule.target_provider_id = rule
141            .target_provider_id
142            .map(|value| value.trim().to_string())
143            .filter(|value| !value.is_empty());
144        if rule.target_contract_id.is_empty() {
145            bail!("verification target_contract_id must not be empty");
146        }
147        if rule.verifier_provider_id.is_empty() {
148            bail!(
149                "verification verifier_provider_id must not be empty for '{}'",
150                rule.target_contract_id
151            );
152        }
153        if !rule.verifier_args.is_object() {
154            bail!(
155                "verification verifier_args must be a JSON object for '{}'",
156                rule.target_contract_id
157            );
158        }
159        let key = (
160            rule.target_contract_id.clone(),
161            rule.target_provider_id.clone(),
162        );
163        if !seen.insert(key) {
164            bail!(
165                "duplicate verification rule for contract '{}' and provider '{}'",
166                rule.target_contract_id,
167                rule.target_provider_id.as_deref().unwrap_or("*")
168            );
169        }
170        normalized.push(rule);
171    }
172    Ok(normalized)
173}
174
175/// Read the `ROBONIX_ATLAS` env var as an atlas-endpoint alias.
176///
177/// rbnx, the Python API, and liaison all configure the atlas endpoint via
178/// `ROBONIX_ATLAS`, while executor/pilot historically only honored
179/// `ROBONIX_ATLAS_ENDPOINT` (the clap `env`). Accepting `ROBONIX_ATLAS` here as
180/// well means a single env var configures every component. Without it, setting
181/// only `ROBONIX_ATLAS` left executor silently falling back to
182/// `DEFAULT_ATLAS_ENDPOINT` (127.0.0.1:50051) — it would then dial the wrong
183/// atlas and log 127.0.0.1 even after the operator "changed" the endpoint.
184/// Empty values are ignored so an exported-but-blank var doesn't shadow later
185/// sources.
186fn env_atlas() -> Option<String> {
187    std::env::var("ROBONIX_ATLAS")
188        .ok()
189        .filter(|v| !v.is_empty())
190}
191
192fn load_yaml(path: &Path) -> Result<FileConfig> {
193    let raw = std::fs::read_to_string(path)
194        .with_context(|| format!("read executor config '{}'", path.display()))?;
195    serde_yaml::from_str(&raw)
196        .with_context(|| format!("parse executor config '{}'", path.display()))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn args(config_json: Option<&str>) -> Args {
204        Args {
205            atlas: None,
206            listen: None,
207            id: None,
208            config: None,
209            log: None,
210            config_json: config_json.map(str::to_string),
211        }
212    }
213
214    #[test]
215    fn parses_manifest_verification_rules() {
216        let cfg = ExecutorConfig::resolve(args(Some(
217            r#"{
218                "verification": [{
219                    "target_contract_id": "robonix/service/navigation/navigate",
220                    "verifier_provider_id": "scene_verifier",
221                    "verifier_args": {"scene_provider_id": "scene"}
222                }]
223            }"#,
224        )))
225        .unwrap();
226        assert_eq!(cfg.verification.len(), 1);
227        assert_eq!(
228            cfg.verification[0].verifier_args["scene_provider_id"],
229            "scene"
230        );
231    }
232
233    #[test]
234    fn explicit_empty_manifest_rules_disable_defaults() {
235        let cfg = ExecutorConfig::resolve(args(Some(r#"{"verification": []}"#))).unwrap();
236        assert!(cfg.verification.is_empty());
237    }
238
239    #[test]
240    fn rejects_duplicate_rules_at_the_same_specificity() {
241        let error = ExecutorConfig::resolve(args(Some(
242            r#"{
243                "verification": [
244                    {"target_contract_id":"cap/a","verifier_provider_id":"v1"},
245                    {"target_contract_id":"cap/a","verifier_provider_id":"v2"}
246                ]
247            }"#,
248        )))
249        .unwrap_err();
250        assert!(error.to_string().contains("duplicate verification rule"));
251    }
252
253    #[test]
254    fn rejects_non_object_verifier_args() {
255        let error = ExecutorConfig::resolve(args(Some(
256            r#"{
257                "verification": [{
258                    "target_contract_id":"cap/a",
259                    "verifier_provider_id":"v1",
260                    "verifier_args": ["bad"]
261                }]
262            }"#,
263        )))
264        .unwrap_err();
265        assert!(error.to_string().contains("must be a JSON object"));
266    }
267}