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