Skip to main content

soma_config/
config.rs

1//! Configuration structs for the Soma MCP server.
2//!
3//! Values are loaded in priority order:
4//!   1. `config.toml` (checked in, defaults only — no secrets)
5//!   2. Environment variables (`SOMA_*`, `SOMA_MCP_*`)
6//!
7//! **Customize**: rename `SomaConfig` to match your service. Adjust env prefixes
8//! throughout. Add any domain-specific config fields you need.
9
10use serde::{Deserialize, Serialize};
11
12use crate::python_environment::PythonEnvironmentConfig;
13mod env;
14use env::{
15    boolean as env_bool, list as env_list, optional_parse as env_opt_parse,
16    optional_string as env_opt_str, parse as env_parse, string as env_str,
17};
18#[path = "python_runner_profile.rs"]
19mod python_runner_profile;
20pub use python_runner_profile::{PythonExecutionProfile, PythonRunnerMode};
21
22/// CUSTOMIZE: Replace with your service name (e.g. ".unraid", ".gotify").
23const SERVICE_HOME_DIRNAME: &str = ".soma";
24
25/// Top-level config (maps to `config.toml` sections).
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27#[serde(default)]
28pub struct Config {
29    pub mcp: McpConfig,
30    pub soma: SomaConfig,
31    pub python: PythonRunnerConfig,
32}
33
34/// Limits for the optional persistent Python provider runner.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(default)]
37pub struct PythonRunnerConfig {
38    pub environment: PythonEnvironmentConfig,
39    pub mode: PythonRunnerMode,
40    pub execution_profile: PythonExecutionProfile,
41    /// Provider identities eligible for broker deployment grants.
42    pub broker_allowed_providers: Vec<String>,
43    /// Deployment-policy allowlist for brokered outbound HTTP.
44    pub broker_allowed_http_hosts: Vec<String>,
45    /// Deployment-policy allowlist for secret handles exposed to providers.
46    pub broker_secret_names: Vec<String>,
47    /// Deployment-policy allowlist for durable state namespaces.
48    pub broker_state_namespaces: Vec<String>,
49    pub broker_allow_logging: bool,
50    pub broker_allow_metrics: bool,
51    pub broker_allow_progress: bool,
52    pub startup_timeout_ms: u64,
53    pub request_timeout_ms: u64,
54    pub shutdown_grace_ms: u64,
55    pub max_restarts: u32,
56    pub restart_window_ms: u64,
57    pub restart_backoff_ms: u64,
58    pub max_stderr_bytes: usize,
59    pub max_pending_bytes: usize,
60    pub max_workers: usize,
61    pub max_candidate_starts: usize,
62}
63
64impl Default for PythonRunnerConfig {
65    fn default() -> Self {
66        Self {
67            environment: PythonEnvironmentConfig::default(),
68            mode: PythonRunnerMode::OneShot,
69            execution_profile: PythonExecutionProfile::Trusted,
70            broker_allowed_providers: Vec::new(),
71            broker_allowed_http_hosts: Vec::new(),
72            broker_secret_names: Vec::new(),
73            broker_state_namespaces: Vec::new(),
74            broker_allow_logging: false,
75            broker_allow_metrics: false,
76            broker_allow_progress: false,
77            startup_timeout_ms: 10_000,
78            request_timeout_ms: 10_000,
79            shutdown_grace_ms: 2_000,
80            max_restarts: 3,
81            restart_window_ms: 60_000,
82            restart_backoff_ms: 250,
83            max_stderr_bytes: 64 * 1024,
84            max_pending_bytes: 512 * 1024,
85            max_workers: 32,
86            max_candidate_starts: 4,
87        }
88    }
89}
90
91impl PythonRunnerConfig {
92    fn validate(&self) -> anyhow::Result<()> {
93        if self.execution_profile != PythonExecutionProfile::Trusted
94            && self.mode != PythonRunnerMode::Persistent
95        {
96            anyhow::bail!(
97                "SOMA_PYTHON_EXECUTION_PROFILE={} requires SOMA_PYTHON_RUNNER_MODE=persistent",
98                match self.execution_profile {
99                    PythonExecutionProfile::Disabled => "disabled",
100                    PythonExecutionProfile::Trusted => "trusted",
101                    PythonExecutionProfile::Brokered => "brokered",
102                }
103            );
104        }
105        for (name, value) in [
106            (
107                "SOMA_PYTHON_RUNNER_STARTUP_TIMEOUT_MS",
108                self.startup_timeout_ms,
109            ),
110            (
111                "SOMA_PYTHON_RUNNER_REQUEST_TIMEOUT_MS",
112                self.request_timeout_ms,
113            ),
114            (
115                "SOMA_PYTHON_RUNNER_SHUTDOWN_GRACE_MS",
116                self.shutdown_grace_ms,
117            ),
118            (
119                "SOMA_PYTHON_RUNNER_RESTART_WINDOW_MS",
120                self.restart_window_ms,
121            ),
122        ] {
123            if value == 0 {
124                anyhow::bail!("{name} must be greater than zero");
125            }
126        }
127        for (name, value) in [
128            ("SOMA_PYTHON_RUNNER_MAX_STDERR_BYTES", self.max_stderr_bytes),
129            (
130                "SOMA_PYTHON_RUNNER_MAX_PENDING_BYTES",
131                self.max_pending_bytes,
132            ),
133            ("SOMA_PYTHON_RUNNER_MAX_WORKERS", self.max_workers),
134            (
135                "SOMA_PYTHON_RUNNER_MAX_CANDIDATE_STARTS",
136                self.max_candidate_starts,
137            ),
138        ] {
139            if value == 0 {
140                anyhow::bail!("{name} must be greater than zero");
141            }
142        }
143        self.environment.validate()?;
144        Ok(())
145    }
146}
147
148/// Config for the Soma runtime or deployed platform API.
149///
150/// For application/platform servers, the local CLI + stdio MCP adapter uses
151/// `api_url` as the deployed `soma serve` API base URL. For upstream-client
152/// servers, replace this with config for the actual upstream service.
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154#[serde(default)]
155pub struct SomaConfig {
156    /// Base URL of the deployed platform API or upstream service (SOMA_API_URL).
157    /// Example: `https://example.example.com/`
158    pub api_url: String,
159    /// API key or bearer token (SOMA_API_KEY).
160    pub api_key: String,
161    /// Runtime adapter mode (SOMA_RUNTIME_MODE).
162    pub runtime_mode: RuntimeMode,
163}
164
165#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
166#[serde(rename_all = "lowercase")]
167pub enum RuntimeMode {
168    /// Preserve compatibility: remote when SOMA_API_URL is set, otherwise local.
169    #[default]
170    Auto,
171    /// Execute business actions in this process.
172    Local,
173    /// Treat this binary as a client adapter for a running HTTP server.
174    Remote,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum EffectiveRuntimeMode {
179    Local,
180    Remote,
181}
182
183impl SomaConfig {
184    pub fn effective_runtime_mode(&self) -> EffectiveRuntimeMode {
185        match self.runtime_mode {
186            RuntimeMode::Auto if self.api_url.trim().is_empty() => EffectiveRuntimeMode::Local,
187            RuntimeMode::Auto => EffectiveRuntimeMode::Remote,
188            RuntimeMode::Local => EffectiveRuntimeMode::Local,
189            RuntimeMode::Remote => EffectiveRuntimeMode::Remote,
190        }
191    }
192
193    pub fn is_remote_adapter(&self) -> bool {
194        self.effective_runtime_mode() == EffectiveRuntimeMode::Remote
195    }
196}
197
198/// MCP HTTP server configuration.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct McpConfig {
202    /// Bind host (SOMA_MCP_HOST). Default: `127.0.0.1` (loopback).
203    /// Set to `0.0.0.0` to listen on all interfaces — requires auth configured.
204    #[serde(default = "default_mcp_host")]
205    pub host: String,
206    /// Bind port (SOMA_MCP_PORT). Default: `40060`.
207    #[serde(default = "default_mcp_port")]
208    pub port: u16,
209    /// MCP server name advertised to clients (SOMA_MCP_SERVER_NAME).
210    #[serde(default = "default_server_name")]
211    pub server_name: String,
212    /// Disable auth entirely — only safe when bound to loopback (SOMA_MCP_NO_AUTH).
213    pub no_auth: bool,
214    /// Allow unauthenticated access on non-loopback when behind a trusted reverse proxy
215    /// that enforces its own auth (SOMA_NOAUTH). Loaded here so it participates in
216    /// typed config rather than being a raw env read at call sites.
217    pub trusted_gateway: bool,
218    /// Advertise official MCP conformance reference fixtures.
219    ///
220    /// This is a test harness switch, not part of Soma's production
221    /// surface. Keep it false for real derived servers.
222    pub conformance_fixtures: bool,
223    /// Static bearer token for simple auth (SOMA_MCP_TOKEN).
224    pub api_token: Option<String>,
225    /// Grant the static bearer token `soma:write` in addition to the
226    /// default `soma:read` (SOMA_MCP_STATIC_TOKEN_WRITE). Off by default so
227    /// a leaked static token cannot perform write actions unless the
228    /// operator explicitly opted in (pattern ported from cortex's
229    /// `static_token_is_admin`).
230    pub static_token_write: bool,
231    /// Additional allowed Host header values (comma-separated in env).
232    pub allowed_hosts: Vec<String>,
233    /// Additional allowed CORS origins (comma-separated in env).
234    pub allowed_origins: Vec<String>,
235    /// Trusted HTTP trace-header extraction mode (SOMA_MCP_TRACE_HEADERS).
236    /// Only meaningful when the resolved auth policy is a real trust boundary
237    /// (loopback bind or a trusted gateway) — see
238    /// `soma_runtime::server::resolve_auth_policy_kind`.
239    pub trace_headers: TraceHeaderMode,
240    /// OAuth sub-config (nested under `[mcp.auth]` in config.toml).
241    pub auth: AuthConfig,
242}
243
244impl McpConfig {
245    pub fn bind_addr(&self) -> String {
246        format!("{}:{}", self.host, self.port)
247    }
248
249    /// Return true if the configured bind host resolves to a loopback address.
250    ///
251    /// Uses `IpAddr::is_loopback()` for numeric addresses. Accepts "localhost"
252    /// as a canonical loopback hostname. Any other hostname or parse failure is
253    /// treated as non-loopback — callers must not assume safety in that case.
254    pub fn is_loopback(&self) -> bool {
255        let host = &self.host;
256        // Match "localhost" literal and numeric loopback addresses.
257        // Strip bracket notation ([::1]) before parsing so IPv6 loopback works.
258        host == "localhost"
259            || host
260                .trim_start_matches('[')
261                .trim_end_matches(']')
262                .parse::<std::net::IpAddr>()
263                .map(|ip| ip.is_loopback())
264                .unwrap_or(false)
265    }
266}
267
268/// OAuth / JWT auth sub-config.
269///
270/// This struct types every env var `soma_auth::AuthConfigBuilder` consumes
271/// (`SOMA_MCP_*`). Fields left unset (`None` / empty) are deliberately NOT
272/// given soma-side defaults: `soma_integrations::auth` synthesizes a var list
273/// from set fields only, so the auth crate's own defaults (see
274/// `crates/shared/auth/src/config.rs`) apply exactly as they would have when
275/// the builder read process env directly.
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
277#[serde(default)]
278pub struct AuthConfig {
279    pub mode: AuthMode,
280    /// Public base URL for OAuth metadata (SOMA_MCP_PUBLIC_URL).
281    pub public_url: Option<String>,
282    /// Google OAuth client ID (SOMA_MCP_GOOGLE_CLIENT_ID).
283    pub google_client_id: Option<String>,
284    /// Google OAuth client secret (SOMA_MCP_GOOGLE_CLIENT_SECRET).
285    pub google_client_secret: Option<String>,
286    /// Google OAuth callback path override (SOMA_MCP_GOOGLE_CALLBACK_PATH).
287    pub google_callback_path: Option<String>,
288    /// Google OAuth scopes override (SOMA_MCP_GOOGLE_SCOPES, comma-separated in env).
289    pub google_scopes: Vec<String>,
290    /// Authelia OIDC issuer URL (SOMA_MCP_AUTHELIA_ISSUER_URL, must be https).
291    pub authelia_issuer_url: Option<String>,
292    /// Authelia OIDC client ID (SOMA_MCP_AUTHELIA_CLIENT_ID).
293    pub authelia_client_id: Option<String>,
294    /// Authelia OIDC client secret (SOMA_MCP_AUTHELIA_CLIENT_SECRET).
295    pub authelia_client_secret: Option<String>,
296    /// Authelia callback path override (SOMA_MCP_AUTHELIA_CALLBACK_PATH).
297    pub authelia_callback_path: Option<String>,
298    /// Authelia scopes override (SOMA_MCP_AUTHELIA_SCOPES, comma-separated in env).
299    pub authelia_scopes: Vec<String>,
300    /// GitHub OAuth App client ID (SOMA_MCP_GITHUB_CLIENT_ID).
301    pub github_client_id: Option<String>,
302    /// GitHub OAuth App client secret (SOMA_MCP_GITHUB_CLIENT_SECRET).
303    pub github_client_secret: Option<String>,
304    /// GitHub callback path override (SOMA_MCP_GITHUB_CALLBACK_PATH).
305    pub github_callback_path: Option<String>,
306    /// GitHub scopes override (SOMA_MCP_GITHUB_SCOPES, comma-separated in env;
307    /// must include `user:email`).
308    pub github_scopes: Vec<String>,
309    /// Default OAuth provider (SOMA_MCP_AUTH_DEFAULT_PROVIDER). Unset =
310    /// automatic priority: Google, Authelia, GitHub.
311    pub default_provider: Option<String>,
312    /// OAuth admin email (SOMA_MCP_AUTH_ADMIN_EMAIL).
313    pub admin_email: String,
314    pub allowed_emails: Vec<String>,
315    /// Native-flow bootstrap secret (SOMA_MCP_AUTH_BOOTSTRAP_SECRET).
316    pub bootstrap_secret: Option<String>,
317    /// Auth SQLite DB path (SOMA_MCP_AUTH_SQLITE_PATH).
318    pub sqlite_path: Option<String>,
319    /// Ed25519 JWT signing key path (SOMA_MCP_AUTH_KEY_PATH).
320    pub key_path: Option<String>,
321    /// Access-token TTL in seconds (SOMA_MCP_AUTH_ACCESS_TOKEN_TTL_SECS).
322    pub access_token_ttl_secs: Option<u64>,
323    /// Refresh-token TTL in seconds (SOMA_MCP_AUTH_REFRESH_TOKEN_TTL_SECS).
324    pub refresh_token_ttl_secs: Option<u64>,
325    /// Auth-code TTL in seconds (SOMA_MCP_AUTH_CODE_TTL_SECS).
326    pub auth_code_ttl_secs: Option<u64>,
327    /// `/register` rate limit (SOMA_MCP_AUTH_REGISTER_REQUESTS_PER_MINUTE).
328    pub register_rpm: Option<u32>,
329    /// `/authorize` rate limit (SOMA_MCP_AUTH_AUTHORIZE_REQUESTS_PER_MINUTE).
330    pub authorize_rpm: Option<u32>,
331    /// Pending OAuth state cap (SOMA_MCP_AUTH_MAX_PENDING_OAUTH_STATES).
332    pub max_pending_oauth_states: Option<usize>,
333    /// Allowed dynamic-client redirect URIs (SOMA_MCP_AUTH_ALLOWED_REDIRECT_URIS,
334    /// comma-separated in env).
335    pub allowed_client_redirect_uris: Vec<String>,
336    /// At-rest refresh-token encryption key (SOMA_MCP_TOKEN_ENCRYPTION_KEY,
337    /// 64 hex digits or 43 base64url chars — validated by soma-auth).
338    pub token_encryption_key: Option<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
342#[serde(rename_all = "lowercase")]
343pub enum AuthMode {
344    #[default]
345    Bearer,
346    OAuth,
347}
348
349#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
350#[serde(rename_all = "kebab-case")]
351pub enum TraceHeaderMode {
352    /// No HTTP trace-header extraction. Default — safe for every deployment.
353    #[default]
354    Off,
355    /// Extract `traceparent`/`tracestate` from inbound HTTP headers after auth.
356    /// Baggage is never extracted in this mode.
357    Trusted,
358    /// Like `Trusted`, but also extracts validated `baggage`. Baggage can carry
359    /// sensitive user/session/application data — enable deliberately.
360    TrustedWithBaggage,
361}
362
363impl TraceHeaderMode {
364    pub const fn as_str(self) -> &'static str {
365        match self {
366            Self::Off => "off",
367            Self::Trusted => "trusted",
368            Self::TrustedWithBaggage => "trusted-with-baggage",
369        }
370    }
371}
372
373impl std::fmt::Display for TraceHeaderMode {
374    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375        formatter.write_str(self.as_str())
376    }
377}
378
379// ── defaults ──────────────────────────────────────────────────────────────────
380
381fn default_mcp_host() -> String {
382    // Default to loopback for safety. Operators who need external access must
383    // explicitly set SOMA_MCP_HOST=0.0.0.0 (and configure auth).
384    "127.0.0.1".into()
385}
386fn default_mcp_port() -> u16 {
387    40060
388}
389fn default_server_name() -> String {
390    "soma".into()
391}
392
393impl Default for McpConfig {
394    fn default() -> Self {
395        Self {
396            host: default_mcp_host(),
397            port: default_mcp_port(),
398            server_name: default_server_name(),
399            no_auth: false,
400            trusted_gateway: false,
401            conformance_fixtures: false,
402            api_token: None,
403            static_token_write: false,
404            allowed_hosts: Vec::new(),
405            allowed_origins: Vec::new(),
406            trace_headers: TraceHeaderMode::default(),
407            auth: AuthConfig::default(),
408        }
409    }
410}
411
412// ── Appdata directory ─────────────────────────────────────────────────────────
413
414/// Return the default local data directory for this service.
415///
416/// Pattern §25 + §28: The same `.env` and `config.toml` in `~/.<service>/`
417/// work for both Docker and bare-metal deployment without modification.
418///
419/// | Environment   | Path                                |
420/// |---------------|-------------------------------------|
421/// | Container     | `/data` (bind-mounted from host)     |
422/// | Bare-metal    | `~/.soma` (user home dir)        |
423///
424/// The name should match the docker-compose.yml volume mount source.
425pub fn default_data_dir() -> anyhow::Result<std::path::PathBuf> {
426    if let Some(path) = std::env::var_os("SOMA_HOME") {
427        return Ok(std::path::PathBuf::from(path));
428    }
429
430    // Running inside a Docker container — /data is always the mount point.
431    // Detection uses /.dockerenv (created by the Docker runtime) or an explicit
432    // RUNNING_IN_CONTAINER env var (useful for testing or systemd-nspawn).
433    if std::path::Path::new("/.dockerenv").exists()
434        || std::env::var("RUNNING_IN_CONTAINER").is_ok()
435        || std::env::var("container").is_ok()
436    {
437        return Ok(std::path::PathBuf::from("/data"));
438    }
439
440    // Bare-metal or local dev — use ~/.<service>/
441    let home = dirs::home_dir().ok_or_else(|| {
442        anyhow::anyhow!("cannot determine home directory — set HOME or RUNNING_IN_CONTAINER=1")
443    })?;
444    Ok(home.join(SERVICE_HOME_DIRNAME))
445}
446
447/// Load `<appdata>/.env` (`~/.<service>/.env` on bare metal, `/data/.env` in a
448/// container) into the process environment if present.
449///
450/// Best-effort: a missing file is ignored, and existing env vars are NOT
451/// overridden — values injected by docker-compose/systemd or the plugin hook's
452/// `CLAUDE_PLUGIN_OPTION_*` mapping still take precedence. This lets the binary
453/// find its credentials directly from `~/.<service>/.env` without relying on a
454/// process manager to inject them. Call once at startup before `Config::load`.
455pub fn load_dotenv() {
456    let Ok(dir) = default_data_dir() else {
457        return;
458    };
459    let env_path = dir.join(".env");
460    // Reject symlinks under the appdata dir — it holds secrets and we do not want
461    // a planted symlink redirecting us to attacker-controlled env (mirrors axon).
462    // Bare `dotenvy::from_path` would follow the symlink via `File::open`.
463    match std::fs::symlink_metadata(&env_path) {
464        Ok(md) if md.file_type().is_symlink() => {
465            eprintln!(
466                "error: refusing to load symlinked .env at {} (potential symlink attack)",
467                env_path.display()
468            );
469            std::process::exit(1);
470        }
471        Ok(_) => {
472            let _ = dotenvy::from_path(&env_path);
473        }
474        // Missing or inaccessible — best effort; fall back to process env / config.toml.
475        Err(_) => {}
476    }
477}
478
479// ── Config loading ────────────────────────────────────────────────────────────
480
481impl Config {
482    pub fn load() -> anyhow::Result<Self> {
483        let mut config = Config::default();
484
485        // Search for config.toml in priority order (§25: appdata convention):
486        //   1. ~/<SERVICE_HOME_DIRNAME>/config.toml  — user's persistent config (primary)
487        //   2. ./config.toml                         — local dev / Docker mount fallback
488        let candidate_paths = {
489            let mut paths = vec![];
490            if let Some(home) = std::env::var_os("HOME") {
491                paths.push(
492                    std::path::PathBuf::from(home)
493                        .join(SERVICE_HOME_DIRNAME)
494                        .join("config.toml"),
495                );
496            }
497            paths.push(std::path::PathBuf::from("config.toml"));
498            paths
499        };
500
501        for path in &candidate_paths {
502            match std::fs::read_to_string(path) {
503                Ok(contents) => {
504                    config = toml::from_str(&contents)
505                        .map_err(|e| anyhow::anyhow!("Failed to parse {}: {e}", path.display()))?;
506                    break;
507                }
508                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
509                Err(e) => return Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())),
510            }
511        }
512
513        // Env overrides — SOMA_MCP_* for server config, SOMA_API_* for upstream
514        env_str("SOMA_MCP_HOST", &mut config.mcp.host);
515        env_parse("SOMA_MCP_PORT", &mut config.mcp.port)?;
516        env_str("SOMA_MCP_SERVER_NAME", &mut config.mcp.server_name);
517        env_bool("SOMA_MCP_NO_AUTH", &mut config.mcp.no_auth)?;
518        env_bool("SOMA_NOAUTH", &mut config.mcp.trusted_gateway)?;
519        env_bool(
520            "SOMA_MCP_CONFORMANCE_FIXTURES",
521            &mut config.mcp.conformance_fixtures,
522        )?;
523        env_opt_str("SOMA_MCP_TOKEN", &mut config.mcp.api_token);
524        env_bool(
525            "SOMA_MCP_STATIC_TOKEN_WRITE",
526            &mut config.mcp.static_token_write,
527        )?;
528        env_list("SOMA_MCP_ALLOWED_HOSTS", &mut config.mcp.allowed_hosts);
529        env_list("SOMA_MCP_ALLOWED_ORIGINS", &mut config.mcp.allowed_origins);
530        env_opt_str("SOMA_MCP_PUBLIC_URL", &mut config.mcp.auth.public_url);
531        env_str(
532            "SOMA_MCP_AUTH_ADMIN_EMAIL",
533            &mut config.mcp.auth.admin_email,
534        );
535        env_opt_str(
536            "SOMA_MCP_GOOGLE_CLIENT_ID",
537            &mut config.mcp.auth.google_client_id,
538        );
539        env_opt_str(
540            "SOMA_MCP_GOOGLE_CLIENT_SECRET",
541            &mut config.mcp.auth.google_client_secret,
542        );
543        env_opt_str(
544            "SOMA_MCP_GOOGLE_CALLBACK_PATH",
545            &mut config.mcp.auth.google_callback_path,
546        );
547        env_list("SOMA_MCP_GOOGLE_SCOPES", &mut config.mcp.auth.google_scopes);
548        env_opt_str(
549            "SOMA_MCP_AUTHELIA_ISSUER_URL",
550            &mut config.mcp.auth.authelia_issuer_url,
551        );
552        env_opt_str(
553            "SOMA_MCP_AUTHELIA_CLIENT_ID",
554            &mut config.mcp.auth.authelia_client_id,
555        );
556        env_opt_str(
557            "SOMA_MCP_AUTHELIA_CLIENT_SECRET",
558            &mut config.mcp.auth.authelia_client_secret,
559        );
560        env_opt_str(
561            "SOMA_MCP_AUTHELIA_CALLBACK_PATH",
562            &mut config.mcp.auth.authelia_callback_path,
563        );
564        env_list(
565            "SOMA_PYTHON_BROKER_ALLOWED_PROVIDERS",
566            &mut config.python.broker_allowed_providers,
567        );
568        env_list(
569            "SOMA_MCP_AUTHELIA_SCOPES",
570            &mut config.mcp.auth.authelia_scopes,
571        );
572        env_opt_str(
573            "SOMA_MCP_GITHUB_CLIENT_ID",
574            &mut config.mcp.auth.github_client_id,
575        );
576        env_opt_str(
577            "SOMA_MCP_GITHUB_CLIENT_SECRET",
578            &mut config.mcp.auth.github_client_secret,
579        );
580        env_opt_str(
581            "SOMA_MCP_GITHUB_CALLBACK_PATH",
582            &mut config.mcp.auth.github_callback_path,
583        );
584        env_list("SOMA_MCP_GITHUB_SCOPES", &mut config.mcp.auth.github_scopes);
585        env_opt_str(
586            "SOMA_MCP_AUTH_DEFAULT_PROVIDER",
587            &mut config.mcp.auth.default_provider,
588        );
589        env_opt_str(
590            "SOMA_MCP_AUTH_BOOTSTRAP_SECRET",
591            &mut config.mcp.auth.bootstrap_secret,
592        );
593        env_opt_str(
594            "SOMA_MCP_AUTH_SQLITE_PATH",
595            &mut config.mcp.auth.sqlite_path,
596        );
597        env_opt_str("SOMA_MCP_AUTH_KEY_PATH", &mut config.mcp.auth.key_path);
598        env_opt_parse(
599            "SOMA_MCP_AUTH_ACCESS_TOKEN_TTL_SECS",
600            &mut config.mcp.auth.access_token_ttl_secs,
601        )?;
602        env_opt_parse(
603            "SOMA_MCP_AUTH_REFRESH_TOKEN_TTL_SECS",
604            &mut config.mcp.auth.refresh_token_ttl_secs,
605        )?;
606        env_opt_parse(
607            "SOMA_MCP_AUTH_CODE_TTL_SECS",
608            &mut config.mcp.auth.auth_code_ttl_secs,
609        )?;
610        env_opt_parse(
611            "SOMA_MCP_AUTH_REGISTER_REQUESTS_PER_MINUTE",
612            &mut config.mcp.auth.register_rpm,
613        )?;
614        env_opt_parse(
615            "SOMA_MCP_AUTH_AUTHORIZE_REQUESTS_PER_MINUTE",
616            &mut config.mcp.auth.authorize_rpm,
617        )?;
618        env_opt_parse(
619            "SOMA_MCP_AUTH_MAX_PENDING_OAUTH_STATES",
620            &mut config.mcp.auth.max_pending_oauth_states,
621        )?;
622        env_list(
623            "SOMA_MCP_AUTH_ALLOWED_REDIRECT_URIS",
624            &mut config.mcp.auth.allowed_client_redirect_uris,
625        );
626        env_opt_str(
627            "SOMA_MCP_TOKEN_ENCRYPTION_KEY",
628            &mut config.mcp.auth.token_encryption_key,
629        );
630        if let Ok(v) = std::env::var("SOMA_MCP_AUTH_MODE")
631            && !v.is_empty()
632        {
633            config.mcp.auth.mode = match v.to_lowercase().as_str() {
634                "oauth" => AuthMode::OAuth,
635                "bearer" => AuthMode::Bearer,
636                other => {
637                    return Err(anyhow::anyhow!(
638                        "invalid SOMA_MCP_AUTH_MODE {:?}: must be \"bearer\" or \"oauth\"",
639                        other
640                    ));
641                }
642            };
643        }
644        if let Ok(v) = std::env::var("SOMA_MCP_TRACE_HEADERS")
645            && !v.is_empty()
646        {
647            config.mcp.trace_headers = match v.to_lowercase().as_str() {
648                "off" => TraceHeaderMode::Off,
649                "trusted" => TraceHeaderMode::Trusted,
650                "trusted-with-baggage" => TraceHeaderMode::TrustedWithBaggage,
651                other => {
652                    return Err(anyhow::anyhow!(
653                        "invalid SOMA_MCP_TRACE_HEADERS {:?}: must be \"off\", \"trusted\", \
654                             or \"trusted-with-baggage\"",
655                        other
656                    ));
657                }
658            };
659        }
660
661        // Upstream service config
662        env_str("SOMA_API_URL", &mut config.soma.api_url);
663        env_str("SOMA_API_KEY", &mut config.soma.api_key);
664        if let Ok(v) = std::env::var("SOMA_RUNTIME_MODE")
665            && !v.is_empty()
666        {
667            config.soma.runtime_mode = match v.to_lowercase().as_str() {
668                "auto" => RuntimeMode::Auto,
669                "local" => RuntimeMode::Local,
670                "remote" | "api" => RuntimeMode::Remote,
671                other => {
672                    return Err(anyhow::anyhow!(
673                        "invalid SOMA_RUNTIME_MODE {:?}: must be \"auto\", \"local\", or \"remote\"",
674                        other
675                    ));
676                }
677            };
678        }
679
680        if let Ok(value) = std::env::var("SOMA_PYTHON_RUNNER_MODE")
681            && !value.is_empty()
682        {
683            config.python.mode = match value.to_ascii_lowercase().as_str() {
684                "one-shot" | "oneshot" => PythonRunnerMode::OneShot,
685                "persistent" => PythonRunnerMode::Persistent,
686                other => anyhow::bail!(
687                    "invalid SOMA_PYTHON_RUNNER_MODE {other:?}: must be \"one-shot\" or \"persistent\""
688                ),
689            };
690        }
691        if let Ok(value) = std::env::var("SOMA_PYTHON_EXECUTION_PROFILE")
692            && !value.is_empty()
693        {
694            config.python.execution_profile = match value.to_ascii_lowercase().as_str() {
695                "disabled" => PythonExecutionProfile::Disabled,
696                "trusted" => PythonExecutionProfile::Trusted,
697                "brokered" => PythonExecutionProfile::Brokered,
698                other => anyhow::bail!(
699                    "invalid SOMA_PYTHON_EXECUTION_PROFILE {other:?}: must be \"disabled\", \"trusted\", or \"brokered\""
700                ),
701            };
702        }
703        env_list(
704            "SOMA_PYTHON_BROKER_ALLOWED_HTTP_HOSTS",
705            &mut config.python.broker_allowed_http_hosts,
706        );
707        env_list(
708            "SOMA_PYTHON_BROKER_SECRET_NAMES",
709            &mut config.python.broker_secret_names,
710        );
711        env_list(
712            "SOMA_PYTHON_BROKER_STATE_NAMESPACES",
713            &mut config.python.broker_state_namespaces,
714        );
715        env_bool(
716            "SOMA_PYTHON_BROKER_ALLOW_LOGGING",
717            &mut config.python.broker_allow_logging,
718        )?;
719        env_bool(
720            "SOMA_PYTHON_BROKER_ALLOW_METRICS",
721            &mut config.python.broker_allow_metrics,
722        )?;
723        env_bool(
724            "SOMA_PYTHON_BROKER_ALLOW_PROGRESS",
725            &mut config.python.broker_allow_progress,
726        )?;
727        env_parse(
728            "SOMA_PYTHON_RUNNER_STARTUP_TIMEOUT_MS",
729            &mut config.python.startup_timeout_ms,
730        )?;
731        env_parse(
732            "SOMA_PYTHON_RUNNER_REQUEST_TIMEOUT_MS",
733            &mut config.python.request_timeout_ms,
734        )?;
735        env_parse(
736            "SOMA_PYTHON_RUNNER_SHUTDOWN_GRACE_MS",
737            &mut config.python.shutdown_grace_ms,
738        )?;
739        env_parse(
740            "SOMA_PYTHON_RUNNER_MAX_RESTARTS",
741            &mut config.python.max_restarts,
742        )?;
743        env_parse(
744            "SOMA_PYTHON_RUNNER_RESTART_WINDOW_MS",
745            &mut config.python.restart_window_ms,
746        )?;
747        env_parse(
748            "SOMA_PYTHON_RUNNER_RESTART_BACKOFF_MS",
749            &mut config.python.restart_backoff_ms,
750        )?;
751        env_parse(
752            "SOMA_PYTHON_RUNNER_MAX_STDERR_BYTES",
753            &mut config.python.max_stderr_bytes,
754        )?;
755        env_parse(
756            "SOMA_PYTHON_RUNNER_MAX_PENDING_BYTES",
757            &mut config.python.max_pending_bytes,
758        )?;
759        env_parse(
760            "SOMA_PYTHON_RUNNER_MAX_WORKERS",
761            &mut config.python.max_workers,
762        )?;
763        env_parse(
764            "SOMA_PYTHON_RUNNER_MAX_CANDIDATE_STARTS",
765            &mut config.python.max_candidate_starts,
766        )?;
767        env_bool(
768            "SOMA_PYTHON_ENVIRONMENT_ENABLED",
769            &mut config.python.environment.enabled,
770        )?;
771        env_str(
772            "SOMA_PYTHON_ENVIRONMENT_CACHE_ROOT",
773            &mut config.python.environment.cache_root,
774        );
775        env_str(
776            "SOMA_PYTHON_ENVIRONMENT_UV_PROGRAM",
777            &mut config.python.environment.uv_program,
778        );
779        env_str(
780            "SOMA_PYTHON_ENVIRONMENT_UV_VERSION",
781            &mut config.python.environment.uv_version,
782        );
783        env_str(
784            "SOMA_PYTHON_ENVIRONMENT_PYTHON_EXECUTABLE",
785            &mut config.python.environment.python_executable,
786        );
787        env_str(
788            "SOMA_PYTHON_ENVIRONMENT_RUNTIME_IMPLEMENTATION",
789            &mut config.python.environment.runtime_implementation,
790        );
791        env_str(
792            "SOMA_PYTHON_ENVIRONMENT_RUNTIME_VERSION",
793            &mut config.python.environment.runtime_version,
794        );
795        env_str(
796            "SOMA_PYTHON_ENVIRONMENT_RUNTIME_PLATFORM",
797            &mut config.python.environment.runtime_platform,
798        );
799        env_str(
800            "SOMA_PYTHON_ENVIRONMENT_WHEEL_PLATFORM_TAG",
801            &mut config.python.environment.wheel_platform_tag,
802        );
803        env_str(
804            "SOMA_PYTHON_ENVIRONMENT_SDK_WHEEL",
805            &mut config.python.environment.sdk_wheel,
806        );
807        env_str(
808            "SOMA_PYTHON_ENVIRONMENT_SDK_WHEEL_SHA256",
809            &mut config.python.environment.sdk_wheel_sha256,
810        );
811        env_bool(
812            "SOMA_PYTHON_ENVIRONMENT_OFFLINE",
813            &mut config.python.environment.offline,
814        )?;
815        env_bool(
816            "SOMA_PYTHON_ENVIRONMENT_UPDATE",
817            &mut config.python.environment.update,
818        )?;
819        env_parse(
820            "SOMA_PYTHON_ENVIRONMENT_POLICY_VERSION",
821            &mut config.python.environment.policy_version,
822        )?;
823        config.python.validate()?;
824
825        Ok(config)
826    }
827}
828
829#[cfg(test)]
830#[path = "config_tests.rs"]
831mod tests;