use crate::config::Config;
pub fn resolve_url(local_config: &Config) -> Option<String> {
std::env::var("DEVBOY_REMOTE_CONFIG_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
local_config
.remote_config
.as_ref()
.and_then(|rc| rc.url.as_ref().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
})
}
pub fn redact_url_for_display(raw: &str) -> String {
let raw = raw.trim();
let (scheme_with_sep, rest) = match raw.find("://") {
Some(idx) => (&raw[..idx + 3], &raw[idx + 3..]),
None => {
let stripped = raw.split_once('?').map(|(p, _)| p).unwrap_or(raw);
let stripped = stripped.split_once('#').map(|(p, _)| p).unwrap_or(stripped);
return stripped.to_string();
}
};
let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let (auth, tail) = rest.split_at(auth_end);
let host = match auth.rfind('@') {
Some(at) => &auth[at + 1..],
None => auth,
};
let tail = tail.split_once('?').map(|(p, _)| p).unwrap_or(tail);
let tail = tail.split_once('#').map(|(p, _)| p).unwrap_or(tail);
format!("{scheme_with_sep}{host}{tail}")
}
pub async fn fetch_and_merge(local_config: Config, token_from_keychain: Option<&str>) -> Config {
let url = std::env::var("DEVBOY_REMOTE_CONFIG_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
local_config
.remote_config
.as_ref()
.and_then(|rc| rc.url.as_ref().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
});
let url = match url {
Some(url) => url,
None => return local_config,
};
let token = std::env::var("DEVBOY_REMOTE_CONFIG_TOKEN")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| token_from_keychain.map(|s| s.to_string()));
match fetch_remote_toml(&url, token.as_deref()).await {
Ok(remote_config) => merge_configs(local_config, remote_config),
Err(e) => {
let safe_url = redact_url(&url);
eprintln!(
"[devboy] Failed to fetch remote config from {safe_url}: {e}. Using local config."
);
local_config
}
}
}
const MAX_REMOTE_CONFIG_SIZE: u64 = 1_024 * 1_024;
fn redact_url(url: &str) -> String {
let without_query = url.split('?').next().unwrap_or(url);
if let Some(scheme_end) = without_query.find("://") {
let after_scheme = &without_query[scheme_end + 3..];
if let Some(at_pos) = after_scheme.find('@') {
return format!(
"{}://{}",
&without_query[..scheme_end],
&after_scheme[at_pos + 1..]
);
}
}
without_query.to_string()
}
async fn fetch_remote_toml(url: &str, token: Option<&str>) -> Result<Config, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
let mut request = client
.get(url)
.header("Accept", "application/toml, text/plain");
if let Some(token) = token {
request = request.bearer_auth(token);
}
let response = request.send().await.map_err(|e| format!("{e}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
if let Some(len) = response.content_length()
&& len > MAX_REMOTE_CONFIG_SIZE
{
return Err(format!(
"Response too large: {len} bytes (max {MAX_REMOTE_CONFIG_SIZE})"
));
}
let body = response.text().await.map_err(|e| format!("{e}"))?;
if body.len() as u64 > MAX_REMOTE_CONFIG_SIZE {
return Err(format!(
"Response too large: {} bytes (max {MAX_REMOTE_CONFIG_SIZE})",
body.len()
));
}
toml::from_str::<Config>(&body).map_err(|e| format!("TOML parse error: {e}"))
}
fn merge_configs(mut local: Config, remote: Config) -> Config {
if remote.github.is_some() {
local.github = remote.github;
}
if remote.gitlab.is_some() {
local.gitlab = remote.gitlab;
}
if remote.clickup.is_some() {
local.clickup = remote.clickup;
}
if remote.jira.is_some() {
local.jira = remote.jira;
}
if remote.fireflies.is_some() {
local.fireflies = remote.fireflies;
}
if remote.slack.is_some() {
local.slack = remote.slack;
}
for (name, context) in remote.contexts {
local.contexts.insert(name, context);
}
if remote.active_context.is_some() {
local.active_context = remote.active_context;
}
if !remote.proxy_mcp_servers.is_empty() {
local.proxy_mcp_servers.extend(remote.proxy_mcp_servers);
}
if !remote.builtin_tools.is_empty() {
local.builtin_tools = remote.builtin_tools;
}
if remote.format_pipeline.is_some() {
local.format_pipeline = remote.format_pipeline;
}
if remote.sentry.is_some() {
local.sentry = remote.sentry;
}
local
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{RemoteConfigSettings, SentryConfig};
#[test]
fn redact_url_strips_userinfo_and_query() {
assert_eq!(
redact_url_for_display("https://user:pass@example.com/api/config?token=abc"),
"https://example.com/api/config"
);
}
#[test]
fn redact_url_keeps_path_and_port() {
assert_eq!(
redact_url_for_display("https://host.example:8443/api/config/mcp"),
"https://host.example:8443/api/config/mcp"
);
}
#[test]
fn redact_url_strips_only_userinfo_when_no_query() {
assert_eq!(
redact_url_for_display("https://alice@example.com/p"),
"https://example.com/p"
);
}
#[test]
fn redact_url_strips_only_query_when_no_userinfo() {
assert_eq!(
redact_url_for_display("https://example.com/p?secret=xyz#frag"),
"https://example.com/p"
);
}
#[test]
fn redact_url_handles_non_url_string_without_panic() {
assert_eq!(redact_url_for_display("not-a-url"), "not-a-url");
assert_eq!(redact_url_for_display("not-a-url?q=secret"), "not-a-url");
}
#[test]
fn redact_url_handles_at_in_path() {
assert_eq!(
redact_url_for_display("https://example.com/users/foo@bar/items"),
"https://example.com/users/foo@bar/items"
);
}
#[test]
fn resolve_url_returns_config_url_when_set() {
let cfg = Config {
remote_config: Some(RemoteConfigSettings {
url: Some("https://from-config.example/".to_string()),
token_key: None,
}),
..Default::default()
};
assert_eq!(
resolve_url(&cfg).as_deref(),
Some("https://from-config.example/")
);
}
#[test]
fn resolve_url_returns_none_for_default_config() {
let cfg = Config::default();
let got = resolve_url(&cfg);
match (std::env::var("DEVBOY_REMOTE_CONFIG_URL").ok(), got) {
(None, None) => {}
(Some(env), Some(got)) => assert_eq!(env.trim(), got),
(None, Some(got)) => panic!("expected None, got Some({got})"),
(Some(env), None) => panic!("expected Some({env}), got None"),
}
}
#[test]
fn test_merge_configs_remote_overrides_sentry() {
let local = Config::default();
let remote = Config {
sentry: Some(SentryConfig {
dsn: Some("https://key@sentry.io/1".to_string()),
environment: Some("production".to_string()),
..Default::default()
}),
..Default::default()
};
let merged = merge_configs(local, remote);
let sentry = merged.sentry.unwrap();
assert_eq!(sentry.dsn.unwrap(), "https://key@sentry.io/1");
assert_eq!(sentry.environment.unwrap(), "production");
}
#[test]
fn test_merge_configs_local_preserved_when_remote_empty() {
let local = Config {
sentry: Some(SentryConfig {
dsn: Some("https://local@sentry.io/1".to_string()),
..Default::default()
}),
..Default::default()
};
let remote = Config::default();
let merged = merge_configs(local, remote);
let sentry = merged.sentry.unwrap();
assert_eq!(sentry.dsn.unwrap(), "https://local@sentry.io/1");
}
#[test]
fn test_merge_configs_contexts_merged() {
let mut local = Config::default();
local.contexts.insert(
"local-ctx".to_string(),
crate::config::ContextConfig::default(),
);
let mut remote = Config::default();
remote.contexts.insert(
"remote-ctx".to_string(),
crate::config::ContextConfig::default(),
);
let merged = merge_configs(local, remote);
assert!(merged.contexts.contains_key("local-ctx"));
assert!(merged.contexts.contains_key("remote-ctx"));
}
#[test]
fn test_merge_configs_remote_config_not_copied() {
let local = Config {
remote_config: Some(RemoteConfigSettings {
url: Some("https://local.com/config".to_string()),
token_key: None,
}),
..Default::default()
};
let remote = Config {
remote_config: Some(RemoteConfigSettings {
url: Some("https://should-not-be-copied.com".to_string()),
token_key: None,
}),
..Default::default()
};
let merged = merge_configs(local, remote);
assert_eq!(
merged.remote_config.unwrap().url.unwrap(),
"https://local.com/config"
);
}
}