1use anyhow::Context as _;
4use secrecy::{ExposeSecret, SecretString};
5use std::path::{Path, PathBuf};
6
7use agentics_domain::models::names::MoltbookSubmoltName;
8use agentics_domain::models::urls::{
9 GithubApiUserUrl, GithubAppAuthorizeUrl, GithubAppRedirectUrl, GithubAppTokenUrl,
10 MoltbookSubmoltUrl,
11};
12use agentics_storage::{LocalStorageOptions, S3StorageOptions, StorageFactoryOptions};
13pub use local_urls::{local_api_base_url, local_web_base_url};
14pub use runtime_modes::{
15 AgentRegistrationMode, HostProbeMode, OfficialLogRedactionMode, RunnerNamespace,
16 RunnerSecurityProfile, RunnerWritableStorageMode, WorkerAccelerators,
17};
18pub use storage_config::{
19 DEFAULT_S3_BUCKET, DEFAULT_S3_ENDPOINT_URL, DEFAULT_S3_FORCE_PATH_STYLE, DEFAULT_S3_REGION,
20 DEFAULT_STORAGE_BACKEND, DEFAULT_STORAGE_ROOT, ENV_AGENTICS_S3_BUCKET,
21 ENV_AGENTICS_S3_ENDPOINT_URL, ENV_AGENTICS_S3_FORCE_PATH_STYLE, ENV_AGENTICS_S3_PREFIX,
22 ENV_AGENTICS_S3_REGION, ENV_AGENTICS_STORAGE_BACKEND, ENV_AGENTICS_STORAGE_ROOT,
23 ENV_AGENTICS_STORAGE_WORK_ROOT, StorageBackend,
24};
25
26mod env;
27mod env_policy;
28mod groups;
29mod local_urls;
30mod runtime_modes;
31mod storage_config;
32mod validation;
33pub use env::{
34 RawApiWebEnv, RawAppEnv, RawAuthEnv, RawDatabaseEnv, RawGithubAppEnv, RawLoggingEnv,
35 RawMoltbookEnv, RawQuotaEnv, RawRunnerEnv, RawStorageEnv, RawWorkerEnv,
36};
37pub use env_policy::{
38 DeploymentStage, ENV_AGENTICS_DEPLOYMENT_STAGE, ENV_AGENTICS_REHEARSAL_ENVIRONMENT,
39 ENV_AGENTICS_WEB_HOST, ENV_REVIEW_RECORD_LIMIT, ENV_RUST_LOG, ENV_STALE_REVIEW_RECORD_LIMIT,
40 EnvPolicyReport, EnvPolicyWarning, EnvServiceRole, deployment_stage_from_env_map,
41 known_stage_env_names, process_env_map, validate_current_env_policy, validate_env_policy,
42};
43pub use groups::{
44 ApiWebConfig, AuthConfig, Config, DatabaseConfig, GithubAppConfig, LoggingConfig,
45 MoltbookConfig, QuotaConfig, RunnerConfig, StorageConfig, WorkerConfig,
46};
47
48pub const ENV_AGENTICS_API_PORT: &str = "AGENTICS_API_PORT";
50pub const ENV_AGENTICS_API_BASE_URL: &str = "AGENTICS_API_BASE_URL";
52pub const ENV_AGENTICS_WEB_BASE_URL: &str = "AGENTICS_WEB_BASE_URL";
54pub const ENV_AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS: &str =
56 "AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS";
57pub const ENV_AGENTICS_HOST_PROBE_COMMAND: &str = "AGENTICS_HOST_PROBE_COMMAND";
59pub const ENV_AGENTICS_POSTGRES_PORT: &str = "AGENTICS_POSTGRES_PORT";
61pub const ENV_AGENTICS_WEB_PORT: &str = "AGENTICS_WEB_PORT";
63pub const ENV_AGENTICS_RUNNER_NAMESPACE: &str = "AGENTICS_RUNNER_NAMESPACE";
65pub const ENV_AGENTICS_MOLTBOOK_SUBMOLT_NAME: &str = "AGENTICS_MOLTBOOK_SUBMOLT_NAME";
67pub const ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL: &str = "AGENTICS_MOLTBOOK_SUBMOLT_URL";
69pub const ENV_AGENTICS_OFFICIAL_LOG_REDACTION: &str = "AGENTICS_OFFICIAL_LOG_REDACTION";
71
72pub const DEFAULT_API_HOST: &str = "127.0.0.1";
74pub const DEFAULT_API_PORT: u16 = 3100;
76pub const DEFAULT_WEB_PORT: u16 = 3001;
78pub const DEFAULT_HOST_PROBE_COMMAND: &str = "bin/agentics-check-dgx-spark-profile";
80pub const DEFAULT_POSTGRES_PORT: u16 = 5432;
82pub const DEFAULT_CHALLENGES_ROOT: &str = "challenge-repos/agentics-challenges/challenges";
84pub const DEFAULT_WEB_SESSION_COOKIE_NAME: &str = "agentics_session";
86pub const DEFAULT_WEB_CSRF_COOKIE_NAME: &str = "agentics_csrf";
88pub const DEFAULT_WEB_SESSION_TTL_HOURS: i64 = 24;
90pub const DEFAULT_WEB_SESSION_COOKIE_SECURE: bool = false;
92pub const DEFAULT_AGENT_REGISTRATION_MODE: AgentRegistrationMode =
94 AgentRegistrationMode::PioneerCode;
95pub const DEFAULT_WORKER_POLL_INTERVAL_MS: u64 = 3000;
97pub const DEFAULT_WORKER_STALE_JOB_MINUTES: i32 = 1;
99pub const DEFAULT_WORKER_ACCELERATORS: WorkerAccelerators = WorkerAccelerators::None;
101pub const DEFAULT_VALIDATION_RUNS_PER_AGENT_CHALLENGE_DAY: u32 = 20;
103pub const DEFAULT_OFFICIAL_RUNS_PER_AGENT_CHALLENGE_DAY: u32 = 5;
105pub const DEFAULT_MAX_ACTIVE_OFFICIAL_JOBS: u32 = 20;
107pub const DEFAULT_MAX_ACTIVE_AGENTS: u32 = 1_000;
109pub const DEFAULT_MAX_ACTIVE_CHALLENGE_REVIEW_RECORDS_PER_HUMAN: u32 = 10;
111pub const DEFAULT_CHALLENGE_PRIVATE_ASSET_BYTES_PER_REVIEW_RECORD: u64 = 1024 * 1024 * 1024;
113pub const DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATIONS_PER_DAY: u32 = 10;
115pub const DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATION_TIMEOUT_MINUTES: i32 = 30;
117pub const DEFAULT_CHALLENGE_PRIVATE_ASSET_PENDING_TIMEOUT_MINUTES: i32 = 30;
119pub const DEFAULT_CHALLENGE_REVIEW_RECORD_PUBLISH_TIMEOUT_MINUTES: i32 = 30;
121pub const DEFAULT_CHALLENGE_REVIEW_RECORD_TTL_DAYS: i64 = 14;
123pub const DEFAULT_UNPUBLISHED_CHALLENGE_ASSET_GRACE_DAYS: i64 = 7;
125pub const DEFAULT_HOST_PROBE_MODE: HostProbeMode = HostProbeMode::Off;
127pub const DEFAULT_REQUIRE_DIGEST_PINNED_IMAGES: bool = false;
129const DEFAULT_MOLTBOOK_SUBMOLT_NAME: &str = "agentics-platform";
130const DEFAULT_MOLTBOOK_SUBMOLT_URL: &str = "https://www.moltbook.com/m/agentics-platform";
131pub const DEFAULT_RUNNER_SECURITY_PROFILE: RunnerSecurityProfile =
133 RunnerSecurityProfile::Development;
134pub const DEFAULT_OFFICIAL_LOG_REDACTION_MODE: OfficialLogRedactionMode =
136 OfficialLogRedactionMode::ContractBased;
137pub const DEFAULT_RUNNER_WRITABLE_STORAGE_MODE: RunnerWritableStorageMode =
139 RunnerWritableStorageMode::Unbounded;
140const DEFAULT_RUNNER_NAMESPACE: &str = "default";
141const DEFAULT_RUNNER_WRITABLE_SLOT_CLASSES_MB: &str = "64,256,1024,4096";
142const DEFAULT_RUNNER_MAX_OUTPUT_FILES: u64 = 8192;
143const DEFAULT_RUNNER_MAX_OUTPUT_DIRS: u64 = 1024;
144const DEFAULT_RUNNER_MAX_OUTPUT_DEPTH: u64 = 32;
145const DEFAULT_RUNNER_MAX_RUNS: u64 =
146 agentics_contracts::challenge_bundle::MAX_CHALLENGE_RUNS_PER_EVALUATION;
147const DEFAULT_RUNNER_MAX_RESULT_JSON_BYTES: u64 = 4 * 1024 * 1024;
148const DEFAULT_RUNNER_MAX_PUBLIC_RESULTS: u64 = 1024;
149const DEFAULT_RUNNER_MAX_RESULT_LOG_BYTES: u64 = 256 * 1024;
150const DEFAULT_RUNNER_MAX_INTERACTION_BYTES_PER_DIRECTION: u64 = 256 * 1024 * 1024;
151const DEFAULT_RUNNER_INTERACTION_SHUTDOWN_GRACE_SECS: u64 = 2;
152pub const DEFAULT_RUNNER_DOCKER_LAYER_QUOTA: bool = false;
154pub const DEFAULT_LOG_LEVEL: &str = "info";
156
157impl Default for Config {
158 fn default() -> Self {
160 Self {
161 database: DatabaseConfig {
162 url: local_database_url(DEFAULT_POSTGRES_PORT),
163 },
164 api_web: ApiWebConfig {
165 api_host: DEFAULT_API_HOST.to_string(),
166 api_port: DEFAULT_API_PORT,
167 cors_allowed_origins: local_cors_allowed_origins(DEFAULT_WEB_PORT),
168 web_session_cookie_name: DEFAULT_WEB_SESSION_COOKIE_NAME.to_string(),
169 web_csrf_cookie_name: DEFAULT_WEB_CSRF_COOKIE_NAME.to_string(),
170 web_session_ttl_hours: DEFAULT_WEB_SESSION_TTL_HOURS,
171 web_session_cookie_secure: DEFAULT_WEB_SESSION_COOKIE_SECURE,
172 },
173 storage: StorageConfig {
174 root: DEFAULT_STORAGE_ROOT.to_string(),
175 backend: DEFAULT_STORAGE_BACKEND,
176 work_root: None,
177 s3_bucket: Some(DEFAULT_S3_BUCKET.to_string()),
178 s3_prefix: None,
179 s3_region: DEFAULT_S3_REGION.to_string(),
180 s3_endpoint_url: Some(builtin_s3_endpoint_url()),
181 s3_force_path_style: DEFAULT_S3_FORCE_PATH_STYLE,
182 max_bundle_archive_bytes: storage_config::DEFAULT_STORAGE_MAX_BUNDLE_ARCHIVE_BYTES,
183 max_statement_bytes: storage_config::DEFAULT_STORAGE_MAX_STATEMENT_BYTES,
184 max_json_artifact_bytes: storage_config::DEFAULT_STORAGE_MAX_JSON_ARTIFACT_BYTES,
185 tmp_object_grace_hours: storage_config::DEFAULT_STORAGE_TMP_OBJECT_GRACE_HOURS,
186 challenges_root: DEFAULT_CHALLENGES_ROOT.to_string(),
187 },
188 auth: AuthConfig {
189 bootstrap_admin_github_user_ids: Vec::new(),
190 agent_registration_mode: DEFAULT_AGENT_REGISTRATION_MODE,
191 },
192 moltbook: MoltbookConfig {
193 submolt_name: builtin_moltbook_submolt_name(),
194 submolt_url: builtin_moltbook_submolt_url(),
195 },
196 worker: WorkerConfig {
197 poll_interval_ms: DEFAULT_WORKER_POLL_INTERVAL_MS,
198 stale_job_minutes: DEFAULT_WORKER_STALE_JOB_MINUTES,
199 accelerators: DEFAULT_WORKER_ACCELERATORS,
200 gpu_probe_image: None,
201 },
202 quotas: QuotaConfig {
203 validation_runs_per_agent_challenge_day:
204 DEFAULT_VALIDATION_RUNS_PER_AGENT_CHALLENGE_DAY,
205 official_runs_per_agent_challenge_day:
206 DEFAULT_OFFICIAL_RUNS_PER_AGENT_CHALLENGE_DAY,
207 max_active_official_jobs: DEFAULT_MAX_ACTIVE_OFFICIAL_JOBS,
208 max_active_agents: DEFAULT_MAX_ACTIVE_AGENTS,
209 max_active_challenge_review_records_per_human:
210 DEFAULT_MAX_ACTIVE_CHALLENGE_REVIEW_RECORDS_PER_HUMAN,
211 challenge_private_asset_bytes_per_review_record:
212 DEFAULT_CHALLENGE_PRIVATE_ASSET_BYTES_PER_REVIEW_RECORD,
213 challenge_review_record_validations_per_day:
214 DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATIONS_PER_DAY,
215 challenge_review_record_validation_timeout_minutes:
216 DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATION_TIMEOUT_MINUTES,
217 challenge_private_asset_pending_timeout_minutes:
218 DEFAULT_CHALLENGE_PRIVATE_ASSET_PENDING_TIMEOUT_MINUTES,
219 challenge_review_record_publish_timeout_minutes:
220 DEFAULT_CHALLENGE_REVIEW_RECORD_PUBLISH_TIMEOUT_MINUTES,
221 challenge_review_record_ttl_days: DEFAULT_CHALLENGE_REVIEW_RECORD_TTL_DAYS,
222 unpublished_challenge_asset_grace_days:
223 DEFAULT_UNPUBLISHED_CHALLENGE_ASSET_GRACE_DAYS,
224 },
225 github_app: GithubAppConfig {
226 client_id: None,
227 client_secret: None,
228 redirect_url: None,
229 authorize_url: builtin_github_app_authorize_url(),
230 token_url: builtin_github_app_token_url(),
231 api_user_url: builtin_github_api_user_url(),
232 },
233 runner: RunnerConfig {
234 docker_host: None,
235 host_probe_mode: DEFAULT_HOST_PROBE_MODE,
236 host_probe_command: DEFAULT_HOST_PROBE_COMMAND.to_string(),
237 security_profile: DEFAULT_RUNNER_SECURITY_PROFILE,
238 official_log_redaction: DEFAULT_OFFICIAL_LOG_REDACTION_MODE,
239 require_digest_pinned_images: DEFAULT_REQUIRE_DIGEST_PINNED_IMAGES,
240 writable_storage_mode: DEFAULT_RUNNER_WRITABLE_STORAGE_MODE,
241 namespace: builtin_runner_namespace(),
242 runtime_root: None,
243 phase_mount_root: None,
244 writable_slot_classes_mb: DEFAULT_RUNNER_WRITABLE_SLOT_CLASSES_MB.to_string(),
245 docker_layer_quota: DEFAULT_RUNNER_DOCKER_LAYER_QUOTA,
246 max_output_files: DEFAULT_RUNNER_MAX_OUTPUT_FILES,
247 max_output_dirs: DEFAULT_RUNNER_MAX_OUTPUT_DIRS,
248 max_output_depth: DEFAULT_RUNNER_MAX_OUTPUT_DEPTH,
249 max_runs: DEFAULT_RUNNER_MAX_RUNS,
250 max_result_json_bytes: DEFAULT_RUNNER_MAX_RESULT_JSON_BYTES,
251 max_public_results: DEFAULT_RUNNER_MAX_PUBLIC_RESULTS,
252 max_result_log_bytes: DEFAULT_RUNNER_MAX_RESULT_LOG_BYTES,
253 max_interaction_bytes_per_direction:
254 DEFAULT_RUNNER_MAX_INTERACTION_BYTES_PER_DIRECTION,
255 interaction_shutdown_grace_secs: DEFAULT_RUNNER_INTERACTION_SHUTDOWN_GRACE_SECS,
256 },
257 logging: LoggingConfig {
258 log_level: DEFAULT_LOG_LEVEL.to_string(),
259 },
260 }
261 }
262}
263
264fn local_database_url(postgres_port: u16) -> SecretString {
266 SecretString::from(format!(
267 "postgres://agentics:agentics@127.0.0.1:{postgres_port}/agentics"
268 ))
269}
270
271fn validate_cors_origin(origin: &str) -> anyhow::Result<()> {
273 origin.parse::<http::HeaderValue>().map_err(|e| {
274 anyhow::anyhow!("AGENTICS_CORS_ALLOWED_ORIGINS contains invalid origin `{origin}`: {e}")
275 })?;
276 let parsed = url::Url::parse(origin).map_err(|e| {
277 anyhow::anyhow!("AGENTICS_CORS_ALLOWED_ORIGINS contains invalid origin `{origin}`: {e}")
278 })?;
279 if !matches!(parsed.scheme(), "http" | "https")
280 || parsed.host_str().is_none()
281 || parsed.path() != "/"
282 || parsed.query().is_some()
283 || parsed.fragment().is_some()
284 {
285 anyhow::bail!(
286 "AGENTICS_CORS_ALLOWED_ORIGINS contains invalid origin `{origin}`: expected an http(s) origin without path, query, or fragment"
287 );
288 }
289 Ok(())
290}
291
292fn local_cors_allowed_origins(web_port: u16) -> String {
294 format!("http://127.0.0.1:{web_port},http://localhost:{web_port}")
295}
296
297#[allow(
298 clippy::expect_used,
299 reason = "hard-coded S3 endpoint is validated at compile-time by tests and has no runtime fallback"
300)]
301fn builtin_s3_endpoint_url() -> url::Url {
303 DEFAULT_S3_ENDPOINT_URL
304 .parse()
305 .expect("built-in S3 endpoint URL must be valid")
306}
307
308#[allow(
309 clippy::expect_used,
310 reason = "hard-coded Moltbook Submolt name must satisfy the domain parser"
311)]
312fn builtin_moltbook_submolt_name() -> MoltbookSubmoltName {
314 MoltbookSubmoltName::try_new(DEFAULT_MOLTBOOK_SUBMOLT_NAME.to_string())
315 .expect("built-in Moltbook Submolt name must be valid")
316}
317
318#[allow(
319 clippy::expect_used,
320 reason = "hard-coded Moltbook Submolt URL must satisfy the domain parser"
321)]
322fn builtin_moltbook_submolt_url() -> MoltbookSubmoltUrl {
324 MoltbookSubmoltUrl::try_new(DEFAULT_MOLTBOOK_SUBMOLT_URL)
325 .expect("built-in Moltbook Submolt URL must be valid")
326}
327
328#[allow(
329 clippy::expect_used,
330 reason = "static URLs are validated by type constructors and have no runtime fallback"
331)]
332fn builtin_github_app_authorize_url() -> GithubAppAuthorizeUrl {
334 GithubAppAuthorizeUrl::try_new("https://github.com/login/oauth/authorize")
335 .expect("built-in GitHub sign-in authorize URL must be valid")
336}
337
338#[allow(
339 clippy::expect_used,
340 reason = "static URLs are validated by type constructors and have no runtime fallback"
341)]
342fn builtin_github_app_token_url() -> GithubAppTokenUrl {
344 GithubAppTokenUrl::try_new("https://github.com/login/oauth/access_token")
345 .expect("built-in GitHub sign-in token URL must be valid")
346}
347
348#[allow(
349 clippy::expect_used,
350 reason = "static URLs are validated by type constructors and have no runtime fallback"
351)]
352fn builtin_github_api_user_url() -> GithubApiUserUrl {
354 GithubApiUserUrl::try_new("https://api.github.com/user")
355 .expect("built-in GitHub API user URL must be valid")
356}
357
358#[allow(
359 clippy::expect_used,
360 reason = "hard-coded runner namespace must satisfy the domain parser"
361)]
362fn builtin_runner_namespace() -> RunnerNamespace {
364 RunnerNamespace::try_new(DEFAULT_RUNNER_NAMESPACE)
365 .expect("built-in runner namespace must be valid")
366}
367
368impl Config {
369 pub fn from_env() -> anyhow::Result<Self> {
371 let raw = RawAppEnv::from_env().context("failed to load AGENTICS_* environment")?;
372 Self::try_from(raw)
373 }
374
375 pub fn validate_api_security(&self) -> anyhow::Result<()> {
378 validation::validate_report(&self.auth)?;
379 validation::validate_report(&self.api_web)?;
380 validation::validate_report(&self.quotas)?;
381 validation::validate_report(&self.github_app)?;
382 if !local_urls::is_loopback_host(&self.api_web.api_host)
383 && self.auth.agent_registration_mode == AgentRegistrationMode::Public
384 {
385 anyhow::bail!(
386 "refusing to bind API to `{}` with AGENTICS_AGENT_REGISTRATION_MODE=public; public registration is local-development only",
387 self.api_web.api_host
388 );
389 }
390
391 if self.api_web.web_session_cookie_name == self.api_web.web_csrf_cookie_name {
392 anyhow::bail!(
393 "AGENTICS_WEB_SESSION_COOKIE_NAME and AGENTICS_WEB_CSRF_COOKIE_NAME must differ"
394 );
395 }
396 self.validate_moltbook_config()?;
397 self.validate_session_cookie_security()?;
398 if self.github_app.client_id.is_some()
399 || self.github_app.client_secret.is_some()
400 || self.github_app.redirect_url.is_some()
401 {
402 validate_required_trimmed(
403 self.github_app.client_id.as_deref(),
404 "AGENTICS_GITHUB_APP_CLIENT_ID",
405 )?;
406 validate_required_trimmed(
407 self.github_app
408 .client_secret
409 .as_ref()
410 .map(ExposeSecret::expose_secret),
411 "AGENTICS_GITHUB_APP_CLIENT_SECRET",
412 )?;
413 validate_required_trimmed(
414 self.github_app
415 .redirect_url
416 .as_ref()
417 .map(GithubAppRedirectUrl::as_str),
418 "AGENTICS_GITHUB_APP_REDIRECT_URL",
419 )?;
420 self.validate_github_app_redirect_policy()?;
421 }
422 if (!local_urls::is_loopback_host(&self.api_web.api_host)
423 || !self.auth.bootstrap_admin_github_user_ids.is_empty())
424 && !self.github_app_enabled()
425 {
426 anyhow::bail!(
427 "GitHub sign-in must be fully configured with AGENTICS_GITHUB_APP_CLIENT_ID, AGENTICS_GITHUB_APP_CLIENT_SECRET, and AGENTICS_GITHUB_APP_REDIRECT_URL before human admin login or bootstrap can work"
428 );
429 }
430 self.validate_hosted_image_policy()?;
431 self.validate_object_storage_config()?;
432
433 Ok(())
434 }
435
436 fn validate_github_app_redirect_policy(&self) -> anyhow::Result<()> {
437 let Some(redirect_url) = self.github_app.redirect_url.as_ref() else {
438 return Ok(());
439 };
440 let url = redirect_url.to_url();
441 if url.scheme() == "https" {
442 return Ok(());
443 }
444 if url.scheme() == "http" && local_urls::is_loopback_url(&url) {
445 return Ok(());
446 }
447 anyhow::bail!(
448 "AGENTICS_GITHUB_APP_REDIRECT_URL must use HTTPS except for loopback local development callbacks"
449 );
450 }
451
452 fn validate_session_cookie_security(&self) -> anyhow::Result<()> {
453 if self.api_web.web_session_cookie_secure {
454 return Ok(());
455 }
456 if let Some(redirect_url) = self.github_app.redirect_url.as_ref() {
457 let url = redirect_url.to_url();
458 if local_urls::is_loopback_url(&url) {
459 return Ok(());
460 }
461 }
462 if !local_urls::is_loopback_host(&self.api_web.api_host) {
463 anyhow::bail!(
464 "AGENTICS_WEB_SESSION_COOKIE_SECURE=false is allowed only for loopback GitHub sign-in callbacks"
465 );
466 }
467 Ok(())
468 }
469
470 pub fn validate_object_storage_config(&self) -> anyhow::Result<()> {
472 storage_config::validate_object_storage_config(self)
473 }
474
475 pub fn storage_factory_options(&self) -> anyhow::Result<StorageFactoryOptions> {
477 self.validate_object_storage_config()?;
478 match self.storage.backend {
479 StorageBackend::Local => Ok(StorageFactoryOptions::Local(LocalStorageOptions {
480 root: PathBuf::from(&self.storage.root),
481 })),
482 StorageBackend::S3 => {
483 let bucket = self
484 .storage
485 .s3_bucket
486 .as_deref()
487 .map(str::trim)
488 .filter(|value| !value.is_empty())
489 .ok_or_else(|| anyhow::anyhow!("AGENTICS_S3_BUCKET must be set"))?
490 .to_string();
491 Ok(StorageFactoryOptions::S3(S3StorageOptions {
492 bucket,
493 prefix: self.storage.s3_prefix.clone(),
494 region: self.storage.s3_region.clone(),
495 endpoint_url: self.storage.s3_endpoint_url.clone(),
496 force_path_style: self.storage.s3_force_path_style,
497 work_root: Some(self.storage_work_root()?),
498 }))
499 }
500 }
501 }
502
503 pub fn storage_work_root(&self) -> agentics_storage::Result<PathBuf> {
505 let work_root = self
506 .storage
507 .work_root
508 .as_deref()
509 .map(str::trim)
510 .filter(|value| !value.is_empty())
511 .map(PathBuf::from);
512 agentics_storage::storage_work_root(work_root.as_deref())
513 }
514
515 fn validate_moltbook_config(&self) -> anyhow::Result<()> {
517 let url_name = self.moltbook.submolt_url.submolt_name().map_err(|e| {
518 anyhow::anyhow!("{} is invalid: {e}", ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL)
519 })?;
520 if url_name != self.moltbook.submolt_name {
521 anyhow::bail!(
522 "{} must match the Submolt name in {}",
523 ENV_AGENTICS_MOLTBOOK_SUBMOLT_NAME,
524 ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL
525 );
526 }
527 Ok(())
528 }
529
530 pub fn validate_runner_storage(&self) -> anyhow::Result<()> {
532 self.validate_object_storage_config()?;
533 self.validate_runner_output_limits()?;
534 self.validate_worker_accelerator_config()?;
535 self.validate_hosted_image_policy()?;
536
537 match self.runner.writable_storage_mode {
538 RunnerWritableStorageMode::Unbounded => {
539 if self.runner.security_profile == RunnerSecurityProfile::Production {
540 anyhow::bail!(
541 "AGENTICS_RUNNER_SECURITY_PROFILE=production requires AGENTICS_RUNNER_WRITABLE_STORAGE_MODE=xfs-project-quota-slots"
542 );
543 }
544 }
545 RunnerWritableStorageMode::XfsProjectQuotaSlots => {
546 if !cfg!(target_os = "linux") {
547 anyhow::bail!(
548 "AGENTICS_RUNNER_WRITABLE_STORAGE_MODE=xfs-project-quota-slots is Linux-only"
549 );
550 }
551 if !self.runner.docker_layer_quota {
552 anyhow::bail!(
553 "AGENTICS_RUNNER_DOCKER_LAYER_QUOTA=true is required alongside AGENTICS_RUNNER_WRITABLE_STORAGE_MODE=xfs-project-quota-slots"
554 );
555 }
556 self.validate_required_runner_runtime_root(
557 "AGENTICS_RUNNER_WRITABLE_STORAGE_MODE=xfs-project-quota-slots",
558 )?;
559 let mount_root = self
560 .runner
561 .phase_mount_root
562 .as_deref()
563 .map(str::trim)
564 .filter(|value| !value.is_empty())
565 .ok_or_else(|| {
566 anyhow::anyhow!(
567 "AGENTICS_RUNNER_PHASE_MOUNT_ROOT must be set when AGENTICS_RUNNER_WRITABLE_STORAGE_MODE=xfs-project-quota-slots"
568 )
569 })?;
570 if !std::path::Path::new(mount_root).is_absolute() {
571 anyhow::bail!("AGENTICS_RUNNER_PHASE_MOUNT_ROOT must be an absolute path");
572 }
573 if self.runner_writable_slot_classes_mb()?.is_empty() {
574 anyhow::bail!("AGENTICS_RUNNER_WRITABLE_SLOT_CLASSES_MB must not be empty");
575 }
576 }
577 }
578
579 if self.runner.docker_layer_quota && !cfg!(target_os = "linux") {
580 anyhow::bail!("AGENTICS_RUNNER_DOCKER_LAYER_QUOTA=true is Linux-only");
581 }
582 if self.runner.security_profile == RunnerSecurityProfile::Production
583 && self.runner.host_probe_mode != HostProbeMode::Require
584 {
585 anyhow::bail!(
586 "AGENTICS_RUNNER_SECURITY_PROFILE=production requires AGENTICS_HOST_PROBE_MODE=require"
587 );
588 }
589 if self.runner.host_probe_mode == HostProbeMode::Require && !self.runner.docker_layer_quota
590 {
591 anyhow::bail!(
592 "AGENTICS_RUNNER_DOCKER_LAYER_QUOTA=true is required when AGENTICS_HOST_PROBE_MODE=require"
593 );
594 }
595 if self.runner.host_probe_mode != HostProbeMode::Off && !cfg!(target_os = "linux") {
596 anyhow::bail!(
597 "AGENTICS_HOST_PROBE_MODE={} is Linux-only",
598 self.runner.host_probe_mode.as_str()
599 );
600 }
601 if self.runner.host_probe_mode != HostProbeMode::Off {
602 validate_required_trimmed(
603 Some(&self.runner.host_probe_command),
604 ENV_AGENTICS_HOST_PROBE_COMMAND,
605 )?;
606 self.validate_required_runner_runtime_root("AGENTICS_HOST_PROBE_MODE is enabled")?;
607 }
608 if let Some(runtime_root) = self
609 .runner
610 .runtime_root
611 .as_deref()
612 .map(str::trim)
613 .filter(|value| !value.is_empty())
614 && !Path::new(runtime_root).is_absolute()
615 {
616 anyhow::bail!("AGENTICS_RUNNER_RUNTIME_ROOT must be an absolute path");
617 }
618 if self.runner.security_profile == RunnerSecurityProfile::Production {
619 self.validate_private_host_directory(
620 "AGENTICS_RUNNER_RUNTIME_ROOT",
621 self.runner.runtime_root.as_deref(),
622 )?;
623 if self.runner.writable_storage_mode == RunnerWritableStorageMode::XfsProjectQuotaSlots
624 {
625 self.validate_private_host_directory(
626 "AGENTICS_RUNNER_PHASE_MOUNT_ROOT",
627 self.runner.phase_mount_root.as_deref(),
628 )?;
629 }
630 }
631
632 Ok(())
633 }
634
635 fn validate_worker_accelerator_config(&self) -> anyhow::Result<()> {
637 match self.worker.accelerators {
638 WorkerAccelerators::None => {
639 if let Some(image) = self.worker.gpu_probe_image.as_deref()
640 && image.trim().is_empty()
641 {
642 anyhow::bail!("AGENTICS_WORKER_GPU_PROBE_IMAGE must not be empty");
643 }
644 }
645 WorkerAccelerators::Gpu => {
646 if !cfg!(target_os = "linux") {
647 anyhow::bail!("AGENTICS_WORKER_ACCELERATORS=gpu is Linux-only");
648 }
649 self.worker_gpu_probe_image()?;
650 }
651 }
652 Ok(())
653 }
654
655 pub fn worker_gpu_probe_image(&self) -> anyhow::Result<&str> {
657 let image = self
658 .worker
659 .gpu_probe_image
660 .as_deref()
661 .map(str::trim)
662 .filter(|value| !value.is_empty())
663 .ok_or_else(|| {
664 anyhow::anyhow!(
665 "AGENTICS_WORKER_GPU_PROBE_IMAGE must be set when AGENTICS_WORKER_ACCELERATORS=gpu"
666 )
667 })?;
668 Ok(image)
669 }
670
671 pub fn requires_digest_pinned_images(&self) -> bool {
673 self.runner.require_digest_pinned_images
674 || self.runner.host_probe_mode == HostProbeMode::Require
675 || self.runner.security_profile == RunnerSecurityProfile::Production
676 }
677
678 fn validate_hosted_image_policy(&self) -> anyhow::Result<()> {
680 if self.requires_digest_pinned_images() && !self.runner.require_digest_pinned_images {
681 anyhow::bail!(
682 "AGENTICS_REQUIRE_DIGEST_PINNED_IMAGES must be true for profiles using AGENTICS_HOST_PROBE_MODE=require or AGENTICS_RUNNER_SECURITY_PROFILE=production"
683 );
684 }
685 Ok(())
686 }
687
688 fn validate_runner_output_limits(&self) -> anyhow::Result<()> {
690 validation::validate_report(&self.runner)
691 }
692
693 pub fn runner_writable_storage_mode(&self) -> RunnerWritableStorageMode {
695 self.runner.writable_storage_mode
696 }
697
698 pub fn runner_runtime_root(&self) -> anyhow::Result<PathBuf> {
700 let Some(runtime_root) = self
701 .runner
702 .runtime_root
703 .as_deref()
704 .map(str::trim)
705 .filter(|value| !value.is_empty())
706 else {
707 return Ok(std::env::temp_dir());
708 };
709 if !Path::new(runtime_root).is_absolute() {
710 anyhow::bail!("AGENTICS_RUNNER_RUNTIME_ROOT must be an absolute path");
711 }
712 Ok(PathBuf::from(runtime_root))
713 }
714
715 fn validate_required_runner_runtime_root(&self, reason: &str) -> anyhow::Result<()> {
717 let runtime_root = self
718 .runner
719 .runtime_root
720 .as_deref()
721 .map(str::trim)
722 .filter(|value| !value.is_empty())
723 .ok_or_else(|| {
724 anyhow::anyhow!("AGENTICS_RUNNER_RUNTIME_ROOT must be set when {reason}")
725 })?;
726 if !Path::new(runtime_root).is_absolute() {
727 anyhow::bail!("AGENTICS_RUNNER_RUNTIME_ROOT must be an absolute path");
728 }
729 Ok(())
730 }
731
732 fn validate_private_host_directory(
734 &self,
735 env_name: &str,
736 value: Option<&str>,
737 ) -> anyhow::Result<()> {
738 let path = value
739 .map(str::trim)
740 .filter(|value| !value.is_empty())
741 .ok_or_else(|| anyhow::anyhow!("{env_name} must be set for production runners"))?;
742 let path = Path::new(path);
743 if !path.is_absolute() {
744 anyhow::bail!("{env_name} must be an absolute path");
745 }
746 validate_private_host_directory_path(env_name, path)
747 }
748
749 pub fn agent_registration_mode(&self) -> AgentRegistrationMode {
751 self.auth.agent_registration_mode
752 }
753
754 pub fn allows_local_registration_testing_knobs(&self) -> bool {
756 local_urls::is_loopback_host(&self.api_web.api_host)
757 }
758
759 pub fn runner_writable_slot_classes_mb(&self) -> anyhow::Result<Vec<u64>> {
761 let mut classes = Vec::new();
762 for raw in self
763 .runner
764 .writable_slot_classes_mb
765 .split(|ch: char| ch == ',' || ch.is_ascii_whitespace())
766 {
767 let value = raw.trim();
768 if value.is_empty() {
769 continue;
770 }
771 let parsed = value.parse::<u64>().map_err(|e| {
772 anyhow::anyhow!(
773 "invalid AGENTICS_RUNNER_WRITABLE_SLOT_CLASSES_MB entry `{value}`: {e}"
774 )
775 })?;
776 if parsed == 0 {
777 anyhow::bail!("AGENTICS_RUNNER_WRITABLE_SLOT_CLASSES_MB entries must be positive");
778 }
779 classes.push(parsed);
780 }
781 classes.sort_unstable();
782 classes.dedup();
783 Ok(classes)
784 }
785
786 pub fn cors_allowed_origin_values(&self) -> Vec<String> {
788 self.api_web
789 .cors_allowed_origins
790 .split(',')
791 .map(str::trim)
792 .filter(|origin| !origin.is_empty())
793 .map(ToOwned::to_owned)
794 .collect()
795 }
796
797 pub fn github_app_enabled(&self) -> bool {
799 self.github_app
800 .client_id
801 .as_deref()
802 .is_some_and(|value| !value.trim().is_empty())
803 && self
804 .github_app
805 .client_secret
806 .as_ref()
807 .map(ExposeSecret::expose_secret)
808 .is_some_and(|value| !value.trim().is_empty())
809 && self.github_app.redirect_url.is_some()
810 }
811}
812
813pub(crate) fn validate_required_trimmed(value: Option<&str>, field: &str) -> anyhow::Result<()> {
815 if value.is_none_or(|value| value.trim().is_empty()) {
816 anyhow::bail!("{field} must be set");
817 }
818 Ok(())
819}
820
821fn validate_private_host_directory_path(env_name: &str, path: &Path) -> anyhow::Result<()> {
823 #[cfg(unix)]
824 {
825 use std::os::unix::fs::{MetadataExt, PermissionsExt};
826
827 let metadata = std::fs::metadata(path)
828 .with_context(|| format!("{env_name} must exist for production runners"))?;
829 if !metadata.is_dir() {
830 anyhow::bail!("{env_name} must point to a directory");
831 }
832 let mode = metadata.permissions().mode() & 0o777;
833 if mode & 0o077 != 0 {
834 anyhow::bail!("{env_name} must be mode 0700 or stricter, got {mode:o}");
835 }
836 let effective_uid = nix::unistd::Uid::effective().as_raw();
837 if metadata.uid() != effective_uid {
838 anyhow::bail!(
839 "{env_name} must be owned by the worker service user uid {effective_uid}, got uid {}",
840 metadata.uid()
841 );
842 }
843 }
844 #[cfg(not(unix))]
845 {
846 let metadata = std::fs::metadata(path)
847 .with_context(|| format!("{env_name} must exist for production runners"))?;
848 if !metadata.is_dir() {
849 anyhow::bail!("{env_name} must point to a directory");
850 }
851 }
852 Ok(())
853}
854
855#[cfg(test)]
856mod tests;