use crate::command_exec::run_shell_command_with_code_cleared;
use crate::error::{EngineError, Result};
use crate::event_log::EventLog;
use crate::events::{Event, EventKind};
use crate::orchestrator::{first_incomplete, MissionEngine};
use crate::types::{BlockContext, MilestoneStatus, MissionStatus};
use std::collections::HashMap;
pub const BOOTSTRAP_SUMMARY_PREFIX: &str = "workspace bootstrap:";
pub const READINESS_SUMMARY_PREFIX: &str = "workspace readiness:";
pub(crate) const GATE_REASON_PREFIX: &str = "workspace gate:";
pub(crate) const GATE_LIFT_REASON: &str = "workspace gate now passing: bootstrap and readiness ok";
#[derive(Debug, Clone)]
pub struct CommandOutcome {
pub(crate) ordinal: usize,
pub(crate) total: usize,
pub(crate) command: String,
pub(crate) code: Option<i32>,
pub(crate) output_tail: String,
}
impl CommandOutcome {
pub(crate) fn ok(&self) -> bool {
self.code == Some(0)
}
pub(crate) fn exit_phrase(&self) -> String {
match self.code {
Some(code) => format!("exit code {code}"),
None => "no exit code (spawn failure, timeout, or signal)".to_string(),
}
}
}
pub(crate) struct GatePhase<'a> {
pub(crate) kind: &'static str,
pub(crate) unit: &'static str,
pub(crate) plural: &'static str,
pub(crate) prefix: &'static str,
pub(crate) commands: &'a [String],
pub(crate) stop_at_first_failure: bool,
}
impl MissionEngine {
pub(crate) fn block_on_gate_failure(
&mut self,
kind: &str,
failed: &CommandOutcome,
) -> Result<Option<MissionStatus>> {
self.block_with_gate_reason(gate_block_reason(kind, failed))
}
pub(crate) fn block_with_gate_reason(
&mut self,
reason: String,
) -> Result<Option<MissionStatus>> {
let Some(mi) = first_incomplete(&self.state) else {
return Err(EngineError::InvalidState(format!(
"{reason} — and no incomplete milestone remains to block; \
fix the workspace setup (owner: repo-setup) and re-run"
)));
};
if self.state.mission.milestones[mi].status == MilestoneStatus::Pending {
let start_sha = self.active_repo().head_sha()?;
let milestone_id = self.state.mission.milestones[mi].id.clone();
self.emit(EventKind::MilestoneStarted {
milestone_id,
start_sha,
})?;
}
let milestone_id = self.state.mission.milestones[mi].id.clone();
self.emit(EventKind::MilestoneBlocked {
block_context: Some(BlockContext::WORKSPACE_GATE),
milestone_id,
reason,
})?;
Ok(Some(MissionStatus::Blocked))
}
pub(crate) fn lift_gate_block(&mut self) -> Result<()> {
if self.state.mission.status != MissionStatus::Blocked {
return Ok(());
}
let Some(mi) = first_incomplete(&self.state) else {
return Ok(());
};
if self.state.mission.milestones[mi].status != MilestoneStatus::Blocked {
return Ok(());
}
let milestone_id = self.state.mission.milestones[mi].id.clone();
self.log.flush()?;
let events = EventLog::read_events(&self.paths.events_file())?;
if latest_block_is_gate_owned(&events, &milestone_id) {
self.emit(EventKind::MilestoneUnblocked {
block_context: Some(BlockContext::WORKSPACE_GATE),
milestone_id,
reason: GATE_LIFT_REASON.to_string(),
validator_guidance: None,
})?;
}
Ok(())
}
}
pub(crate) fn mission_gate_home(runtime_dir: &std::path::Path) -> std::path::PathBuf {
runtime_dir.join("runs").join("workspace-gate")
}
fn ensure_gate_home(root: &std::path::Path) {
let _ = std::fs::create_dir_all(root);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700));
}
}
pub(crate) fn remove_gate_home(root: &std::path::Path) {
if root.as_os_str().is_empty() {
return;
}
let _ = std::fs::remove_dir_all(root);
}
pub(crate) const GATE_OPERATIONAL_ENV: &[&str] = &[
"SSH_AUTH_SOCK",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"GIT_SSL_CAINFO",
];
fn operator_global_gitconfig() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Some(explicit) = std::env::var_os("GIT_CONFIG_GLOBAL").filter(|v| !v.is_empty()) {
candidates.push(std::path::PathBuf::from(explicit));
}
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
candidates.push(std::path::PathBuf::from(xdg).join("git").join("config"));
}
if let Some(home) = crate::agent_env::operator_home() {
candidates.push(home.join(".config").join("git").join("config"));
candidates.push(home.join(".gitconfig"));
}
candidates.into_iter().find(|path| path.is_file())
}
pub(crate) fn two_party_secrets(declared: &[String], operator_allows: &[String]) -> Vec<String> {
let mut allowed = Vec::new();
for name in declared {
let name = name.trim();
if name.is_empty() {
continue;
}
if operator_allows
.iter()
.any(|allowed| allowed.trim().eq_ignore_ascii_case(name))
{
allowed.push(name.to_string());
} else {
tracing::warn!(
key = name,
config_key = "contractEnvPassthrough",
"workspace contract secrets[] entry refused: the repo declared it but the \
operator's contractEnvPassthrough does not list it, so it does not cross \
into the gate command environment"
);
}
}
allowed
}
pub(crate) fn gate_command_env(
policy: &crate::workspace_provider::GateEnvPolicy,
handle_env: &HashMap<String, String>,
contract: Option<&crate::workspace_contract::WorkspaceContract>,
) -> HashMap<String, String> {
ensure_gate_home(&policy.home);
let declared = contract.map(|c| c.secrets.as_slice()).unwrap_or(&[]);
let secrets = two_party_secrets(declared, &policy.passthrough);
let mut env = crate::agent_env::contract_command_env(
&policy.home,
handle_env.get("KRANZ_BASE_SHA").map(String::as_str),
&secrets,
);
for name in GATE_OPERATIONAL_ENV {
if let Some(value) = std::env::var_os(name).filter(|value| !value.is_empty()) {
env.insert((*name).to_string(), value.to_string_lossy().into_owned());
}
}
if let Some(gitconfig) = operator_global_gitconfig() {
env.insert(
"GIT_CONFIG_GLOBAL".to_string(),
gitconfig.display().to_string(),
);
}
for (key, value) in handle_env {
env.insert(key.clone(), value.clone());
}
env
}
pub(crate) async fn run_gate_commands(
cwd: &std::path::Path,
phase: &GatePhase<'_>,
policy: &crate::workspace_provider::GateEnvPolicy,
handle_env: &HashMap<String, String>,
contract: Option<&crate::workspace_contract::WorkspaceContract>,
) -> Vec<CommandOutcome> {
let env = gate_command_env(policy, handle_env, contract);
let total = phase.commands.len();
let mut outcomes = Vec::with_capacity(total);
for (i, command) in phase.commands.iter().enumerate() {
let (code, output_tail) = run_shell_command_with_code_cleared(cwd, command, &env).await;
let outcome = CommandOutcome {
ordinal: i + 1,
total,
command: command.clone(),
code,
output_tail,
};
let failed = !outcome.ok();
outcomes.push(outcome);
if failed && phase.stop_at_first_failure {
break;
}
}
outcomes
}
pub(crate) fn outcomes_detail(kind: &str, outcomes: &[CommandOutcome]) -> String {
use std::fmt::Write as _;
let mut detail = String::new();
for o in outcomes {
let verdict = if o.ok() { "ok" } else { "FAILED" };
let _ = writeln!(
detail,
"{kind} {}/{} `{}` → {verdict} ({})",
o.ordinal,
o.total,
o.command,
o.exit_phrase()
);
}
if let Some(failed) = outcomes.iter().find(|o| !o.ok()) {
let tail = failed.output_tail.trim();
if !tail.is_empty() {
let _ = write!(detail, "\noutput tail:\n{tail}");
}
}
detail
}
pub(crate) fn gate_block_reason(kind: &str, failed: &CommandOutcome) -> String {
crate::scrub::scrub(&format!(
"{GATE_REASON_PREFIX} {kind} {}/{} failed (owner: repo-setup): `{}` {}: {}",
failed.ordinal,
failed.total,
failed.command,
failed.exit_phrase(),
failed.output_tail.trim(),
))
}
fn latest_block_is_gate_owned(events: &[Event], milestone_id: &str) -> bool {
events.iter().rev().find_map(|event| match &event.kind {
EventKind::MilestoneBlocked {
milestone_id: id,
reason,
block_context,
} if id == milestone_id => Some(match block_context {
Some(context) => context.is_workspace_gate(),
None => reason.starts_with(GATE_REASON_PREFIX),
}),
EventKind::MilestoneUnblocked {
milestone_id: id, ..
} if id == milestone_id => Some(false),
_ => None,
}) == Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome(
ordinal: usize,
total: usize,
command: &str,
code: Option<i32>,
tail: &str,
) -> CommandOutcome {
CommandOutcome {
ordinal,
total,
command: command.to_string(),
code,
output_tail: tail.to_string(),
}
}
fn ev(seq: u64, kind: EventKind) -> Event {
Event {
seq,
ts: chrono::Utc::now(),
mission_id: "m-test".to_string(),
kind,
}
}
fn blocked(seq: u64, milestone_id: &str, reason: &str) -> Event {
ev(
seq,
EventKind::MilestoneBlocked {
block_context: None,
milestone_id: milestone_id.to_string(),
reason: reason.to_string(),
},
)
}
fn policy(
home: &std::path::Path,
passthrough: &[&str],
) -> crate::workspace_provider::GateEnvPolicy {
crate::workspace_provider::GateEnvPolicy {
home: home.to_path_buf(),
passthrough: passthrough.iter().map(|s| s.to_string()).collect(),
}
}
fn contract_declaring(secrets: &[&str]) -> crate::workspace_contract::WorkspaceContract {
let json = format!(
r#"{{"schemaVersion": 1, "readiness": ["true"], "secrets": {}}}"#,
serde_json::to_string(secrets).unwrap()
);
crate::workspace_contract::parse_workspace_contract(json.as_bytes()).expect("contract")
}
#[test]
fn gate_env_crosses_a_secret_only_with_both_repo_and_operator_consent() {
let home = tempfile::tempdir().expect("tempdir");
let _guard = crate::agent_env::EnvTestGuard::engage(&[("GH_TOKEN", "ghp-operator-secret")]);
let contract = contract_declaring(&["GH_TOKEN"]);
let handle_env = HashMap::new();
let env = gate_command_env(&policy(home.path(), &[]), &handle_env, Some(&contract));
assert!(
!env.contains_key("GH_TOKEN"),
"a repo-chosen credential must not cross on the repo's say-so alone: {env:?}"
);
let env = gate_command_env(
&policy(home.path(), &["GH_TOKEN"]),
&handle_env,
Some(&contract),
);
assert_eq!(
env.get("GH_TOKEN").map(String::as_str),
Some("ghp-operator-secret"),
"both parties consented, so the named credential crosses"
);
let env = gate_command_env(&policy(home.path(), &["GH_TOKEN"]), &handle_env, None);
assert!(
!env.contains_key("GH_TOKEN"),
"an operator grant does not push a credential into a contract that never asked"
);
}
#[test]
fn two_party_secrets_intersects_case_insensitively_and_drops_the_rest() {
let declared = ["GH_TOKEN", "AWS_SECRET_ACCESS_KEY", "KRANZ_TOKEN", " "]
.map(str::to_string)
.to_vec();
let allowed = two_party_secrets(&declared, &["gh_token".to_string()]);
assert_eq!(allowed, vec!["GH_TOKEN".to_string()]);
assert!(two_party_secrets(&declared, &[]).is_empty());
}
#[test]
fn gate_env_always_carries_the_operational_allowlist_but_never_a_program_var() {
let home = tempfile::tempdir().expect("tempdir");
let _guard = crate::agent_env::EnvTestGuard::engage(&[
("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock"),
("HTTPS_PROXY", "http://proxy.corp.example:3128"),
("NO_PROXY", "localhost"),
("SSL_CERT_FILE", "/etc/ssl/corp-bundle.pem"),
("GIT_SSH_COMMAND", "/tmp/evil-ssh"),
]);
let env = gate_command_env(&policy(home.path(), &[]), &HashMap::new(), None);
assert_eq!(
env.get("SSH_AUTH_SOCK").map(String::as_str),
Some("/tmp/ssh-agent.sock")
);
assert_eq!(
env.get("HTTPS_PROXY").map(String::as_str),
Some("http://proxy.corp.example:3128")
);
assert_eq!(env.get("NO_PROXY").map(String::as_str), Some("localhost"));
assert_eq!(
env.get("SSL_CERT_FILE").map(String::as_str),
Some("/etc/ssl/corp-bundle.pem")
);
assert!(
!env.contains_key("GIT_SSH_COMMAND"),
"a var that names a PROGRAM turns a later git call into host execution: {env:?}"
);
assert!(!env.contains_key("GIT_SSL_CAINFO"));
}
#[test]
fn gate_env_points_git_at_the_operator_global_config() {
let home = tempfile::tempdir().expect("tempdir");
let operator = tempfile::tempdir().expect("tempdir");
let gitconfig = operator.path().join("gitconfig");
std::fs::write(&gitconfig, "[user]\n\tname = Operator\n").expect("write");
let _guard = crate::agent_env::EnvTestGuard::engage(&[
("GIT_CONFIG_GLOBAL", gitconfig.to_str().unwrap()),
("HOME", operator.path().to_str().unwrap()),
]);
let env = gate_command_env(&policy(home.path(), &[]), &HashMap::new(), None);
assert_eq!(
env.get("GIT_CONFIG_GLOBAL").map(String::as_str),
Some(gitconfig.to_str().unwrap()),
"git reads the operator's own global config: {env:?}"
);
assert_ne!(
env.get("HOME").map(String::as_str),
Some(operator.path().to_str().unwrap()),
"naming the config file must not un-relocate HOME"
);
}
#[test]
fn the_gate_home_is_one_stable_dir_under_the_mission_runs_dir() {
let mission = tempfile::tempdir().expect("tempdir");
let home = mission_gate_home(mission.path());
assert_eq!(home, mission.path().join("runs").join("workspace-gate"));
assert_eq!(
home,
mission_gate_home(mission.path()),
"the same mission resolves to the same home on every phase"
);
let env = gate_command_env(&policy(&home, &[]), &HashMap::new(), None);
assert_eq!(env.get("HOME").map(String::as_str), home.to_str());
std::fs::write(home.join("installed-by-bootstrap"), "x").expect("write");
let _ = gate_command_env(&policy(&home, &[]), &HashMap::new(), None);
assert!(
home.join("installed-by-bootstrap").is_file(),
"a later phase must find what an earlier phase installed"
);
remove_gate_home(&home);
assert!(!home.exists(), "teardown removes the shared home");
}
#[test]
fn gate_block_reason_names_command_exit_owner_and_scrubs_the_tail() {
let failed = outcome(
2,
3,
"npm ci",
Some(42),
"registry auth token sk-ant-api03-a1b2c3d4e5f6 failed",
);
let reason = gate_block_reason("bootstrap command", &failed);
assert!(reason.starts_with("workspace gate:"), "{reason}");
assert!(reason.contains("bootstrap command 2/3 failed"), "{reason}");
assert!(reason.contains("owner: repo-setup"), "{reason}");
assert!(reason.contains("`npm ci`"), "{reason}");
assert!(reason.contains("exit code 42"), "{reason}");
assert!(
!reason.contains("sk-ant-api03-a1b2c3d4e5f6"),
"the output tail must be scrubbed: {reason}"
);
assert!(reason.contains("[REDACTED]"), "{reason}");
}
#[test]
fn gate_block_reason_without_exit_code_says_so() {
let failed = outcome(1, 1, "./setup.sh", None, "timed out after 600s");
let reason = gate_block_reason("readiness check", &failed);
assert!(reason.contains("readiness check 1/1 failed"), "{reason}");
assert!(reason.contains("no exit code"), "{reason}");
assert!(reason.contains("timed out after 600s"), "{reason}");
}
#[test]
fn outcomes_detail_lists_every_command_that_ran_plus_the_failing_tail() {
let outcomes = vec![
outcome(1, 3, "cargo fetch", Some(0), ""),
outcome(2, 3, "npm ci", Some(1), "npm ERR! 401"),
];
let detail = outcomes_detail("bootstrap command", &outcomes);
assert!(
detail.contains("bootstrap command 1/3 `cargo fetch` → ok (exit code 0)"),
"{detail}"
);
assert!(
detail.contains("bootstrap command 2/3 `npm ci` → FAILED (exit code 1)"),
"{detail}"
);
assert!(detail.contains("output tail:\nnpm ERR! 401"), "{detail}");
assert!(!detail.contains("3/3"), "{detail}");
}
#[test]
fn latest_block_is_gate_owned_only_for_an_unlifted_gate_block() {
let gate_reason = "workspace gate: bootstrap command 1/1 failed (owner: repo-setup): `x` exit code 1: boom";
let other_reason = "validator command denied: `rm -rf /` — deny-default";
let events = vec![blocked(1, "ms-1", gate_reason)];
assert!(latest_block_is_gate_owned(&events, "ms-1"));
let events = vec![
blocked(1, "ms-1", gate_reason),
ev(
2,
EventKind::MilestoneUnblocked {
block_context: None,
milestone_id: "ms-1".to_string(),
reason: "workspace gate now passing".to_string(),
validator_guidance: None,
},
),
];
assert!(!latest_block_is_gate_owned(&events, "ms-1"));
let events = vec![blocked(1, "ms-1", other_reason)];
assert!(!latest_block_is_gate_owned(&events, "ms-1"));
let events = vec![blocked(1, "ms-2", gate_reason)];
assert!(!latest_block_is_gate_owned(&events, "ms-1"));
assert!(!latest_block_is_gate_owned(&[], "ms-1"));
let events = vec![
blocked(1, "ms-1", gate_reason),
ev(
2,
EventKind::MilestoneUnblocked {
block_context: None,
milestone_id: "ms-1".to_string(),
reason: "workspace gate now passing".to_string(),
validator_guidance: None,
},
),
blocked(3, "ms-1", other_reason),
];
assert!(!latest_block_is_gate_owned(&events, "ms-1"));
}
}
#[cfg(test)]
#[path = "block_context_tests.rs"]
mod block_context_tests;