//! Configuration loading for all Clawmates binaries. //! //! One TOML file plus `CLAWMATES_*` environment overrides (nested keys split //! on `__`, e.g. `CLAWMATES_DATABASE__URL`). The same configuration tree //! drives both deployment targets; semantic validation rejects combinations //! that would only fail at runtime (e.g. an OpenAI-compatible provider with //! no endpoint to call). use std::net::SocketAddr; use std::path::Path; use figment::providers::{Env, Format, Toml}; use figment::Figment; use serde::Deserialize; /// Which of the two first-class deployment targets this instance runs as. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DeployTarget { AirGapped, Cloud, } /// Which `LlmProvider` implementation the runtime instantiates. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LlmProviderKind { Anthropic, #[serde(rename = "openai_compat")] OpenAiCompat, Scripted, } /// Which `AuthProvider` implementation handles sign-in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuthMode { Local, Oidc, /// Clerk hosted identity: the instance's Frontend API URL is the OIDC /// issuer; session JWTs are verified against its JWKS. Cloud only — /// air-gapped installs use `local`. Clerk, } #[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, #[serde(default = "default_max_connections")] pub max_connections: u32, } fn default_max_connections() -> u32 { 10 } #[derive(Debug, Clone, Deserialize)] pub struct LlmConfig { pub provider: LlmProviderKind, /// Endpoint for `openai_compat`; unused by other providers. pub base_url: Option, pub model: String, /// Scenario file for the deterministic `scripted` provider. pub scenario_path: Option, /// Additional named providers exposed alongside the default — selectable as /// `":"` by judges (`CLAWMATES_JUDGE_MODEL`) and topology nodes. /// All are OpenAI-compatible (GLM, Kimi/Moonshot, etc.). #[serde(default)] pub providers: Vec, } /// An extra provider in the registry (e.g. GLM or Kimi). Used as judge or /// topology-exec model via `":"`. #[derive(Debug, Clone, Deserialize)] pub struct NamedProvider { /// Selector prefix, e.g. `glm` or `kimi` (used as `"glm:glm-4.6"`). pub name: String, /// Base URL incl. version, e.g. `https://open.bigmodel.cn/api/paas/v4` /// (OpenAI-compat) or `https://api.z.ai/api/anthropic` (Anthropic-format). pub base_url: String, /// Env var holding this provider's API key, e.g. `GLM_API_KEY`. pub api_key_env: String, /// Wire format: `openai_compat` (default) or `anthropic`. GLM's coding /// OpenAI endpoint is ToS-throttled for raw SDK access, but its Anthropic /// endpoint (`api.z.ai/api/anthropic`) accepts raw API calls — so a GLM /// judge uses `format = "anthropic"`. #[serde(default = "default_provider_format")] pub format: String, } fn default_provider_format() -> String { "openai_compat".to_string() } #[derive(Debug, Clone, Deserialize)] pub struct AuthConfig { pub mode: AuthMode, pub issuer_url: Option, pub client_id: Option, /// When true (SaaS), a brand-new hosted-identity (Clerk/OIDC) sign-in /// provisions its OWN workspace and owns it. When false (appliance), the /// first sign-in joins the instance's single workspace as a member. #[serde(default)] pub per_signup_workspace: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StorageBackend { Local, S3, } #[derive(Debug, Clone, Deserialize)] pub struct StorageConfig { /// Root directory for file-drive blobs (volume-mounted in compose). pub data_dir: String, #[serde(default = "default_backend")] pub backend: StorageBackend, /// S3 backend settings; keys arrive via CLAWMATES_STORAGE__* env vars. pub s3_endpoint: Option, pub s3_bucket: Option, pub s3_access_key: Option, pub s3_secret_key: Option, } fn default_backend() -> StorageBackend { StorageBackend::Local } impl Default for StorageConfig { fn default() -> Self { StorageConfig { data_dir: "./data".into(), backend: StorageBackend::Local, s3_endpoint: None, s3_bucket: None, s3_access_key: None, s3_secret_key: None, } } } #[derive(Debug, Clone, Deserialize)] pub struct BrokerConfig { /// Unix socket the secret broker daemon listens on. pub socket_path: String, } impl Default for BrokerConfig { fn default() -> Self { BrokerConfig { socket_path: "/tmp/clawmates-broker.sock".into(), } } } #[derive(Debug, Clone, Deserialize)] pub struct SlackConfig { /// Slack API base; e2e points it at the local sink. pub base_url: String, } impl Default for SlackConfig { fn default() -> Self { SlackConfig { base_url: "https://slack.com/api".into(), } } } #[derive(Debug, Clone, Deserialize)] pub struct SandboxConfig { /// Agent sandbox image (must exist locally / be preloaded in cluster). pub image: String, /// Chromium image for browser.goto (the only egress-enabled sandbox). pub browser_image: String, /// Disable to run without environment tools (shell.exec errors). pub enabled: bool, /// Pre-provisioned sandboxes kept ready (0 = provision on demand). pub warm_pool: usize, /// Themed image for the interactive Terminal computer app /// (zsh + oh-my-zsh + powerlevel10k). #[serde(default = "default_terminal_image")] pub terminal_image: String, /// Give terminal containers network egress (a networked dev shell). Off by /// default — the safe, isolated posture. #[serde(default)] pub terminal_egress: bool, /// The named Docker volume holding the file-drive blobs, mounted (per-agent /// subpath) into the Terminal at ~/drives. Must match the compose volume. #[serde(default = "default_terminal_drive_volume")] pub terminal_drive_volume: String, } fn default_terminal_image() -> String { "clawmates/agent-terminal:dev".into() } fn default_terminal_drive_volume() -> String { "clawmates_filedata".into() } impl Default for SandboxConfig { fn default() -> Self { SandboxConfig { image: "clawmates/agent-base:dev".into(), browser_image: "clawmates/agent-browser:dev".into(), enabled: true, warm_pool: 0, terminal_image: default_terminal_image(), terminal_egress: false, terminal_drive_volume: default_terminal_drive_volume(), } } } #[derive(Debug, Clone, Default, Deserialize)] pub struct BillingConfig { /// Stripe secret key (sk_*); enables Buy-credits when set. pub stripe_secret_key: Option, /// Price id (price_*) of the credit pack sold at checkout. pub stripe_price_id: Option, /// Webhook signing secret (whsec_*) for verifying Stripe callbacks. pub stripe_webhook_secret: Option, /// Credits granted per completed checkout (one pack). #[serde(default = "default_pack_credits")] pub credits_per_pack: i64, /// Public base URL for the checkout success/cancel return. pub return_base: Option, } fn default_pack_credits() -> i64 { 1000 } #[derive(Debug, Clone, Default, Deserialize)] pub struct TelemetryConfig { /// OTLP/HTTP collector base (e.g. http://otel-collector:4318). /// Unset = no export; logs only. Nothing ever phones home uninvited. pub otlp_endpoint: Option, } #[derive(Debug, Clone, Default, Deserialize)] pub struct OAuthConfig { /// Default identity provider for directory-app OAuth connects. pub issuer_url: Option, pub client_id: Option, pub client_secret: Option, /// Public base URL of this server (builds the redirect_uri). pub redirect_base: Option, } #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub deploy_target: DeployTarget, pub listen_addr: SocketAddr, pub database: DatabaseConfig, pub llm: LlmConfig, pub auth: AuthConfig, #[serde(default)] pub storage: StorageConfig, #[serde(default)] pub broker: BrokerConfig, #[serde(default)] pub slack: SlackConfig, #[serde(default)] pub oauth: OAuthConfig, #[serde(default)] pub sandbox: SandboxConfig, #[serde(default)] pub telemetry: TelemetryConfig, #[serde(default)] pub billing: BillingConfig, } #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("failed to load configuration: {0}")] Load(String), #[error("invalid configuration: {0}")] Invalid(String), } impl AppConfig { /// Loads configuration from `path`, overlaying `CLAWMATES_*` environment /// variables, and validates it. pub fn load_from(path: &Path) -> Result { if !path.is_file() { return Err(ConfigError::Load(format!( "configuration file not found: {}", path.display() ))); } let config: AppConfig = Figment::new() .merge(Toml::file(path)) .merge(Env::prefixed("CLAWMATES_").split("__")) .extract() .map_err(|e| ConfigError::Load(e.to_string()))?; config.validate()?; Ok(config) } fn validate(&self) -> Result<(), ConfigError> { match self.llm.provider { LlmProviderKind::OpenAiCompat if self.llm.base_url.is_none() => { return Err(ConfigError::Invalid( "llm.provider = \"openai_compat\" requires llm.base_url".into(), )); } LlmProviderKind::Scripted if self.llm.scenario_path.is_none() => { return Err(ConfigError::Invalid( "llm.provider = \"scripted\" requires llm.scenario_path".into(), )); } _ => {} } if self.storage.backend == StorageBackend::S3 && (self.storage.s3_endpoint.is_none() || self.storage.s3_bucket.is_none()) { return Err(ConfigError::Invalid( "storage.backend = \"s3\" requires storage.s3_endpoint and storage.s3_bucket" .into(), )); } if self.auth.mode == AuthMode::Oidc { if self.auth.issuer_url.is_none() { return Err(ConfigError::Invalid( "auth.mode = \"oidc\" requires auth.issuer_url".into(), )); } if self.auth.client_id.is_none() { return Err(ConfigError::Invalid( "auth.mode = \"oidc\" requires auth.client_id".into(), )); } } if self.auth.mode == AuthMode::Clerk && self.auth.issuer_url.is_none() { return Err(ConfigError::Invalid( "auth.mode = \"clerk\" requires auth.issuer_url \ (the instance's https://.clerk.accounts.dev)" .into(), )); } Ok(()) } }