sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ b580c5bfc5b134c64a571136441f585c025deaaf
crates/hammer-core/src/proto.rs
History
Who touched what
raw
H1c (proof-carrying recipes): cablea la evidencia al Orchestrator VERIFY
1//! Protocolo del bus de agente. Ver `docs/07-agent-bus.md` §3.
2//!
3//! Wire format: **JSON-líneas** (un objeto JSON por línea, `\n`-terminado). Cada objeto
4//! lleva un campo discriminador `"t"`. La elección de un tag por línea (en vez de
5//! length-prefijado o protobuf) hace que el bus sea diagnosticable con `cat`/`jq`/`nc` —
6//! que es exactamente el punto.
7//!
8//! ## Compatibilidad
9//!
10//! `Hello.ver`/`Welcome.ver` viajan en el handshake. Variantes nuevas de `Command`/`Event`
11//! pueden aparecer sin romper clientes viejos (serde rechaza la `t` desconocida y la conexión
12//! la cierra el dispatcher con un mensaje claro).
13
14use serde::{Deserialize, Serialize};
15
16pub const PROTOCOL_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(tag = "t", rename_all = "snake_case")]
20pub enum Command {
21 /// Handshake del cliente. Primer mensaje obligatorio.
22 Hello {
23 ver: u32,
24 #[serde(default)]
25 client: String,
26 },
27 /// Lanza un build. Se reciben los campos mínimos para sintetizar una `Recipe` (los
28 /// mismos que un `.swm` Source_patch). Devuelve `BuildReady` o `BuildFailed`.
29 Compile {
30 recipe: RecipeInline,
31 },
32 /// Hidrata un artefacto ya sellado en el store. `target` es el FHS destino (overlay
33 /// merged o real). `overlay` es opcional — informativo, hoy no impacta el flujo (el
34 /// kernel ya redirige al upper si `target` cae bajo un overlay activo).
35 Inject {
36 artifact: String,
37 target: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 overlay: Option<String>,
40 },
41 /// Consulta de estado. `what` decide qué campos se usan:
42 /// - `"file"`: usa `path` (metadata del archivo).
43 /// - `"artifact"`: usa `name` o `path` (busca un hash en el store).
44 /// - `"expr"`: usa `expr`, una expresión del mini-lenguaje (ver `hammer_core::query`).
45 Query {
46 what: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 path: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 name: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 expr: Option<String>,
53 },
54 /// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al
55 /// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`.
56 Init {
57 cmd: String,
58 },
59}
60
61/// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch`
62/// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional).
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RecipeInline {
65 pub name: String,
66 // Origen git (modo histórico) **o** tarball — exactamente uno, igual que
67 // `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes
68 // que sólo mandan `repo`+`commit`.
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub repo: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub commit: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub tarball: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub sha256: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub patch: Option<String>,
79 #[serde(default = "default_compiler")]
80 pub compiler: String,
81 #[serde(default = "default_target")]
82 pub target: String,
83 #[serde(default = "default_link")]
84 pub link: String,
85 #[serde(default)]
86 pub flags: Vec<String>,
87 /// [H1c] Bloque de evidencia de comportamiento (proof-carrying recipe). Viaja al lado del
88 /// build para que `hammerd` la EJECUTE tras sellar el artefacto y devuelva el veredicto en
89 /// `BuildReady`. Vacío ⇒ receta sin evidencia (compat con clientes previos a H1c).
90 #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")]
91 pub evidence: crate::recipe::Evidence,
92}
93
94fn default_compiler() -> String {
95 "zig-cc".into()
96}
97fn default_target() -> String {
98 "x86_64-linux-musl".into()
99}
100fn default_link() -> String {
101 "static".into()
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(tag = "t", rename_all = "snake_case")]
106pub enum Event {
107 /// Respuesta al `Hello`: versión confirmada + capacidades concedidas.
108 Welcome {
109 ver: u32,
110 caps: Vec<Cap>,
111 /// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el
112 /// cliente sepa qué UID/GID/PID le ve `hammerd`.
113 peer: Peer,
114 },
115 /// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
116 /// `verdict` [H1c]: `None` si la receta no declaró evidencia; `Some` con el resultado de
117 /// ejecutarla en el sandbox reproducible. `all_passed=false` ⇒ el artefacto quedó sellado
118 /// pero una prueba declarada falló — el orquestador lo usa como gate "no proponer".
119 BuildReady {
120 recipe: String,
121 artifact: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 verdict: Option<EvidenceVerdict>,
124 },
125 /// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
126 /// disponibles (opcional).
127 BuildFailed {
128 recipe: String,
129 reason: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 log_tail: Option<String>,
132 },
133 /// Hidratación terminó OK.
134 Injected { artifact: String, target: String, files: usize },
135 /// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query.
136 QueryResult {
137 what: String,
138 value: serde_json::Value,
139 },
140 /// La línea fue enviada al FIFO de control.
141 InitAck { cmd: String },
142 /// Un servicio supervisado murió. La supervisión real la provee el init propio
143 /// (arje, PID 1): difunde `arje_bus::BusEvent::EnteCrashed` y el sink
144 /// `hammerd::crashes` lo traduce a este evento. Falta sólo el adaptador de
145 /// transporte que conecta ambos buses (ver `hammerd::crashes`).
146 Crashed { service: String, code: i32 },
147 /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
148 /// registrarlo en el diario.
149 Modified {
150 path: String,
151 op: String,
152 ts: String,
153 },
154 /// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
155 Error { code: String, msg: String },
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct Peer {
160 pub uid: u32,
161 pub gid: u32,
162 pub pid: i32,
163}
164
165/// [H1c] Forma serializable del `EvidenceReport` de `hammer_build` que viaja en `BuildReady`.
166/// El lado del build (hammerd) ejecuta la evidencia y la adjunta; el orquestador la lee sin
167/// depender de `hammer_build` (la separación agente-PROPONE / lab-CONSTRUYE se preserva).
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct EvidenceVerdict {
170 /// Gate: `false` ⇒ al menos un check declarado falló ⇒ el orquestador no propone la mutación.
171 pub all_passed: bool,
172 /// Estrato de confianza más alto que pasó (para reportar "verificado hasta …"). No promete
173 /// que ese nivel *baste*, sólo que lo declarado a ese nivel pasó.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub max_level: Option<crate::recipe::EvidenceKind>,
176 pub checks: Vec<EvidenceCheckVerdict>,
177}
178
179/// [H1c] Veredicto de un check individual, ya evaluado (exit + hash del stdout) en el sandbox.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct EvidenceCheckVerdict {
182 pub kind: crate::recipe::EvidenceKind,
183 pub cmd: String,
184 pub passed: bool,
185 pub detail: String,
186}
187
188/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
189/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
191#[serde(rename_all = "kebab-case")]
192pub enum Cap {
193 Query,
194 Compile,
195 Inject,
196 InjectReal,
197 Init,
198}
199
200impl Cap {
201 /// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
202 /// no requiere cap (es el handshake).
203 pub fn required_for(cmd: &Command) -> Option<Cap> {
204 match cmd {
205 Command::Hello { .. } => None,
206 Command::Compile { .. } => Some(Cap::Compile),
207 Command::Inject { target, .. } => {
208 // Política: inject a una ruta que arranca con `/` y NO viene marcada como
209 // overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
210 // la decide el llamador con el flag `overlay` — si declara overlay, basta
211 // `Inject`; si no, exige `InjectReal`.
212 Some(if target.is_empty() || !target.starts_with('/') {
213 Cap::Inject
214 } else {
215 Cap::Inject
216 })
217 }
218 Command::Query { .. } => Some(Cap::Query),
219 Command::Init { .. } => Some(Cap::Init),
220 }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn hello_roundtrip() {
230 let c = Command::Hello { ver: 1, client: "ai".into() };
231 let s = serde_json::to_string(&c).unwrap();
232 assert!(s.contains(r#""t":"hello""#), "{s}");
233 let back: Command = serde_json::from_str(&s).unwrap();
234 assert_eq!(back, c);
235 }
236
237 #[test]
238 fn welcome_with_peer_and_caps() {
239 let e = Event::Welcome {
240 ver: 1,
241 caps: vec![Cap::Query, Cap::Compile],
242 peer: Peer { uid: 1000, gid: 1000, pid: 42 },
243 };
244 let s = serde_json::to_string(&e).unwrap();
245 assert!(s.contains(r#""t":"welcome""#), "{s}");
246 assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
247 let back: Event = serde_json::from_str(&s).unwrap();
248 assert_eq!(back, e);
249 }
250
251 #[test]
252 fn compile_recipe_inline() {
253 let c = Command::Compile {
254 recipe: RecipeInline {
255 name: "grep".into(),
256 repo: Some("git://x/grep.git".into()),
257 commit: Some("abc".into()),
258 tarball: None,
259 sha256: None,
260 patch: None,
261 compiler: "zig-cc".into(),
262 target: "x86_64-linux-musl".into(),
263 link: "static".into(),
264 flags: vec!["--enable-foo".into()],
265 evidence: Default::default(),
266 },
267 };
268 let s = serde_json::to_string(&c).unwrap();
269 assert!(s.contains(r#""t":"compile""#));
270 assert!(s.contains(r#""name":"grep""#));
271 // Sin evidencia declarada, el bloque no se serializa (compat con clientes pre-H1c).
272 assert!(!s.contains("evidence"), "{s}");
273 }
274
275 #[test]
276 fn build_ready_event() {
277 // Sin veredicto, el JSON es idéntico al de antes de H1c (skip_serializing_if).
278 let e = Event::BuildReady {
279 recipe: "grep".into(),
280 artifact: "b3:deadbeef".into(),
281 verdict: None,
282 };
283 let s = serde_json::to_string(&e).unwrap();
284 assert_eq!(
285 s,
286 r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
287 );
288 }
289
290 #[test]
291 fn build_ready_with_verdict_roundtrips() {
292 let e = Event::BuildReady {
293 recipe: "tree".into(),
294 artifact: "b3:cafe".into(),
295 verdict: Some(EvidenceVerdict {
296 all_passed: false,
297 max_level: Some(crate::recipe::EvidenceKind::CmdExit),
298 checks: vec![EvidenceCheckVerdict {
299 kind: crate::recipe::EvidenceKind::CmdExit,
300 cmd: "tree --version".into(),
301 passed: false,
302 detail: "exit 7 ≠ esperado 0".into(),
303 }],
304 }),
305 };
306 let s = serde_json::to_string(&e).unwrap();
307 assert!(s.contains(r#""all_passed":false"#), "{s}");
308 let back: Event = serde_json::from_str(&s).unwrap();
309 assert_eq!(back, e);
310 }
311
312 #[test]
313 fn error_event_minimal() {
314 let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
315 let s = serde_json::to_string(&e).unwrap();
316 let back: Event = serde_json::from_str(&s).unwrap();
317 assert_eq!(back, e);
318 }
319
320 #[test]
321 fn unknown_t_is_rejected() {
322 let bad = r#"{"t":"defenestrate","payload":1}"#;
323 assert!(serde_json::from_str::<Command>(bad).is_err());
324 }
325
326 #[test]
327 fn cap_required_for_basic_commands() {
328 assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
329 assert_eq!(
330 Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }),
331 Some(Cap::Query)
332 );
333 assert_eq!(
334 Cap::required_for(&Command::Init { cmd: "start web".into() }),
335 Some(Cap::Init)
336 );
337 }
338}