Skip to main content

agentics_config/
env.rs

1//! Raw `AGENTICS_*` environment loading and validation.
2
3use super::{
4    AgentRegistrationMode, Config, DEFAULT_AGENT_REGISTRATION_MODE, DEFAULT_API_HOST,
5    DEFAULT_API_PORT, DEFAULT_CHALLENGE_PRIVATE_ASSET_BYTES_PER_REVIEW_RECORD,
6    DEFAULT_CHALLENGE_PRIVATE_ASSET_PENDING_TIMEOUT_MINUTES,
7    DEFAULT_CHALLENGE_REVIEW_RECORD_PUBLISH_TIMEOUT_MINUTES,
8    DEFAULT_CHALLENGE_REVIEW_RECORD_TTL_DAYS,
9    DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATION_TIMEOUT_MINUTES,
10    DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATIONS_PER_DAY, DEFAULT_HOST_PROBE_COMMAND,
11    DEFAULT_HOST_PROBE_MODE, DEFAULT_LOG_LEVEL, DEFAULT_MAX_ACTIVE_AGENTS,
12    DEFAULT_MAX_ACTIVE_CHALLENGE_REVIEW_RECORDS_PER_HUMAN, DEFAULT_MAX_ACTIVE_OFFICIAL_JOBS,
13    DEFAULT_OFFICIAL_LOG_REDACTION_MODE, DEFAULT_OFFICIAL_RUNS_PER_AGENT_CHALLENGE_DAY,
14    DEFAULT_POSTGRES_PORT, DEFAULT_REQUIRE_DIGEST_PINNED_IMAGES, DEFAULT_RUNNER_DOCKER_LAYER_QUOTA,
15    DEFAULT_RUNNER_INTERACTION_SHUTDOWN_GRACE_SECS,
16    DEFAULT_RUNNER_MAX_INTERACTION_BYTES_PER_DIRECTION, DEFAULT_RUNNER_MAX_OUTPUT_DEPTH,
17    DEFAULT_RUNNER_MAX_OUTPUT_DIRS, DEFAULT_RUNNER_MAX_OUTPUT_FILES,
18    DEFAULT_RUNNER_MAX_PUBLIC_RESULTS, DEFAULT_RUNNER_MAX_RESULT_JSON_BYTES,
19    DEFAULT_RUNNER_MAX_RESULT_LOG_BYTES, DEFAULT_RUNNER_MAX_RUNS, DEFAULT_RUNNER_SECURITY_PROFILE,
20    DEFAULT_RUNNER_WRITABLE_SLOT_CLASSES_MB, DEFAULT_RUNNER_WRITABLE_STORAGE_MODE,
21    DEFAULT_S3_BUCKET, DEFAULT_S3_FORCE_PATH_STYLE, DEFAULT_S3_REGION, DEFAULT_STORAGE_BACKEND,
22    DEFAULT_STORAGE_ROOT, DEFAULT_UNPUBLISHED_CHALLENGE_ASSET_GRACE_DAYS,
23    DEFAULT_VALIDATION_RUNS_PER_AGENT_CHALLENGE_DAY, DEFAULT_WEB_CSRF_COOKIE_NAME,
24    DEFAULT_WEB_PORT, DEFAULT_WEB_SESSION_COOKIE_NAME, DEFAULT_WEB_SESSION_COOKIE_SECURE,
25    DEFAULT_WEB_SESSION_TTL_HOURS, DEFAULT_WORKER_ACCELERATORS, DEFAULT_WORKER_POLL_INTERVAL_MS,
26    DEFAULT_WORKER_STALE_JOB_MINUTES, ENV_AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS,
27    ENV_AGENTICS_HOST_PROBE_COMMAND, ENV_AGENTICS_MOLTBOOK_SUBMOLT_NAME,
28    ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL, ENV_AGENTICS_RUNNER_NAMESPACE, ENV_AGENTICS_S3_ENDPOINT_URL,
29    ENV_AGENTICS_S3_REGION, ENV_AGENTICS_STORAGE_ROOT, GithubApiUserUrl, GithubAppAuthorizeUrl,
30    GithubAppRedirectUrl, GithubAppTokenUrl, HostProbeMode, MoltbookSubmoltName,
31    MoltbookSubmoltUrl, OfficialLogRedactionMode, RunnerNamespace, RunnerSecurityProfile,
32    RunnerWritableStorageMode, StorageBackend, WorkerAccelerators, builtin_github_api_user_url,
33    builtin_github_app_authorize_url, builtin_github_app_token_url, builtin_moltbook_submolt_name,
34    builtin_moltbook_submolt_url, builtin_runner_namespace, builtin_s3_endpoint_url,
35    local_cors_allowed_origins, local_database_url, storage_config,
36};
37use agentics_domain::models::auth::GithubUserId;
38use secrecy::SecretString;
39use serde::{Deserialize, de::DeserializeOwned};
40
41const ENV_PREFIX: &str = "AGENTICS_";
42
43/// Raw application environment grouped by runtime concern.
44#[derive(Debug, Clone, Default)]
45pub struct RawAppEnv {
46    pub database: RawDatabaseEnv,
47    pub api_web: RawApiWebEnv,
48    pub storage: RawStorageEnv,
49    pub auth: RawAuthEnv,
50    pub moltbook: RawMoltbookEnv,
51    pub worker: RawWorkerEnv,
52    pub quotas: RawQuotaEnv,
53    pub github_app: RawGithubAppEnv,
54    pub runner: RawRunnerEnv,
55    pub logging: RawLoggingEnv,
56}
57
58impl RawAppEnv {
59    /// Load grouped raw env structs from the current process.
60    pub fn from_env() -> envy::Result<Self> {
61        Self::from_env_iter(std::env::vars())
62    }
63
64    /// Load grouped raw env structs from one prefixed env snapshot.
65    pub fn from_env_iter<Iter>(iter: Iter) -> envy::Result<Self>
66    where
67        Iter: IntoIterator<Item = (String, String)>,
68    {
69        let vars: Vec<_> = iter.into_iter().collect();
70        Ok(Self {
71            database: load_group(&vars)?,
72            api_web: load_group(&vars)?,
73            storage: load_group(&vars)?,
74            auth: load_group(&vars)?,
75            moltbook: load_group(&vars)?,
76            worker: load_group(&vars)?,
77            quotas: load_group(&vars)?,
78            github_app: load_group(&vars)?,
79            runner: load_group(&vars)?,
80            logging: load_group(&vars)?,
81        })
82    }
83}
84
85fn load_group<T>(vars: &[(String, String)]) -> envy::Result<T>
86where
87    T: DeserializeOwned,
88{
89    envy::prefixed(ENV_PREFIX).from_iter(vars.iter().cloned())
90}
91
92/// Raw database environment values.
93#[derive(Debug, Clone, Default, Deserialize)]
94pub struct RawDatabaseEnv {
95    pub database_url: Option<String>,
96    pub postgres_port: Option<u16>,
97}
98
99/// Raw API and web environment values.
100#[derive(Debug, Clone, Default, Deserialize)]
101pub struct RawApiWebEnv {
102    pub api_host: Option<String>,
103    pub api_port: Option<u16>,
104    pub web_port: Option<u16>,
105    pub cors_allowed_origins: Option<String>,
106    pub web_session_cookie_name: Option<String>,
107    pub web_csrf_cookie_name: Option<String>,
108    pub web_session_ttl_hours: Option<i64>,
109    pub web_session_cookie_secure: Option<bool>,
110}
111
112/// Raw durable storage environment values.
113#[derive(Debug, Clone, Default, Deserialize)]
114pub struct RawStorageEnv {
115    pub storage_root: Option<String>,
116    pub storage_backend: Option<StorageBackend>,
117    pub storage_work_root: Option<String>,
118    pub s3_bucket: Option<String>,
119    pub s3_prefix: Option<String>,
120    pub s3_region: Option<String>,
121    pub s3_endpoint_url: Option<String>,
122    pub s3_force_path_style: Option<bool>,
123    pub challenges_root: Option<String>,
124    pub storage_max_bundle_archive_bytes: Option<u64>,
125    pub storage_max_statement_bytes: Option<u64>,
126    pub storage_max_json_artifact_bytes: Option<u64>,
127    pub storage_tmp_object_grace_hours: Option<u64>,
128}
129
130/// Raw administrator and registration environment values.
131#[derive(Debug, Clone, Default, Deserialize)]
132pub struct RawAuthEnv {
133    pub bootstrap_admin_github_user_ids: Option<String>,
134    pub agent_registration_mode: Option<AgentRegistrationMode>,
135}
136
137/// Raw Moltbook environment values.
138#[derive(Debug, Clone, Default, Deserialize)]
139pub struct RawMoltbookEnv {
140    pub moltbook_submolt_name: Option<String>,
141    pub moltbook_submolt_url: Option<String>,
142}
143
144/// Raw worker environment values.
145#[derive(Debug, Clone, Default, Deserialize)]
146pub struct RawWorkerEnv {
147    pub worker_poll_interval_ms: Option<u64>,
148    pub worker_stale_job_minutes: Option<i32>,
149    pub worker_accelerators: Option<WorkerAccelerators>,
150    pub worker_gpu_probe_image: Option<String>,
151}
152
153/// Raw platform quota and lifecycle environment values.
154#[derive(Debug, Clone, Default, Deserialize)]
155pub struct RawQuotaEnv {
156    pub validation_runs_per_agent_challenge_day: Option<u32>,
157    pub official_runs_per_agent_challenge_day: Option<u32>,
158    pub max_active_official_jobs: Option<u32>,
159    pub max_active_agents: Option<u32>,
160    pub max_active_challenge_review_records_per_human: Option<u32>,
161    pub challenge_private_asset_bytes_per_review_record: Option<u64>,
162    pub challenge_review_record_validations_per_day: Option<u32>,
163    pub challenge_review_record_validation_timeout_minutes: Option<i32>,
164    pub challenge_private_asset_pending_timeout_minutes: Option<i32>,
165    pub challenge_review_record_publish_timeout_minutes: Option<i32>,
166    pub challenge_review_record_ttl_days: Option<i64>,
167    pub unpublished_challenge_asset_grace_days: Option<i64>,
168}
169
170/// Raw GitHub sign-in environment values.
171#[derive(Debug, Clone, Default, Deserialize)]
172pub struct RawGithubAppEnv {
173    pub github_app_client_id: Option<String>,
174    pub github_app_client_secret: Option<String>,
175    pub github_app_redirect_url: Option<String>,
176    pub github_app_authorize_url: Option<String>,
177    pub github_app_token_url: Option<String>,
178    pub github_api_user_url: Option<String>,
179}
180
181/// Raw runner environment values.
182#[derive(Debug, Clone, Default, Deserialize)]
183pub struct RawRunnerEnv {
184    pub docker_host: Option<String>,
185    pub host_probe_mode: Option<HostProbeMode>,
186    pub host_probe_command: Option<String>,
187    pub runner_security_profile: Option<RunnerSecurityProfile>,
188    pub official_log_redaction: Option<OfficialLogRedactionMode>,
189    pub require_digest_pinned_images: Option<bool>,
190    pub runner_writable_storage_mode: Option<RunnerWritableStorageMode>,
191    pub runner_namespace: Option<String>,
192    pub runner_runtime_root: Option<String>,
193    pub runner_phase_mount_root: Option<String>,
194    pub runner_writable_slot_classes_mb: Option<String>,
195    pub runner_docker_layer_quota: Option<bool>,
196    pub runner_max_output_files: Option<u64>,
197    pub runner_max_output_dirs: Option<u64>,
198    pub runner_max_output_depth: Option<u64>,
199    pub runner_max_runs: Option<u64>,
200    pub runner_max_result_json_bytes: Option<u64>,
201    pub runner_max_public_results: Option<u64>,
202    pub runner_max_result_log_bytes: Option<u64>,
203    pub runner_max_interaction_bytes_per_direction: Option<u64>,
204    pub runner_interaction_shutdown_grace_secs: Option<u64>,
205}
206
207/// Raw logging environment values.
208#[derive(Debug, Clone, Default, Deserialize)]
209pub struct RawLoggingEnv {
210    pub log_level: Option<String>,
211}
212
213impl TryFrom<RawAppEnv> for Config {
214    type Error = anyhow::Error;
215
216    /// Convert raw environment strings into typed runtime configuration.
217    fn try_from(raw: RawAppEnv) -> anyhow::Result<Self> {
218        let mut config = Self::default();
219        let postgres_port = raw.database.postgres_port.unwrap_or(DEFAULT_POSTGRES_PORT);
220        let web_port = raw.api_web.web_port.unwrap_or(DEFAULT_WEB_PORT);
221
222        config.database.url = match raw.database.database_url {
223            Some(value) => {
224                SecretString::from(required_trimmed_string("AGENTICS_DATABASE_URL", value)?)
225            }
226            None => local_database_url(postgres_port),
227        };
228        config.api_web.api_host =
229            string_or_default("AGENTICS_API_HOST", raw.api_web.api_host, DEFAULT_API_HOST)?;
230        config.api_web.api_port = raw.api_web.api_port.unwrap_or(DEFAULT_API_PORT);
231        config.api_web.cors_allowed_origins = match raw.api_web.cors_allowed_origins {
232            Some(value) => required_trimmed_string("AGENTICS_CORS_ALLOWED_ORIGINS", value)?,
233            None => local_cors_allowed_origins(web_port),
234        };
235        config.api_web.web_session_cookie_name = string_or_default(
236            "AGENTICS_WEB_SESSION_COOKIE_NAME",
237            raw.api_web.web_session_cookie_name,
238            DEFAULT_WEB_SESSION_COOKIE_NAME,
239        )?;
240        config.api_web.web_csrf_cookie_name = string_or_default(
241            "AGENTICS_WEB_CSRF_COOKIE_NAME",
242            raw.api_web.web_csrf_cookie_name,
243            DEFAULT_WEB_CSRF_COOKIE_NAME,
244        )?;
245        config.api_web.web_session_ttl_hours = raw
246            .api_web
247            .web_session_ttl_hours
248            .unwrap_or(DEFAULT_WEB_SESSION_TTL_HOURS);
249        config.api_web.web_session_cookie_secure = raw
250            .api_web
251            .web_session_cookie_secure
252            .unwrap_or(DEFAULT_WEB_SESSION_COOKIE_SECURE);
253
254        apply_storage_env(&mut config, raw.storage)?;
255        apply_auth_env(&mut config, raw.auth)?;
256        apply_moltbook_env(&mut config, raw.moltbook)?;
257        apply_worker_env(&mut config, raw.worker)?;
258        apply_quota_env(&mut config, raw.quotas)?;
259        apply_github_app_env(&mut config, raw.github_app)?;
260        apply_runner_env(&mut config, raw.runner)?;
261        config.logging.log_level = string_or_default(
262            "AGENTICS_LOG_LEVEL",
263            raw.logging.log_level,
264            DEFAULT_LOG_LEVEL,
265        )?;
266
267        Ok(config)
268    }
269}
270
271fn apply_storage_env(config: &mut Config, raw: RawStorageEnv) -> anyhow::Result<()> {
272    config.storage.root = string_or_default(
273        ENV_AGENTICS_STORAGE_ROOT,
274        raw.storage_root,
275        DEFAULT_STORAGE_ROOT,
276    )?;
277    config.storage.backend = raw.storage_backend.unwrap_or(DEFAULT_STORAGE_BACKEND);
278    config.storage.work_root = optional_non_empty_string(raw.storage_work_root);
279    config.storage.s3_bucket = raw
280        .s3_bucket
281        .map(trimmed_string)
282        .or_else(|| Some(DEFAULT_S3_BUCKET.to_string()));
283    config.storage.s3_prefix = optional_non_empty_string(raw.s3_prefix);
284    config.storage.s3_region =
285        string_or_default(ENV_AGENTICS_S3_REGION, raw.s3_region, DEFAULT_S3_REGION)?;
286    config.storage.s3_endpoint_url = match raw.s3_endpoint_url {
287        Some(value) => Some(parse_url_env(ENV_AGENTICS_S3_ENDPOINT_URL, value)?),
288        None => Some(builtin_s3_endpoint_url()),
289    };
290    config.storage.s3_force_path_style = raw
291        .s3_force_path_style
292        .unwrap_or(DEFAULT_S3_FORCE_PATH_STYLE);
293    if let Some(value) = raw.challenges_root {
294        config.storage.challenges_root =
295            required_trimmed_string("AGENTICS_CHALLENGES_ROOT", value)?;
296    }
297    config.storage.max_bundle_archive_bytes = raw
298        .storage_max_bundle_archive_bytes
299        .unwrap_or(storage_config::DEFAULT_STORAGE_MAX_BUNDLE_ARCHIVE_BYTES);
300    config.storage.max_statement_bytes = raw
301        .storage_max_statement_bytes
302        .unwrap_or(storage_config::DEFAULT_STORAGE_MAX_STATEMENT_BYTES);
303    config.storage.max_json_artifact_bytes = raw
304        .storage_max_json_artifact_bytes
305        .unwrap_or(storage_config::DEFAULT_STORAGE_MAX_JSON_ARTIFACT_BYTES);
306    config.storage.tmp_object_grace_hours = raw
307        .storage_tmp_object_grace_hours
308        .unwrap_or(storage_config::DEFAULT_STORAGE_TMP_OBJECT_GRACE_HOURS);
309    Ok(())
310}
311
312fn apply_auth_env(config: &mut Config, raw: RawAuthEnv) -> anyhow::Result<()> {
313    config.auth.bootstrap_admin_github_user_ids = match raw.bootstrap_admin_github_user_ids {
314        Some(value) => {
315            parse_github_user_id_list(ENV_AGENTICS_BOOTSTRAP_ADMIN_GITHUB_USER_IDS, &value)?
316        }
317        None => Vec::new(),
318    };
319    config.auth.agent_registration_mode = raw
320        .agent_registration_mode
321        .unwrap_or(DEFAULT_AGENT_REGISTRATION_MODE);
322    Ok(())
323}
324
325fn apply_moltbook_env(config: &mut Config, raw: RawMoltbookEnv) -> anyhow::Result<()> {
326    config.moltbook.submolt_name = match raw.moltbook_submolt_name {
327        Some(value) => MoltbookSubmoltName::try_new(required_trimmed_string(
328            ENV_AGENTICS_MOLTBOOK_SUBMOLT_NAME,
329            value,
330        )?)?,
331        None => builtin_moltbook_submolt_name(),
332    };
333    config.moltbook.submolt_url = match raw.moltbook_submolt_url {
334        Some(value) => MoltbookSubmoltUrl::try_new(required_trimmed_string(
335            ENV_AGENTICS_MOLTBOOK_SUBMOLT_URL,
336            value,
337        )?)?,
338        None => builtin_moltbook_submolt_url(),
339    };
340    Ok(())
341}
342
343fn apply_worker_env(config: &mut Config, raw: RawWorkerEnv) -> anyhow::Result<()> {
344    config.worker.poll_interval_ms = raw
345        .worker_poll_interval_ms
346        .unwrap_or(DEFAULT_WORKER_POLL_INTERVAL_MS);
347    config.worker.stale_job_minutes = raw
348        .worker_stale_job_minutes
349        .unwrap_or(DEFAULT_WORKER_STALE_JOB_MINUTES);
350    config.worker.accelerators = raw
351        .worker_accelerators
352        .unwrap_or(DEFAULT_WORKER_ACCELERATORS);
353    config.worker.gpu_probe_image = optional_non_empty_string(raw.worker_gpu_probe_image);
354    Ok(())
355}
356
357fn apply_quota_env(config: &mut Config, raw: RawQuotaEnv) -> anyhow::Result<()> {
358    config.quotas.validation_runs_per_agent_challenge_day = raw
359        .validation_runs_per_agent_challenge_day
360        .unwrap_or(DEFAULT_VALIDATION_RUNS_PER_AGENT_CHALLENGE_DAY);
361    config.quotas.official_runs_per_agent_challenge_day = raw
362        .official_runs_per_agent_challenge_day
363        .unwrap_or(DEFAULT_OFFICIAL_RUNS_PER_AGENT_CHALLENGE_DAY);
364    config.quotas.max_active_official_jobs = raw
365        .max_active_official_jobs
366        .unwrap_or(DEFAULT_MAX_ACTIVE_OFFICIAL_JOBS);
367    config.quotas.max_active_agents = raw.max_active_agents.unwrap_or(DEFAULT_MAX_ACTIVE_AGENTS);
368    config.quotas.max_active_challenge_review_records_per_human = raw
369        .max_active_challenge_review_records_per_human
370        .unwrap_or(DEFAULT_MAX_ACTIVE_CHALLENGE_REVIEW_RECORDS_PER_HUMAN);
371    config
372        .quotas
373        .challenge_private_asset_bytes_per_review_record = raw
374        .challenge_private_asset_bytes_per_review_record
375        .unwrap_or(DEFAULT_CHALLENGE_PRIVATE_ASSET_BYTES_PER_REVIEW_RECORD);
376    config.quotas.challenge_review_record_validations_per_day = raw
377        .challenge_review_record_validations_per_day
378        .unwrap_or(DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATIONS_PER_DAY);
379    config
380        .quotas
381        .challenge_review_record_validation_timeout_minutes = raw
382        .challenge_review_record_validation_timeout_minutes
383        .unwrap_or(DEFAULT_CHALLENGE_REVIEW_RECORD_VALIDATION_TIMEOUT_MINUTES);
384    config
385        .quotas
386        .challenge_private_asset_pending_timeout_minutes = raw
387        .challenge_private_asset_pending_timeout_minutes
388        .unwrap_or(DEFAULT_CHALLENGE_PRIVATE_ASSET_PENDING_TIMEOUT_MINUTES);
389    config
390        .quotas
391        .challenge_review_record_publish_timeout_minutes = raw
392        .challenge_review_record_publish_timeout_minutes
393        .unwrap_or(DEFAULT_CHALLENGE_REVIEW_RECORD_PUBLISH_TIMEOUT_MINUTES);
394    config.quotas.challenge_review_record_ttl_days = raw
395        .challenge_review_record_ttl_days
396        .unwrap_or(DEFAULT_CHALLENGE_REVIEW_RECORD_TTL_DAYS);
397    config.quotas.unpublished_challenge_asset_grace_days = raw
398        .unpublished_challenge_asset_grace_days
399        .unwrap_or(DEFAULT_UNPUBLISHED_CHALLENGE_ASSET_GRACE_DAYS);
400    Ok(())
401}
402
403fn apply_github_app_env(config: &mut Config, raw: RawGithubAppEnv) -> anyhow::Result<()> {
404    config.github_app.client_id = optional_non_empty_string(raw.github_app_client_id);
405    config.github_app.client_secret =
406        optional_non_empty_string(raw.github_app_client_secret).map(SecretString::from);
407    config.github_app.redirect_url = raw
408        .github_app_redirect_url
409        .map(|value| -> anyhow::Result<GithubAppRedirectUrl> {
410            let value = required_trimmed_string("AGENTICS_GITHUB_APP_REDIRECT_URL", value)?;
411            Ok(GithubAppRedirectUrl::try_new(value)?)
412        })
413        .transpose()?;
414    config.github_app.authorize_url = match raw.github_app_authorize_url {
415        Some(value) => GithubAppAuthorizeUrl::try_new(required_trimmed_string(
416            "AGENTICS_GITHUB_APP_AUTHORIZE_URL",
417            value,
418        )?)?,
419        None => builtin_github_app_authorize_url(),
420    };
421    config.github_app.token_url = match raw.github_app_token_url {
422        Some(value) => GithubAppTokenUrl::try_new(required_trimmed_string(
423            "AGENTICS_GITHUB_APP_TOKEN_URL",
424            value,
425        )?)?,
426        None => builtin_github_app_token_url(),
427    };
428    config.github_app.api_user_url = match raw.github_api_user_url {
429        Some(value) => GithubApiUserUrl::try_new(required_trimmed_string(
430            "AGENTICS_GITHUB_API_USER_URL",
431            value,
432        )?)?,
433        None => builtin_github_api_user_url(),
434    };
435    Ok(())
436}
437
438fn apply_runner_env(config: &mut Config, raw: RawRunnerEnv) -> anyhow::Result<()> {
439    config.runner.docker_host = optional_non_empty_string(raw.docker_host);
440    config.runner.host_probe_mode = raw.host_probe_mode.unwrap_or(DEFAULT_HOST_PROBE_MODE);
441    config.runner.host_probe_command = string_or_default(
442        ENV_AGENTICS_HOST_PROBE_COMMAND,
443        raw.host_probe_command,
444        DEFAULT_HOST_PROBE_COMMAND,
445    )?;
446    config.runner.security_profile = raw
447        .runner_security_profile
448        .unwrap_or(DEFAULT_RUNNER_SECURITY_PROFILE);
449    config.runner.official_log_redaction = raw
450        .official_log_redaction
451        .unwrap_or(DEFAULT_OFFICIAL_LOG_REDACTION_MODE);
452    config.runner.require_digest_pinned_images = raw
453        .require_digest_pinned_images
454        .unwrap_or(DEFAULT_REQUIRE_DIGEST_PINNED_IMAGES);
455    config.runner.writable_storage_mode = raw
456        .runner_writable_storage_mode
457        .unwrap_or(DEFAULT_RUNNER_WRITABLE_STORAGE_MODE);
458    config.runner.namespace = match raw.runner_namespace {
459        Some(value) => RunnerNamespace::try_new(required_trimmed_string(
460            ENV_AGENTICS_RUNNER_NAMESPACE,
461            value,
462        )?)?,
463        None => builtin_runner_namespace(),
464    };
465    config.runner.runtime_root = optional_non_empty_string(raw.runner_runtime_root);
466    config.runner.phase_mount_root = optional_non_empty_string(raw.runner_phase_mount_root);
467    config.runner.writable_slot_classes_mb = string_or_default(
468        "AGENTICS_RUNNER_WRITABLE_SLOT_CLASSES_MB",
469        raw.runner_writable_slot_classes_mb,
470        DEFAULT_RUNNER_WRITABLE_SLOT_CLASSES_MB,
471    )?;
472    config.runner.docker_layer_quota = raw
473        .runner_docker_layer_quota
474        .unwrap_or(DEFAULT_RUNNER_DOCKER_LAYER_QUOTA);
475    config.runner.max_output_files = raw
476        .runner_max_output_files
477        .unwrap_or(DEFAULT_RUNNER_MAX_OUTPUT_FILES);
478    config.runner.max_output_dirs = raw
479        .runner_max_output_dirs
480        .unwrap_or(DEFAULT_RUNNER_MAX_OUTPUT_DIRS);
481    config.runner.max_output_depth = raw
482        .runner_max_output_depth
483        .unwrap_or(DEFAULT_RUNNER_MAX_OUTPUT_DEPTH);
484    config.runner.max_runs = raw.runner_max_runs.unwrap_or(DEFAULT_RUNNER_MAX_RUNS);
485    config.runner.max_result_json_bytes = raw
486        .runner_max_result_json_bytes
487        .unwrap_or(DEFAULT_RUNNER_MAX_RESULT_JSON_BYTES);
488    config.runner.max_public_results = raw
489        .runner_max_public_results
490        .unwrap_or(DEFAULT_RUNNER_MAX_PUBLIC_RESULTS);
491    config.runner.max_result_log_bytes = raw
492        .runner_max_result_log_bytes
493        .unwrap_or(DEFAULT_RUNNER_MAX_RESULT_LOG_BYTES);
494    config.runner.max_interaction_bytes_per_direction = raw
495        .runner_max_interaction_bytes_per_direction
496        .unwrap_or(DEFAULT_RUNNER_MAX_INTERACTION_BYTES_PER_DIRECTION);
497    config.runner.interaction_shutdown_grace_secs = raw
498        .runner_interaction_shutdown_grace_secs
499        .unwrap_or(DEFAULT_RUNNER_INTERACTION_SHUTDOWN_GRACE_SECS);
500    Ok(())
501}
502
503fn string_or_default(
504    field: &'static str,
505    value: Option<String>,
506    default: &str,
507) -> anyhow::Result<String> {
508    match value {
509        Some(value) => required_trimmed_string(field, value),
510        None => Ok(default.to_string()),
511    }
512}
513
514fn optional_non_empty_string(value: Option<String>) -> Option<String> {
515    value.map(trimmed_string).filter(|value| !value.is_empty())
516}
517
518fn trimmed_string(value: String) -> String {
519    value.trim().to_string()
520}
521
522fn required_trimmed_string(field: &'static str, value: String) -> anyhow::Result<String> {
523    let trimmed = trimmed_string(value);
524    if trimmed.is_empty() {
525        anyhow::bail!("{field} must not be empty");
526    }
527    Ok(trimmed)
528}
529
530fn parse_github_user_id_list(
531    field: &'static str,
532    value: &str,
533) -> anyhow::Result<Vec<GithubUserId>> {
534    let trimmed = value.trim();
535    if trimmed.is_empty() {
536        return Ok(Vec::new());
537    }
538    trimmed
539        .split(',')
540        .map(str::trim)
541        .filter(|item| !item.is_empty())
542        .map(|item| {
543            let id = item
544                .parse::<i64>()
545                .map_err(|error| anyhow::anyhow!("invalid {field} entry `{item}`: {error}"))?;
546            GithubUserId::try_new(id)
547                .map_err(|error| anyhow::anyhow!("invalid {field} entry `{item}`: {error}"))
548        })
549        .collect()
550}
551
552fn parse_url_env(field: &'static str, value: String) -> anyhow::Result<url::Url> {
553    let trimmed = required_trimmed_string(field, value)?;
554    trimmed
555        .parse::<url::Url>()
556        .map_err(|error| anyhow::anyhow!("invalid {field} value `{trimmed}`: {error}"))
557}