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