greentic_dev/
pack_run.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::bail;
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::flow_bundle::load_and_validate_bundle;
9use greentic_runner::desktop::{
10    HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
11};
12use serde_json::{Value as JsonValue, json};
13use serde_yaml_bw as serde_yaml;
14
15#[derive(Debug, Clone)]
16pub struct PackRunConfig<'a> {
17    pub pack_path: &'a Path,
18    pub entry: Option<String>,
19    pub input: Option<String>,
20    pub policy: RunPolicy,
21    pub otlp: Option<String>,
22    pub allow_hosts: Option<Vec<String>>,
23    pub mocks: MockSetting,
24    pub artifacts_dir: Option<&'a Path>,
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum RunPolicy {
29    Strict,
30    DevOk,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub enum MockSetting {
35    On,
36    Off,
37}
38
39pub fn run(config: PackRunConfig<'_>) -> Result<()> {
40    // Print runner diagnostics even if the caller did not configure tracing.
41    let _ = tracing_subscriber::fmt::try_init();
42
43    // Ensure Wasmtime cache/config paths live inside the workspace so sandboxed runs can create them.
44    if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
45        let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
46        let home = workspace.join(".greentic").join("wasmtime-home");
47        let cache_dir = home
48            .join("Library")
49            .join("Caches")
50            .join("BytecodeAlliance.wasmtime");
51        let config_dir = home
52            .join("Library")
53            .join("Application Support")
54            .join("wasmtime");
55        fs::create_dir_all(&cache_dir)
56            .with_context(|| format!("failed to create {}", cache_dir.display()))?;
57        fs::create_dir_all(&config_dir)
58            .with_context(|| format!("failed to create {}", config_dir.display()))?;
59        // SAFETY: we scope HOME and cache dir to a workspace-local directory to avoid
60        // writing outside the sandbox; this only affects the child Wasmtime engine.
61        unsafe {
62            std::env::set_var("HOME", &home);
63            std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
64        }
65    }
66
67    let input_value = parse_input(config.input)?;
68    let otlp_hook = config.otlp.map(|endpoint| OtlpHook {
69        endpoint,
70        headers: Vec::new(),
71        sample_all: true,
72    });
73
74    // Avoid system proxy discovery (reqwest on macOS can panic in sandboxed CI).
75    unsafe {
76        std::env::set_var("NO_PROXY", "*");
77        std::env::set_var("HTTPS_PROXY", "");
78        std::env::set_var("HTTP_PROXY", "");
79        std::env::set_var("CFNETWORK_DISABLE_SYSTEM_PROXY", "1");
80    }
81
82    let allow_hosts = config.allow_hosts.unwrap_or_default();
83    let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
84
85    let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
86    if let Some(dir) = &artifacts_override {
87        fs::create_dir_all(dir)
88            .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
89    }
90
91    let runner = Runner::new();
92    let run_result = runner
93        .run_pack_with(config.pack_path, |opts| {
94            opts.entry_flow = config.entry.clone();
95            opts.input = input_value.clone();
96            opts.signing = signing_policy(config.policy);
97            if let Some(hook) = otlp_hook.clone() {
98                opts.otlp = Some(hook);
99            }
100            opts.mocks = mocks_config.clone();
101            opts.artifacts_dir = artifacts_override.clone();
102        })
103        .context("pack execution failed")?;
104
105    let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
106    let status = value
107        .get("status")
108        .and_then(|v| v.as_str())
109        .unwrap_or_default();
110    let rendered =
111        serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
112    println!("{rendered}");
113
114    if status == "Failure" || status == "PartialFailure" {
115        let err = value
116            .get("error")
117            .and_then(|v| v.as_str())
118            .unwrap_or("pack run returned failure status");
119        bail!("pack run failed: {err}");
120    }
121
122    Ok(())
123}
124
125fn parse_input(input: Option<String>) -> Result<JsonValue> {
126    if let Some(raw) = input {
127        if raw.trim().is_empty() {
128            return Ok(json!({}));
129        }
130        serde_json::from_str(&raw).context("failed to parse --input JSON")
131    } else {
132        Ok(json!({}))
133    }
134}
135
136fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
137    let mut config = MocksConfig {
138        net_allowlist: allow_hosts
139            .into_iter()
140            .map(|host| host.trim().to_ascii_lowercase())
141            .filter(|host| !host.is_empty())
142            .collect(),
143        ..MocksConfig::default()
144    };
145
146    if matches!(setting, MockSetting::On) {
147        config.http = Some(HttpMock {
148            record_replay_dir: None,
149            mode: HttpMockMode::RecordReplay,
150            rewrites: Vec::new(),
151        });
152
153        let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
154        fs::create_dir_all(&tools_dir)
155            .with_context(|| format!("failed to create {}", tools_dir.display()))?;
156        config.mcp_tools = Some(ToolsMock {
157            directory: None,
158            script_dir: Some(tools_dir),
159            short_circuit: true,
160        });
161    }
162
163    Ok(config)
164}
165
166fn signing_policy(policy: RunPolicy) -> SigningPolicy {
167    match policy {
168        RunPolicy::Strict => SigningPolicy::Strict,
169        RunPolicy::DevOk => SigningPolicy::DevOk,
170    }
171}
172
173/// Run a config flow and return the final payload as a JSON string.
174#[allow(dead_code)]
175pub fn run_config_flow(flow_path: &Path) -> Result<String> {
176    let source = std::fs::read_to_string(flow_path)
177        .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
178    // Validate against embedded schema to catch malformed flows.
179    load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
180
181    let doc: serde_yaml::Value = serde_yaml::from_str(&source)
182        .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
183    let nodes = doc
184        .get("nodes")
185        .and_then(|v| v.as_mapping())
186        .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
187
188    let mut current = nodes
189        .iter()
190        .next()
191        .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
192        .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
193    let mut state: BTreeMap<String, String> = BTreeMap::new();
194    let mut visited: BTreeSet<String> = BTreeSet::new();
195    let is_tty = io::stdin().is_terminal();
196
197    loop {
198        if !visited.insert(current.clone()) {
199            bail!("config flow routing loop detected at {}", current);
200        }
201
202        let node_val = nodes
203            .get(serde_yaml::Value::String(current.clone(), None))
204            .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
205        let mapping = node_val
206            .as_mapping()
207            .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
208
209        // questions node
210        if let Some(fields) = mapping
211            .get(serde_yaml::Value::String("questions".to_string(), None))
212            .and_then(|q| {
213                q.as_mapping()
214                    .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
215            })
216            .and_then(|v| v.as_sequence())
217        {
218            for field in fields {
219                let Some(field_map) = field.as_mapping() else {
220                    continue;
221                };
222                let id = field_map
223                    .get(serde_yaml::Value::String("id".to_string(), None))
224                    .and_then(|v| v.as_str())
225                    .unwrap_or("")
226                    .to_string();
227                if id.is_empty() {
228                    continue;
229                }
230                let prompt = field_map
231                    .get(serde_yaml::Value::String("prompt".to_string(), None))
232                    .and_then(|v| v.as_str())
233                    .unwrap_or(&id);
234                let default = field_map
235                    .get(serde_yaml::Value::String("default".to_string(), None))
236                    .and_then(|v| v.as_str())
237                    .unwrap_or("");
238                let value = if is_tty {
239                    print!("{prompt} [{default}]: ");
240                    let _ = io::stdout().flush();
241                    let mut buf = String::new();
242                    io::stdin().read_line(&mut buf).ok();
243                    let trimmed = buf.trim();
244                    if trimmed.is_empty() {
245                        default.to_string()
246                    } else {
247                        trimmed.to_string()
248                    }
249                } else {
250                    default.to_string()
251                };
252                state.insert(id, value);
253            }
254        }
255
256        // template string path
257        if let Some(template) = mapping
258            .get(serde_yaml::Value::String("template".to_string(), None))
259            .and_then(|v| v.as_str())
260        {
261            let mut rendered = template.to_string();
262            for (k, v) in &state {
263                let needle = format!("{{{{state.{k}}}}}");
264                rendered = rendered.replace(&needle, v);
265            }
266            return Ok(rendered);
267        }
268
269        // payload with node_id/node
270        if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
271            let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
272                payload.clone(),
273            )?)
274            .context("failed to render config flow payload")?;
275            return Ok(json_str);
276        }
277
278        // follow routing if present
279        if let Some(next) = mapping
280            .get(serde_yaml::Value::String("routing".to_string(), None))
281            .and_then(|r| r.as_sequence())
282            .and_then(|seq| seq.first())
283            .and_then(|entry| {
284                entry
285                    .as_mapping()
286                    .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
287                    .and_then(|v| v.as_str())
288            })
289        {
290            current = next.to_string();
291            continue;
292        }
293
294        bail!("config flow ended without producing template or payload");
295    }
296}