use std::{fmt, fs, path::Path};
use serde::Deserialize;
use thiserror::Error;
#[derive(Clone, Default, Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
pub fn expose(&self) -> &str {
&self.0
}
fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeMode {
Local,
Production,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct RuntimeConfig {
pub mode: RuntimeMode,
pub server: ServerConfig,
pub scheduler: SchedulerConfig,
#[serde(alias = "agentInfra")]
pub agent_infra: AgentInfraConfig,
pub journal: JournalConfig,
pub auth: AuthConfig,
pub binding: AgentBindingConfig,
#[serde(default)]
pub context: ContextConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ServerConfig {
pub bind: String,
#[serde(alias = "shutdownGraceSeconds")]
pub shutdown_grace_seconds: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct SchedulerConfig {
#[serde(alias = "maxConcurrentExecutions")]
pub max_concurrent_executions: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct AgentInfraConfig {
#[serde(alias = "gatewayUrl")]
pub gateway_url: String,
#[serde(alias = "requestTimeoutMs")]
pub request_timeout_ms: u64,
#[serde(alias = "maxResponseBytes")]
pub max_response_bytes: usize,
#[serde(alias = "trustedMeshHttp")]
pub trusted_mesh_http: bool,
#[serde(alias = "workloadSubject")]
pub workload_subject: String,
#[serde(default, alias = "workloadToken")]
pub workload_token: SecretString,
#[serde(alias = "delegationTtlSeconds")]
pub delegation_ttl_seconds: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "snake_case",
deny_unknown_fields
)]
pub enum JournalConfig {
Memory {
#[serde(alias = "maxEventsPerExecution")]
max_events_per_execution: usize,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "snake_case",
deny_unknown_fields
)]
pub enum AuthConfig {
LocalStatic {
#[serde(default)]
token: SecretString,
subject: String,
#[serde(alias = "tenantId")]
tenant_id: String,
#[serde(alias = "projectId")]
project_id: String,
#[serde(default)]
capabilities: Vec<String>,
},
TrustedGateway {
#[serde(default, alias = "sharedSecret")]
shared_secret: SecretString,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct AgentBindingConfig {
#[serde(alias = "runtimeInstanceId")]
pub runtime_instance_id: String,
#[serde(alias = "runtimeType")]
pub runtime_type: String,
pub model: String,
#[serde(alias = "workspaceId")]
pub workspace_id: String,
#[serde(alias = "packageId")]
pub package_id: String,
#[serde(alias = "packageVersion")]
pub package_version: String,
#[serde(default, alias = "packagePath")]
pub package_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ContextConfig {
#[serde(alias = "maxMessages")]
pub max_messages: usize,
#[serde(alias = "maxInputTokens")]
pub max_input_tokens: u64,
#[serde(alias = "reservedOutputTokens")]
pub reserved_output_tokens: u32,
#[serde(alias = "protocolOverheadTokens")]
pub protocol_overhead_tokens: u64,
#[serde(alias = "autoCompact")]
pub auto_compact: bool,
pub compaction: ContextCompactionConfig,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
max_messages: 128,
max_input_tokens: 128 * 1024,
reserved_output_tokens: 8 * 1024,
protocol_overhead_tokens: 256,
auto_compact: true,
compaction: ContextCompactionConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ContextCompactionConfig {
#[serde(alias = "triggerPercent")]
pub trigger_percent: u8,
#[serde(alias = "targetPercent")]
pub target_percent: u8,
#[serde(alias = "emergencyPercent")]
pub emergency_percent: u8,
#[serde(alias = "minReclaimPercent")]
pub min_reclaim_percent: u8,
#[serde(alias = "minTurnsBetween")]
pub min_turns_between: usize,
#[serde(alias = "checkpointMaxChars")]
pub checkpoint_max_chars: usize,
}
impl Default for ContextCompactionConfig {
fn default() -> Self {
Self {
trigger_percent: 82,
target_percent: 60,
emergency_percent: 95,
min_reclaim_percent: 10,
min_turns_between: 4,
checkpoint_max_chars: 8_000,
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read settings file: {0}")]
Read(#[from] std::io::Error),
#[error("config JSON is invalid: {0}")]
JsonDecode(#[from] serde_json::Error),
#[error("config TOML is invalid: {0}")]
TomlDecode(#[from] toml::de::Error),
#[error("settings are invalid: {0}")]
Invalid(String),
}
pub struct ConfigLoadReport {
pub config: RuntimeConfig,
pub warnings: Vec<String>,
}
impl RuntimeConfig {
pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
Ok(Self::load_report(path)?.config)
}
pub fn load_report(path: Option<&Path>) -> Result<ConfigLoadReport, ConfigError> {
let (mut config, warnings) = match path {
Some(path)
if path
.extension()
.is_some_and(|extension| extension == "json") =>
{
(
serde_json::from_slice(&fs::read(path)?)?,
vec!["JSON Runtime config is deprecated; migrate to TOML".into()],
)
}
Some(path) => (toml::from_str(&fs::read_to_string(path)?)?, Vec::new()),
None => (Self::local_defaults(), Vec::new()),
};
config.apply_environment()?;
config.validate()?;
Ok(ConfigLoadReport { config, warnings })
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.server
.bind
.parse::<std::net::SocketAddr>()
.map_err(|_| ConfigError::Invalid("server.bind must be a socket address".into()))?;
if self.server.shutdown_grace_seconds == 0
|| self.server.shutdown_grace_seconds > 300
|| self.scheduler.max_concurrent_executions < 2
|| self.scheduler.max_concurrent_executions > 10_000
{
return Err(ConfigError::Invalid(
"shutdown grace must be 1..=300s and execution concurrency 2..=10000".into(),
));
}
let gateway = url::Url::parse(&self.agent_infra.gateway_url)
.map_err(|_| ConfigError::Invalid("Agent Infra Gateway URL is invalid".into()))?;
if gateway.host_str().is_none()
|| !gateway.username().is_empty()
|| gateway.password().is_some()
{
return Err(ConfigError::Invalid(
"Agent Infra Gateway URL requires a host and must not contain userinfo".into(),
));
}
if self.agent_infra.request_timeout_ms == 0
|| self.agent_infra.request_timeout_ms > 600_000
|| !(1024..=1024 * 1024 * 1024).contains(&self.agent_infra.max_response_bytes)
{
return Err(ConfigError::Invalid(
"Agent Infra request/body limits are invalid".into(),
));
}
if self.agent_infra.workload_subject.trim().is_empty()
|| self.agent_infra.workload_token.is_empty()
|| !(30..=7 * 24 * 60 * 60).contains(&self.agent_infra.delegation_ttl_seconds)
{
return Err(ConfigError::Invalid(
"Agent Infra workload identity/delegation settings are invalid".into(),
));
}
let JournalConfig::Memory {
max_events_per_execution,
} = &self.journal;
if !(1..=1_000_000).contains(max_events_per_execution) {
return Err(ConfigError::Invalid(
"memory journal events must be 1..=1000000".into(),
));
}
if let AuthConfig::LocalStatic { token, .. } = &self.auth
&& token.is_empty()
{
return Err(ConfigError::Invalid("local auth token is empty".into()));
}
if let AuthConfig::TrustedGateway { shared_secret } = &self.auth
&& !(32..=4096).contains(&shared_secret.expose().len())
{
return Err(ConfigError::Invalid(
"trusted gateway shared secret must contain 32..=4096 bytes".into(),
));
}
if self.mode == RuntimeMode::Production {
if !matches!(self.auth, AuthConfig::TrustedGateway { .. }) {
return Err(ConfigError::Invalid(
"production requires trusted gateway ingress authentication".into(),
));
}
if gateway.scheme() != "https" || self.agent_infra.trusted_mesh_http {
return Err(ConfigError::Invalid(
"production Agent Infra Gateway must use HTTPS".into(),
));
}
}
let compaction = &self.context.compaction;
if self.context.max_messages < 2
|| self.context.max_input_tokens < 1_024
|| u64::from(self.context.reserved_output_tokens) >= self.context.max_input_tokens
|| self.context.protocol_overhead_tokens >= self.context.max_input_tokens
|| !(1..=99).contains(&compaction.target_percent)
|| compaction.trigger_percent <= compaction.target_percent
|| compaction.emergency_percent <= compaction.trigger_percent
|| compaction.emergency_percent > 100
|| compaction.min_reclaim_percent > 100
|| !(1_024..=1_000_000).contains(&compaction.checkpoint_max_chars)
{
return Err(ConfigError::Invalid(
"context limits and compaction watermarks are invalid".into(),
));
}
Ok(())
}
fn apply_environment(&mut self) -> Result<(), ConfigError> {
if let Ok(value) = std::env::var("RUNTIME_BIND") {
self.server.bind = value;
}
if let Ok(value) = std::env::var("AGENT_INFRA_GATEWAY_URL") {
self.agent_infra.gateway_url = value;
}
if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_TOKEN") {
self.agent_infra.workload_token = SecretString(value);
}
if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_SUBJECT") {
self.agent_infra.workload_subject = value;
}
if let Ok(value) = std::env::var("RUNTIME_INSTANCE_ID") {
self.binding.runtime_instance_id = value;
}
if let Ok(value) = std::env::var("RUNTIME_MODEL") {
self.binding.model = value;
}
if let Ok(value) = std::env::var("RUNTIME_WORKSPACE_ID") {
self.binding.workspace_id = value;
}
if let Ok(value) = std::env::var("RUNTIME_LOCAL_TOKEN") {
match &mut self.auth {
AuthConfig::LocalStatic { token, .. } => *token = SecretString(value),
AuthConfig::TrustedGateway { .. } => {
return Err(ConfigError::Invalid(
"RUNTIME_LOCAL_TOKEN cannot override trusted gateway auth".into(),
));
}
}
}
if let Ok(value) = std::env::var("RUNTIME_GATEWAY_SHARED_SECRET") {
match &mut self.auth {
AuthConfig::TrustedGateway { shared_secret } => {
*shared_secret = SecretString(value)
}
AuthConfig::LocalStatic { .. } => {
return Err(ConfigError::Invalid(
"RUNTIME_GATEWAY_SHARED_SECRET requires trusted gateway auth".into(),
));
}
}
}
if self.mode == RuntimeMode::Local {
if self.agent_infra.workload_token.is_empty() {
self.agent_infra.workload_token =
SecretString("local-runtime-workload-token".into());
}
if let AuthConfig::LocalStatic { token, .. } = &mut self.auth
&& token.is_empty()
{
*token = SecretString("local-runtime-token".into());
}
}
Ok(())
}
fn local_defaults() -> Self {
Self {
mode: RuntimeMode::Local,
server: ServerConfig {
bind: "127.0.0.1:8080".into(),
shutdown_grace_seconds: 30,
},
scheduler: SchedulerConfig {
max_concurrent_executions: 16,
},
agent_infra: AgentInfraConfig {
gateway_url: "http://127.0.0.1:5100".into(),
request_timeout_ms: 30_000,
max_response_bytes: 16 * 1024 * 1024,
trusted_mesh_http: true,
workload_subject: "local-anonymous".into(),
workload_token: SecretString("local-runtime-workload-token".into()),
delegation_ttl_seconds: 300,
},
journal: JournalConfig::Memory {
max_events_per_execution: 10_000,
},
auth: AuthConfig::LocalStatic {
token: SecretString("local-runtime-token".into()),
subject: "local-user".into(),
tenant_id: "local-tenant".into(),
project_id: "local-project".into(),
capabilities: vec![
"*".into(),
"files:read".into(),
"files:write".into(),
"commands:execute".into(),
"interaction:request".into(),
"agent:delegate".into(),
],
},
binding: AgentBindingConfig {
runtime_instance_id: "runtime-local".into(),
runtime_type: "code".into(),
model: "local-model".into(),
workspace_id: "workspace-local".into(),
package_id: "runtime-code-agent".into(),
package_version: env!("CARGO_PKG_VERSION").into(),
package_path: None,
},
context: ContextConfig::default(),
}
}
}
pub type RuntimeSettings = RuntimeConfig;
pub type ServerSettings = ServerConfig;
pub type SchedulerSettings = SchedulerConfig;
pub type AgentInfraSettings = AgentInfraConfig;
pub type JournalSettings = JournalConfig;
pub type AuthSettings = AuthConfig;
pub type RuntimeBindingSettings = AgentBindingConfig;
pub type SettingsError = ConfigError;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unknown_fields_and_redacts_secrets() {
let error = serde_json::from_str::<RuntimeConfig>(r#"{"unknown":true}"#).unwrap_err();
assert!(error.to_string().contains("unknown field"));
assert_eq!(format!("{:?}", SecretString("secret".into())), "[REDACTED]");
}
#[test]
fn production_accepts_memory_but_rejects_local_auth() {
let mut settings = RuntimeConfig::local_defaults();
settings.mode = RuntimeMode::Production;
assert!(settings.validate().is_err());
settings.agent_infra.gateway_url = "https://agent-infra.example.internal".into();
settings.agent_infra.trusted_mesh_http = false;
settings.auth = AuthConfig::TrustedGateway {
shared_secret: SecretString("a-secure-gateway-secret-with-32-bytes".into()),
};
assert!(settings.validate().is_ok());
}
#[test]
fn preferred_toml_and_legacy_json_decode_to_the_same_context_policy() {
let toml: RuntimeConfig = toml::from_str(
r#"
mode = "local"
[server]
bind = "127.0.0.1:8080"
shutdown_grace_seconds = 30
[scheduler]
max_concurrent_executions = 16
[agent_infra]
gateway_url = "http://127.0.0.1:5100"
request_timeout_ms = 30000
max_response_bytes = 16777216
trusted_mesh_http = true
workload_subject = "local-anonymous"
delegation_ttl_seconds = 300
[journal]
type = "memory"
max_events_per_execution = 10000
[auth]
type = "local_static"
subject = "local-user"
tenant_id = "local-tenant"
project_id = "local-project"
[binding]
runtime_instance_id = "runtime-local"
runtime_type = "code"
model = "local-model"
workspace_id = "workspace-local"
package_id = "runtime-code-agent"
package_version = "0.1.0"
[context]
max_messages = 128
max_input_tokens = 131072
reserved_output_tokens = 8192
protocol_overhead_tokens = 256
auto_compact = true
[context.compaction]
trigger_percent = 82
target_percent = 60
emergency_percent = 95
min_reclaim_percent = 10
min_turns_between = 4
checkpoint_max_chars = 8000
"#,
)
.unwrap();
let json: RuntimeConfig = serde_json::from_str(
r#"{
"mode":"local",
"server":{"bind":"127.0.0.1:8080","shutdownGraceSeconds":30},
"scheduler":{"maxConcurrentExecutions":16},
"agentInfra":{"gatewayUrl":"http://127.0.0.1:5100","requestTimeoutMs":30000,"maxResponseBytes":16777216,"trustedMeshHttp":true,"workloadSubject":"local-anonymous","delegationTtlSeconds":300},
"journal":{"type":"memory","maxEventsPerExecution":10000},
"auth":{"type":"local_static","subject":"local-user","tenantId":"local-tenant","projectId":"local-project"},
"binding":{"runtimeInstanceId":"runtime-local","runtimeType":"code","model":"local-model","workspaceId":"workspace-local","packageId":"runtime-code-agent","packageVersion":"0.1.0"},
"context":{"maxMessages":128,"maxInputTokens":131072,"reservedOutputTokens":8192,"protocolOverheadTokens":256,"autoCompact":true,"compaction":{"triggerPercent":82,"targetPercent":60,"emergencyPercent":95,"minReclaimPercent":10,"minTurnsBetween":4,"checkpointMaxChars":8000}}
}"#,
)
.unwrap();
assert_eq!(toml.context.max_input_tokens, 128 * 1024);
assert_eq!(toml.context.max_input_tokens, json.context.max_input_tokens);
assert_eq!(toml.binding.package_id, json.binding.package_id);
}
}