1use std::{fmt, fs, path::Path};
2
3use serde::Deserialize;
4use thiserror::Error;
5
6#[derive(Clone, Default, Deserialize)]
7#[serde(transparent)]
8pub struct SecretString(String);
9
10impl SecretString {
11 pub fn expose(&self) -> &str {
12 &self.0
13 }
14 fn is_empty(&self) -> bool {
15 self.0.is_empty()
16 }
17}
18
19impl fmt::Debug for SecretString {
20 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21 formatter.write_str("[REDACTED]")
22 }
23}
24
25#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum RuntimeMode {
28 Local,
29 Production,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33#[serde(rename_all = "snake_case", deny_unknown_fields)]
34pub struct RuntimeConfig {
35 pub mode: RuntimeMode,
36 pub server: ServerConfig,
37 pub scheduler: SchedulerConfig,
38 #[serde(alias = "agentInfra")]
39 pub agent_infra: AgentInfraConfig,
40 pub journal: JournalConfig,
41 pub auth: AuthConfig,
42 pub binding: AgentBindingConfig,
43 #[serde(default)]
44 pub context: ContextConfig,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48#[serde(rename_all = "snake_case", deny_unknown_fields)]
49pub struct ServerConfig {
50 pub bind: String,
51 #[serde(alias = "shutdownGraceSeconds")]
52 pub shutdown_grace_seconds: u64,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56#[serde(rename_all = "snake_case", deny_unknown_fields)]
57pub struct SchedulerConfig {
58 #[serde(alias = "maxConcurrentExecutions")]
59 pub max_concurrent_executions: usize,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "snake_case", deny_unknown_fields)]
64pub struct AgentInfraConfig {
65 #[serde(alias = "gatewayUrl")]
66 pub gateway_url: String,
67 #[serde(alias = "requestTimeoutMs")]
68 pub request_timeout_ms: u64,
69 #[serde(alias = "maxResponseBytes")]
70 pub max_response_bytes: usize,
71 #[serde(alias = "trustedMeshHttp")]
72 pub trusted_mesh_http: bool,
73 #[serde(alias = "workloadSubject")]
74 pub workload_subject: String,
75 #[serde(default, alias = "workloadToken")]
76 pub workload_token: SecretString,
77 #[serde(alias = "delegationTtlSeconds")]
78 pub delegation_ttl_seconds: u64,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82#[serde(
83 tag = "type",
84 rename_all = "snake_case",
85 rename_all_fields = "snake_case",
86 deny_unknown_fields
87)]
88pub enum JournalConfig {
89 Memory {
90 #[serde(alias = "maxEventsPerExecution")]
91 max_events_per_execution: usize,
92 },
93}
94
95#[derive(Debug, Clone, Deserialize)]
96#[serde(
97 tag = "type",
98 rename_all = "snake_case",
99 rename_all_fields = "snake_case",
100 deny_unknown_fields
101)]
102pub enum AuthConfig {
103 LocalStatic {
104 #[serde(default)]
105 token: SecretString,
106 subject: String,
107 #[serde(alias = "tenantId")]
108 tenant_id: String,
109 #[serde(alias = "projectId")]
110 project_id: String,
111 #[serde(default)]
112 capabilities: Vec<String>,
113 },
114 TrustedGateway {
115 #[serde(default, alias = "sharedSecret")]
116 shared_secret: SecretString,
117 },
118}
119
120#[derive(Debug, Clone, Deserialize)]
121#[serde(rename_all = "snake_case", deny_unknown_fields)]
122pub struct AgentBindingConfig {
123 #[serde(alias = "runtimeInstanceId")]
124 pub runtime_instance_id: String,
125 #[serde(alias = "runtimeType")]
126 pub runtime_type: String,
127 pub model: String,
128 #[serde(alias = "workspaceId")]
129 pub workspace_id: String,
130 #[serde(alias = "packageId")]
131 pub package_id: String,
132 #[serde(alias = "packageVersion")]
133 pub package_version: String,
134 #[serde(default, alias = "packagePath")]
135 pub package_path: Option<String>,
136}
137
138#[derive(Debug, Clone, Deserialize)]
139#[serde(rename_all = "snake_case", deny_unknown_fields)]
140pub struct ContextConfig {
141 #[serde(alias = "maxMessages")]
142 pub max_messages: usize,
143 #[serde(alias = "maxInputTokens")]
144 pub max_input_tokens: u64,
145 #[serde(alias = "reservedOutputTokens")]
146 pub reserved_output_tokens: u32,
147 #[serde(alias = "protocolOverheadTokens")]
148 pub protocol_overhead_tokens: u64,
149 #[serde(alias = "autoCompact")]
150 pub auto_compact: bool,
151 pub compaction: ContextCompactionConfig,
152}
153
154impl Default for ContextConfig {
155 fn default() -> Self {
156 Self {
157 max_messages: 128,
158 max_input_tokens: 128 * 1024,
159 reserved_output_tokens: 8 * 1024,
160 protocol_overhead_tokens: 256,
161 auto_compact: true,
162 compaction: ContextCompactionConfig::default(),
163 }
164 }
165}
166
167#[derive(Debug, Clone, Deserialize)]
168#[serde(rename_all = "snake_case", deny_unknown_fields)]
169pub struct ContextCompactionConfig {
170 #[serde(alias = "triggerPercent")]
171 pub trigger_percent: u8,
172 #[serde(alias = "targetPercent")]
173 pub target_percent: u8,
174 #[serde(alias = "emergencyPercent")]
175 pub emergency_percent: u8,
176 #[serde(alias = "minReclaimPercent")]
177 pub min_reclaim_percent: u8,
178 #[serde(alias = "minTurnsBetween")]
179 pub min_turns_between: usize,
180 #[serde(alias = "checkpointMaxChars")]
181 pub checkpoint_max_chars: usize,
182}
183
184impl Default for ContextCompactionConfig {
185 fn default() -> Self {
186 Self {
187 trigger_percent: 82,
188 target_percent: 60,
189 emergency_percent: 95,
190 min_reclaim_percent: 10,
191 min_turns_between: 4,
192 checkpoint_max_chars: 8_000,
193 }
194 }
195}
196
197#[derive(Debug, Error)]
198pub enum ConfigError {
199 #[error("failed to read settings file: {0}")]
200 Read(#[from] std::io::Error),
201 #[error("config JSON is invalid: {0}")]
202 JsonDecode(#[from] serde_json::Error),
203 #[error("config TOML is invalid: {0}")]
204 TomlDecode(#[from] toml::de::Error),
205 #[error("settings are invalid: {0}")]
206 Invalid(String),
207}
208
209pub struct ConfigLoadReport {
210 pub config: RuntimeConfig,
211 pub warnings: Vec<String>,
212}
213
214impl RuntimeConfig {
215 pub fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
216 Ok(Self::load_report(path)?.config)
217 }
218
219 pub fn load_report(path: Option<&Path>) -> Result<ConfigLoadReport, ConfigError> {
220 let (mut config, warnings) = match path {
221 Some(path)
222 if path
223 .extension()
224 .is_some_and(|extension| extension == "json") =>
225 {
226 (
227 serde_json::from_slice(&fs::read(path)?)?,
228 vec!["JSON Runtime config is deprecated; migrate to TOML".into()],
229 )
230 }
231 Some(path) => (toml::from_str(&fs::read_to_string(path)?)?, Vec::new()),
232 None => (Self::local_defaults(), Vec::new()),
233 };
234 config.apply_environment()?;
235 config.validate()?;
236 Ok(ConfigLoadReport { config, warnings })
237 }
238
239 pub fn validate(&self) -> Result<(), ConfigError> {
240 self.server
241 .bind
242 .parse::<std::net::SocketAddr>()
243 .map_err(|_| ConfigError::Invalid("server.bind must be a socket address".into()))?;
244 if self.server.shutdown_grace_seconds == 0
245 || self.server.shutdown_grace_seconds > 300
246 || self.scheduler.max_concurrent_executions < 2
247 || self.scheduler.max_concurrent_executions > 10_000
248 {
249 return Err(ConfigError::Invalid(
250 "shutdown grace must be 1..=300s and execution concurrency 2..=10000".into(),
251 ));
252 }
253 let gateway = url::Url::parse(&self.agent_infra.gateway_url)
254 .map_err(|_| ConfigError::Invalid("Agent Infra Gateway URL is invalid".into()))?;
255 if gateway.host_str().is_none()
256 || !gateway.username().is_empty()
257 || gateway.password().is_some()
258 {
259 return Err(ConfigError::Invalid(
260 "Agent Infra Gateway URL requires a host and must not contain userinfo".into(),
261 ));
262 }
263 if self.agent_infra.request_timeout_ms == 0
264 || self.agent_infra.request_timeout_ms > 600_000
265 || !(1024..=1024 * 1024 * 1024).contains(&self.agent_infra.max_response_bytes)
266 {
267 return Err(ConfigError::Invalid(
268 "Agent Infra request/body limits are invalid".into(),
269 ));
270 }
271 if self.agent_infra.workload_subject.trim().is_empty()
272 || self.agent_infra.workload_token.is_empty()
273 || !(30..=7 * 24 * 60 * 60).contains(&self.agent_infra.delegation_ttl_seconds)
274 {
275 return Err(ConfigError::Invalid(
276 "Agent Infra workload identity/delegation settings are invalid".into(),
277 ));
278 }
279 let JournalConfig::Memory {
280 max_events_per_execution,
281 } = &self.journal;
282 if !(1..=1_000_000).contains(max_events_per_execution) {
283 return Err(ConfigError::Invalid(
284 "memory journal events must be 1..=1000000".into(),
285 ));
286 }
287 if let AuthConfig::LocalStatic { token, .. } = &self.auth
288 && token.is_empty()
289 {
290 return Err(ConfigError::Invalid("local auth token is empty".into()));
291 }
292 if let AuthConfig::TrustedGateway { shared_secret } = &self.auth
293 && !(32..=4096).contains(&shared_secret.expose().len())
294 {
295 return Err(ConfigError::Invalid(
296 "trusted gateway shared secret must contain 32..=4096 bytes".into(),
297 ));
298 }
299 if self.mode == RuntimeMode::Production {
300 if !matches!(self.auth, AuthConfig::TrustedGateway { .. }) {
301 return Err(ConfigError::Invalid(
302 "production requires trusted gateway ingress authentication".into(),
303 ));
304 }
305 if gateway.scheme() != "https" || self.agent_infra.trusted_mesh_http {
306 return Err(ConfigError::Invalid(
307 "production Agent Infra Gateway must use HTTPS".into(),
308 ));
309 }
310 }
311 let compaction = &self.context.compaction;
312 if self.context.max_messages < 2
313 || self.context.max_input_tokens < 1_024
314 || u64::from(self.context.reserved_output_tokens) >= self.context.max_input_tokens
315 || self.context.protocol_overhead_tokens >= self.context.max_input_tokens
316 || !(1..=99).contains(&compaction.target_percent)
317 || compaction.trigger_percent <= compaction.target_percent
318 || compaction.emergency_percent <= compaction.trigger_percent
319 || compaction.emergency_percent > 100
320 || compaction.min_reclaim_percent > 100
321 || !(1_024..=1_000_000).contains(&compaction.checkpoint_max_chars)
322 {
323 return Err(ConfigError::Invalid(
324 "context limits and compaction watermarks are invalid".into(),
325 ));
326 }
327 Ok(())
328 }
329
330 fn apply_environment(&mut self) -> Result<(), ConfigError> {
331 if let Ok(value) = std::env::var("RUNTIME_BIND") {
332 self.server.bind = value;
333 }
334 if let Ok(value) = std::env::var("AGENT_INFRA_GATEWAY_URL") {
335 self.agent_infra.gateway_url = value;
336 }
337 if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_TOKEN") {
338 self.agent_infra.workload_token = SecretString(value);
339 }
340 if let Ok(value) = std::env::var("AGENT_INFRA_WORKLOAD_SUBJECT") {
341 self.agent_infra.workload_subject = value;
342 }
343 if let Ok(value) = std::env::var("RUNTIME_INSTANCE_ID") {
344 self.binding.runtime_instance_id = value;
345 }
346 if let Ok(value) = std::env::var("RUNTIME_MODEL") {
347 self.binding.model = value;
348 }
349 if let Ok(value) = std::env::var("RUNTIME_WORKSPACE_ID") {
350 self.binding.workspace_id = value;
351 }
352 if let Ok(value) = std::env::var("RUNTIME_LOCAL_TOKEN") {
353 match &mut self.auth {
354 AuthConfig::LocalStatic { token, .. } => *token = SecretString(value),
355 AuthConfig::TrustedGateway { .. } => {
356 return Err(ConfigError::Invalid(
357 "RUNTIME_LOCAL_TOKEN cannot override trusted gateway auth".into(),
358 ));
359 }
360 }
361 }
362 if let Ok(value) = std::env::var("RUNTIME_GATEWAY_SHARED_SECRET") {
363 match &mut self.auth {
364 AuthConfig::TrustedGateway { shared_secret } => {
365 *shared_secret = SecretString(value)
366 }
367 AuthConfig::LocalStatic { .. } => {
368 return Err(ConfigError::Invalid(
369 "RUNTIME_GATEWAY_SHARED_SECRET requires trusted gateway auth".into(),
370 ));
371 }
372 }
373 }
374 if self.mode == RuntimeMode::Local {
375 if self.agent_infra.workload_token.is_empty() {
376 self.agent_infra.workload_token =
377 SecretString("local-runtime-workload-token".into());
378 }
379 if let AuthConfig::LocalStatic { token, .. } = &mut self.auth
380 && token.is_empty()
381 {
382 *token = SecretString("local-runtime-token".into());
383 }
384 }
385 Ok(())
386 }
387
388 fn local_defaults() -> Self {
389 Self {
390 mode: RuntimeMode::Local,
391 server: ServerConfig {
392 bind: "127.0.0.1:8080".into(),
393 shutdown_grace_seconds: 30,
394 },
395 scheduler: SchedulerConfig {
396 max_concurrent_executions: 16,
397 },
398 agent_infra: AgentInfraConfig {
399 gateway_url: "http://127.0.0.1:5100".into(),
400 request_timeout_ms: 30_000,
401 max_response_bytes: 16 * 1024 * 1024,
402 trusted_mesh_http: true,
403 workload_subject: "local-anonymous".into(),
404 workload_token: SecretString("local-runtime-workload-token".into()),
405 delegation_ttl_seconds: 300,
406 },
407 journal: JournalConfig::Memory {
408 max_events_per_execution: 10_000,
409 },
410 auth: AuthConfig::LocalStatic {
411 token: SecretString("local-runtime-token".into()),
412 subject: "local-user".into(),
413 tenant_id: "local-tenant".into(),
414 project_id: "local-project".into(),
415 capabilities: vec![
416 "*".into(),
417 "files:read".into(),
418 "files:write".into(),
419 "commands:execute".into(),
420 "interaction:request".into(),
421 "agent:delegate".into(),
422 ],
423 },
424 binding: AgentBindingConfig {
425 runtime_instance_id: "runtime-local".into(),
426 runtime_type: "code".into(),
427 model: "local-model".into(),
428 workspace_id: "workspace-local".into(),
429 package_id: "runtime-code-agent".into(),
430 package_version: env!("CARGO_PKG_VERSION").into(),
431 package_path: None,
432 },
433 context: ContextConfig::default(),
434 }
435 }
436}
437
438pub type RuntimeSettings = RuntimeConfig;
441pub type ServerSettings = ServerConfig;
442pub type SchedulerSettings = SchedulerConfig;
443pub type AgentInfraSettings = AgentInfraConfig;
444pub type JournalSettings = JournalConfig;
445pub type AuthSettings = AuthConfig;
446pub type RuntimeBindingSettings = AgentBindingConfig;
447pub type SettingsError = ConfigError;
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn rejects_unknown_fields_and_redacts_secrets() {
455 let error = serde_json::from_str::<RuntimeConfig>(r#"{"unknown":true}"#).unwrap_err();
456 assert!(error.to_string().contains("unknown field"));
457 assert_eq!(format!("{:?}", SecretString("secret".into())), "[REDACTED]");
458 }
459
460 #[test]
461 fn production_accepts_memory_but_rejects_local_auth() {
462 let mut settings = RuntimeConfig::local_defaults();
463 settings.mode = RuntimeMode::Production;
464 assert!(settings.validate().is_err());
465
466 settings.agent_infra.gateway_url = "https://agent-infra.example.internal".into();
467 settings.agent_infra.trusted_mesh_http = false;
468 settings.auth = AuthConfig::TrustedGateway {
469 shared_secret: SecretString("a-secure-gateway-secret-with-32-bytes".into()),
470 };
471 assert!(settings.validate().is_ok());
472 }
473
474 #[test]
475 fn preferred_toml_and_legacy_json_decode_to_the_same_context_policy() {
476 let toml: RuntimeConfig = toml::from_str(
477 r#"
478mode = "local"
479[server]
480bind = "127.0.0.1:8080"
481shutdown_grace_seconds = 30
482[scheduler]
483max_concurrent_executions = 16
484[agent_infra]
485gateway_url = "http://127.0.0.1:5100"
486request_timeout_ms = 30000
487max_response_bytes = 16777216
488trusted_mesh_http = true
489workload_subject = "local-anonymous"
490delegation_ttl_seconds = 300
491[journal]
492type = "memory"
493max_events_per_execution = 10000
494[auth]
495type = "local_static"
496subject = "local-user"
497tenant_id = "local-tenant"
498project_id = "local-project"
499[binding]
500runtime_instance_id = "runtime-local"
501runtime_type = "code"
502model = "local-model"
503workspace_id = "workspace-local"
504package_id = "runtime-code-agent"
505package_version = "0.1.0"
506[context]
507max_messages = 128
508max_input_tokens = 131072
509reserved_output_tokens = 8192
510protocol_overhead_tokens = 256
511auto_compact = true
512[context.compaction]
513trigger_percent = 82
514target_percent = 60
515emergency_percent = 95
516min_reclaim_percent = 10
517min_turns_between = 4
518checkpoint_max_chars = 8000
519"#,
520 )
521 .unwrap();
522 let json: RuntimeConfig = serde_json::from_str(
523 r#"{
524 "mode":"local",
525 "server":{"bind":"127.0.0.1:8080","shutdownGraceSeconds":30},
526 "scheduler":{"maxConcurrentExecutions":16},
527 "agentInfra":{"gatewayUrl":"http://127.0.0.1:5100","requestTimeoutMs":30000,"maxResponseBytes":16777216,"trustedMeshHttp":true,"workloadSubject":"local-anonymous","delegationTtlSeconds":300},
528 "journal":{"type":"memory","maxEventsPerExecution":10000},
529 "auth":{"type":"local_static","subject":"local-user","tenantId":"local-tenant","projectId":"local-project"},
530 "binding":{"runtimeInstanceId":"runtime-local","runtimeType":"code","model":"local-model","workspaceId":"workspace-local","packageId":"runtime-code-agent","packageVersion":"0.1.0"},
531 "context":{"maxMessages":128,"maxInputTokens":131072,"reservedOutputTokens":8192,"protocolOverheadTokens":256,"autoCompact":true,"compaction":{"triggerPercent":82,"targetPercent":60,"emergencyPercent":95,"minReclaimPercent":10,"minTurnsBetween":4,"checkpointMaxChars":8000}}
532}"#,
533 )
534 .unwrap();
535 assert_eq!(toml.context.max_input_tokens, 128 * 1024);
536 assert_eq!(toml.context.max_input_tokens, json.context.max_input_tokens);
537 assert_eq!(toml.binding.package_id, json.binding.package_id);
538 }
539}