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
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