Hand off meta and secrets to quire-ci via a dispatch file
Single JSON file (`<run_dir>/dispatch.json`, mode 0600) carries push
metadata and secret values from the orchestrator to the subprocess —
keeps secrets off argv and out of the env, and gives one place to
extend when more handoff fields surface. Standalone `quire-ci run`
omits `--dispatch` and falls back to placeholder meta with no secrets.

Assisted-by: Claude Opus 4.7 via Claude Code
change xmtuwprtmvnvwtuzpuqurspqspotzxwz
commit 1d0fc76ca7cf13c448960a450a30dc2984696bf3
author Alpha Chen <alpha@kejadlen.dev>
date
parent rzxlrlmp
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2045bc4..c4eda5b 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -34,11 +34,13 @@ enum Commands {
 
     /// Run the whole pipeline against the workspace, in topo order.
     ///
-    /// Synthesizes a placeholder `quire/push` and runs with no
-    /// secrets — `(secret :name)` calls error, and `(jobs upstream)`
-    /// reads return Nil for everything except `quire/push` (the
-    /// runtime doesn't yet propagate run-fn outputs into downstream
-    /// jobs' input views).
+    /// `--dispatch <path>` points at a JSON file (see
+    /// [`quire_core::ci::dispatch::Dispatch`]) that supplies push
+    /// metadata and secrets when the orchestrator dispatches via
+    /// `:executor :quire-ci`. Standalone invocations omit the flag
+    /// and fall back to placeholder meta with no secrets — `(secret
+    /// :name)` calls error, and `(jobs upstream)` reads return Nil
+    /// for everything except `quire/push`.
     Run {
         /// Where to send the structured event stream. Accepts:
         ///   `null`   — drop events (default).
@@ -54,6 +56,13 @@ enum Commands {
         /// run.
         #[arg(long)]
         out_dir: Option<PathBuf>,
+
+        /// Path to a JSON dispatch file produced by the orchestrator.
+        /// Carries push metadata and the secrets the run-fns may
+        /// resolve. Omit for standalone runs (placeholder meta, no
+        /// secrets).
+        #[arg(long)]
+        dispatch: Option<PathBuf>,
     },
 }
 
@@ -134,7 +143,11 @@ fn main() -> miette::Result<()> {
     let cli = Cli::parse();
     match cli.command {
         Commands::Validate => validate(cli.workspace),
-        Commands::Run { events, out_dir } => {
+        Commands::Run {
+            events,
+            out_dir,
+            dispatch,
+        } => {
             let sink: Box<dyn EventSink> = match events {
                 EventsTarget::Null => Box::new(NullSink),
                 EventsTarget::Stdout => Box::new(JsonlSink::new(io::stdout())),
@@ -154,7 +167,11 @@ fn main() -> miette::Result<()> {
                     (path, Some(DumpLogsOnDrop { dir }))
                 }
             };
-            run_pipeline(cli.workspace, sink, log_dir)
+            let (meta, secrets) = match dispatch {
+                Some(path) => load_dispatch(&path)?,
+                None => (placeholder_meta(), HashMap::new()),
+            };
+            run_pipeline(cli.workspace, sink, log_dir, meta, secrets)
         }
     }
 }
@@ -184,10 +201,40 @@ fn validate(workspace: PathBuf) -> miette::Result<()> {
     Ok(())
 }
 
+/// Standalone runs synthesize a placeholder `quire/push`. Real meta
+/// arrives via `--dispatch` from the orchestrator.
+fn placeholder_meta() -> RunMeta {
+    RunMeta {
+        sha: "0".repeat(40),
+        r#ref: "HEAD".to_string(),
+        pushed_at: jiff::Timestamp::now(),
+    }
+}
+
+/// Read and parse the dispatch file the orchestrator wrote before
+/// spawning. Wraps revealed secret values back into `SecretString`.
+fn load_dispatch(
+    path: &std::path::Path,
+) -> miette::Result<(RunMeta, HashMap<String, quire_core::secret::SecretString>)> {
+    use quire_core::ci::dispatch::Dispatch;
+    use quire_core::secret::SecretString;
+
+    let bytes = fs_err::read(path).into_diagnostic()?;
+    let dispatch: Dispatch = serde_json::from_slice(&bytes).into_diagnostic()?;
+    let secrets = dispatch
+        .secrets
+        .into_iter()
+        .map(|(name, value)| (name, SecretString::from(value)))
+        .collect();
+    Ok((dispatch.meta, secrets))
+}
+
 fn run_pipeline(
     workspace: PathBuf,
     sink: Box<dyn EventSink>,
     log_dir: PathBuf,
+    meta: RunMeta,
+    secrets: HashMap<String, quire_core::secret::SecretString>,
 ) -> miette::Result<()> {
     let pipeline = compile_at(&workspace)?;
 
@@ -202,20 +249,9 @@ fn run_pipeline(
 
     let sink: Rc<RefCell<Box<dyn EventSink>>> = Rc::new(RefCell::new(sink));
 
-    let meta = RunMeta {
-        sha: "0".repeat(40),
-        r#ref: "HEAD".to_string(),
-        pushed_at: jiff::Timestamp::now(),
-    };
-
     let git_dir = workspace.join(".git");
     let runtime = Rc::new(Runtime::new(
-        pipeline,
-        HashMap::new(),
-        &meta,
-        &git_dir,
-        workspace,
-        log_dir,
+        pipeline, secrets, &meta, &git_dir, workspace, log_dir,
     ));
 
     // Active job pointer, shared between the main loop and the
diff --git a/quire-core/src/ci/dispatch.rs b/quire-core/src/ci/dispatch.rs
new file mode 100644
index 0000000..9c1556b
--- /dev/null
+++ b/quire-core/src/ci/dispatch.rs
@@ -0,0 +1,31 @@
+//! Wire format for handing off a run from the orchestrator to
+//! `quire-ci`.
+//!
+//! The orchestrator writes a [`Dispatch`] as JSON to a file inside
+//! the run directory and passes the path via `--dispatch`. `quire-ci`
+//! deserializes it on startup to recover push metadata and the
+//! secrets the run-fns may resolve. Standalone `quire-ci run`
+//! invocations skip the file entirely and fall back to placeholder
+//! values.
+//!
+//! The file contains live secret values; callers must restrict
+//! permissions (mode 0600 on Unix) before writing.
+
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::ci::run::RunMeta;
+
+/// Inputs the orchestrator supplies to a quire-ci subprocess.
+///
+/// Secret values cross as plaintext — `SecretString` deliberately
+/// doesn't implement `Serialize` to avoid accidental leaks. The
+/// orchestrator reveals values into this map before writing the
+/// file (mode 0600); quire-ci wraps them back into `SecretString`s
+/// on read.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Dispatch {
+    pub meta: RunMeta,
+    pub secrets: HashMap<String, String>,
+}
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
index d2dcc9c..34c4aff 100644
--- a/quire-core/src/ci/mod.rs
+++ b/quire-core/src/ci/mod.rs
@@ -5,6 +5,7 @@
 //! (where `quire-ci` invokes them) and on the server (where the
 //! orchestrator drives them).
 
+pub mod dispatch;
 pub mod event;
 pub mod logs;
 pub mod mirror;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index f7471fb..c23f17e 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -197,7 +197,7 @@ fn trigger_ref(
             // The orchestrator already validated `pipeline` to fail-fast on
             // bad ci.fnl; `quire-ci` recompiles inside its own process.
             drop(pipeline);
-            run.execute_via_quire_ci(&workspace)?;
+            run.execute_via_quire_ci(&workspace, &meta, secrets)?;
         }
     }
     Ok(())
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index c1e624c..3fac96a 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -325,17 +325,25 @@ impl Run {
     /// Run finishes `Complete` on exit 0, `Failed` otherwise. The DB
     /// rows are written even on failure so the web UI can render
     /// partial progress.
-    pub fn execute_via_quire_ci(mut self, workspace: &Path) -> Result<()> {
+    pub fn execute_via_quire_ci(
+        mut self,
+        workspace: &Path,
+        meta: &RunMeta,
+        secrets: &HashMap<String, SecretString>,
+    ) -> Result<()> {
         self.transition(RunState::Active)?;
 
         let run_dir = self.path();
         let log_path = run_dir.join("quire-ci.log");
         let events_path = run_dir.join("events.jsonl");
+        let dispatch_path = run_dir.join("dispatch.json");
         // fs_err for the path-bearing IO error; unwrap to std::fs::File so
         // it's convertible into Stdio.
         let log = fs_err::File::create(&log_path)?.into_parts().0;
         let log_clone = log.try_clone()?;
 
+        write_dispatch(&dispatch_path, meta, secrets)?;
+
         tracing::info!(
             run_id = %self.id,
             log = %log_path.display(),
@@ -351,6 +359,8 @@ impl Run {
             .arg(&run_dir)
             .arg("--events")
             .arg(&events_path)
+            .arg("--dispatch")
+            .arg(&dispatch_path)
             .stdout(std::process::Stdio::from(log))
             .stderr(std::process::Stdio::from(log_clone))
             .status()
@@ -598,6 +608,44 @@ impl Run {
     }
 }
 
+/// Serialize the dispatch payload as JSON and write it to `path` with
+/// owner-only permissions on Unix. Secrets cross as plaintext so the
+/// 0600 mode is the line of defense against other local users; failure
+/// to set the mode aborts the dispatch (better than leaking).
+fn write_dispatch(
+    path: &Path,
+    meta: &RunMeta,
+    secrets: &HashMap<String, SecretString>,
+) -> Result<()> {
+    use quire_core::ci::dispatch::Dispatch;
+
+    let mut revealed: HashMap<String, String> = HashMap::with_capacity(secrets.len());
+    for (name, value) in secrets {
+        revealed.insert(
+            name.clone(),
+            value.reveal().map_err(Error::Secret)?.to_string(),
+        );
+    }
+    let dispatch = Dispatch {
+        meta: meta.clone(),
+        secrets: revealed,
+    };
+    let json = serde_json::to_vec_pretty(&dispatch).map_err(std::io::Error::other)?;
+
+    // Open with mode 0600 from the start so there's no window where
+    // the file is world-readable.
+    use fs_err::os::unix::fs::OpenOptionsExt;
+    use std::io::Write;
+    let mut file = fs_err::OpenOptions::new()
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .mode(0o600)
+        .open(path)?;
+    file.write_all(&json)?;
+    Ok(())
+}
+
 /// Take the final path component of a runs base (`runs/<repo>/`) and
 /// sanitize it for use as the tag segment in `quire-ci/<segment>:<id>`.
 /// Materialize a working tree at `sha` into `workspace` via