Skip to main content

agentics_config/
lib.rs

1//! Environment-backed runtime configuration.
2
3use 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
48/// Environment variable that configures the API listen port.
49pub const ENV_AGENTICS_API_PORT: &str = "AGENTICS_API_PORT";
50/// Environment variable that configures the API base URL for clients and tools.
51pub const ENV_AGENTICS_API_BASE_URL: &str = "AGENTICS_API_BASE_URL";
52/// Environment variable that configures the web frontend base URL for checks.
53pub const ENV_AGENTICS_WEB_BASE_URL: &str = "AGENTICS_WEB_BASE_URL";
54/// Environment variable that configures GitHub users allowed to bootstrap the first admin.
55pub const ENV_AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS: &str =
56    "AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS";
57/// Environment variable that overrides the hosted runner profile probe command.
58pub const ENV_AGENTICS_HOST_PROBE_COMMAND: &str = "AGENTICS_HOST_PROBE_COMMAND";
59/// Environment variable used to derive the default local Postgres URL.
60pub const ENV_AGENTICS_POSTGRES_PORT: &str = "AGENTICS_POSTGRES_PORT";
61/// Environment variable used to derive the default local CORS origins.
62pub const ENV_AGENTICS_WEB_PORT: &str = "AGENTICS_WEB_PORT";
63/// Environment variable that separates runner containers sharing one Docker daemon.
64pub const ENV_AGENTICS_RUNNER_NAMESPACE: &str = "AGENTICS_RUNNER_NAMESPACE";
65/// Environment variable that configures the shared Moltbook Submolt name.
66pub const ENV_AGENTICS_MOLTBOOK_SUBMOLT_NAME: &str = "AGENTICS_MOLTBOOK_SUBMOLT_NAME";
67/// Environment variable that configures the shared Moltbook Submolt URL.
68pub const ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL: &str = "AGENTICS_MOLTBOOK_SUBMOLT_URL";
69/// Environment variable that controls official-evaluation runner log redaction.
70pub const ENV_AGENTICS_OFFICIAL_LOG_REDACTION: &str = "AGENTICS_OFFICIAL_LOG_REDACTION";
71
72/// Default API listen host for local development.
73pub const DEFAULT_API_HOST: &str = "127.0.0.1";
74/// Default API listen port for local development.
75pub const DEFAULT_API_PORT: u16 = 3100;
76/// Default web listen port for local development.
77pub const DEFAULT_WEB_PORT: u16 = 3001;
78/// Default hosted runner profile probe command in packaged deployments.
79pub const DEFAULT_HOST_PROBE_COMMAND: &str = "bin/agentics-check-dgx-spark-profile";
80/// Default local Postgres port used to derive the local database URL.
81pub const DEFAULT_POSTGRES_PORT: u16 = 5432;
82/// Default challenge bundle root for local development.
83pub const DEFAULT_CHALLENGES_ROOT: &str = "challenge-repos/agentics-challenges/challenges";
84/// Default web session cookie name.
85pub const DEFAULT_WEB_SESSION_COOKIE_NAME: &str = "agentics_session";
86/// Default web CSRF cookie name.
87pub const DEFAULT_WEB_CSRF_COOKIE_NAME: &str = "agentics_csrf";
88/// Default web session lifetime in hours.
89pub const DEFAULT_WEB_SESSION_TTL_HOURS: i64 = 24;
90/// Default secure-cookie requirement for local development.
91pub const DEFAULT_WEB_SESSION_COOKIE_SECURE: bool = false;
92/// Default unauthenticated agent registration policy.
93pub const DEFAULT_AGENT_REGISTRATION_MODE: AgentRegistrationMode =
94    AgentRegistrationMode::PioneerCode;
95/// Default worker poll interval in milliseconds.
96pub const DEFAULT_WORKER_POLL_INTERVAL_MS: u64 = 3000;
97/// Default stale job lease threshold in minutes.
98pub const DEFAULT_WORKER_STALE_JOB_MINUTES: i32 = 1;
99/// Default worker accelerator capability.
100pub const DEFAULT_WORKER_ACCELERATORS: WorkerAccelerators = WorkerAccelerators::None;
101/// Default validation runs allowed per agent, challenge, and day.
102pub const DEFAULT_VALIDATION_RUNS_PER_AGENT_CHALLENGE_DAY: u32 = 20;
103/// Default official runs allowed per agent, challenge, and day.
104pub const DEFAULT_OFFICIAL_RUNS_PER_AGENT_CHALLENGE_DAY: u32 = 5;
105/// Default global active official job limit.
106pub const DEFAULT_MAX_ACTIVE_OFFICIAL_JOBS: u32 = 20;
107/// Default active agent limit.
108pub const DEFAULT_MAX_ACTIVE_AGENTS: u32 = 1_000;
109/// Default active challenge review record limit per human creator.
110pub const DEFAULT_MAX_ACTIVE_CHALLENGE_REVIEW_RECORDS_PER_HUMAN: u32 = 10;
111/// Default private asset byte budget per challenge review record.
112pub const DEFAULT_CHALLENGE_PRIVATE_ASSET_BYTES_PER_REVIEW_RECORD: u64 = 1024 * 1024 * 1024;
113/// Default challenge review record validation count per day.
114pub const DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATIONS_PER_DAY: u32 = 10;
115/// Default challenge review record validation timeout in minutes.
116pub const DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATION_TIMEOUT_MINUTES: i32 = 30;
117/// Default pending private asset timeout in minutes.
118pub const DEFAULT_CHALLENGE_PRIVATE_ASSET_PENDING_TIMEOUT_MINUTES: i32 = 30;
119/// Default review record publish timeout in minutes.
120pub const DEFAULT_CHALLENGE_REVIEW_RECORD_PUBLISH_TIMEOUT_MINUTES: i32 = 30;
121/// Default unpublished challenge review record TTL in days.
122pub const DEFAULT_CHALLENGE_REVIEW_RECORD_TTL_DAYS: i64 = 14;
123/// Default grace period for unpublished challenge assets in days.
124pub const DEFAULT_UNPUBLISHED_CHALLENGE_ASSET_GRACE_DAYS: i64 = 7;
125/// Default worker host-probe mode.
126pub const DEFAULT_HOST_PROBE_MODE: HostProbeMode = HostProbeMode::Off;
127/// Default requirement for digest-pinned runner images.
128pub 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";
131/// Default runner security profile.
132pub const DEFAULT_RUNNER_SECURITY_PROFILE: RunnerSecurityProfile =
133    RunnerSecurityProfile::Development;
134/// Default official-evaluation runner log redaction policy.
135pub const DEFAULT_OFFICIAL_LOG_REDACTION_MODE: OfficialLogRedactionMode =
136    OfficialLogRedactionMode::ContractBased;
137/// Default runner writable-storage mode.
138pub 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;
152/// Default runner Docker writable-layer quota enforcement flag.
153pub const DEFAULT_RUNNER_DOCKER_LAYER_QUOTA: bool = false;
154/// Default runtime log level.
155pub const DEFAULT_LOG_LEVEL: &str = "info";
156
157impl Default for Config {
158    /// Build local-development configuration used when env/config fields are absent.
159    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
264/// Build the local database URL without exposing it through Debug output.
265fn 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
271/// Validate one configured CORS origin before the router accepts it.
272fn 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
292/// Build local CORS origins from the configured local web port.
293fn 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)]
301/// Built-in local RustFS endpoint for non-Compose S3-backed development.
302fn 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)]
312/// Built-in shared Moltbook Submolt name.
313fn 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)]
322/// Built-in shared Moltbook Submolt URL.
323fn 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)]
332/// Built-in GitHub sign-in authorize URL.
333fn 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)]
342/// Built-in GitHub sign-in token URL.
343fn 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)]
352/// Built-in GitHub API user URL.
353fn 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)]
362/// Built-in runner namespace for non-containerized local development.
363fn 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    /// Load configuration from `AGENTICS_*` environment variables with defaults.
370    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    /// Reject settings that are acceptable for local development but dangerous
376    /// when the API is reachable from another machine.
377    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    /// Validate durable object storage configuration.
471    pub fn validate_object_storage_config(&self) -> anyhow::Result<()> {
472        storage_config::validate_object_storage_config(self)
473    }
474
475    /// Build backend-specific durable storage options from validated runtime config.
476    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    /// Resolve the host-local work root for storage staging and materialization.
504    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    /// Validate Moltbook platform-community configuration.
516    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    /// Validate worker-only storage settings before claiming evaluation jobs.
531    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    /// Validate worker accelerator capability knobs before accepting jobs.
636    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    /// Return the validated GPU probe image for GPU-capable workers.
656    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    /// Return whether this configuration must enforce immutable hosted images.
672    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    /// Reject hosted profiles that try to opt out of immutable image references.
679    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    /// Validate platform-owned output tree limits.
689    fn validate_runner_output_limits(&self) -> anyhow::Result<()> {
690        validation::validate_report(&self.runner)
691    }
692
693    /// Handles runner writable storage mode for this module.
694    pub fn runner_writable_storage_mode(&self) -> RunnerWritableStorageMode {
695        self.runner.writable_storage_mode
696    }
697
698    /// Return the host-visible root for transient runner artifacts.
699    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    /// Require a Docker-daemon-visible runner runtime root for hosted paths.
716    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    /// Validate a production runner host directory cannot be traversed by other users.
733    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    /// Return the configured agent-registration mode.
750    pub fn agent_registration_mode(&self) -> AgentRegistrationMode {
751        self.auth.agent_registration_mode
752    }
753
754    /// Return whether local-only testing knobs such as unlimited pioneer codes may be used.
755    pub fn allows_local_registration_testing_knobs(&self) -> bool {
756        local_urls::is_loopback_host(&self.api_web.api_host)
757    }
758
759    /// Handles runner writable slot classes mb for this module.
760    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    /// Split the comma-separated CORS allowlist into trimmed origin strings.
787    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    /// Return whether GitHub sign-in is fully configured for creator login.
798    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
813/// Validates required trimmed invariants for this contract.
814pub(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
821/// Validate a production runner directory is owned by this worker user and non-traversable.
822fn 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;