H1c (proof-carrying recipes): cablea la evidencia al Orchestrator VERIFY
La evidencia corre del lado del BUILD (hammer-agent no depende de hammer-build:
separación PROPONE/CONSTRUYE). Piezas:
- proto: RecipeInline lleva `evidence`; Event::BuildReady lleva
`verdict: Option<EvidenceVerdict>` (+ EvidenceCheckVerdict). Ambos con
skip_serializing_if ⇒ wire compatible con clientes pre-H1c.
- hammerd/bus: run_compile ejecuta la evidencia declarada tras sellar el
artefacto (run_evidence vía swm_bridge::recipe_from_source_patch) y adjunta
el veredicto en BuildReady. Si ni se pudo ejecutar ⇒ veredicto fallido
sintético (no pasa en silencio). El lab reporta; el gate vive en el agente.
- client: compile() devuelve CompileOutcome { artifact, verdict }.
- orchestrator: VERIFY lee el veredicto; si all_passed=false empuja
VerifyCheck::fail y ABORTA antes de hidratar (artefacto sellado, no toca el
sistema). Nuevo constructor VerifyCheck::fail.
Tests: roundtrip del veredicto en proto; stub-bus refleja evidencia→verdict;
nueva prueba de integración orchestrator_evidence_gate (veredicto fallido ⇒
run() aborta con "no se propone" y no hidrata). Suite completa verde (33 suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,14 @@ pub struct Welcome {
|
||||
pub peer: Peer,
|
||||
}
|
||||
|
||||
/// Resultado de `compile`: el hash del artefacto sellado más el veredicto de la evidencia (H1c).
|
||||
/// `verdict` es `None` cuando la receta no declaró evidencia.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompileOutcome {
|
||||
pub artifact: String,
|
||||
pub verdict: Option<hammer_core::proto::EvidenceVerdict>,
|
||||
}
|
||||
|
||||
/// Estado compartido entre el reader thread y el caller.
|
||||
struct Inbox {
|
||||
/// Eventos asíncronos (Modified/Crashed) en orden de llegada.
|
||||
@@ -123,10 +131,10 @@ impl AgentClient {
|
||||
|
||||
/// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo —
|
||||
/// el lab puede tardar varios minutos, así que el default debería ser generoso.
|
||||
pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String> {
|
||||
pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<CompileOutcome> {
|
||||
self.send(&Command::Compile { recipe })?;
|
||||
match self.recv_pending(timeout)? {
|
||||
Event::BuildReady { artifact, .. } => Ok(artifact),
|
||||
Event::BuildReady { artifact, verdict, .. } => Ok(CompileOutcome { artifact, verdict }),
|
||||
Event::BuildFailed { reason, .. } => Err(ClientError::Bus {
|
||||
code: "build_failed".into(),
|
||||
msg: reason,
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod orchestrator;
|
||||
pub mod translator;
|
||||
pub mod translator_claude;
|
||||
|
||||
pub use client::{AgentClient, ClientError, Welcome};
|
||||
pub use client::{AgentClient, ClientError, CompileOutcome, Welcome};
|
||||
pub use orchestrator::{Orchestrator, Proposal, VerifyCheck};
|
||||
pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError};
|
||||
pub use translator_claude::{ClaudeConfig, ClaudeTranslator, HttpClient};
|
||||
|
||||
@@ -170,7 +170,7 @@ impl<T: IntentTranslator> Orchestrator<T> {
|
||||
target_bin,
|
||||
expected_hash,
|
||||
deps: _,
|
||||
evidence: _, // H1c cableará la ejecución de la evidencia en el paso VERIFY
|
||||
evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady
|
||||
} => {
|
||||
let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
|
||||
skipped_source_patches += 1;
|
||||
@@ -195,11 +195,13 @@ impl<T: IntentTranslator> Orchestrator<T> {
|
||||
target: build.target.clone(),
|
||||
link: build.link.clone(),
|
||||
flags: build.flags.clone(),
|
||||
evidence: evidence.clone(),
|
||||
};
|
||||
let client = maybe_client.as_mut().expect("client conectado arriba");
|
||||
let artifact = client
|
||||
let outcome = client
|
||||
.compile(recipe, *timeout)
|
||||
.map_err(Error::Client)?;
|
||||
let artifact = outcome.artifact;
|
||||
if let Some(expected) = expected_hash {
|
||||
let want = expected.strip_prefix("b3:").unwrap_or(expected);
|
||||
let got = artifact.strip_prefix("b3:").unwrap_or(&artifact);
|
||||
@@ -210,6 +212,37 @@ impl<T: IntentTranslator> Orchestrator<T> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
// [H1c] VERIFY con evidencia: el lab ya ejecutó la evidencia declarada tras
|
||||
// sellar el artefacto y nos devolvió el veredicto. Es el gate "no proponer":
|
||||
// si algún check falló, NO hidratamos (el artefacto queda sellado en el store,
|
||||
// pero no toca el sistema) y abortamos con el detalle.
|
||||
if let Some(verdict) = &outcome.verdict {
|
||||
for c in &verdict.checks {
|
||||
if !c.passed {
|
||||
checks.push(VerifyCheck::fail(format!(
|
||||
"evidencia [{}] {}: {}",
|
||||
c.kind.as_str(),
|
||||
c.cmd,
|
||||
c.detail
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !verdict.all_passed {
|
||||
let n_fail = verdict.checks.iter().filter(|c| !c.passed).count();
|
||||
return Err(Error::Orchestrate(format!(
|
||||
"source_patch {target_bin}: {n_fail}/{} check(s) de evidencia \
|
||||
fallaron ⇒ no se propone (artefacto {artifact} sellado pero \
|
||||
NO hidratado)",
|
||||
verdict.checks.len()
|
||||
)));
|
||||
}
|
||||
let lvl = verdict.max_level.map(|k| k.as_str()).unwrap_or("—");
|
||||
checks.push(VerifyCheck::pass(format!(
|
||||
"evidencia: {} check(s) pasan (verificado hasta el estrato '{lvl}')",
|
||||
verdict.checks.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Hidratamos localmente desde el store (sin pasar por el bus, evita un
|
||||
// segundo Inject que duplicaría trabajo).
|
||||
let store = hammer_core::Store::open(store)?;
|
||||
@@ -430,6 +463,10 @@ impl VerifyCheck {
|
||||
pub fn warn(msg: impl Into<String>) -> Self {
|
||||
Self { level: CheckLevel::Warn, msg: msg.into() }
|
||||
}
|
||||
/// [H1c] Un check que FALLÓ. Hoy lo produce el gate de evidencia: la mutación no se propone.
|
||||
pub fn fail(msg: impl Into<String>) -> Self {
|
||||
Self { level: CheckLevel::Fail, msg: msg.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// El resultado del bucle. El humano lo lee para decidir `hammer commit <overlay_id>` (si
|
||||
|
||||
@@ -82,13 +82,35 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
|
||||
Err(_) => continue,
|
||||
};
|
||||
let response = match cmd {
|
||||
Command::Compile { recipe } => Event::BuildReady {
|
||||
recipe: recipe.name.clone(),
|
||||
artifact: format!(
|
||||
"b3:stub-{}",
|
||||
recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
|
||||
),
|
||||
},
|
||||
Command::Compile { recipe } => {
|
||||
// El stub refleja la evidencia declarada como un veredicto "todo pasa" para
|
||||
// ejercitar el camino H1c del cliente (sin lab real que la ejecute).
|
||||
let verdict = (!recipe.evidence.is_empty()).then(|| {
|
||||
hammer_core::proto::EvidenceVerdict {
|
||||
all_passed: true,
|
||||
max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(),
|
||||
checks: recipe
|
||||
.evidence
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| hammer_core::proto::EvidenceCheckVerdict {
|
||||
kind: c.kind,
|
||||
cmd: c.cmd.clone(),
|
||||
passed: true,
|
||||
detail: "stub ok".into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
});
|
||||
Event::BuildReady {
|
||||
recipe: recipe.name.clone(),
|
||||
artifact: format!(
|
||||
"b3:stub-{}",
|
||||
recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
|
||||
),
|
||||
verdict,
|
||||
}
|
||||
}
|
||||
Command::Query { what, .. } => Event::QueryResult {
|
||||
what,
|
||||
value: serde_json::json!({"stub": true}),
|
||||
@@ -145,11 +167,24 @@ fn client_handshake_compile_and_async_modified() {
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
evidence: hammer_core::Evidence {
|
||||
checks: vec![hammer_core::EvidenceCheck {
|
||||
kind: hammer_core::EvidenceKind::CmdExit,
|
||||
cmd: "grep --version".into(),
|
||||
expected_exit: 0,
|
||||
expected_output: None,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let artifact = client
|
||||
let outcome = client
|
||||
.compile(recipe, Duration::from_secs(2))
|
||||
.expect("BuildReady");
|
||||
assert_eq!(artifact, "b3:stub-abc123");
|
||||
assert_eq!(outcome.artifact, "b3:stub-abc123");
|
||||
// [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone.
|
||||
let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)");
|
||||
assert!(verdict.all_passed);
|
||||
assert_eq!(verdict.checks.len(), 1);
|
||||
assert_eq!(verdict.max_level, Some(hammer_core::EvidenceKind::CmdExit));
|
||||
|
||||
// El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos
|
||||
// que el cliente lo entrega por drain_async()/next_async().
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! [H1c] Gate de evidencia en el orquestador contra un bus stub.
|
||||
//!
|
||||
//! El lab (aquí, un stub) ejecuta la evidencia tras sellar el artefacto y devuelve el veredicto
|
||||
//! en `BuildReady`. Si algún check falla, `Orchestrator::run` debe ABORTAR con "no se propone" y
|
||||
//! NO hidratar nada al `prefix` — el gate "falla ⇒ no se propone" del SDD 15 §H1.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use hammer_agent::orchestrator::{ApplyTarget, CompileMode, Orchestrator};
|
||||
use hammer_agent::MockTranslator;
|
||||
use hammer_core::proto::{
|
||||
Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, PROTOCOL_VERSION,
|
||||
};
|
||||
use hammer_core::swm::{Base, Mutation, Swm, SwmBuild};
|
||||
use hammer_core::{BaseRef, Evidence, EvidenceCheck, EvidenceKind};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Bus stub que responde a `Compile` con `BuildReady` cuyo veredicto tiene `all_passed=false`.
|
||||
fn start_failing_bus(sock: PathBuf) {
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
let listener = UnixListener::bind(&sock).expect("bind stub");
|
||||
thread::spawn(move || {
|
||||
if let Some(Ok(stream)) = listener.incoming().next() {
|
||||
thread::spawn(move || serve(stream));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn serve(stream: UnixStream) {
|
||||
let mut writer = stream.try_clone().unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let send = |w: &mut UnixStream, ev: &Event| {
|
||||
w.write_all(serde_json::to_string(ev).unwrap().as_bytes()).unwrap();
|
||||
w.write_all(b"\n").unwrap();
|
||||
w.flush().unwrap();
|
||||
};
|
||||
|
||||
let mut first = String::new();
|
||||
reader.read_line(&mut first).unwrap();
|
||||
let Ok(Command::Hello { .. }) = serde_json::from_str::<Command>(first.trim()) else {
|
||||
return;
|
||||
};
|
||||
send(
|
||||
&mut writer,
|
||||
&Event::Welcome {
|
||||
ver: PROTOCOL_VERSION,
|
||||
caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
|
||||
peer: Peer { uid: 1000, gid: 1000, pid: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(cmd) = serde_json::from_str::<Command>(line.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if let Command::Compile { recipe } = cmd {
|
||||
send(
|
||||
&mut writer,
|
||||
&Event::BuildReady {
|
||||
recipe: recipe.name.clone(),
|
||||
artifact: "b3:stub-fail".into(),
|
||||
verdict: Some(EvidenceVerdict {
|
||||
all_passed: false,
|
||||
max_level: None,
|
||||
checks: vec![EvidenceCheckVerdict {
|
||||
kind: EvidenceKind::CmdExit,
|
||||
cmd: "grep --version".into(),
|
||||
passed: false,
|
||||
detail: "exit 7 ≠ esperado 0".into(),
|
||||
}],
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn swm_with_evidence() -> Swm {
|
||||
Swm {
|
||||
swm_version: 1,
|
||||
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
|
||||
mutations: vec![Mutation::SourcePatch {
|
||||
repo: Some("git://x/grep.git".into()),
|
||||
commit: Some("abc".into()),
|
||||
tarball: None,
|
||||
sha256: None,
|
||||
strip_components: None,
|
||||
patch: None,
|
||||
patch_url: None,
|
||||
build: SwmBuild {
|
||||
compiler: "zig-cc".into(),
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
phases: Default::default(),
|
||||
zig_version: None,
|
||||
},
|
||||
target_bin: "/usr/bin/grep".into(),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
evidence: Evidence {
|
||||
checks: vec![EvidenceCheck {
|
||||
kind: EvidenceKind::CmdExit,
|
||||
cmd: "grep --version".into(),
|
||||
expected_exit: 0,
|
||||
expected_output: None,
|
||||
}],
|
||||
},
|
||||
}],
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_evidence_aborts_and_does_not_hydrate() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let sock = d.path().join("agent.sock");
|
||||
start_failing_bus(sock.clone());
|
||||
for _ in 0..50 {
|
||||
if sock.exists() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let prefix = d.path().join("prefix");
|
||||
let store = d.path().join("store");
|
||||
let base = BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() };
|
||||
let t = MockTranslator::empty().with_intent("compile grep", swm_with_evidence());
|
||||
let o = Orchestrator::new(
|
||||
t,
|
||||
base,
|
||||
ApplyTarget::Prefix { prefix: prefix.clone() },
|
||||
CompileMode::ViaBus { sock, store, timeout: Duration::from_secs(5) },
|
||||
);
|
||||
|
||||
let err = o.run("compile grep").unwrap_err().to_string();
|
||||
assert!(err.contains("no se propone"), "mensaje inesperado: {err}");
|
||||
assert!(err.contains("evidencia") || err.contains("check(s)"), "{err}");
|
||||
|
||||
// El gate corta ANTES de hidratar: el target_bin no debe existir bajo el prefix.
|
||||
assert!(
|
||||
!prefix.join("usr/bin/grep").exists(),
|
||||
"el binario se hidrató pese al fallo de evidencia"
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,11 @@ pub struct RecipeInline {
|
||||
pub link: String,
|
||||
#[serde(default)]
|
||||
pub flags: Vec<String>,
|
||||
/// [H1c] Bloque de evidencia de comportamiento (proof-carrying recipe). Viaja al lado del
|
||||
/// build para que `hammerd` la EJECUTE tras sellar el artefacto y devuelva el veredicto en
|
||||
/// `BuildReady`. Vacío ⇒ receta sin evidencia (compat con clientes previos a H1c).
|
||||
#[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
|
||||
pub evidence: crate::recipe::Evidence,
|
||||
}
|
||||
|
||||
fn default_compiler() -> String {
|
||||
@@ -108,7 +113,15 @@ pub enum Event {
|
||||
peer: Peer,
|
||||
},
|
||||
/// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
|
||||
BuildReady { recipe: String, artifact: String },
|
||||
/// `verdict` [H1c]: `None` si la receta no declaró evidencia; `Some` con el resultado de
|
||||
/// ejecutarla en el sandbox reproducible. `all_passed=false` ⇒ el artefacto quedó sellado
|
||||
/// pero una prueba declarada falló — el orquestador lo usa como gate "no proponer".
|
||||
BuildReady {
|
||||
recipe: String,
|
||||
artifact: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
verdict: Option<EvidenceVerdict>,
|
||||
},
|
||||
/// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
|
||||
/// disponibles (opcional).
|
||||
BuildFailed {
|
||||
@@ -149,6 +162,29 @@ pub struct Peer {
|
||||
pub pid: i32,
|
||||
}
|
||||
|
||||
/// [H1c] Forma serializable del `EvidenceReport` de `hammer_build` que viaja en `BuildReady`.
|
||||
/// El lado del build (hammerd) ejecuta la evidencia y la adjunta; el orquestador la lee sin
|
||||
/// depender de `hammer_build` (la separación agente-PROPONE / lab-CONSTRUYE se preserva).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EvidenceVerdict {
|
||||
/// Gate: `false` ⇒ al menos un check declarado falló ⇒ el orquestador no propone la mutación.
|
||||
pub all_passed: bool,
|
||||
/// Estrato de confianza más alto que pasó (para reportar "verificado hasta …"). No promete
|
||||
/// que ese nivel *baste*, sólo que lo declarado a ese nivel pasó.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_level: Option<crate::recipe::EvidenceKind>,
|
||||
pub checks: Vec<EvidenceCheckVerdict>,
|
||||
}
|
||||
|
||||
/// [H1c] Veredicto de un check individual, ya evaluado (exit + hash del stdout) en el sandbox.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EvidenceCheckVerdict {
|
||||
pub kind: crate::recipe::EvidenceKind,
|
||||
pub cmd: String,
|
||||
pub passed: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
|
||||
/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
@@ -226,18 +262,23 @@ mod tests {
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec!["--enable-foo".into()],
|
||||
evidence: Default::default(),
|
||||
},
|
||||
};
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
assert!(s.contains(r#""t":"compile""#));
|
||||
assert!(s.contains(r#""name":"grep""#));
|
||||
// Sin evidencia declarada, el bloque no se serializa (compat con clientes pre-H1c).
|
||||
assert!(!s.contains("evidence"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ready_event() {
|
||||
// Sin veredicto, el JSON es idéntico al de antes de H1c (skip_serializing_if).
|
||||
let e = Event::BuildReady {
|
||||
recipe: "grep".into(),
|
||||
artifact: "b3:deadbeef".into(),
|
||||
verdict: None,
|
||||
};
|
||||
let s = serde_json::to_string(&e).unwrap();
|
||||
assert_eq!(
|
||||
@@ -246,6 +287,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ready_with_verdict_roundtrips() {
|
||||
let e = Event::BuildReady {
|
||||
recipe: "tree".into(),
|
||||
artifact: "b3:cafe".into(),
|
||||
verdict: Some(EvidenceVerdict {
|
||||
all_passed: false,
|
||||
max_level: Some(crate::recipe::EvidenceKind::CmdExit),
|
||||
checks: vec![EvidenceCheckVerdict {
|
||||
kind: crate::recipe::EvidenceKind::CmdExit,
|
||||
cmd: "tree --version".into(),
|
||||
passed: false,
|
||||
detail: "exit 7 ≠ esperado 0".into(),
|
||||
}],
|
||||
}),
|
||||
};
|
||||
let s = serde_json::to_string(&e).unwrap();
|
||||
assert!(s.contains(r#""all_passed":false"#), "{s}");
|
||||
let back: Event = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_event_minimal() {
|
||||
let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
|
||||
|
||||
@@ -25,7 +25,10 @@ use std::thread;
|
||||
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
|
||||
|
||||
use crate::events::EventBus;
|
||||
use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
|
||||
use hammer_core::proto::{
|
||||
Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, RecipeInline,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
/// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué
|
||||
/// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`;
|
||||
@@ -379,7 +382,9 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
|
||||
target_bin: format!("/usr/bin/{name}"),
|
||||
expected_hash: None,
|
||||
deps: Default::default(),
|
||||
evidence: Default::default(),
|
||||
// [H1c] La evidencia declarada por el cliente viaja a la mutación para ejecutarse tras
|
||||
// sellar el artefacto (ver `evidence_verdict`).
|
||||
evidence: recipe.evidence,
|
||||
};
|
||||
let store = match hammer_core::Store::open(&store_root) {
|
||||
Ok(s) => s,
|
||||
@@ -395,9 +400,15 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
|
||||
let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root());
|
||||
match hammer_build::build_source_patch(&mutation, &cfg, &store, None, None) {
|
||||
Ok(h) => {
|
||||
// [H1c] El artefacto está sellado; ahora EJECUTAMOS la evidencia declarada y
|
||||
// adjuntamos el veredicto. El gate ("no proponer" si falla) vive en el orquestador
|
||||
// —el lab sólo construye y reporta—; por eso enviamos `BuildReady` con veredicto
|
||||
// aunque algún check falle.
|
||||
let verdict = evidence_verdict(&mutation, &cfg, &store);
|
||||
let _ = tx.send(Event::BuildReady {
|
||||
recipe: name,
|
||||
artifact: h.as_str().to_string(),
|
||||
verdict,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -416,6 +427,67 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender<Event>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [H1c] Ejecuta la evidencia declarada de una mutación `source_patch` y la reduce al veredicto
|
||||
/// serializable del bus. Devuelve:
|
||||
/// - `None` si la receta no declara evidencia (nada que correr).
|
||||
/// - `Some(all_passed=true/false, …)` con el resultado por check.
|
||||
/// - `Some(all_passed=false, …)` sintético si la evidencia ni siquiera pudo ejecutarse (falta una
|
||||
/// build-dep, error de sandbox…): eso también debe bloquear la propuesta, no pasar en silencio.
|
||||
fn evidence_verdict(
|
||||
mutation: &hammer_core::swm::Mutation,
|
||||
cfg: &hammer_build::BuildConfig,
|
||||
store: &hammer_core::Store,
|
||||
) -> Option<EvidenceVerdict> {
|
||||
let has_evidence = matches!(
|
||||
mutation,
|
||||
hammer_core::swm::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty()
|
||||
);
|
||||
if !has_evidence {
|
||||
return None;
|
||||
}
|
||||
match run_evidence_inner(mutation, cfg, store) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "evidence: no se pudo ejecutar; veredicto = fallo");
|
||||
Some(EvidenceVerdict {
|
||||
all_passed: false,
|
||||
max_level: None,
|
||||
checks: vec![EvidenceCheckVerdict {
|
||||
kind: hammer_core::EvidenceKind::CmdExit,
|
||||
cmd: "<runner>".into(),
|
||||
passed: false,
|
||||
detail: format!("la evidencia no pudo ejecutarse: {e}"),
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_evidence_inner(
|
||||
mutation: &hammer_core::swm::Mutation,
|
||||
cfg: &hammer_build::BuildConfig,
|
||||
store: &hammer_core::Store,
|
||||
) -> hammer_core::Result<EvidenceVerdict> {
|
||||
let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(cfg, None);
|
||||
std::fs::create_dir_all(&catalog_dir)?;
|
||||
let recipe = hammer_build::swm_bridge::recipe_from_source_patch(mutation, None, &catalog_dir)?;
|
||||
let report = hammer_build::run_evidence(&recipe, cfg, store)?;
|
||||
Ok(EvidenceVerdict {
|
||||
all_passed: report.all_passed(),
|
||||
max_level: report.max_level_passed(),
|
||||
checks: report
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| EvidenceCheckVerdict {
|
||||
kind: r.kind,
|
||||
cmd: r.cmd.clone(),
|
||||
passed: r.outcome.passed,
|
||||
detail: r.outcome.detail.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn query_file(path: &str) -> serde_json::Value {
|
||||
let p = Path::new(path);
|
||||
let meta = match std::fs::symlink_metadata(p) {
|
||||
@@ -510,6 +582,7 @@ caps = ["query", "compile", "inject", "inject-real", "init"]
|
||||
target: "t".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
evidence: Default::default(),
|
||||
},
|
||||
}),
|
||||
"compile"
|
||||
|
||||
@@ -147,6 +147,7 @@ fn missing_cap_returns_error_no_cap() {
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
evidence: Default::default(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user