Skip to main content

agent_engine/extensions/
config.rs

1//! Config redaction helpers and diagnostics types for extensions.
2//!
3//! These helpers are used by `/extensions status` and similar UX to display
4//! extension config without leaking secret values, and to mirror the
5//! resolution order implemented in `manager::resolve_config`.
6
7use super::manifest::ExtensionConfigEntry;
8
9/// Redact a secret value for display. Always cosmetic; never returns the full value.
10pub fn redact_secret_value(value: &str) -> String {
11    let len = value.chars().count();
12    if len == 0 {
13        return String::new();
14    }
15    if len <= 3 {
16        return "***".to_string();
17    }
18    let tail_len = if len <= 7 { 2 } else { 4 };
19    let tail: String = value.chars().skip(len - tail_len).collect();
20    format!("***{}", tail)
21}
22
23/// Compute the env override variable name for a given extension id + config key.
24pub fn extension_env_var(extension_id: &str, key: &str) -> String {
25    let id_upper = extension_id.replace('-', "_").to_ascii_uppercase();
26    let key_upper = key.replace('-', "_").to_ascii_uppercase();
27    format!("SYNAPS_EXTENSION_{}_{}", id_upper, key_upper)
28}
29
30/// Where a resolved config value originated from.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ConfigSource {
33    /// Resolved from trusted host context recorded by the extension
34    /// manager (e.g. `project_root`). Never user/env/model supplied.
35    HostContext(&'static str),
36    /// Resolved from the `SYNAPS_EXTENSION_<ID>_<KEY>` env override.
37    EnvOverride(String),
38    /// Resolved from the manifest-declared `secret_env` variable.
39    SecretEnv(String),
40    /// Resolved from the plugin-owned config file `plugins/<id>/config`.
41    PluginConfig,
42    /// Resolved from the deprecated persisted config key `extension.<id>.<key>`.
43    LegacyConfigKey(String),
44    /// Resolved from the manifest-declared default value.
45    Default,
46    /// No value available from any source.
47    Missing,
48}
49
50/// Diagnostic status for a single config entry.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ConfigEntryStatus {
53    pub key: String,
54    pub description: Option<String>,
55    pub required: bool,
56    pub source: ConfigSource,
57    pub has_value: bool,
58}
59
60/// Classify a config entry against the same resolution order as
61/// `manager::resolve_config`, using injected lookup closures so callers
62/// (and tests) need not touch real `std::env` or persisted config.
63pub fn classify_config_entry(
64    extension_id: &str,
65    entry: &ExtensionConfigEntry,
66    env_lookup: &impl Fn(&str) -> Option<String>,
67    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
68    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
69) -> ConfigEntryStatus {
70    let env_var = extension_env_var(extension_id, &entry.key);
71    let legacy_config_key = format!("extension.{}.{}", extension_id, entry.key);
72
73    let source = if let Some(host_key) = entry.host_context {
74        match host_key {
75            crate::extensions::manifest::HostContextKey::ProjectRoot => {
76                ConfigSource::HostContext("project_root")
77            }
78        }
79    } else if env_lookup(&env_var).is_some() {
80        ConfigSource::EnvOverride(env_var)
81    } else if let Some(secret_env) = entry.secret_env.as_ref() {
82        if env_lookup(secret_env).is_some() {
83            ConfigSource::SecretEnv(secret_env.clone())
84        } else if plugin_config_lookup(&entry.key).is_some() {
85            ConfigSource::PluginConfig
86        } else if legacy_config_lookup(&legacy_config_key).is_some() {
87            ConfigSource::LegacyConfigKey(legacy_config_key)
88        } else if entry.default.is_some() {
89            ConfigSource::Default
90        } else {
91            ConfigSource::Missing
92        }
93    } else if plugin_config_lookup(&entry.key).is_some() {
94        ConfigSource::PluginConfig
95    } else if legacy_config_lookup(&legacy_config_key).is_some() {
96        ConfigSource::LegacyConfigKey(legacy_config_key)
97    } else if entry.default.is_some() {
98        ConfigSource::Default
99    } else {
100        ConfigSource::Missing
101    };
102
103    let has_value = !matches!(source, ConfigSource::Missing);
104
105    ConfigEntryStatus {
106        key: entry.key.clone(),
107        description: entry.description.clone(),
108        required: entry.required,
109        source,
110        has_value,
111    }
112}
113
114/// Aggregated config diagnostics for a single extension.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ExtensionConfigDiagnostics {
117    pub extension_id: String,
118    pub entries: Vec<ConfigEntryStatus>,
119    /// Provider-level required keys that aren't satisfied by any entry above.
120    /// Each item is `(provider_id, missing_key)`.
121    pub provider_missing: Vec<(String, String)>,
122}
123
124/// Compute diagnostics for an extension by combining manifest config classification
125/// with provider-level `config_schema.required` checks. Lookups are injected so
126/// the function is fully testable without touching real env or persisted config.
127pub fn diagnose_extension_config(
128    extension_id: &str,
129    manifest_config: &[ExtensionConfigEntry],
130    provider_required: &[(String, Vec<String>)],
131    env_lookup: &impl Fn(&str) -> Option<String>,
132    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
133    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
134) -> ExtensionConfigDiagnostics {
135    let entries: Vec<ConfigEntryStatus> = manifest_config
136        .iter()
137        .map(|entry| {
138            classify_config_entry(
139                extension_id,
140                entry,
141                env_lookup,
142                plugin_config_lookup,
143                legacy_config_lookup,
144            )
145        })
146        .collect();
147
148    let mut provider_missing: Vec<(String, String)> = Vec::new();
149    for (provider_id, required_keys) in provider_required {
150        for key in required_keys {
151            let satisfied = entries
152                .iter()
153                .any(|status| status.key == *key && status.has_value);
154            if !satisfied {
155                provider_missing.push((provider_id.clone(), key.clone()));
156            }
157        }
158    }
159
160    ExtensionConfigDiagnostics {
161        extension_id: extension_id.to_string(),
162        entries,
163        provider_missing,
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use serde_json::Value;
171
172    fn empty_lookup(_: &str) -> Option<String> {
173        None
174    }
175
176    fn entry(key: &str) -> ExtensionConfigEntry {
177        ExtensionConfigEntry {
178            key: key.to_string(),
179            value_type: None,
180            description: None,
181            required: false,
182            default: None,
183            secret_env: None,
184            host_context: None,
185        }
186    }
187
188    #[test]
189    fn redact_empty() {
190        assert_eq!(redact_secret_value(""), "");
191    }
192
193    #[test]
194    fn redact_short() {
195        assert_eq!(redact_secret_value("a"), "***");
196        assert_eq!(redact_secret_value("abc"), "***");
197    }
198
199    #[test]
200    fn redact_medium() {
201        assert_eq!(redact_secret_value("abcd"), "***cd");
202        assert_eq!(redact_secret_value("abc1234"), "***34");
203    }
204
205    #[test]
206    fn redact_long() {
207        assert_eq!(redact_secret_value("abc12345"), "***2345");
208        assert_eq!(redact_secret_value("abcdefghijklmnopqrst"), "***qrst");
209        // sanity: never contains the full value beyond the tail
210        let s = redact_secret_value("supersecretvalue1234");
211        assert!(s.starts_with("***"));
212        assert!(!s.contains("supersecret"));
213    }
214
215    #[test]
216    fn env_var_uppercases_and_replaces_dashes() {
217        assert_eq!(
218            extension_env_var("my-ext", "api-key"),
219            "SYNAPS_EXTENSION_MY_EXT_API_KEY"
220        );
221    }
222
223    #[test]
224    fn classify_env_override() {
225        let e = entry("api-key");
226        let env = |k: &str| {
227            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
228                Some("v".to_string())
229            } else {
230                None
231            }
232        };
233        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
234        assert_eq!(
235            status.source,
236            ConfigSource::EnvOverride("SYNAPS_EXTENSION_MY_EXT_API_KEY".to_string())
237        );
238        assert!(status.has_value);
239    }
240
241    #[test]
242    fn classify_secret_env() {
243        let mut e = entry("api-key");
244        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
245        let env = |k: &str| {
246            if k == "MY_PROVIDER_KEY" {
247                Some("v".to_string())
248            } else {
249                None
250            }
251        };
252        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
253        assert_eq!(
254            status.source,
255            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
256        );
257        assert!(status.has_value);
258    }
259
260    #[test]
261    fn classify_plugin_config() {
262        let e = entry("api-key");
263        let plugin = |k: &str| {
264            if k == "api-key" {
265                Some("v".to_string())
266            } else {
267                None
268            }
269        };
270        let status = classify_config_entry("my-ext", &e, &empty_lookup, &plugin, &empty_lookup);
271        assert_eq!(status.source, ConfigSource::PluginConfig);
272        assert!(status.has_value);
273    }
274
275    #[test]
276    fn classify_config_key() {
277        let e = entry("api-key");
278        let cfg = |k: &str| {
279            if k == "extension.my-ext.api-key" {
280                Some("v".to_string())
281            } else {
282                None
283            }
284        };
285        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
286        assert_eq!(
287            status.source,
288            ConfigSource::LegacyConfigKey("extension.my-ext.api-key".to_string())
289        );
290        assert!(status.has_value);
291    }
292
293    #[test]
294    fn classify_default() {
295        let mut e = entry("region");
296        e.default = Some(Value::String("us-east-1".to_string()));
297        let status =
298            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
299        assert_eq!(status.source, ConfigSource::Default);
300        assert!(status.has_value);
301    }
302
303    #[test]
304    fn classify_missing() {
305        let mut e = entry("api-key");
306        e.required = true;
307        let status =
308            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
309        assert_eq!(status.source, ConfigSource::Missing);
310        assert!(!status.has_value);
311        assert!(status.required);
312    }
313
314    #[test]
315    fn env_override_wins_over_all() {
316        let mut e = entry("api-key");
317        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
318        e.default = Some(Value::String("d".to_string()));
319        let env = |k: &str| Some(format!("env-{}", k));
320        let cfg = |_: &str| Some("cfg".to_string());
321        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
322        assert!(matches!(status.source, ConfigSource::EnvOverride(_)));
323    }
324
325    #[test]
326    fn secret_env_wins_over_config_and_default() {
327        let mut e = entry("api-key");
328        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
329        e.default = Some(Value::String("d".to_string()));
330        let env = |k: &str| {
331            if k == "MY_PROVIDER_KEY" {
332                Some("s".to_string())
333            } else {
334                None
335            }
336        };
337        let cfg = |_: &str| Some("cfg".to_string());
338        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
339        assert_eq!(
340            status.source,
341            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
342        );
343    }
344
345    #[test]
346    fn config_key_wins_over_default() {
347        let mut e = entry("region");
348        e.default = Some(Value::String("us-east-1".to_string()));
349        let cfg = |k: &str| {
350            if k == "extension.my-ext.region" {
351                Some("eu-west-1".to_string())
352            } else {
353                None
354            }
355        };
356        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
357        assert!(matches!(status.source, ConfigSource::LegacyConfigKey(_)));
358    }
359
360    #[test]
361    fn default_only_when_no_env_or_config() {
362        let mut e = entry("region");
363        e.default = Some(Value::String("us-east-1".to_string()));
364        let status =
365            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
366        assert_eq!(status.source, ConfigSource::Default);
367    }
368
369    #[test]
370    fn diagnose_empty_manifest_no_providers() {
371        let diag = diagnose_extension_config(
372            "my-ext",
373            &[],
374            &[],
375            &empty_lookup,
376            &empty_lookup,
377            &empty_lookup,
378        );
379        assert_eq!(diag.extension_id, "my-ext");
380        assert!(diag.entries.is_empty());
381        assert!(diag.provider_missing.is_empty());
382    }
383
384    #[test]
385    fn diagnose_entry_with_default_resolves() {
386        let mut e = entry("region");
387        e.default = Some(Value::String("us-east-1".to_string()));
388        let diag = diagnose_extension_config(
389            "my-ext",
390            std::slice::from_ref(&e),
391            &[],
392            &empty_lookup,
393            &empty_lookup,
394            &empty_lookup,
395        );
396        assert_eq!(diag.entries.len(), 1);
397        assert_eq!(diag.entries[0].source, ConfigSource::Default);
398        assert!(diag.entries[0].has_value);
399        assert!(diag.provider_missing.is_empty());
400    }
401
402    #[test]
403    fn diagnose_provider_requires_undeclared_key() {
404        let diag = diagnose_extension_config(
405            "my-ext",
406            &[],
407            &[("p".to_string(), vec!["api-key".to_string()])],
408            &empty_lookup,
409            &empty_lookup,
410            &empty_lookup,
411        );
412        assert_eq!(
413            diag.provider_missing,
414            vec![("p".to_string(), "api-key".to_string())]
415        );
416    }
417
418    #[test]
419    fn diagnose_provider_required_key_resolved_via_env() {
420        let mut e = entry("api-key");
421        e.required = true;
422        let env = |k: &str| {
423            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
424                Some("v".to_string())
425            } else {
426                None
427            }
428        };
429        let diag = diagnose_extension_config(
430            "my-ext",
431            std::slice::from_ref(&e),
432            &[("p".to_string(), vec!["api-key".to_string()])],
433            &env,
434            &empty_lookup,
435            &empty_lookup,
436        );
437        assert!(diag.entries[0].has_value);
438        assert!(diag.provider_missing.is_empty());
439    }
440
441    #[test]
442    fn diagnose_provider_required_key_declared_but_missing() {
443        let mut e = entry("api-key");
444        e.required = true;
445        let diag = diagnose_extension_config(
446            "my-ext",
447            std::slice::from_ref(&e),
448            &[("p".to_string(), vec!["api-key".to_string()])],
449            &empty_lookup,
450            &empty_lookup,
451            &empty_lookup,
452        );
453        assert!(!diag.entries[0].has_value);
454        assert_eq!(
455            diag.provider_missing,
456            vec![("p".to_string(), "api-key".to_string())]
457        );
458    }
459}