Skip to main content

clawft_kernel/workload_runtime/
host_contract.rs

1//! Host contract common to every cog runtime (ADR-100 section 4):
2//! `COG_CSI_BIND`, `COG_SENSOR_URL`, a per-instance `COGNITUM_COG_TOKEN`
3//! and `COGNITUM_COG_DATA_DIR`.
4//!
5//! The token is a secret: it is handed to the instance's environment only,
6//! and anything written to the chain carries [`HostContract::token_hash`].
7
8use std::net::SocketAddr;
9
10use clawft_types::secret::SecretString;
11use rand::RngCore;
12
13use super::types::RuntimeError;
14
15/// Env var: UDP bind address for the ESP32 CSI feed.
16pub const ENV_CSI_BIND: &str = "COG_CSI_BIND";
17/// Env var: HTTP sensor fallback (`host:port`).
18pub const ENV_SENSOR_URL: &str = "COG_SENSOR_URL";
19/// Env var: per-instance token accepted by that instance's ingest bridge.
20pub const ENV_TOKEN: &str = "COGNITUM_COG_TOKEN";
21/// Env var: full URL of this instance's ingest bridge (for cogs that honour
22/// it; released cogs post to the fixed `127.0.0.1:80`).
23pub const ENV_INGEST_URL: &str = "COGNITUM_INGEST_URL";
24/// Env var: writable data directory.
25pub const ENV_DATA_DIR: &str = "COGNITUM_COG_DATA_DIR";
26/// Default CSI feed port (ADR-069).
27pub const DEFAULT_CSI_PORT: u16 = 5006;
28
29/// Host contract for one instance.
30#[derive(Debug, Clone)]
31pub struct HostContract {
32    /// UDP bind the cog listens on for the sensor feed.
33    pub csi_bind: SocketAddr,
34    /// HTTP sensor fallback address (`host:port`), if any.
35    pub sensor_url: Option<SocketAddr>,
36    /// Per-instance token.
37    pub token: SecretString,
38    /// The node's ingest bridge as the instance's network sees it. A cog
39    /// always posts to its own `127.0.0.1:80`; container adapters relay
40    /// that port here ([`super::container_relay`]). `None` means the cog's
41    /// loopback already reaches the bridge (native, `network=host`).
42    pub ingest_upstream: Option<SocketAddr>,
43    /// Ingest bridge URL injected as [`ENV_INGEST_URL`], if any.
44    pub ingest_url: Option<String>,
45    /// False: the instance gets no [`ENV_TOKEN`] (and no ingest URL). Set
46    /// when the node's ingest bridge is down, so a cog never carries a
47    /// credential for a bridge that is not there.
48    pub expose_token: bool,
49}
50
51impl HostContract {
52    /// Contract binding the feed on `csi_bind`, with a fresh random token.
53    pub fn new(csi_bind: SocketAddr) -> Self {
54        Self {
55            csi_bind,
56            sensor_url: None,
57            token: Self::fresh_token(),
58            ingest_upstream: None,
59            ingest_url: None,
60            expose_token: true,
61        }
62    }
63
64    /// Default: feed on `0.0.0.0:5006`.
65    pub fn default_feed() -> Self {
66        Self::new(SocketAddr::from(([0, 0, 0, 0], DEFAULT_CSI_PORT)))
67    }
68
69    /// Builder: HTTP sensor fallback.
70    pub fn with_sensor(mut self, addr: SocketAddr) -> Self {
71        self.sensor_url = Some(addr);
72        self
73    }
74
75    /// Builder: ingest bridge address as seen from inside the instance.
76    pub fn with_ingest_upstream(mut self, addr: SocketAddr) -> Self {
77        self.ingest_upstream = Some(addr);
78        self
79    }
80
81    /// Builder: bridge URL handed to the cog as [`ENV_INGEST_URL`].
82    pub fn with_ingest_url(mut self, url: impl Into<String>) -> Self {
83        self.ingest_url = Some(url.into());
84        self
85    }
86
87    /// Builder: hand the instance no token and no ingest URL.
88    pub fn without_ingest(mut self) -> Self {
89        self.expose_token = false;
90        self.ingest_url = None;
91        self
92    }
93
94    /// 32 random bytes, lower-case hex.
95    pub fn fresh_token() -> SecretString {
96        let mut b = [0u8; 32];
97        rand::thread_rng().fill_bytes(&mut b);
98        SecretString::new(crate::workload_pkg::codec::hex_encode(&b))
99    }
100
101    /// Short BLAKE3 fingerprint of the token, safe for chain payloads.
102    pub fn token_hash(&self) -> String {
103        blake3::hash(self.token.expose().as_bytes()).to_hex()[..16].to_string()
104    }
105
106    /// Validate before use.
107    pub fn validate(&self) -> Result<(), RuntimeError> {
108        if self.csi_bind.port() == 0 {
109            return Err(RuntimeError::InvalidConfig("csi_bind needs a port".into()));
110        }
111        let t = self.token.expose();
112        if t.len() < 32 || t.len() > 256 || !t.bytes().all(|b| b.is_ascii_graphic()) {
113            return Err(RuntimeError::InvalidConfig(
114                "cog token must be 32..256 printable characters".into(),
115            ));
116        }
117        Ok(())
118    }
119
120    /// Environment for the instance. `data_dir` is where the runtime mounts
121    /// or creates the instance's writable directory.
122    pub fn env(&self, data_dir: &str) -> Vec<(String, String)> {
123        let mut env = vec![
124            (ENV_CSI_BIND.to_string(), self.csi_bind.to_string()),
125            (ENV_DATA_DIR.to_string(), data_dir.to_string()),
126        ];
127        if self.expose_token {
128            env.push((ENV_TOKEN.to_string(), self.token.expose().to_string()));
129        }
130        if let Some(s) = self.sensor_url {
131            env.push((ENV_SENSOR_URL.to_string(), s.to_string()));
132        }
133        if let Some(u) = &self.ingest_url {
134            env.push((ENV_INGEST_URL.to_string(), u.clone()));
135        }
136        env
137    }
138
139    /// Chain-safe summary (token as hash only).
140    pub fn audit(&self) -> serde_json::Value {
141        serde_json::json!({
142            "csi_bind": self.csi_bind.to_string(),
143            "sensor_url": self.sensor_url.map(|s| s.to_string()),
144            "token_hash": self.token_hash(),
145            "ingest_upstream": self.ingest_upstream.map(|s| s.to_string()),
146            "ingest_url": self.ingest_url,
147        })
148    }
149}