Decouples "many users" + "many server replicas" from "many machines" so the platform is tenant-isolated and horizontally safe on the current single node. - Per-signup workspaces (cm-auth): a new hosted-identity sign-in provisions and owns its own workspace instead of joining the first. Config-gated by auth.per_signup_workspace (default off); concurrent first-logins serialized by a per-subject advisory lock so no duplicate workspaces. - Terminal tickets in Postgres (migration 0016, hashed, single-use): any replica can redeem a ticket minted by another. Drops the in-process ticket map. - Container registry in Postgres (migration 0017, agent_containers): Terminal and Sandbox managers resolve an agent's container through a shared registry, so a 2nd replica reuses it instead of spawning a duplicate. node_id recorded as 'local' (Phase 2 hook). Boot reconcile removes only true orphans, so terminals now survive a redeploy (tmux sessions resume). - Per-workspace quotas (cm-api/quota.rs): plan-tier caps on agents + live containers, enforced at agent create + terminal spin-up (reconnects allowed), returned as HTTP 402. New GET /api/quota surfaces usage vs limits. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
348 lines
11 KiB
Rust
348 lines
11 KiB
Rust
//! 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<String>,
|
|
pub model: String,
|
|
/// Scenario file for the deterministic `scripted` provider.
|
|
pub scenario_path: Option<String>,
|
|
/// Additional named providers exposed alongside the default — selectable as
|
|
/// `"<name>:<model>"` by judges (`CLAWMATES_JUDGE_MODEL`) and topology nodes.
|
|
/// All are OpenAI-compatible (GLM, Kimi/Moonshot, etc.).
|
|
#[serde(default)]
|
|
pub providers: Vec<NamedProvider>,
|
|
}
|
|
|
|
/// An extra provider in the registry (e.g. GLM or Kimi). Used as judge or
|
|
/// topology-exec model via `"<name>:<model>"`.
|
|
#[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<String>,
|
|
pub client_id: Option<String>,
|
|
/// 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<String>,
|
|
pub s3_bucket: Option<String>,
|
|
pub s3_access_key: Option<String>,
|
|
pub s3_secret_key: Option<String>,
|
|
}
|
|
|
|
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<String>,
|
|
/// Price id (price_*) of the credit pack sold at checkout.
|
|
pub stripe_price_id: Option<String>,
|
|
/// Webhook signing secret (whsec_*) for verifying Stripe callbacks.
|
|
pub stripe_webhook_secret: Option<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct OAuthConfig {
|
|
/// Default identity provider for directory-app OAuth connects.
|
|
pub issuer_url: Option<String>,
|
|
pub client_id: Option<String>,
|
|
pub client_secret: Option<String>,
|
|
/// Public base URL of this server (builds the redirect_uri).
|
|
pub redirect_base: Option<String>,
|
|
}
|
|
|
|
#[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<AppConfig, ConfigError> {
|
|
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://<slug>.clerk.accounts.dev)"
|
|
.into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|