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
17pub fn provider_api_key_intents(
20 patch_obj: &Map<String, Value>,
21) -> std::collections::BTreeSet<String> {
22 let mut providers = std::collections::BTreeSet::new();
23 let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) else {
24 return providers;
25 };
26
27 for (provider_name, provider_patch) in root.iter() {
28 let Some(obj) = provider_patch.as_object() else {
29 continue;
30 };
31 let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) else {
32 continue;
33 };
34 if is_masked_api_key(api_key) {
36 continue;
37 }
38 providers.insert(provider_name.clone());
39 }
40
41 providers
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ReloadMode {
47 None,
48 BestEffort,
50 Strict,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct PatchEffects {
57 pub reload_provider: ReloadMode,
58 pub reconcile_mcp: bool,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct DomainChanges {
64 pub provider: bool,
65 pub proxy: bool,
66 pub setup: bool,
67 pub mcp: bool,
68 pub keyword_masking: bool,
69 pub hooks: bool,
70 pub model_mapping: bool,
71}
72
73pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
75 let mut changes = DomainChanges::default();
76
77 for key in patch_obj.keys() {
78 match key.as_str() {
79 "provider" | "providers" | "model" => changes.provider = true,
81
82 "http_proxy"
84 | "https_proxy"
85 | "proxy_auth"
86 | "proxy_auth_encrypted"
87 | "http_proxy_auth_encrypted"
88 | "https_proxy_auth_encrypted" => changes.proxy = true,
89
90 "setup" => changes.setup = true,
92
93 "mcp" | "mcpServers" => changes.mcp = true,
95
96 "keyword_masking" => changes.keyword_masking = true,
98 "hooks" => changes.hooks = true,
99 "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
100
101 _ => {}
102 }
103 }
104
105 changes
106}
107
108pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
110 let domains = domains_for_root_patch(patch_obj);
111
112 let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
113 let touches_proxy = domains.proxy;
114 let touches_mcp = domains.mcp;
115
116 PatchEffects {
117 reload_provider: if touches_provider || touches_proxy {
118 ReloadMode::BestEffort
119 } else {
120 ReloadMode::None
121 },
122 reconcile_mcp: touches_mcp || touches_proxy,
125 }
126}
127
128pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
133 patch_obj.remove("proxy_auth");
135 patch_obj.remove("proxy_auth_encrypted");
136 patch_obj.remove("http_proxy_auth_encrypted");
138 patch_obj.remove("https_proxy_auth_encrypted");
139 patch_obj.remove("data_dir");
140
141 if let Some(providers) = patch_obj
143 .get_mut("providers")
144 .and_then(|v| v.as_object_mut())
145 {
146 for (_provider_name, provider_cfg) in providers.iter_mut() {
147 let Some(obj) = provider_cfg.as_object_mut() else {
148 continue;
149 };
150 obj.remove("api_key_encrypted");
151 }
152 }
153
154 if let Some(mcp_servers) = patch_obj
159 .get_mut("mcpServers")
160 .and_then(|v| v.as_object_mut())
161 {
162 for (_id, server) in mcp_servers.iter_mut() {
163 let Some(server_obj) = server.as_object_mut() else {
164 continue;
165 };
166 server_obj.remove("env_encrypted");
167 if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
168 for header in headers.iter_mut() {
169 let Some(header_obj) = header.as_object_mut() else {
170 continue;
171 };
172 header_obj.remove("value_encrypted");
173 }
174 }
175 }
176 }
177
178 if let Some(servers) = patch_obj
181 .get_mut("mcp")
182 .and_then(|m| m.get_mut("servers"))
183 .and_then(|v| v.as_array_mut())
184 {
185 for server in servers.iter_mut() {
186 let Some(server_obj) = server.as_object_mut() else {
187 continue;
188 };
189 let Some(transport) = server_obj
190 .get_mut("transport")
191 .and_then(|v| v.as_object_mut())
192 else {
193 continue;
194 };
195
196 match transport.get("type").and_then(|v| v.as_str()) {
197 Some("stdio") => {
198 transport.remove("env_encrypted");
199 }
200 Some("sse") => {
201 if let Some(headers) =
202 transport.get_mut("headers").and_then(|v| v.as_array_mut())
203 {
204 for header in headers.iter_mut() {
205 let Some(header_obj) = header.as_object_mut() else {
206 continue;
207 };
208 header_obj.remove("value_encrypted");
209 }
210 }
211 }
212 _ => {}
213 }
214 }
215 }
216}
217
218pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
223 let Some(patch_providers) = patch_obj
224 .get_mut("providers")
225 .and_then(|v| v.as_object_mut())
226 else {
227 return;
228 };
229
230 for (provider_name, provider_patch) in patch_providers.iter_mut() {
231 let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
232 continue;
233 };
234
235 let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
236 continue;
237 };
238 if !is_masked_api_key(api_key) {
239 continue;
240 }
241
242 let existing_plain = match provider_name.as_str() {
243 "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
244 "anthropic" => current
245 .providers
246 .anthropic
247 .as_ref()
248 .map(|c| c.api_key.clone()),
249 "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
250 _ => None,
251 };
252
253 if let Some(existing_plain) = existing_plain {
254 if !existing_plain.trim().is_empty() {
255 patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
256 } else {
257 patch_cfg_obj.remove("api_key");
258 }
259 } else {
260 patch_cfg_obj.remove("api_key");
261 }
262 }
263}
264
265pub fn deep_merge_json(dst: &mut Value, src: Value) {
267 match (dst, src) {
268 (Value::Object(dst_map), Value::Object(src_map)) => {
269 for (key, value) in src_map {
270 match dst_map.get_mut(&key) {
271 Some(existing) => deep_merge_json(existing, value),
272 None => {
273 dst_map.insert(key, value);
274 }
275 }
276 }
277 }
278 (dst_slot, src_value) => {
279 *dst_slot = src_value;
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 #[test]
290 fn domains_for_root_patch_detects_proxy_and_provider() {
291 let patch = json!({
292 "provider": "openai",
293 "http_proxy": "http://proxy:8080",
294 "setup": { "completed": false },
295 "mcpServers": {}
296 });
297
298 let domains = domains_for_root_patch(patch.as_object().unwrap());
299 assert!(domains.provider);
300 assert!(domains.proxy);
301 assert!(domains.setup);
302 assert!(domains.mcp);
303 }
304
305 #[test]
306 fn provider_api_key_intents_ignores_masked_placeholders() {
307 let patch = json!({
308 "providers": {
309 "openai": { "api_key": "****...****" },
310 "gemini": { "api_key": "sk-real" }
311 }
312 });
313 let intents = provider_api_key_intents(patch.as_object().unwrap());
314 assert!(intents.contains("gemini"));
315 assert!(!intents.contains("openai"));
316 }
317}