Skip to main content

devboy_core/
remote_config.rs

1//! Remote configuration fetching.
2//!
3//! Fetches TOML configuration from a remote URL and merges it with the local
4//! config. This provides a generic mechanism for centralized configuration
5//! management — any server can serve TOML config over HTTP with Bearer auth.
6//!
7//! # Configuration
8//!
9//! Via `config.toml`:
10//! ```toml
11//! [remote_config]
12//! url = "https://example.com/api/devboy-config"
13//! token_key = "remote_config.token"
14//! ```
15//!
16//! Via environment variables (take priority over config file):
17//! - `DEVBOY_REMOTE_CONFIG_URL` — URL to fetch config from
18//! - `DEVBOY_REMOTE_CONFIG_TOKEN` — Bearer token for authentication
19//!
20//! # Behavior
21//!
22//! - Remote values override local values (remote wins)
23//! - If fetch fails, a warning is printed and local config is used unchanged
24//! - Timeout: 10 seconds
25//! - Response must be valid TOML that deserializes into `Config`
26
27use crate::config::Config;
28
29/// Fetch remote config and merge it into the provided local config.
30///
31/// Returns the merged config. If fetch fails for any reason, returns the
32/// original config unchanged (with a warning printed to stderr).
33///
34/// # Arguments
35///
36/// Resolved remote-config URL from env var or `[remote_config]` config
37/// block. Returns `None` if neither source provides a non-empty URL.
38///
39/// Used by `devboy doctor` and `devboy context list` to detect the
40/// "thin client / proxy" mode regardless of whether the URL came from
41/// the env var (which `fetch_and_merge` honours but doesn't write into
42/// `Config`) or the on-disk config file.
43pub 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
57/// Redact a URL for safe display in diagnostic messages: drop userinfo
58/// (basic-auth credentials in `https://user:pass@host/...`) and any
59/// query string or fragment. Scheme + host + port + path are preserved.
60///
61/// Lightweight string-level parser (no `url` crate dep) that mirrors
62/// the redaction we already do when logging remote-config fetch
63/// failures. Anything that doesn't look like an `<scheme>://...` URL
64/// passes through with only the query/fragment stripped — we'd rather
65/// echo a malformed value than panic, but credentials in non-URL
66/// strings are not detected.
67pub 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            // Not a `scheme://` URL — strip query/fragment and return.
73            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    // Authority ends at the first `/`, `?`, or `#`. Userinfo is
80    // everything before the rightmost `@` inside that authority.
81    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    // Strip query string and fragment from tail.
89    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
95/// * `local_config` - The locally loaded config
96/// * `token_from_keychain` - Optional token resolved from keychain via `token_key`
97pub async fn fetch_and_merge(local_config: Config, token_from_keychain: Option<&str>) -> Config {
98    // Resolve URL: env var overrides config
99    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    // Resolve token: env var → keychain → none
117    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            // Strip query params AND userinfo to avoid leaking credentials
127            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
136/// Maximum response size for remote config (1 MB). Prevents OOM from malicious endpoints.
137const MAX_REMOTE_CONFIG_SIZE: u64 = 1_024 * 1_024;
138
139/// Redact URL for safe logging: strip query params and userinfo.
140/// `https://user:pass@host.com/path?token=x` → `https://host.com/path`
141fn redact_url(url: &str) -> String {
142    let without_query = url.split('?').next().unwrap_or(url);
143    // Strip userinfo (user:pass@)
144    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    // Check Content-Length if available
179    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    // Also check actual body size (Content-Length may be absent)
190    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
200/// Merge remote config into local config.
201///
202/// Only fields that are present (Some/non-empty) in the remote config override
203/// local values. Remote config cannot clear/reset a local value — omitting a
204/// field in remote config preserves the local value.
205fn merge_configs(mut local: Config, remote: Config) -> Config {
206    // Provider configs: remote overrides if present
207    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    // Contexts: merge by name (remote contexts override local ones with same name)
227    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    // Proxy servers: append remote proxies (don't replace local ones)
236    if !remote.proxy_mcp_servers.is_empty() {
237        local.proxy_mcp_servers.extend(remote.proxy_mcp_servers);
238    }
239
240    // Builtin tools: remote overrides if non-empty
241    if !remote.builtin_tools.is_empty() {
242        local.builtin_tools = remote.builtin_tools;
243    }
244
245    // Format pipeline: remote overrides if present
246    if remote.format_pipeline.is_some() {
247        local.format_pipeline = remote.format_pipeline;
248    }
249
250    // Sentry: remote overrides if present
251    if remote.sentry.is_some() {
252        local.sentry = remote.sentry;
253    }
254
255    // Don't copy remote_config from remote (avoid recursive fetching)
256
257    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        // No scheme://, no panic — just strip query/fragment if any.
300        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        // The `@` in `/users/foo@bar/items` is part of the path, not
307        // userinfo — must not be stripped.
308        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        // Note: env var precedence (DEVBOY_REMOTE_CONFIG_URL > config)
324        // is exercised end-to-end via the existing remote_config
325        // integration test fixtures; not unit-tested here because
326        // `unsafe_code=forbid` blocks `set_var`.
327        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        // May still return Some(...) if a stray DEVBOY_REMOTE_CONFIG_URL
337        // is set in the test process environment — assert "none, OR
338        // exactly the env var value" so the test is order-independent.
339        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        // remote_config should stay as the local one (not overwritten)
420        assert_eq!(
421            merged.remote_config.unwrap().url.unwrap(),
422            "https://local.com/config"
423        );
424    }
425}