diff --git a/crates/homeboy-agents/src/agent_task_lifecycle/runner_exec.rs b/crates/homeboy-agents/src/agent_task_lifecycle/runner_exec.rs index 6a80c9b4b..b1684f6c1 100644 --- a/crates/homeboy-agents/src/agent_task_lifecycle/runner_exec.rs +++ b/crates/homeboy-agents/src/agent_task_lifecycle/runner_exec.rs @@ -65,6 +65,10 @@ pub(crate) fn accepted_lab_runner_job_identity_from_record( pub const RUNNER_EXEC_RUN_KIND: &str = "runner_exec"; const RUNNER_EXEC_OBSERVATION_KIND: &str = "runner_execution"; +const COMMAND_RESULT_SCHEMA: &str = "homeboy/command-result/v3"; +const COMMAND_RESULT_STDOUT_LIMIT_BYTES: usize = 64 * 1024; +const COMMAND_RESULT_RUN_REF_LIMIT: usize = 32; +const DESCENDANT_RUN_GRAPH_LIMIT: usize = 64; fn ensure_runner_exec_observation_run( run_id: &str, @@ -531,6 +535,12 @@ pub fn project_terminal_runner_exec_result( 1 }; let metadata = run.metadata_json.as_object_mut().expect("metadata object"); + if let Some(descendants) = terminal_command_result_descendants(&store, &run.id, snapshot) { + metadata.insert( + "descendant_run_evidence".to_string(), + serde_json::to_value(descendants).expect("descendant refs serialize"), + ); + } metadata.insert("runner_job_id".to_string(), json!(snapshot.job.id)); metadata.insert("runner_job_status".to_string(), json!(snapshot.job.status)); metadata.insert("runner_job_events".to_string(), json!(snapshot.events)); @@ -573,6 +583,135 @@ pub fn project_terminal_runner_exec_result( Ok(true) } +/// Accept only a complete, bounded command-result envelope whose run refs point +/// to locally persisted child observations. A terminal log is runner-controlled, +/// so any malformed field, truncation marker, or unresolved reference rejects +/// the whole projection rather than preserving a plausible false edge. +pub(crate) fn terminal_command_result_descendants( + store: &homeboy_core::observation::ObservationStore, + parent_run_id: &str, + snapshot: &RunnerJobLogSnapshot, +) -> Option> { + let result = snapshot + .events + .iter() + .rev() + .find(|event| matches!(event.kind, homeboy_core::api_jobs::JobEventKind::Result))? + .data + .as_ref()?; + if result + .pointer("/capture/stdout/truncated") + .and_then(Value::as_bool) + == Some(true) + { + return None; + } + let stdout = result.get("stdout")?.as_str()?; + if stdout.len() > COMMAND_RESULT_STDOUT_LIMIT_BYTES { + return None; + } + let envelope: Value = serde_json::from_str(stdout).ok()?; + if envelope.get("schema").and_then(Value::as_str) != Some(COMMAND_RESULT_SCHEMA) + || !bounded_string(&envelope, "command", 128) + || envelope.get("success").and_then(Value::as_bool).is_none() + || envelope + .get("exit_code") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .is_none() + || !bounded_string(&envelope, "status", 64) + { + return None; + } + let runs = envelope.pointer("/refs/runs")?.as_array()?; + if runs.len() > COMMAND_RESULT_RUN_REF_LIMIT { + return None; + } + let mut descendants = Vec::with_capacity(runs.len()); + for run in runs { + let id = bounded_required_string(run, "id", 256)?; + let kind = bounded_required_string(run, "kind", 128)?; + let _source = bounded_required_string(run, "source", 256)?; + if id == parent_run_id { + return None; + } + let child = store.get_run(id).ok().flatten()?; + if kind != child.kind { + return None; + } + if descendant_run_reaches(store, &child.id, parent_run_id)? { + return None; + } + let reference = homeboy_core::observation::evidence_report::DescendantRunEvidenceRef { + schema: homeboy_core::observation::evidence_report::DESCENDANT_RUN_EVIDENCE_REF_SCHEMA + .to_string(), + run_id: child.id, + kind: child.kind, + source: homeboy_core::observation::evidence_report::DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT.to_string(), + }; + if !reference.is_valid() || descendants.iter().any(|existing: &homeboy_core::observation::evidence_report::DescendantRunEvidenceRef| existing.run_id == reference.run_id) { + return None; + } + descendants.push(reference); + } + Some(descendants) +} + +/// Follow only persisted, typed descendant edges. Every hop is revalidated +/// against the current observation record and the traversal fails closed once +/// its bounded budget is exhausted. +fn descendant_run_reaches( + store: &homeboy_core::observation::ObservationStore, + start_run_id: &str, + target_run_id: &str, +) -> Option { + let mut pending = vec![start_run_id.to_string()]; + let mut visited = std::collections::HashSet::new(); + while let Some(run_id) = pending.pop() { + if run_id == target_run_id { + return Some(true); + } + if !visited.insert(run_id.clone()) { + continue; + } + if visited.len() > DESCENDANT_RUN_GRAPH_LIMIT { + return None; + } + let run = store.get_run(&run_id).ok().flatten()?; + let Some(value) = run.metadata_json.get("descendant_run_evidence") else { + continue; + }; + let refs = serde_json::from_value::< + Vec, + >(value.clone()) + .ok()?; + if refs.len() > COMMAND_RESULT_RUN_REF_LIMIT { + return None; + } + for reference in refs { + if !reference.is_valid() { + return None; + } + let child = store.get_run(&reference.run_id).ok().flatten()?; + if child.kind != reference.kind { + return None; + } + pending.push(child.id); + } + } + Some(false) +} + +fn bounded_string(value: &Value, field: &str, limit: usize) -> bool { + bounded_required_string(value, field, limit).is_some() +} + +fn bounded_required_string<'a>(value: &'a Value, field: &str, limit: usize) -> Option<&'a str> { + let value = value.get(field)?.as_str()?; + (!value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control)) + .then_some(value) +} + /// Finish a synchronous transport that has no daemon job identity (diagnostic /// SSH/local execution) after its declared evidence is safely retained. pub fn finish_runner_exec_direct(run_id: &str, transport: &str, exit_code: i32) -> Result { diff --git a/crates/homeboy-agents/src/agent_task_lifecycle/tests/runner_exec.rs b/crates/homeboy-agents/src/agent_task_lifecycle/tests/runner_exec.rs index b42339695..bbbabd407 100644 --- a/crates/homeboy-agents/src/agent_task_lifecycle/tests/runner_exec.rs +++ b/crates/homeboy-agents/src/agent_task_lifecycle/tests/runner_exec.rs @@ -264,6 +264,167 @@ fn generic_runner_exec_terminal_projection_is_authoritative_and_idempotent() { }); } +#[test] +fn terminal_runner_exec_projects_only_complete_valid_nested_run_references() { + with_isolated_home(|_| { + let store = homeboy_core::observation::ObservationStore::open_initialized().expect("store"); + let child = store + .start_run(homeboy_core::observation::NewRunRecord::builder("bench").build()) + .expect("child run"); + let diagnostic = tempfile::NamedTempFile::new().expect("diagnostic"); + std::fs::write(diagnostic.path(), "child failure").expect("write diagnostic"); + store + .record_artifact_with_metadata( + &child.id, + "failure_diagnostic", + diagnostic.path(), + serde_json::json!({ "failure_diagnostic": true, "failure_diagnostic_rank": 1 }), + ) + .expect("child diagnostic"); + store + .finish_run(&child.id, homeboy_core::observation::RunStatus::Fail, None) + .expect("finish child"); + + let run_id = "outer-nested-command-result"; + record_runner_exec_job_identity( + run_id, + "homeboy-lab", + "00000000-0000-0000-0000-000000000123", + "/runner/workspace", + &["homeboy".to_string(), "bench".to_string()], + ) + .expect("outer run"); + record_runner_exec_artifact_refs(run_id, &[]).expect("complete artifact projection"); + let mut snapshot = runner_snapshot("succeeded"); + snapshot.events[0].data = Some(serde_json::json!({ + "stdout": serde_json::json!({ + "schema": "homeboy/command-result/v3", + "command": "bench", + "success": true, + "exit_code": 0, + "status": "succeeded", + "refs": { "runs": [{ + "id": child.id, + "kind": "bench", + "source": "forged-runner-label" + }] } + }).to_string() + })); + project_terminal_runner_exec_result(run_id, &snapshot).expect("project outer run"); + + let outer = store + .get_run(run_id) + .expect("read outer") + .expect("outer run"); + assert_eq!(outer.status, "pass"); + assert_eq!( + outer.metadata_json["descendant_run_evidence"][0]["run_id"], + child.id + ); + assert_eq!( + outer.metadata_json["descendant_run_evidence"][0]["source"], + homeboy_core::observation::evidence_report::DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT + ); + assert!(store + .list_artifacts(run_id) + .expect("outer artifacts") + .is_empty()); + + let malformed = + crate::agent_task_lifecycle::runner_exec::terminal_command_result_descendants( + &store, + run_id, + &RunnerJobLogSnapshot { + events: vec![JobEvent { + data: Some(serde_json::json!({ + "capture": { "stdout": { "truncated": true } }, + "stdout": "{\"schema\":\"homeboy/command-result/v3\"}" + })), + ..snapshot.events[0].clone() + }], + ..snapshot.clone() + }, + ); + assert!(malformed.is_none()); + + let adversarial = + crate::agent_task_lifecycle::runner_exec::terminal_command_result_descendants( + &store, + run_id, + &RunnerJobLogSnapshot { + events: vec![JobEvent { + data: Some(serde_json::json!({ + "stdout": serde_json::json!({ + "schema": "homeboy/command-result/v3", + "command": "bench", + "success": true, + "exit_code": 0, + "status": "succeeded", + "refs": { "runs": [{ + "id": "untrusted-child", + "kind": "bench", + "source": "attacker" + }] } + }).to_string() + })), + ..snapshot.events[0].clone() + }], + ..snapshot + }, + ); + assert!(adversarial.is_none()); + }); +} + +#[test] +fn terminal_runner_exec_rejects_indirect_descendant_cycles() { + with_isolated_home(|_| { + let parent_id = "cycle-parent"; + record_runner_exec_job_identity( + parent_id, + "homeboy-lab", + "00000000-0000-0000-0000-000000000123", + "/runner/workspace", + &["homeboy".to_string(), "bench".to_string()], + ) + .expect("parent run"); + let store = homeboy_core::observation::ObservationStore::open_initialized().expect("store"); + let child = store + .start_run(homeboy_core::observation::NewRunRecord::builder("bench").build()) + .expect("child run"); + store + .update_run_metadata( + &child.id, + serde_json::json!({ + "descendant_run_evidence": [{ + "schema": "homeboy/descendant-run-evidence-ref/v1", + "run_id": parent_id, + "kind": "runner_execution", + "source": "controller.terminal_command_result.refs.runs" + }] + }), + ) + .expect("record child edge"); + let mut snapshot = runner_snapshot("succeeded"); + snapshot.events[0].data = Some(serde_json::json!({ + "stdout": serde_json::json!({ + "schema": "homeboy/command-result/v3", + "command": "bench", + "success": true, + "exit_code": 0, + "status": "succeeded", + "refs": { "runs": [{ + "id": child.id, + "kind": "bench", + "source": "forged" + }] } + }).to_string() + })); + + assert!(terminal_command_result_descendants(&store, parent_id, &snapshot).is_none()); + }); +} + #[test] fn generic_runner_exec_rejects_stale_terminal_snapshot_binding() { with_isolated_home(|_| { diff --git a/crates/homeboy-cli/src/commands/runs/evidence.rs b/crates/homeboy-cli/src/commands/runs/evidence.rs index a46d99c7f..d48968c0b 100644 --- a/crates/homeboy-cli/src/commands/runs/evidence.rs +++ b/crates/homeboy-cli/src/commands/runs/evidence.rs @@ -15,6 +15,30 @@ pub fn evidence(run_id: &str) -> CmdResult { artifacts.extend(runs_service::related_lab_artifacts_for_runner_job( &store, &run, )?); + let descendant_evidence = run + .metadata_json + .get("descendant_run_evidence") + .cloned() + .and_then(|value| { + serde_json::from_value::>(value).ok() + }) + .unwrap_or_default() + .into_iter() + .filter(|reference| reference.is_valid() && reference.run_id != run.id) + .filter_map(|reference| { + let child = store.get_run(&reference.run_id).ok().flatten()?; + if child.kind != reference.kind { + return None; + } + let child_artifacts = runs_service::list_artifacts_for_run(&store, &child.id).ok()?; + Some(evidence_report::DescendantRunEvidence { + evidence_command: format!("homeboy runs evidence {}", child.id), + primary_diagnostic: evidence_report::failure_diagnostic_artifact(&child_artifacts), + status: child.status, + reference, + }) + }) + .collect(); let artifact_root = homeboy::core::artifacts::root()?; let disk_budget = disk_budget( &artifact_root, @@ -25,15 +49,17 @@ pub fn evidence(run_id: &str) -> CmdResult { // MCP, automation) can reuse it; this adapter only supplies the CLI-owned // `RunSummary`, disk budget, and command label. let run_summary = run_summary(run.clone()); - let report = - evidence_report::build_run_evidence_report(evidence_report::RunEvidenceReportInputs { + let report = evidence_report::build_run_evidence_report_with_descendant_evidence( + evidence_report::RunEvidenceReportInputs { command: "runs.evidence", run, run_summary, artifacts, artifact_root, disk_budget, - }); + }, + descendant_evidence, + ); Ok((RunsOutput::Evidence(Box::new(report)), 0)) } @@ -385,6 +411,81 @@ mod tests { }); } + #[test] + fn evidence_command_resolves_descendant_run_without_copying_its_artifacts() { + with_isolated_home(|home| { + let store = ObservationStore::open_initialized().expect("store"); + let child = store + .start_run(NewRunRecord::builder("bench").build()) + .expect("child run"); + let diagnostic = home.path().join("child-diagnostic.txt"); + std::fs::write(&diagnostic, "child failed").expect("diagnostic"); + store + .record_artifact_with_metadata( + &child.id, + "failure_diagnostic", + &diagnostic, + serde_json::json!({ "failure_diagnostic": true, "failure_diagnostic_rank": 1 }), + ) + .expect("child artifact"); + store + .finish_run(&child.id, RunStatus::Fail, None) + .expect("finish child"); + + let parent = store + .start_run( + NewRunRecord::builder("runner_execution") + .metadata(serde_json::json!({ + "descendant_run_evidence": [{ + "schema": "homeboy/descendant-run-evidence-ref/v1", + "run_id": child.id, + "kind": "bench", + "source": "controller.terminal_command_result.refs.runs" + }] + })) + .build(), + ) + .expect("parent run"); + store + .finish_run(&parent.id, RunStatus::Pass, None) + .expect("finish parent"); + + let (output, _) = evidence(&parent.id).expect("parent evidence"); + let RunsOutput::Evidence(report) = output else { + panic!("expected evidence report"); + }; + assert_eq!(report.heartbeat.status, "pass"); + assert_eq!(report.artifact_index.count, 0); + assert_eq!(report.descendant_evidence.len(), 1); + assert_eq!(report.descendant_evidence[0].reference.run_id, child.id); + assert_eq!(report.descendant_evidence[0].status, "fail"); + assert_eq!( + report.descendant_evidence[0].evidence_command, + format!("homeboy runs evidence {}", child.id) + ); + assert!(report.descendant_evidence[0].primary_diagnostic.is_some()); + + store + .update_run_metadata( + &parent.id, + serde_json::json!({ + "descendant_run_evidence": [{ + "schema": "homeboy/descendant-run-evidence-ref/v1", + "run_id": child.id, + "kind": "stale-kind", + "source": "controller.terminal_command_result.refs.runs" + }] + }), + ) + .expect("stale descendant kind"); + let (output, _) = evidence(&parent.id).expect("stale parent evidence"); + let RunsOutput::Evidence(report) = output else { + panic!("expected evidence report"); + }; + assert!(report.descendant_evidence.is_empty()); + }); + } + /// Before this wiring the manifest member was structurally always absent: /// nothing in the repository produced one, so `runs evidence` advertised an /// interpretation layer it never populated. A run nobody attached a manifest diff --git a/crates/homeboy-core/src/observation/evidence_report.rs b/crates/homeboy-core/src/observation/evidence_report.rs index de40f0e98..6a34f02f1 100644 --- a/crates/homeboy-core/src/observation/evidence_report.rs +++ b/crates/homeboy-core/src/observation/evidence_report.rs @@ -36,6 +36,42 @@ use crate::observation::disk_budget::DiskBudget; /// Default retention window (days) surfaced in evidence retention guidance. pub const DEFAULT_RETENTION_DAYS: i64 = 30; +pub const DESCENDANT_RUN_EVIDENCE_REF_SCHEMA: &str = "homeboy/descendant-run-evidence-ref/v1"; +pub const DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT: &str = + "controller.terminal_command_result.refs.runs"; + +/// A durable, typed edge from an outer orchestration run to a child run. The +/// child owns its artifacts; this reference deliberately carries no inventory. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DescendantRunEvidenceRef { + pub schema: String, + pub run_id: String, + pub kind: String, + pub source: String, +} + +impl DescendantRunEvidenceRef { + pub fn is_valid(&self) -> bool { + self.schema == DESCENDANT_RUN_EVIDENCE_REF_SCHEMA + && valid_descendant_run_field(&self.run_id, 256) + && valid_descendant_run_field(&self.kind, 128) + && self.source == DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT + } +} + +fn valid_descendant_run_field(value: &str, limit: usize) -> bool { + !value.is_empty() && value.len() <= limit && !value.chars().any(char::is_control) +} + +#[derive(Debug, Clone, Serialize)] +pub struct DescendantRunEvidence { + #[serde(rename = "ref")] + pub reference: DescendantRunEvidenceRef, + pub status: String, + pub evidence_command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_diagnostic: Option, +} /// Fully shaped `runs evidence` report. /// @@ -58,6 +94,8 @@ pub struct RunEvidenceReport { pub failure: EvidenceFailureSummary, pub disk_budget: DiskBudget, pub evidence_links: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub descendant_evidence: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub agent_task_lifecycle_event: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -101,6 +139,16 @@ pub struct RunEvidenceReportInputs { /// previous inline command implementation. pub fn build_run_evidence_report( inputs: RunEvidenceReportInputs, +) -> RunEvidenceReport { + build_run_evidence_report_with_descendant_evidence(inputs, Vec::new()) +} + +/// Assemble a report with controller-resolved descendant evidence. Kept as an +/// additive entrypoint so existing users of [`RunEvidenceReportInputs`] retain +/// their source-compatible construction contract. +pub fn build_run_evidence_report_with_descendant_evidence( + inputs: RunEvidenceReportInputs, + descendant_evidence: Vec, ) -> RunEvidenceReport { let RunEvidenceReportInputs { command, @@ -160,6 +208,7 @@ pub fn build_run_evidence_report( failure, disk_budget, evidence_links, + descendant_evidence, agent_task_lifecycle_event, matrix_summary, evidence_manifest, @@ -750,7 +799,7 @@ pub fn evidence_failure_summary(run: &RunRecord) -> EvidenceFailureSummary { } } -fn failure_diagnostic_artifact(artifacts: &[ArtifactRecord]) -> Option { +pub fn failure_diagnostic_artifact(artifacts: &[ArtifactRecord]) -> Option { artifacts .iter() .filter(|artifact| artifact.metadata_json["failure_diagnostic"] == Value::Bool(true)) @@ -1267,6 +1316,21 @@ mod tests { assert!(report.evidence_manifest_errors.is_empty()); } + #[test] + fn original_report_inputs_caller_remains_compatible_without_descendants() { + let run = sample_run(); + let report = build_run_evidence_report(RunEvidenceReportInputs { + command: "runs.evidence", + run: run.clone(), + run_summary: serde_json::json!({ "id": run.id }), + artifacts: Vec::new(), + artifact_root: PathBuf::from("/tmp/artifacts"), + disk_budget: sample_disk_budget(), + }); + + assert!(report.descendant_evidence.is_empty()); + } + #[test] fn evidence_links_the_marked_deepest_failure_diagnostic() { let mut run = sample_run(); @@ -1596,4 +1660,48 @@ mod tests { let manifest = report.evidence_manifest.expect("derived manifest"); assert_eq!(manifest.source, Some(EvidenceManifestSource::Derived)); } + + #[test] + fn report_keeps_parent_success_while_rendering_descendant_diagnostic() { + let run = sample_run(); + let diagnostic = EvidenceRef::new( + "artifact", + "homeboy://run/child-failure/artifact/diagnostic", + "Failure diagnostic", + ); + let report = build_run_evidence_report_with_descendant_evidence( + RunEvidenceReportInputs { + command: "runs.evidence", + run: run.clone(), + run_summary: serde_json::json!({ "id": run.id }), + artifacts: Vec::new(), + artifact_root: PathBuf::from("/tmp/artifacts"), + disk_budget: sample_disk_budget(), + }, + vec![DescendantRunEvidence { + reference: DescendantRunEvidenceRef { + schema: DESCENDANT_RUN_EVIDENCE_REF_SCHEMA.to_string(), + run_id: "child-failure".to_string(), + kind: "bench".to_string(), + source: DESCENDANT_RUN_EVIDENCE_SOURCE_TERMINAL_COMMAND_RESULT.to_string(), + }, + status: "fail".to_string(), + evidence_command: "homeboy runs evidence child-failure".to_string(), + primary_diagnostic: Some(diagnostic), + }], + ); + + assert_eq!(report.heartbeat.status, "pass"); + assert!(!report.failure.failed); + assert_eq!(report.descendant_evidence.len(), 1); + assert_eq!(report.descendant_evidence[0].status, "fail"); + assert_eq!( + report.descendant_evidence[0] + .primary_diagnostic + .as_ref() + .expect("child diagnostic") + .target, + "homeboy://run/child-failure/artifact/diagnostic" + ); + } }