robonix_executor/
config.rs1use 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#[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 #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
50 pub atlas: Option<String>,
51
52 #[arg(long, env = "ROBONIX_EXECUTOR_LISTEN")]
54 pub listen: Option<String>,
55
56 #[arg(long, env = "ROBONIX_EXECUTOR_PROVIDER_ID")]
58 pub id: Option<String>,
59
60 #[arg(long, env = "ROBONIX_CONFIG_PATH")]
62 pub config: Option<PathBuf>,
63
64 #[arg(long)]
68 pub log: Option<String>,
69
70 #[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 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
133fn 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
175fn 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}