Skip to main content

runx_runtime/execution/
runner.rs

1// rust-style-allow: large-file because RuntimeOptions, checkpoint resume, and
2// the public graph runner surface are still audited as one Rust cutover unit.
3//! The act engine for runx: the single admit -> execute -> seal path every run
4//! takes. A standalone skill is a one-act plan and a graph a multi-act plan;
5//! both run through this one engine.
6//!
7//! The public surface lives here: [`Runtime`], [`RuntimeOptions`], [`StepRun`],
8//! [`GraphRun`], [`GraphCheckpoint`], and the feature-gated [`run_graph_file`]
9//! helper. The internal state machine and the per-step execution helpers live
10//! in private submodules.
11
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use runx_contracts::{ClosureDisposition, FanoutReceiptSyncPoint, JsonObject, JsonValue, Receipt};
16use runx_core::state_machine::{GraphStatus, SequentialGraphState, StepAdmissionWitness};
17use runx_parser::ExecutionGraph;
18use serde::{Deserialize, Serialize};
19
20use super::graph::load_graph;
21use crate::RuntimeError;
22use crate::adapter::{InvocationStatus, SkillAdapter, SkillOutput};
23use crate::effects::RuntimeEffectRegistry;
24use crate::host::{Host, NoopHost};
25use crate::journal::ExecutionJournal;
26use crate::lifecycle::LifecycleEvent;
27use crate::receipts::paths::{RUNX_CWD_ENV, RUNX_PROJECT_DIR_ENV, RUNX_RECEIPT_DIR_ENV};
28use crate::receipts::signing::strip_receipt_signing_env;
29use crate::receipts::{
30    RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV, RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
31    RUNX_RECEIPT_SIGN_KID_ENV, RuntimeReceiptSignatureConfig, RuntimeReceiptSignaturePolicy,
32    graph_receipt_with_disposition_and_policy, graph_receipt_with_effects_and_signature_policy,
33};
34use crate::services::ReceiptServices;
35use crate::{PROVIDER_PERMISSION_GRANT_ID_ENV, PROVIDER_PERMISSION_GRANTED_SCOPES_ENV};
36
37mod authority;
38mod execution;
39mod host_resolution;
40mod inputs;
41mod scheduler;
42mod step_execution;
43mod steps;
44mod sync;
45
46use execution::GraphExecution;
47
48pub const RUNX_MAX_FANOUT_CONCURRENCY_ENV: &str = "RUNX_MAX_FANOUT_CONCURRENCY";
49pub const RUNX_RUN_ID_ENV: &str = "RUNX_RUN_ID";
50pub const RUNX_LOCAL_ENV_ALLOWLIST_ENV: &str = "RUNX_LOCAL_ENV_ALLOWLIST";
51
52#[derive(Clone, Debug)]
53pub struct RuntimeOptions {
54    pub created_at: String,
55    pub env: BTreeMap<String, String>,
56    pub receipt_signature: RuntimeReceiptSignatureConfig,
57    pub effects: RuntimeEffectRegistry,
58    /// Credentials delivered to graph step invocations. Defaults to none; a
59    /// top-level skill run threads its own delivery here so credential-needing
60    /// graph-step tools (e.g. http tools with `${secret:NAME}` headers) resolve.
61    pub credential_delivery: crate::credentials::CredentialDelivery,
62}
63
64impl RuntimeOptions {
65    #[must_use]
66    pub fn local_development() -> Self {
67        let env = safe_default_env();
68        Self {
69            created_at: crate::time::now_iso8601(),
70            env,
71            receipt_signature: RuntimeReceiptSignatureConfig::local_development(),
72            effects: RuntimeEffectRegistry::default(),
73            credential_delivery: crate::credentials::CredentialDelivery::none(),
74        }
75    }
76
77    pub fn from_process_env() -> Result<Self, RuntimeError> {
78        Self::from_env(safe_default_env())
79    }
80
81    #[must_use]
82    pub fn safe_process_env() -> BTreeMap<String, String> {
83        safe_default_env()
84    }
85
86    pub fn from_env(mut env: BTreeMap<String, String>) -> Result<Self, RuntimeError> {
87        let receipt_services =
88            ReceiptServices::from_env(&env).map_err(|error| RuntimeError::ReceiptInvalid {
89                message: error.to_string(),
90            })?;
91        strip_receipt_signing_env(&mut env);
92        Ok(Self {
93            created_at: crate::time::now_iso8601(),
94            env,
95            receipt_signature: receipt_services.signature_config().clone(),
96            effects: RuntimeEffectRegistry::default(),
97            credential_delivery: crate::credentials::CredentialDelivery::none(),
98        })
99    }
100
101    #[must_use]
102    pub fn signature_policy(&self) -> RuntimeReceiptSignaturePolicy<'_> {
103        self.receipt_signature.signature_policy()
104    }
105}
106
107fn safe_default_env() -> BTreeMap<String, String> {
108    safe_default_env_from(crate::services::process_env_value)
109}
110
111fn safe_default_env_from(
112    mut value_for_key: impl FnMut(&str) -> Option<String>,
113) -> BTreeMap<String, String> {
114    let allowed = [
115        "PATH",
116        "SystemRoot",
117        "PATHEXT",
118        RUNX_RECEIPT_DIR_ENV,
119        RUNX_RECEIPT_SIGN_KID_ENV,
120        RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV,
121        RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV,
122        crate::sandbox::RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV,
123        RUNX_MAX_FANOUT_CONCURRENCY_ENV,
124        RUNX_RUN_ID_ENV,
125        RUNX_PROJECT_DIR_ENV,
126        RUNX_CWD_ENV,
127        PROVIDER_PERMISSION_GRANT_ID_ENV,
128        PROVIDER_PERMISSION_GRANTED_SCOPES_ENV,
129        "RUNX_HTTP_ALLOW_PRIVATE_NETWORK",
130        "RUNX_REGISTRY_DIR",
131        "RUNX_REGISTRY_URL",
132    ];
133    let mut env = allowed
134        .into_iter()
135        .filter_map(|key| value_for_key(key).map(|value| (key.to_owned(), value)))
136        .collect::<BTreeMap<_, _>>();
137
138    if let Some(raw_allowlist) = value_for_key(RUNX_LOCAL_ENV_ALLOWLIST_ENV) {
139        for key in parse_local_env_allowlist(&raw_allowlist) {
140            if let Some(value) = value_for_key(&key) {
141                env.insert(key, value);
142            }
143        }
144    }
145
146    env
147}
148
149fn parse_local_env_allowlist(raw: &str) -> impl Iterator<Item = String> + '_ {
150    raw.split([',', ' ', '\n', '\t'])
151        .map(str::trim)
152        .filter(|key| !key.is_empty())
153        .filter(|key| {
154            key.bytes()
155                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
156        })
157        .filter(|key| !key.starts_with("RUNX_RECEIPT_SIGN_"))
158        .map(ToOwned::to_owned)
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize)]
162pub struct StepRun {
163    pub step_id: String,
164    pub attempt: u32,
165    pub skill: String,
166    pub runner: Option<String>,
167    pub fanout_group: Option<String>,
168    pub output: SkillOutput,
169    pub outputs: JsonObject,
170    pub receipt: Receipt,
171    pub admission_witness: StepAdmissionWitness,
172}
173
174#[derive(Clone, Debug)]
175pub struct GraphRun {
176    pub graph: ExecutionGraph,
177    pub state: SequentialGraphState,
178    pub steps: Vec<StepRun>,
179    pub sync_points: Vec<FanoutReceiptSyncPoint>,
180    pub receipt: Receipt,
181    pub journal: ExecutionJournal,
182}
183
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct GraphCheckpoint {
186    pub graph_name: String,
187    pub state: SequentialGraphState,
188    pub steps: Vec<StepRun>,
189    pub sync_points: Vec<FanoutReceiptSyncPoint>,
190    pub journal: ExecutionJournal,
191}
192
193pub struct Runtime<A> {
194    adapter: A,
195    options: RuntimeOptions,
196    step_types: steps::StepTypeRegistry<A>,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200enum BlockedGraphOutcome {
201    Error,
202    Receipt,
203}
204
205impl<A> Runtime<A>
206where
207    A: SkillAdapter,
208{
209    pub fn new(adapter: A, options: RuntimeOptions) -> Self {
210        Self {
211            adapter,
212            options,
213            step_types: steps::StepTypeRegistry::builtins(),
214        }
215    }
216
217    pub(crate) fn options(&self) -> &RuntimeOptions {
218        &self.options
219    }
220
221    pub fn run_graph_file(&self, graph_path: &Path) -> Result<GraphRun, RuntimeError> {
222        let mut host = NoopHost;
223        self.run_graph_file_with_host(graph_path, &mut host)
224    }
225
226    pub fn run_graph_file_with_host(
227        &self,
228        graph_path: &Path,
229        host: &mut dyn Host,
230    ) -> Result<GraphRun, RuntimeError> {
231        let graph = load_graph(graph_path)?;
232        let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
233        self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Error)
234    }
235
236    pub(crate) fn run_graph_file_for_harness(
237        &self,
238        graph_path: &Path,
239        host: &mut dyn Host,
240    ) -> Result<GraphRun, RuntimeError> {
241        let graph = load_graph(graph_path)?;
242        let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
243        self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Receipt)
244    }
245
246    pub fn run_graph_with_host(
247        &self,
248        graph_dir: &Path,
249        graph: ExecutionGraph,
250        host: &mut dyn Host,
251    ) -> Result<GraphRun, RuntimeError> {
252        self.run_graph_with_host_outcome(graph_dir, graph, host, BlockedGraphOutcome::Error)
253    }
254
255    // rust-style-allow: long-function because graph execution drives one ordered
256    // ready-node loop (admit, dispatch to host, fold outcomes, advance frontier)
257    // whose step sequencing must stay in a single scope to keep the run auditable.
258    fn run_graph_with_host_outcome(
259        &self,
260        graph_dir: &Path,
261        graph: ExecutionGraph,
262        host: &mut dyn Host,
263        blocked_outcome: BlockedGraphOutcome,
264    ) -> Result<GraphRun, RuntimeError> {
265        let mut execution = GraphExecution::new(&graph);
266        match execution.run(self, graph_dir, &graph, host, None) {
267            Ok(()) => {
268                let receipt = graph_receipt_with_effects_and_signature_policy(
269                    &graph.name,
270                    &mut execution.runs,
271                    execution.sync_points.clone(),
272                    &self.options.created_at,
273                    self.options.effects.clone(),
274                    self.options.signature_policy(),
275                )?;
276                execution.record_lifecycle(
277                    host,
278                    LifecycleEvent::graph_completed(&graph.name, &receipt),
279                )?;
280                Ok(execution.finish(graph, receipt))
281            }
282            Err(RuntimeError::GraphBlocked { step_id, reason })
283                if blocked_outcome == BlockedGraphOutcome::Receipt =>
284            {
285                let receipt = graph_receipt_with_disposition_and_policy(
286                    &graph.name,
287                    &mut execution.runs,
288                    execution.sync_points.clone(),
289                    &self.options.created_at,
290                    crate::receipts::GraphClosure {
291                        disposition: ClosureDisposition::Blocked,
292                        reason_code: "graph_blocked".to_owned(),
293                        summary: format!("graph {} blocked at {step_id}: {reason}", graph.name),
294                    },
295                    self.options.effects.clone(),
296                    self.options.signature_policy(),
297                )?;
298                execution.record_lifecycle(
299                    host,
300                    LifecycleEvent::graph_blocked(&graph.name, &step_id, &receipt),
301                )?;
302                Ok(execution.finish(graph, receipt))
303            }
304            // A governed authority denial is a policy block, not a runtime fault:
305            // under the receipt-sealing outcome it seals a signed blocked receipt,
306            // the same as any other graph block, so the refusal is provable.
307            Err(RuntimeError::AuthorityDenied {
308                verb,
309                step_id,
310                reason,
311            }) if blocked_outcome == BlockedGraphOutcome::Receipt => {
312                let receipt = graph_receipt_with_disposition_and_policy(
313                    &graph.name,
314                    &mut execution.runs,
315                    execution.sync_points.clone(),
316                    &self.options.created_at,
317                    crate::receipts::GraphClosure {
318                        disposition: ClosureDisposition::Blocked,
319                        reason_code: "authority_denied".to_owned(),
320                        summary: format!(
321                            "graph {} denied {verb:?} at {step_id}: {reason}",
322                            graph.name
323                        ),
324                    },
325                    self.options.effects.clone(),
326                    self.options.signature_policy(),
327                )?;
328                execution.record_lifecycle(
329                    host,
330                    LifecycleEvent::graph_blocked(&graph.name, &step_id, &receipt),
331                )?;
332                Ok(execution.finish(graph, receipt))
333            }
334            Err(error) => Err(error),
335        }
336    }
337
338    pub fn run_graph_file_until_steps(
339        &self,
340        graph_path: &Path,
341        max_steps: usize,
342    ) -> Result<GraphCheckpoint, RuntimeError> {
343        let mut host = NoopHost;
344        self.run_graph_file_until_steps_with_host(graph_path, max_steps, &mut host)
345    }
346
347    pub fn run_graph_file_until_steps_with_host(
348        &self,
349        graph_path: &Path,
350        max_steps: usize,
351        host: &mut dyn Host,
352    ) -> Result<GraphCheckpoint, RuntimeError> {
353        let graph = load_graph(graph_path)?;
354        let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
355        self.run_graph_until_steps_with_host(graph_dir, &graph, max_steps, host)
356    }
357
358    pub fn run_graph_until_steps_with_host(
359        &self,
360        graph_dir: &Path,
361        graph: &ExecutionGraph,
362        max_steps: usize,
363        host: &mut dyn Host,
364    ) -> Result<GraphCheckpoint, RuntimeError> {
365        let mut execution = GraphExecution::new(graph);
366        execution.run(self, graph_dir, graph, host, Some(max_steps))?;
367        Ok(execution.checkpoint(graph.name.clone()))
368    }
369
370    pub fn resume_graph_file(
371        &self,
372        graph_path: &Path,
373        checkpoint: GraphCheckpoint,
374    ) -> Result<GraphRun, RuntimeError> {
375        let mut host = NoopHost;
376        self.resume_graph_file_with_host(graph_path, checkpoint, &mut host)
377    }
378
379    pub fn resume_graph_file_with_host(
380        &self,
381        graph_path: &Path,
382        checkpoint: GraphCheckpoint,
383        host: &mut dyn Host,
384    ) -> Result<GraphRun, RuntimeError> {
385        let graph = load_graph(graph_path)?;
386        let graph_dir = graph_path.parent().unwrap_or_else(|| Path::new("."));
387        self.resume_graph_with_host(graph_dir, graph, checkpoint, host)
388    }
389
390    pub fn resume_graph_with_host(
391        &self,
392        graph_dir: &Path,
393        graph: ExecutionGraph,
394        checkpoint: GraphCheckpoint,
395        host: &mut dyn Host,
396    ) -> Result<GraphRun, RuntimeError> {
397        let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
398        execution.run(self, graph_dir, &graph, host, None)?;
399        let receipt = graph_receipt_with_effects_and_signature_policy(
400            &graph.name,
401            &mut execution.runs,
402            execution.sync_points.clone(),
403            &self.options.created_at,
404            self.options.effects.clone(),
405            self.options.signature_policy(),
406        )?;
407        execution.record_lifecycle(host, LifecycleEvent::graph_completed(&graph.name, &receipt))?;
408        Ok(execution.finish(graph, receipt))
409    }
410
411    pub(crate) fn seal_completed_graph_checkpoint_with_host(
412        &self,
413        graph: ExecutionGraph,
414        checkpoint: GraphCheckpoint,
415        host: &mut dyn Host,
416    ) -> Result<GraphRun, RuntimeError> {
417        if checkpoint.state.status != GraphStatus::Succeeded {
418            return Err(RuntimeError::GraphBlocked {
419                step_id: "graph".to_owned(),
420                reason: format!(
421                    "cannot seal graph checkpoint with status {:?}",
422                    checkpoint.state.status
423                ),
424            });
425        }
426        let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
427        let receipt = graph_receipt_with_effects_and_signature_policy(
428            &graph.name,
429            &mut execution.runs,
430            execution.sync_points.clone(),
431            &self.options.created_at,
432            self.options.effects.clone(),
433            self.options.signature_policy(),
434        )?;
435        execution.record_lifecycle(host, LifecycleEvent::graph_completed(&graph.name, &receipt))?;
436        Ok(execution.finish(graph, receipt))
437    }
438
439    pub(crate) fn seal_blocked_graph_checkpoint_with_host(
440        &self,
441        graph: ExecutionGraph,
442        checkpoint: GraphCheckpoint,
443        step_id: &str,
444        reason_code: impl Into<String>,
445        summary: impl Into<String>,
446        host: &mut dyn Host,
447    ) -> Result<GraphRun, RuntimeError> {
448        let mut execution = GraphExecution::from_checkpoint(&graph, checkpoint)?;
449        let receipt = graph_receipt_with_disposition_and_policy(
450            &graph.name,
451            &mut execution.runs,
452            execution.sync_points.clone(),
453            &self.options.created_at,
454            crate::receipts::GraphClosure {
455                disposition: ClosureDisposition::Blocked,
456                reason_code: reason_code.into(),
457                summary: summary.into(),
458            },
459            self.options.effects.clone(),
460            self.options.signature_policy(),
461        )?;
462        execution.record_lifecycle(
463            host,
464            LifecycleEvent::graph_blocked(&graph.name, step_id, &receipt),
465        )?;
466        Ok(execution.finish(graph, receipt))
467    }
468
469    pub fn resume_graph_until_steps_with_host(
470        &self,
471        graph_dir: &Path,
472        graph: &ExecutionGraph,
473        checkpoint: GraphCheckpoint,
474        max_steps: usize,
475        host: &mut dyn Host,
476    ) -> Result<GraphCheckpoint, RuntimeError> {
477        let mut execution = GraphExecution::from_checkpoint(graph, checkpoint)?;
478        execution.run(self, graph_dir, graph, host, Some(max_steps))?;
479        Ok(execution.checkpoint(graph.name.clone()))
480    }
481}
482
483#[cfg(feature = "cli-tool")]
484pub fn run_graph_file(graph_path: impl AsRef<Path>) -> Result<GraphRun, RuntimeError> {
485    let runtime = Runtime::new(
486        crate::adapters::cli_tool::CliToolAdapter,
487        RuntimeOptions::from_process_env()?,
488    );
489    runtime.run_graph_file(graph_path.as_ref())
490}
491
492// Canonical graph-run payload builder + skill-output wrapper, shared by the
493// nested-step path (`runner::steps`) and the skill-front path
494// (`skill_front::graph`). `include_receipt_id` adds the `graph_receipt_id` field
495// that only the nested-step path surfaces. Every field is an infallible
496// clone/`format!`, so payload assembly is total.
497pub(crate) fn graph_run_payload(run: &GraphRun, include_receipt_id: bool) -> JsonValue {
498    let mut payload = JsonObject::new();
499    payload.insert(
500        "graph".to_owned(),
501        JsonValue::String(run.graph.name.clone()),
502    );
503    payload.insert(
504        "graph_status".to_owned(),
505        JsonValue::String(format!("{:?}", run.state.status)),
506    );
507    if include_receipt_id {
508        payload.insert(
509            "graph_receipt_id".to_owned(),
510            JsonValue::String(run.receipt.id.to_string()),
511        );
512    }
513    let mut step_outputs = JsonObject::new();
514    let mut step_summaries = Vec::new();
515    for step in &run.steps {
516        let mut summary = JsonObject::new();
517        summary.insert(
518            "step_id".to_owned(),
519            JsonValue::String(step.step_id.clone()),
520        );
521        summary.insert("skill".to_owned(), JsonValue::String(step.skill.clone()));
522        summary.insert(
523            "status".to_owned(),
524            JsonValue::String(if step.output.succeeded() {
525                "success".to_owned()
526            } else {
527                "failure".to_owned()
528            }),
529        );
530        summary.insert(
531            "receipt_id".to_owned(),
532            JsonValue::String(step.receipt.id.to_string()),
533        );
534        step_summaries.push(JsonValue::Object(summary));
535        step_outputs.insert(
536            step.step_id.clone(),
537            JsonValue::Object(step.outputs.clone()),
538        );
539    }
540    payload.insert("steps".to_owned(), JsonValue::Array(step_summaries));
541    payload.insert("step_outputs".to_owned(), JsonValue::Object(step_outputs));
542    JsonValue::Object(payload)
543}
544
545pub(crate) fn graph_run_skill_output(
546    payload: &JsonValue,
547    run: &GraphRun,
548) -> Result<SkillOutput, RuntimeError> {
549    let stdout = serde_json::to_string(payload)
550        .map_err(|source| RuntimeError::json("serializing graph payload", source))?;
551    Ok(SkillOutput {
552        status: if run.state.status == GraphStatus::Succeeded {
553            InvocationStatus::Success
554        } else {
555            InvocationStatus::Failure
556        },
557        stdout,
558        stderr: String::new(),
559        exit_code: Some(0),
560        duration_ms: 0,
561        metadata: JsonObject::new(),
562    })
563}
564
565#[cfg(test)]
566mod tests {
567    use super::{
568        RUNX_LOCAL_ENV_ALLOWLIST_ENV, RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV,
569        RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV, RUNX_RECEIPT_SIGN_KID_ENV, RuntimeOptions,
570        safe_default_env_from,
571    };
572    use crate::sandbox::RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV;
573    use std::collections::BTreeMap;
574
575    #[test]
576    fn safe_default_env_preserves_receipt_signing_inputs() {
577        let env = safe_default_env_from(|key| match key {
578            RUNX_RECEIPT_SIGN_KID_ENV => Some("kid_prod".to_owned()),
579            RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV => Some("seed".to_owned()),
580            RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV => Some("hosted".to_owned()),
581            _ => None,
582        });
583
584        assert_eq!(
585            env.get(RUNX_RECEIPT_SIGN_KID_ENV),
586            Some(&"kid_prod".to_owned())
587        );
588        assert_eq!(
589            env.get(RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV),
590            Some(&"seed".to_owned())
591        );
592        assert_eq!(
593            env.get(RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV),
594            Some(&"hosted".to_owned())
595        );
596    }
597
598    #[test]
599    fn safe_default_env_preserves_sandbox_operator_override() {
600        let env = safe_default_env_from(|key| match key {
601            RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV => Some("local".to_owned()),
602            _ => None,
603        });
604
605        assert_eq!(
606            env.get(RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY_ENV),
607            Some(&"local".to_owned())
608        );
609    }
610
611    #[test]
612    fn safe_default_env_preserves_local_operator_allowlisted_env() {
613        let env = safe_default_env_from(|key| match key {
614            RUNX_LOCAL_ENV_ALLOWLIST_ENV => Some(
615                "NITROSEND_API_KEY,NITROSEND_ADMIN_API_KEY RUNX_RECEIPT_SIGN_SECRET bad-key"
616                    .to_owned(),
617            ),
618            "NITROSEND_API_KEY" => Some("nskey_live_test".to_owned()),
619            "NITROSEND_ADMIN_API_KEY" => Some("nskey_live_admin".to_owned()),
620            "RUNX_RECEIPT_SIGN_SECRET" => Some("secret".to_owned()),
621            _ => None,
622        });
623
624        assert_eq!(
625            env.get("NITROSEND_API_KEY"),
626            Some(&"nskey_live_test".to_owned())
627        );
628        assert_eq!(
629            env.get("NITROSEND_ADMIN_API_KEY"),
630            Some(&"nskey_live_admin".to_owned())
631        );
632        assert!(!env.contains_key("RUNX_RECEIPT_SIGN_SECRET"));
633        assert!(!env.contains_key("bad-key"));
634    }
635
636    #[test]
637    fn runtime_options_reject_incomplete_production_signing_env() -> Result<(), String> {
638        let env = [(RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned())]
639            .into_iter()
640            .collect::<BTreeMap<_, _>>();
641
642        let error = RuntimeOptions::from_env(env)
643            .err()
644            .ok_or_else(|| "incomplete signing env unexpectedly succeeded".to_owned())?;
645        assert!(
646            error
647                .to_string()
648                .contains("production receipt signing requires")
649        );
650        Ok(())
651    }
652
653    #[test]
654    fn runtime_options_reject_missing_production_signing_env() -> Result<(), String> {
655        let error = RuntimeOptions::from_env(BTreeMap::new())
656            .err()
657            .ok_or_else(|| "missing signing env unexpectedly succeeded".to_owned())?;
658        assert!(
659            error
660                .to_string()
661                .contains("governed runtime receipt signing")
662        );
663        Ok(())
664    }
665
666    #[test]
667    fn runtime_options_reject_malformed_production_signing_seed() -> Result<(), String> {
668        let env = [
669            (RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned()),
670            (
671                RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV.to_owned(),
672                "not-base64".to_owned(),
673            ),
674            (
675                RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV.to_owned(),
676                "hosted".to_owned(),
677            ),
678        ]
679        .into_iter()
680        .collect::<BTreeMap<_, _>>();
681
682        let error = RuntimeOptions::from_env(env)
683            .err()
684            .ok_or_else(|| "malformed signing env unexpectedly succeeded".to_owned())?;
685        assert!(
686            error
687                .to_string()
688                .contains("production receipt signer key material is malformed")
689        );
690        Ok(())
691    }
692
693    #[test]
694    fn runtime_options_strip_receipt_signing_env_after_signer_construction() -> Result<(), String> {
695        let env = [
696            (RUNX_RECEIPT_SIGN_KID_ENV.to_owned(), "kid_prod".to_owned()),
697            (
698                RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV.to_owned(),
699                "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=".to_owned(),
700            ),
701            (
702                RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV.to_owned(),
703                "hosted".to_owned(),
704            ),
705            ("RUNX_CWD".to_owned(), "/workspace".to_owned()),
706        ]
707        .into_iter()
708        .collect::<BTreeMap<_, _>>();
709
710        let options = RuntimeOptions::from_env(env).map_err(|error| error.to_string())?;
711
712        assert!(!options.env.contains_key(RUNX_RECEIPT_SIGN_KID_ENV));
713        assert!(
714            !options
715                .env
716                .contains_key(RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64_ENV)
717        );
718        assert!(!options.env.contains_key(RUNX_RECEIPT_SIGN_ISSUER_TYPE_ENV));
719        assert_eq!(options.env.get("RUNX_CWD"), Some(&"/workspace".to_owned()));
720        Ok(())
721    }
722}