bamboo_infrastructure/config/
patch.rs1use serde_json::{Map, Value};
7
8use crate::Config;
9
10pub fn is_masked_api_key(value: &str) -> bool {
12 let v = value.trim();
13 v.contains("***") || v.contains("...") || v == "****...****"
15}
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ProviderApiKeyIntents {
22 pub providers: std::collections::BTreeSet<String>,
23 pub provider_instances: std::collections::BTreeSet<String>,
24}
25
26pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
27 let mut intents = ProviderApiKeyIntents::default();
28
29 if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
30 for (provider_name, provider_patch) in root.iter() {
31 let Some(obj) = provider_patch.as_object() else {
32 continue;
33 };
34 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
35 continue;
36 };
37 if is_masked_api_key(api_key) {
38 continue;
39 }
40 intents.providers.insert(provider_name.clone());
41 }
42 }
43
44 if let Some(root) = patch_obj
45 .get("provider_instances")
46 .and_then(|v| v.as_object())
47 {
48 for (instance_id, instance_patch) in root.iter() {
49 let Some(obj) = instance_patch.as_object() else {
50 continue;
51 };
52 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
53 continue;
54 };
55 if is_masked_api_key(api_key) {
56 continue;
57 }
58 intents.provider_instances.insert(instance_id.clone());
59 }
60 }
61
62 intents
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReloadMode {
68 None,
69 BestEffort,
71 Strict,
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct PatchEffects {
78 pub reload_provider: ReloadMode,
79 pub reconcile_mcp: bool,
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub struct DomainChanges {
85 pub provider: bool,
86 pub proxy: bool,
87 pub setup: bool,
88 pub mcp: bool,
89 pub keyword_masking: bool,
90 pub hooks: bool,
91 pub model_mapping: bool,
92}
93
94pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
96 let mut changes = DomainChanges::default();
97
98 for key in patch_obj.keys() {
99 match key.as_str() {
100 "provider"
102 | "providers"
103 | "provider_instances"
104 | "default_provider_instance"
105 | "model"
106 | "defaults"
107 | "features" => changes.provider = true,
108
109 "http_proxy"
111 | "https_proxy"
112 | "proxy_auth"
113 | "proxy_auth_encrypted"
114 | "http_proxy_auth_encrypted"
115 | "https_proxy_auth_encrypted" => changes.proxy = true,
116
117 "setup" => changes.setup = true,
119
120 "mcp" | "mcpServers" => changes.mcp = true,
122
123 "keyword_masking" => changes.keyword_masking = true,
125 "hooks" => changes.hooks = true,
126 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
127
128 _ => {}
129 }
130 }
131
132 changes
133}
134
135pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
137 let domains = domains_for_root_patch(patch_obj);
138
139 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
140 let touches_proxy = domains.proxy;
141 let touches_mcp = domains.mcp;
142
143 PatchEffects {
144 reload_provider: if touches_provider || touches_proxy {
145 ReloadMode::BestEffort
146 } else {
147 ReloadMode::None
148 },
149 reconcile_mcp: touches_mcp || touches_proxy,
152 }
153}
154
155pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
160 patch_obj.remove("proxy_auth");
162 patch_obj.remove("proxy_auth_encrypted");
163 patch_obj.remove("http_proxy_auth_encrypted");
165 patch_obj.remove("https_proxy_auth_encrypted");
166 patch_obj.remove("data_dir");
167
168 if let Some(providers) = patch_obj
170 .get_mut("providers")
171 .and_then(|v| v.as_object_mut())
172 {
173 for (_provider_name, provider_cfg) in providers.iter_mut() {
174 let Some(obj) = provider_cfg.as_object_mut() else {
175 continue;
176 };
177 obj.remove("api_key_encrypted");
178 }
179 }
180
181 if let Some(provider_instances) = patch_obj
182 .get_mut("provider_instances")
183 .and_then(|v| v.as_object_mut())
184 {
185 for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
186 let Some(obj) = instance_cfg.as_object_mut() else {
187 continue;
188 };
189 obj.remove("api_key_encrypted");
190 }
191 }
192
193 if let Some(mcp_servers) = patch_obj
198 .get_mut("mcpServers")
199 .and_then(|v| v.as_object_mut())
200 {
201 for (_id, server) in mcp_servers.iter_mut() {
202 let Some(server_obj) = server.as_object_mut() else {
203 continue;
204 };
205 server_obj.remove("env_encrypted");
206 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
207 for header in headers.iter_mut() {
208 let Some(header_obj) = header.as_object_mut() else {
209 continue;
210 };
211 header_obj.remove("value_encrypted");
212 }
213 }
214 }
215 }
216
217 if let Some(servers) = patch_obj
220 .get_mut("mcp")
221 .and_then(|m| m.get_mut("servers"))
222 .and_then(|v| v.as_array_mut())
223 {
224 for server in servers.iter_mut() {
225 let Some(server_obj) = server.as_object_mut() else {
226 continue;
227 };
228 let Some(transport) = server_obj
229 .get_mut("transport")
230 .and_then(|v| v.as_object_mut())
231 else {
232 continue;
233 };
234
235 match transport.get("type").and_then(|v| v.as_str()) {
236 Some("stdio") => {
237 transport.remove("env_encrypted");
238 }
239 Some("sse") => {
240 if let Some(headers) =
241 transport.get_mut("headers").and_then(|v| v.as_array_mut())
242 {
243 for header in headers.iter_mut() {
244 let Some(header_obj) = header.as_object_mut() else {
245 continue;
246 };
247 header_obj.remove("value_encrypted");
248 }
249 }
250 }
251 _ => {}
252 }
253 }
254 }
255}
256
257pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
262 if let Some(patch_providers) = patch_obj
263 .get_mut("providers")
264 .and_then(|v| v.as_object_mut())
265 {
266 for (provider_name, provider_patch) in patch_providers.iter_mut() {
267 let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
268 continue;
269 };
270
271 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
272 continue;
273 };
274 if !is_masked_api_key(api_key) {
275 continue;
276 }
277
278 let existing_plain = match provider_name.as_str() {
279 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
280 "anthropic" => current
281 .providers
282 .anthropic
283 .as_ref()
284 .map(|c| c.api_key.clone()),
285 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
286 "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
287 _ => None,
288 };
289
290 if let Some(existing_plain) = existing_plain {
291 if !existing_plain.trim().is_empty() {
292 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
293 } else {
294 patch_cfg_obj.remove("api_key");
295 }
296 } else {
297 patch_cfg_obj.remove("api_key");
298 }
299 }
300 }
301
302 if let Some(patch_instances) = patch_obj
303 .get_mut("provider_instances")
304 .and_then(|v| v.as_object_mut())
305 {
306 for (instance_id, instance_patch) in patch_instances.iter_mut() {
307 let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
308 continue;
309 };
310
311 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
312 continue;
313 };
314 if !is_masked_api_key(api_key) {
315 continue;
316 }
317
318 let existing_plain = current
319 .provider_instances
320 .get(instance_id)
321 .map(|instance| instance.api_key.clone());
322
323 if let Some(existing_plain) = existing_plain {
324 if !existing_plain.trim().is_empty() {
325 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
326 } else {
327 patch_cfg_obj.remove("api_key");
328 }
329 } else {
330 patch_cfg_obj.remove("api_key");
331 }
332 }
333 }
334}
335
336pub fn deep_merge_json(dst: &mut Value, src: Value) {
338 match (dst, src) {
339 (Value::Object(dst_map), Value::Object(src_map)) => {
340 for (key, value) in src_map {
341 match dst_map.get_mut(&key) {
342 Some(existing) => deep_merge_json(existing, value),
343 None => {
344 dst_map.insert(key, value);
345 }
346 }
347 }
348 }
349 (dst_slot, src_value) => {
350 *dst_slot = src_value;
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use serde_json::json;
359
360 #[test]
361 fn domains_for_root_patch_detects_proxy_and_provider() {
362 let patch = json!({
363 "provider": "openai",
364 "http_proxy": "http://proxy:8080",
365 "setup": { "completed": false },
366 "mcpServers": {}
367 });
368
369 let domains = domains_for_root_patch(patch.as_object().unwrap());
370 assert!(domains.provider);
371 assert!(domains.proxy);
372 assert!(domains.setup);
373 assert!(domains.mcp);
374 }
375
376 #[test]
377 fn domains_for_root_patch_detects_provider_instances() {
378 let patch = json!({
379 "provider_instances": {
380 "openai-work": { "provider_type": "openai" }
381 },
382 "default_provider_instance": "openai-work",
383 "defaults": {
384 "chat": { "provider": "openai-work", "model": "gpt-4o" }
385 },
386 "features": {
387 "provider_model_ref": true
388 }
389 });
390
391 let domains = domains_for_root_patch(patch.as_object().unwrap());
392 assert!(domains.provider);
393 }
394
395 #[test]
396 fn provider_api_key_intents_ignores_masked_placeholders() {
397 let patch = json!({
398 "providers": {
399 "openai": { "api_key": "****...****" },
400 "gemini": { "api_key": "sk-real" }
401 },
402 "provider_instances": {
403 "work-openai": { "api_key": "****...****" },
404 "personal-openai": { "api_key": "sk-live" }
405 }
406 });
407 let intents = provider_api_key_intents(patch.as_object().unwrap());
408 assert!(intents.providers.contains("gemini"));
409 assert!(!intents.providers.contains("openai"));
410 assert!(intents.provider_instances.contains("personal-openai"));
411 assert!(!intents.provider_instances.contains("work-openai"));
412 }
413}