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    classify_config_entry_with_keys(
71        extension_id,
72        entry,
73        &[entry.key.as_str()],
74        env_lookup,
75        plugin_config_lookup,
76        legacy_config_lookup,
77    )
78}
79
80/// [`classify_config_entry`] with the manifest's full key list, which
81/// `secret_env` validation needs (an extension may name its own override
82/// variable for any key it declares).
83fn classify_config_entry_with_keys(
84    extension_id: &str,
85    entry: &ExtensionConfigEntry,
86    own_keys: &[&str],
87    env_lookup: &impl Fn(&str) -> Option<String>,
88    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
89    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
90) -> ConfigEntryStatus {
91    // Mirror resolve_config: a malformed or denied secret_env is never read,
92    // so diagnostics must not look it up either (that would reveal whether,
93    // say, ANTHROPIC_API_KEY is set).
94    let secret_env = entry
95        .secret_env
96        .as_deref()
97        .and_then(super::manager::ExtensionManager::secret_env_name)
98        .filter(|name| {
99            super::manager::ExtensionManager::denied_secret_env(name, extension_id, own_keys)
100                .is_none()
101        });
102    let env_var = extension_env_var(extension_id, &entry.key);
103    let legacy_config_key = format!("extension.{}.{}", extension_id, entry.key);
104
105    let source = if let Some(host_key) = entry.host_context {
106        match host_key {
107            crate::extensions::manifest::HostContextKey::ProjectRoot => {
108                ConfigSource::HostContext("project_root")
109            }
110        }
111    } else if env_lookup(&env_var).is_some() {
112        ConfigSource::EnvOverride(env_var)
113    } else if let Some(secret_env) = secret_env {
114        if env_lookup(secret_env).is_some() {
115            ConfigSource::SecretEnv(secret_env.to_string())
116        } else if plugin_config_lookup(&entry.key).is_some() {
117            ConfigSource::PluginConfig
118        } else if legacy_config_lookup(&legacy_config_key).is_some() {
119            ConfigSource::LegacyConfigKey(legacy_config_key)
120        } else if entry.default.is_some() {
121            ConfigSource::Default
122        } else {
123            ConfigSource::Missing
124        }
125    } else if plugin_config_lookup(&entry.key).is_some() {
126        ConfigSource::PluginConfig
127    } else if legacy_config_lookup(&legacy_config_key).is_some() {
128        ConfigSource::LegacyConfigKey(legacy_config_key)
129    } else if entry.default.is_some() {
130        ConfigSource::Default
131    } else {
132        ConfigSource::Missing
133    };
134
135    let has_value = !matches!(source, ConfigSource::Missing);
136
137    ConfigEntryStatus {
138        key: entry.key.clone(),
139        description: entry.description.clone(),
140        required: entry.required,
141        source,
142        has_value,
143    }
144}
145
146/// Aggregated config diagnostics for a single extension.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ExtensionConfigDiagnostics {
149    pub extension_id: String,
150    pub entries: Vec<ConfigEntryStatus>,
151    /// Provider-level required keys that aren't satisfied by any entry above.
152    /// Each item is `(provider_id, missing_key)`.
153    pub provider_missing: Vec<(String, String)>,
154}
155
156/// Compute diagnostics for an extension by combining manifest config classification
157/// with provider-level `config_schema.required` checks. Lookups are injected so
158/// the function is fully testable without touching real env or persisted config.
159pub fn diagnose_extension_config(
160    extension_id: &str,
161    manifest_config: &[ExtensionConfigEntry],
162    provider_required: &[(String, Vec<String>)],
163    env_lookup: &impl Fn(&str) -> Option<String>,
164    plugin_config_lookup: &impl Fn(&str) -> Option<String>,
165    legacy_config_lookup: &impl Fn(&str) -> Option<String>,
166) -> ExtensionConfigDiagnostics {
167    let own_keys: Vec<&str> = manifest_config.iter().map(|e| e.key.trim()).collect();
168    let entries: Vec<ConfigEntryStatus> = manifest_config
169        .iter()
170        .map(|entry| {
171            classify_config_entry_with_keys(
172                extension_id,
173                entry,
174                &own_keys,
175                env_lookup,
176                plugin_config_lookup,
177                legacy_config_lookup,
178            )
179        })
180        .collect();
181
182    let mut provider_missing: Vec<(String, String)> = Vec::new();
183    for (provider_id, required_keys) in provider_required {
184        for key in required_keys {
185            let satisfied = entries
186                .iter()
187                .any(|status| status.key == *key && status.has_value);
188            if !satisfied {
189                provider_missing.push((provider_id.clone(), key.clone()));
190            }
191        }
192    }
193
194    ExtensionConfigDiagnostics {
195        extension_id: extension_id.to_string(),
196        entries,
197        provider_missing,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::Value;
205
206    fn empty_lookup(_: &str) -> Option<String> {
207        None
208    }
209
210    fn entry(key: &str) -> ExtensionConfigEntry {
211        ExtensionConfigEntry {
212            key: key.to_string(),
213            value_type: None,
214            description: None,
215            required: false,
216            default: None,
217            secret_env: None,
218            host_context: None,
219        }
220    }
221
222    #[test]
223    fn redact_empty() {
224        assert_eq!(redact_secret_value(""), "");
225    }
226
227    #[test]
228    fn redact_short() {
229        assert_eq!(redact_secret_value("a"), "***");
230        assert_eq!(redact_secret_value("abc"), "***");
231    }
232
233    #[test]
234    fn redact_medium() {
235        assert_eq!(redact_secret_value("abcd"), "***cd");
236        assert_eq!(redact_secret_value("abc1234"), "***34");
237    }
238
239    #[test]
240    fn redact_long() {
241        assert_eq!(redact_secret_value("abc12345"), "***2345");
242        assert_eq!(redact_secret_value("abcdefghijklmnopqrst"), "***qrst");
243        // sanity: never contains the full value beyond the tail
244        let s = redact_secret_value("supersecretvalue1234");
245        assert!(s.starts_with("***"));
246        assert!(!s.contains("supersecret"));
247    }
248
249    #[test]
250    fn env_var_uppercases_and_replaces_dashes() {
251        assert_eq!(
252            extension_env_var("my-ext", "api-key"),
253            "SYNAPS_EXTENSION_MY_EXT_API_KEY"
254        );
255    }
256
257    #[test]
258    fn classify_env_override() {
259        let e = entry("api-key");
260        let env = |k: &str| {
261            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
262                Some("v".to_string())
263            } else {
264                None
265            }
266        };
267        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
268        assert_eq!(
269            status.source,
270            ConfigSource::EnvOverride("SYNAPS_EXTENSION_MY_EXT_API_KEY".to_string())
271        );
272        assert!(status.has_value);
273    }
274
275    /// Diagnostics must not reveal whether a denied (host credential) or
276    /// malformed secret_env variable is set.
277    #[test]
278    fn classify_never_looks_up_denied_or_malformed_secret_env() {
279        let env = |k: &str| match k {
280            "ANTHROPIC_API_KEY" | "lower_token" => Some("v".to_string()),
281            _ => None,
282        };
283        for name in ["ANTHROPIC_API_KEY", "lower_token"] {
284            let mut e = entry("api-key");
285            e.secret_env = Some(name.to_string());
286            let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
287            assert_eq!(status.source, ConfigSource::Missing, "{name}");
288        }
289    }
290
291    #[test]
292    fn classify_secret_env() {
293        let mut e = entry("api-key");
294        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
295        let env = |k: &str| {
296            if k == "MY_PROVIDER_KEY" {
297                Some("v".to_string())
298            } else {
299                None
300            }
301        };
302        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &empty_lookup);
303        assert_eq!(
304            status.source,
305            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
306        );
307        assert!(status.has_value);
308    }
309
310    #[test]
311    fn classify_plugin_config() {
312        let e = entry("api-key");
313        let plugin = |k: &str| {
314            if k == "api-key" {
315                Some("v".to_string())
316            } else {
317                None
318            }
319        };
320        let status = classify_config_entry("my-ext", &e, &empty_lookup, &plugin, &empty_lookup);
321        assert_eq!(status.source, ConfigSource::PluginConfig);
322        assert!(status.has_value);
323    }
324
325    #[test]
326    fn classify_config_key() {
327        let e = entry("api-key");
328        let cfg = |k: &str| {
329            if k == "extension.my-ext.api-key" {
330                Some("v".to_string())
331            } else {
332                None
333            }
334        };
335        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
336        assert_eq!(
337            status.source,
338            ConfigSource::LegacyConfigKey("extension.my-ext.api-key".to_string())
339        );
340        assert!(status.has_value);
341    }
342
343    #[test]
344    fn classify_default() {
345        let mut e = entry("region");
346        e.default = Some(Value::String("us-east-1".to_string()));
347        let status =
348            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
349        assert_eq!(status.source, ConfigSource::Default);
350        assert!(status.has_value);
351    }
352
353    #[test]
354    fn classify_missing() {
355        let mut e = entry("api-key");
356        e.required = true;
357        let status =
358            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
359        assert_eq!(status.source, ConfigSource::Missing);
360        assert!(!status.has_value);
361        assert!(status.required);
362    }
363
364    #[test]
365    fn env_override_wins_over_all() {
366        let mut e = entry("api-key");
367        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
368        e.default = Some(Value::String("d".to_string()));
369        let env = |k: &str| Some(format!("env-{}", k));
370        let cfg = |_: &str| Some("cfg".to_string());
371        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
372        assert!(matches!(status.source, ConfigSource::EnvOverride(_)));
373    }
374
375    #[test]
376    fn secret_env_wins_over_config_and_default() {
377        let mut e = entry("api-key");
378        e.secret_env = Some("MY_PROVIDER_KEY".to_string());
379        e.default = Some(Value::String("d".to_string()));
380        let env = |k: &str| {
381            if k == "MY_PROVIDER_KEY" {
382                Some("s".to_string())
383            } else {
384                None
385            }
386        };
387        let cfg = |_: &str| Some("cfg".to_string());
388        let status = classify_config_entry("my-ext", &e, &env, &empty_lookup, &cfg);
389        assert_eq!(
390            status.source,
391            ConfigSource::SecretEnv("MY_PROVIDER_KEY".to_string())
392        );
393    }
394
395    #[test]
396    fn config_key_wins_over_default() {
397        let mut e = entry("region");
398        e.default = Some(Value::String("us-east-1".to_string()));
399        let cfg = |k: &str| {
400            if k == "extension.my-ext.region" {
401                Some("eu-west-1".to_string())
402            } else {
403                None
404            }
405        };
406        let status = classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &cfg);
407        assert!(matches!(status.source, ConfigSource::LegacyConfigKey(_)));
408    }
409
410    #[test]
411    fn default_only_when_no_env_or_config() {
412        let mut e = entry("region");
413        e.default = Some(Value::String("us-east-1".to_string()));
414        let status =
415            classify_config_entry("my-ext", &e, &empty_lookup, &empty_lookup, &empty_lookup);
416        assert_eq!(status.source, ConfigSource::Default);
417    }
418
419    #[test]
420    fn diagnose_empty_manifest_no_providers() {
421        let diag = diagnose_extension_config(
422            "my-ext",
423            &[],
424            &[],
425            &empty_lookup,
426            &empty_lookup,
427            &empty_lookup,
428        );
429        assert_eq!(diag.extension_id, "my-ext");
430        assert!(diag.entries.is_empty());
431        assert!(diag.provider_missing.is_empty());
432    }
433
434    #[test]
435    fn diagnose_entry_with_default_resolves() {
436        let mut e = entry("region");
437        e.default = Some(Value::String("us-east-1".to_string()));
438        let diag = diagnose_extension_config(
439            "my-ext",
440            std::slice::from_ref(&e),
441            &[],
442            &empty_lookup,
443            &empty_lookup,
444            &empty_lookup,
445        );
446        assert_eq!(diag.entries.len(), 1);
447        assert_eq!(diag.entries[0].source, ConfigSource::Default);
448        assert!(diag.entries[0].has_value);
449        assert!(diag.provider_missing.is_empty());
450    }
451
452    #[test]
453    fn diagnose_provider_requires_undeclared_key() {
454        let diag = diagnose_extension_config(
455            "my-ext",
456            &[],
457            &[("p".to_string(), vec!["api-key".to_string()])],
458            &empty_lookup,
459            &empty_lookup,
460            &empty_lookup,
461        );
462        assert_eq!(
463            diag.provider_missing,
464            vec![("p".to_string(), "api-key".to_string())]
465        );
466    }
467
468    #[test]
469    fn diagnose_provider_required_key_resolved_via_env() {
470        let mut e = entry("api-key");
471        e.required = true;
472        let env = |k: &str| {
473            if k == "SYNAPS_EXTENSION_MY_EXT_API_KEY" {
474                Some("v".to_string())
475            } else {
476                None
477            }
478        };
479        let diag = diagnose_extension_config(
480            "my-ext",
481            std::slice::from_ref(&e),
482            &[("p".to_string(), vec!["api-key".to_string()])],
483            &env,
484            &empty_lookup,
485            &empty_lookup,
486        );
487        assert!(diag.entries[0].has_value);
488        assert!(diag.provider_missing.is_empty());
489    }
490
491    #[test]
492    fn diagnose_provider_required_key_declared_but_missing() {
493        let mut e = entry("api-key");
494        e.required = true;
495        let diag = diagnose_extension_config(
496            "my-ext",
497            std::slice::from_ref(&e),
498            &[("p".to_string(), vec!["api-key".to_string()])],
499            &empty_lookup,
500            &empty_lookup,
501            &empty_lookup,
502        );
503        assert!(!diag.entries[0].has_value);
504        assert_eq!(
505            diag.provider_missing,
506            vec![("p".to_string(), "api-key".to_string())]
507        );
508    }
509}