devboy_core/
remote_config.rs1use crate::config::Config;
28
29pub fn resolve_url(local_config: &Config) -> Option<String> {
44 std::env::var("DEVBOY_REMOTE_CONFIG_URL")
45 .ok()
46 .map(|s| s.trim().to_string())
47 .filter(|s| !s.is_empty())
48 .or_else(|| {
49 local_config
50 .remote_config
51 .as_ref()
52 .and_then(|rc| rc.url.as_ref().map(|s| s.trim().to_string()))
53 .filter(|s| !s.is_empty())
54 })
55}
56
57pub fn redact_url_for_display(raw: &str) -> String {
68 let raw = raw.trim();
69 let (scheme_with_sep, rest) = match raw.find("://") {
70 Some(idx) => (&raw[..idx + 3], &raw[idx + 3..]),
71 None => {
72 let stripped = raw.split_once('?').map(|(p, _)| p).unwrap_or(raw);
74 let stripped = stripped.split_once('#').map(|(p, _)| p).unwrap_or(stripped);
75 return stripped.to_string();
76 }
77 };
78
79 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
82 let (auth, tail) = rest.split_at(auth_end);
83 let host = match auth.rfind('@') {
84 Some(at) => &auth[at + 1..],
85 None => auth,
86 };
87
88 let tail = tail.split_once('?').map(|(p, _)| p).unwrap_or(tail);
90 let tail = tail.split_once('#').map(|(p, _)| p).unwrap_or(tail);
91
92 format!("{scheme_with_sep}{host}{tail}")
93}
94
95pub async fn fetch_and_merge(local_config: Config, token_from_keychain: Option<&str>) -> Config {
98 let url = std::env::var("DEVBOY_REMOTE_CONFIG_URL")
100 .ok()
101 .map(|s| s.trim().to_string())
102 .filter(|s| !s.is_empty())
103 .or_else(|| {
104 local_config
105 .remote_config
106 .as_ref()
107 .and_then(|rc| rc.url.as_ref().map(|s| s.trim().to_string()))
108 .filter(|s| !s.is_empty())
109 });
110
111 let url = match url {
112 Some(url) => url,
113 None => return local_config,
114 };
115
116 let token = std::env::var("DEVBOY_REMOTE_CONFIG_TOKEN")
118 .ok()
119 .map(|s| s.trim().to_string())
120 .filter(|s| !s.is_empty())
121 .or_else(|| token_from_keychain.map(|s| s.to_string()));
122
123 match fetch_remote_toml(&url, token.as_deref()).await {
124 Ok(remote_config) => merge_configs(local_config, remote_config),
125 Err(e) => {
126 let safe_url = redact_url(&url);
128 eprintln!(
129 "[devboy] Failed to fetch remote config from {safe_url}: {e}. Using local config."
130 );
131 local_config
132 }
133 }
134}
135
136const MAX_REMOTE_CONFIG_SIZE: u64 = 1_024 * 1_024;
138
139fn redact_url(url: &str) -> String {
142 let without_query = url.split('?').next().unwrap_or(url);
143 if let Some(scheme_end) = without_query.find("://") {
145 let after_scheme = &without_query[scheme_end + 3..];
146 if let Some(at_pos) = after_scheme.find('@') {
147 return format!(
148 "{}://{}",
149 &without_query[..scheme_end],
150 &after_scheme[at_pos + 1..]
151 );
152 }
153 }
154 without_query.to_string()
155}
156
157async fn fetch_remote_toml(url: &str, token: Option<&str>) -> Result<Config, String> {
158 let client = reqwest::Client::builder()
159 .timeout(std::time::Duration::from_secs(10))
160 .build()
161 .map_err(|e| format!("HTTP client error: {e}"))?;
162
163 let mut request = client
164 .get(url)
165 .header("Accept", "application/toml, text/plain");
166
167 if let Some(token) = token {
168 request = request.bearer_auth(token);
169 }
170
171 let response = request.send().await.map_err(|e| format!("{e}"))?;
172
173 let status = response.status();
174 if !status.is_success() {
175 return Err(format!("HTTP {status}"));
176 }
177
178 if let Some(len) = response.content_length()
180 && len > MAX_REMOTE_CONFIG_SIZE
181 {
182 return Err(format!(
183 "Response too large: {len} bytes (max {MAX_REMOTE_CONFIG_SIZE})"
184 ));
185 }
186
187 let body = response.text().await.map_err(|e| format!("{e}"))?;
188
189 if body.len() as u64 > MAX_REMOTE_CONFIG_SIZE {
191 return Err(format!(
192 "Response too large: {} bytes (max {MAX_REMOTE_CONFIG_SIZE})",
193 body.len()
194 ));
195 }
196
197 toml::from_str::<Config>(&body).map_err(|e| format!("TOML parse error: {e}"))
198}
199
200fn merge_configs(mut local: Config, remote: Config) -> Config {
206 if remote.github.is_some() {
208 local.github = remote.github;
209 }
210 if remote.gitlab.is_some() {
211 local.gitlab = remote.gitlab;
212 }
213 if remote.clickup.is_some() {
214 local.clickup = remote.clickup;
215 }
216 if remote.jira.is_some() {
217 local.jira = remote.jira;
218 }
219 if remote.fireflies.is_some() {
220 local.fireflies = remote.fireflies;
221 }
222 if remote.slack.is_some() {
223 local.slack = remote.slack;
224 }
225
226 for (name, context) in remote.contexts {
228 local.contexts.insert(name, context);
229 }
230
231 if remote.active_context.is_some() {
232 local.active_context = remote.active_context;
233 }
234
235 if !remote.proxy_mcp_servers.is_empty() {
237 local.proxy_mcp_servers.extend(remote.proxy_mcp_servers);
238 }
239
240 if !remote.builtin_tools.is_empty() {
242 local.builtin_tools = remote.builtin_tools;
243 }
244
245 if remote.format_pipeline.is_some() {
247 local.format_pipeline = remote.format_pipeline;
248 }
249
250 if remote.sentry.is_some() {
252 local.sentry = remote.sentry;
253 }
254
255 local
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::config::{RemoteConfigSettings, SentryConfig};
264
265 #[test]
266 fn redact_url_strips_userinfo_and_query() {
267 assert_eq!(
268 redact_url_for_display("https://user:pass@example.com/api/config?token=abc"),
269 "https://example.com/api/config"
270 );
271 }
272
273 #[test]
274 fn redact_url_keeps_path_and_port() {
275 assert_eq!(
276 redact_url_for_display("https://host.example:8443/api/config/mcp"),
277 "https://host.example:8443/api/config/mcp"
278 );
279 }
280
281 #[test]
282 fn redact_url_strips_only_userinfo_when_no_query() {
283 assert_eq!(
284 redact_url_for_display("https://alice@example.com/p"),
285 "https://example.com/p"
286 );
287 }
288
289 #[test]
290 fn redact_url_strips_only_query_when_no_userinfo() {
291 assert_eq!(
292 redact_url_for_display("https://example.com/p?secret=xyz#frag"),
293 "https://example.com/p"
294 );
295 }
296
297 #[test]
298 fn redact_url_handles_non_url_string_without_panic() {
299 assert_eq!(redact_url_for_display("not-a-url"), "not-a-url");
301 assert_eq!(redact_url_for_display("not-a-url?q=secret"), "not-a-url");
302 }
303
304 #[test]
305 fn redact_url_handles_at_in_path() {
306 assert_eq!(
309 redact_url_for_display("https://example.com/users/foo@bar/items"),
310 "https://example.com/users/foo@bar/items"
311 );
312 }
313
314 #[test]
315 fn resolve_url_returns_config_url_when_set() {
316 let cfg = Config {
317 remote_config: Some(RemoteConfigSettings {
318 url: Some("https://from-config.example/".to_string()),
319 token_key: None,
320 }),
321 ..Default::default()
322 };
323 assert_eq!(
328 resolve_url(&cfg).as_deref(),
329 Some("https://from-config.example/")
330 );
331 }
332
333 #[test]
334 fn resolve_url_returns_none_for_default_config() {
335 let cfg = Config::default();
336 let got = resolve_url(&cfg);
340 match (std::env::var("DEVBOY_REMOTE_CONFIG_URL").ok(), got) {
341 (None, None) => {}
342 (Some(env), Some(got)) => assert_eq!(env.trim(), got),
343 (None, Some(got)) => panic!("expected None, got Some({got})"),
344 (Some(env), None) => panic!("expected Some({env}), got None"),
345 }
346 }
347
348 #[test]
349 fn test_merge_configs_remote_overrides_sentry() {
350 let local = Config::default();
351 let remote = Config {
352 sentry: Some(SentryConfig {
353 dsn: Some("https://key@sentry.io/1".to_string()),
354 environment: Some("production".to_string()),
355 ..Default::default()
356 }),
357 ..Default::default()
358 };
359
360 let merged = merge_configs(local, remote);
361 let sentry = merged.sentry.unwrap();
362 assert_eq!(sentry.dsn.unwrap(), "https://key@sentry.io/1");
363 assert_eq!(sentry.environment.unwrap(), "production");
364 }
365
366 #[test]
367 fn test_merge_configs_local_preserved_when_remote_empty() {
368 let local = Config {
369 sentry: Some(SentryConfig {
370 dsn: Some("https://local@sentry.io/1".to_string()),
371 ..Default::default()
372 }),
373 ..Default::default()
374 };
375 let remote = Config::default();
376
377 let merged = merge_configs(local, remote);
378 let sentry = merged.sentry.unwrap();
379 assert_eq!(sentry.dsn.unwrap(), "https://local@sentry.io/1");
380 }
381
382 #[test]
383 fn test_merge_configs_contexts_merged() {
384 let mut local = Config::default();
385 local.contexts.insert(
386 "local-ctx".to_string(),
387 crate::config::ContextConfig::default(),
388 );
389
390 let mut remote = Config::default();
391 remote.contexts.insert(
392 "remote-ctx".to_string(),
393 crate::config::ContextConfig::default(),
394 );
395
396 let merged = merge_configs(local, remote);
397 assert!(merged.contexts.contains_key("local-ctx"));
398 assert!(merged.contexts.contains_key("remote-ctx"));
399 }
400
401 #[test]
402 fn test_merge_configs_remote_config_not_copied() {
403 let local = Config {
404 remote_config: Some(RemoteConfigSettings {
405 url: Some("https://local.com/config".to_string()),
406 token_key: None,
407 }),
408 ..Default::default()
409 };
410 let remote = Config {
411 remote_config: Some(RemoteConfigSettings {
412 url: Some("https://should-not-be-copied.com".to_string()),
413 token_key: None,
414 }),
415 ..Default::default()
416 };
417
418 let merged = merge_configs(local, remote);
419 assert_eq!(
421 merged.remote_config.unwrap().url.unwrap(),
422 "https://local.com/config"
423 );
424 }
425}