agentmux_srv\drone/
data_flow.rs1use std::collections::HashMap;
13
14use serde_json::Value;
15
16const DRONE_ENV_PREFIX: &str = "AGENTMUX_DR_";
20
21#[derive(Debug, Default)]
25pub struct ExecutionScope {
26 pub outputs: HashMap<String, Value>,
27 pub vars: HashMap<String, Value>,
28}
29
30impl ExecutionScope {
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn resolve(&self, input: &str) -> String {
39 let mut out = String::with_capacity(input.len());
40 let bytes = input.as_bytes();
41 let mut i = 0;
42 while i < bytes.len() {
43 if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{' {
50 if let Some(end_rel) = find_close(&input[i + 2..]) {
52 let token = &input[i + 2..i + 2 + end_rel];
53 let resolved = self.lookup(token.trim());
54 match resolved {
55 Some(v) => out.push_str(&value_to_string(&v)),
56 None => {
57 out.push_str("{{");
59 out.push_str(token);
60 out.push_str("}}");
61 }
62 }
63 i = i + 2 + end_rel + 2;
64 continue;
65 }
66 }
67 let ch_len = input[i..]
70 .chars()
71 .next()
72 .expect("byte index inside &str must start a valid char")
73 .len_utf8();
74 out.push_str(&input[i..i + ch_len]);
75 i += ch_len;
76 }
77 out
78 }
79
80 fn lookup(&self, path: &str) -> Option<Value> {
81 let mut parts = path.split('.');
83 let head = parts.next()?;
84 let rest: Vec<&str> = parts.collect();
85
86 let root = if head == "var" || head == "vars" {
87 let name = rest.first()?;
89 let v = self.vars.get(*name)?;
90 return Some(walk(v.clone(), &rest[1..]));
91 } else if head == "env" {
92 let name = rest.first()?;
100 if !name.starts_with(DRONE_ENV_PREFIX) {
101 return None;
102 }
103 return std::env::var(name).ok().map(Value::String);
104 } else {
105 self.outputs.get(head)?
107 };
108 Some(walk(root.clone(), &rest))
109 }
110}
111
112fn find_close(s: &str) -> Option<usize> {
113 let bytes = s.as_bytes();
114 let mut i = 0;
115 while i + 1 < bytes.len() {
116 if bytes[i] == b'}' && bytes[i + 1] == b'}' {
117 return Some(i);
118 }
119 i += 1;
120 }
121 None
122}
123
124fn walk(mut v: Value, path: &[&str]) -> Value {
125 for key in path {
126 match v {
127 Value::Object(mut m) => {
128 v = m.remove(*key).unwrap_or(Value::Null);
129 }
130 _ => return Value::Null,
131 }
132 }
133 v
134}
135
136fn value_to_string(v: &Value) -> String {
137 match v {
138 Value::String(s) => s.clone(),
139 Value::Null => String::new(),
140 other => other.to_string(),
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use serde_json::json;
148
149 #[test]
150 fn resolves_block_output_path() {
151 let mut scope = ExecutionScope::new();
152 scope
153 .outputs
154 .insert("agent_1".to_string(), json!({ "response": "hello" }));
155 assert_eq!(scope.resolve("Got: {{agent_1.response}}"), "Got: hello");
156 }
157
158 #[test]
159 fn resolves_drone_var() {
160 let mut scope = ExecutionScope::new();
161 scope.vars.insert("name".to_string(), json!("world"));
162 assert_eq!(scope.resolve("hi {{var.name}}"), "hi world");
163 }
164
165 #[test]
166 fn leaves_unknown_tokens_intact() {
167 let scope = ExecutionScope::new();
168 assert_eq!(scope.resolve("{{ghost.x}}"), "{{ghost.x}}");
169 }
170
171 #[test]
172 fn deep_path_into_object() {
173 let mut scope = ExecutionScope::new();
174 scope
175 .outputs
176 .insert("api1".to_string(), json!({ "body": { "id": 42 } }));
177 assert_eq!(scope.resolve("got id={{api1.body.id}}"), "got id=42");
178 }
179
180 #[test]
181 fn preserves_text_around_tokens() {
182 let mut scope = ExecutionScope::new();
183 scope.vars.insert("x".to_string(), json!("X"));
184 assert_eq!(scope.resolve("a {{var.x}} b {{var.x}} c"), "a X b X c");
185 }
186
187 #[test]
188 fn empty_input_is_empty() {
189 let scope = ExecutionScope::new();
190 assert_eq!(scope.resolve(""), "");
191 }
192
193 #[test]
198 fn preserves_multibyte_chars_around_tokens() {
199 let mut scope = ExecutionScope::new();
200 scope.vars.insert("name".to_string(), json!("world"));
201 assert_eq!(scope.resolve("Olá {{var.name}}"), "Olá world");
203 }
204
205 #[test]
206 fn preserves_multibyte_token_value() {
207 let mut scope = ExecutionScope::new();
208 scope.vars.insert("greeting".to_string(), json!("こんにちは"));
209 assert_eq!(scope.resolve("hi: {{var.greeting}}"), "hi: こんにちは");
210 }
211
212 #[test]
213 fn preserves_emoji_passthrough() {
214 let scope = ExecutionScope::new();
215 assert_eq!(scope.resolve("ship it 🚀"), "ship it 🚀");
219 }
220
221 #[test]
222 fn preserves_multibyte_in_unresolved_token_passthrough() {
223 let scope = ExecutionScope::new();
224 assert_eq!(scope.resolve("café {{ghost}} ☕"), "café {{ghost}} ☕");
227 }
228
229 #[test]
234 fn env_var_allowed_under_drone_prefix() {
235 let key = "AGENTMUX_DR_TEST_PASS_KEY";
236 std::env::set_var(key, "hello");
237 let scope = ExecutionScope::new();
238 assert_eq!(
239 scope.resolve(&format!("got: {{{{env.{key}}}}}")),
240 "got: hello"
241 );
242 std::env::remove_var(key);
243 }
244
245 #[test]
246 fn env_var_outside_prefix_is_blocked() {
247 let secret_key = "AWS_TEST_SECRET_DO_NOT_LEAK";
251 std::env::set_var(secret_key, "TOPSECRET");
252 let scope = ExecutionScope::new();
253 let out = scope.resolve(&format!("leak: {{{{env.{secret_key}}}}}"));
256 assert!(!out.contains("TOPSECRET"), "secret value leaked: {out}");
257 std::env::remove_var(secret_key);
258 }
259
260 #[test]
261 fn env_var_path_blocked() {
262 let scope = ExecutionScope::new();
266 assert_eq!(scope.resolve("{{env.PATH}}"), "{{env.PATH}}");
267 }
268}