openlatch-client 0.6.19

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! OpenCode plugin load proof (AGT-5 C-2, C-5).
//!
//! The OpenCode plugin reports its own loading (`plugin_loaded`, capture-only)
//! once per server process. The daemon consumes that report locally: it writes
//! the time each surface was last seen to `state/opencode-plugin-seen.json`,
//! and creates no session and no cloud event. The attestation and the binding's
//! liveness read it to tell *seen* from *pending*.
//!
//! One timestamp per surface (`cli`, `acp`, `desktop`, `unknown`), the newest
//! kept, never sent to the cloud. Beside them, under `plugins`, the effective
//! plugin list of the newest report that carried one (the `config` stage): it is
//! what `other_plugins` shows, since a plugin named in config or a repository's
//! `.opencode/plugins` loads without ever appearing in the global folders. Under
//! `relay`, per surface, how the newest report said the plugin armed the local
//! model relay (`relay_arming`, `runtime`) and when (AGT-6 C-8 Leg A, D-14).
//! `uninstall --agent opencode` deletes the
//! file; `--purge` removes it with the rest of the state directory.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// The file name under `<openlatch-dir>/state/`.
pub const SEEN_FILE: &str = "opencode-plugin-seen.json";

/// `<openlatch-dir>/state/opencode-plugin-seen.json`.
#[must_use]
pub fn seen_path(ol_dir: &Path) -> PathBuf {
    ol_dir.join("state").join(SEEN_FILE)
}

/// Surface → last-seen time, epoch milliseconds. Empty when absent or unreadable.
#[must_use]
pub fn read_seen(ol_dir: &Path) -> BTreeMap<String, i64> {
    let Ok(raw) = std::fs::read_to_string(seen_path(ol_dir)) else {
        return BTreeMap::new();
    };
    let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(&raw) else {
        return BTreeMap::new();
    };
    map.into_iter()
        .filter_map(|(k, v)| Some((k, v.as_i64()?)))
        .collect()
}

/// The key the reported plugin list is kept under. Its value is an array, so
/// [`read_seen`] never mistakes it for a surface.
const PLUGINS_KEY: &str = "plugins";

/// Serialises the read-modify-write of the file: reports from two surfaces land
/// on separate blocking tasks, and each must see the other's write.
static WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Record a load report for `surface` at `at_ms`.
///
/// # Errors
///
/// The underlying write error.
pub fn record(ol_dir: &Path, surface: &str, at_ms: i64) -> std::io::Result<()> {
    record_report(ol_dir, surface, at_ms, None, None, None)
}

/// The key the per-surface relay arming is kept under (AGT-6 C-8 Leg A, D-14).
/// Its value is an object, so [`read_seen`] never mistakes it for a surface.
const RELAY_KEY: &str = "relay";

/// Longest `relay_arming` or `runtime` value kept. Both are open strings on the
/// wire; the bound only stops a malformed report from growing the file.
const RELAY_VALUE_MAX: usize = 64;

/// How the newest load report of one surface said the plugin armed the local
/// model relay (C-8 Leg A), and when. The attestation reads it (D-14): on the
/// `node` line, Desktop is proven by an OpenCode turn recorded after `at_ms`
/// while this says `armed_in_process`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelayArming {
    /// `armed_in_process` · `armed_by_environment` · `not_armed` (open string).
    pub relay_arming: String,
    /// `node` · `bun` (open string).
    pub runtime: String,
    /// When the report that said so was recorded, epoch milliseconds.
    pub at_ms: i64,
}

/// Record a load report for `surface` at `at_ms`, with the effective plugin
/// list when the report carried one, and its `relay_arming` and `runtime` when
/// it carried both. A report older than the one already kept for its surface
/// changes nothing: the newest wins whatever order they land in. A newer report
/// without valid arming fields clears the surface's arming evidence: the newest
/// report is the evidence (plan D-14), so an older `armed_in_process` never
/// outlives a later report that did not state it.
///
/// # Errors
///
/// The underlying write error.
pub fn record_report(
    ol_dir: &Path,
    surface: &str,
    at_ms: i64,
    plugins: Option<&[String]>,
    relay_arming: Option<&str>,
    runtime: Option<&str>,
) -> std::io::Result<()> {
    let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner());
    let path = seen_path(ol_dir);
    let mut file = std::fs::read_to_string(&path)
        .ok()
        .and_then(|raw| match serde_json::from_str(&raw) {
            Ok(serde_json::Value::Object(map)) => Some(map),
            _ => None,
        })
        .unwrap_or_default();
    let newer = file
        .get(surface)
        .and_then(serde_json::Value::as_i64)
        .is_none_or(|kept| at_ms >= kept);
    if !newer {
        return Ok(());
    }
    file.insert(surface.to_string(), serde_json::json!(at_ms));
    if let Some(plugins) = plugins {
        file.insert(PLUGINS_KEY.to_string(), serde_json::json!(plugins));
    }
    fn bounded(v: Option<&str>) -> Option<&str> {
        v.filter(|v| !v.is_empty() && v.len() <= RELAY_VALUE_MAX)
    }
    let relay = file
        .entry(RELAY_KEY.to_string())
        .or_insert_with(|| serde_json::json!({}));
    if !relay.is_object() {
        *relay = serde_json::json!({});
    }
    match (bounded(relay_arming), bounded(runtime)) {
        (Some(arming), Some(runtime)) => {
            relay[surface] = serde_json::json!({
                "relay_arming": arming,
                "runtime": runtime,
                "at": at_ms,
            });
        }
        _ => {
            if let Some(map) = relay.as_object_mut() {
                map.remove(surface);
            }
        }
    }
    crate::fs_secure::write_json_state(&path, &file)
}

/// Surface → the relay arming its newest load report stated. Empty when absent,
/// unreadable, or no report has carried the fields yet (an older plugin).
#[must_use]
pub fn relay_arming(ol_dir: &Path) -> BTreeMap<String, RelayArming> {
    let Some(serde_json::Value::Object(map)) = std::fs::read_to_string(seen_path(ol_dir))
        .ok()
        .and_then(|raw| serde_json::from_str(&raw).ok())
    else {
        return BTreeMap::new();
    };
    let Some(serde_json::Value::Object(relay)) = map.get(RELAY_KEY) else {
        return BTreeMap::new();
    };
    relay
        .iter()
        .filter_map(|(surface, v)| {
            Some((
                surface.clone(),
                RelayArming {
                    relay_arming: v.get("relay_arming")?.as_str()?.to_string(),
                    runtime: v.get("runtime")?.as_str()?.to_string(),
                    at_ms: v.get("at")?.as_i64()?,
                },
            ))
        })
        .collect()
}

/// The effective plugin list of the newest load report that carried one, as
/// OpenCode names them (a package spec, or a `file://` URL). `None` when no
/// report has carried one yet.
#[must_use]
pub fn reported_plugins(ol_dir: &Path) -> Option<Vec<String>> {
    let raw = std::fs::read_to_string(seen_path(ol_dir)).ok()?;
    let serde_json::Value::Object(map) = serde_json::from_str(&raw).ok()? else {
        return None;
    };
    let list = map.get(PLUGINS_KEY)?.as_array()?;
    Some(
        list.iter()
            .filter_map(|v| v.as_str().map(str::to_string))
            .collect(),
    )
}

/// The most recent load report, any surface.
#[must_use]
pub fn last_seen(seen: &BTreeMap<String, i64>) -> Option<i64> {
    seen.values().copied().max()
}

/// Whether a load report arrived at or after `since_ms` (the plugin file's
/// write time): the file on disk now is the one OpenCode loaded.
#[must_use]
pub fn seen_since(seen: &BTreeMap<String, i64>, since_ms: i64) -> bool {
    last_seen(seen).is_some_and(|t| t >= since_ms)
}

/// Delete the load proof. Missing is fine.
pub fn forget(ol_dir: &Path) {
    let _ = std::fs::remove_file(seen_path(ol_dir));
}

/// Map OpenCode's `OPENCODE_CLIENT` value onto `agent_surface` (C-3).
#[must_use]
pub fn surface_of(client: Option<&str>) -> &'static str {
    crate::core::envelope::normalize::opencode_surface_of(client)
}

/// A file's modification time in epoch milliseconds.
#[must_use]
pub fn mtime_ms(path: &Path) -> Option<i64> {
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    let d = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
    i64::try_from(d.as_millis()).ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn record_read_and_forget_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        assert!(read_seen(dir.path()).is_empty());
        record(dir.path(), "cli", 100).unwrap();
        record(dir.path(), "acp", 300).unwrap();
        record(dir.path(), "cli", 200).unwrap();
        let seen = read_seen(dir.path());
        assert_eq!(seen.get("cli"), Some(&200));
        assert_eq!(last_seen(&seen), Some(300));
        assert!(seen_since(&seen, 300));
        assert!(!seen_since(&seen, 301));
        forget(dir.path());
        assert!(!seen_path(dir.path()).exists());
    }

    #[test]
    fn an_older_report_never_overwrites_a_newer_one_and_the_plugin_list_is_kept() {
        let dir = tempfile::tempdir().unwrap();
        let list = vec!["opencode-foo@1.0.0".to_string()];
        record_report(dir.path(), "cli", 500, Some(&list), None, None).unwrap();
        record_report(dir.path(), "cli", 400, Some(&[]), None, None).unwrap();
        record(dir.path(), "acp", 450).unwrap();
        let seen = read_seen(dir.path());
        assert_eq!(seen.get("cli"), Some(&500));
        assert_eq!(seen.get("acp"), Some(&450));
        assert!(!seen.contains_key(PLUGINS_KEY), "the list is not a surface");
        assert_eq!(reported_plugins(dir.path()), Some(list));
    }

    #[test]
    fn concurrent_reports_from_two_surfaces_both_land() {
        let dir = tempfile::tempdir().unwrap();
        let handles: Vec<_> = (0..16)
            .map(|i| {
                let ol = dir.path().to_path_buf();
                std::thread::spawn(move || {
                    let surface = if i % 2 == 0 { "cli" } else { "acp" };
                    record(&ol, surface, 1000 + i).unwrap();
                })
            })
            .collect();
        for handle in handles {
            handle.join().unwrap();
        }
        let seen = read_seen(dir.path());
        assert_eq!(seen.get("cli"), Some(&1014));
        assert_eq!(seen.get("acp"), Some(&1015));
    }

    /// D-14 input: the newest report of each surface keeps its own arming and
    /// runtime with its time; an older report, a report without both fields, or
    /// an oversize value changes nothing, and the surfaces stay readable.
    #[test]
    fn relay_arming_is_kept_per_surface_newest_wins() {
        let dir = tempfile::tempdir().unwrap();
        assert!(relay_arming(dir.path()).is_empty());

        record_report(
            dir.path(),
            "desktop",
            100,
            None,
            Some("armed_in_process"),
            Some("node"),
        )
        .unwrap();
        record_report(dir.path(), "cli", 150, None, Some("not_armed"), Some("bun")).unwrap();
        record_report(
            dir.path(),
            "desktop",
            90,
            None,
            Some("not_armed"),
            Some("node"),
        )
        .unwrap();
        let kept = relay_arming(dir.path());
        assert_eq!(
            kept.get("desktop"),
            Some(&RelayArming {
                relay_arming: "armed_in_process".into(),
                runtime: "node".into(),
                at_ms: 100,
            }),
            "an older report never overwrites"
        );
        assert_eq!(kept.get("cli").map(|r| r.runtime.as_str()), Some("bun"));

        // A newer report without the fields (an older plugin), or with a malformed one, clears
        // the surface's arming evidence: the newest report is the evidence (plan D-14).
        record_report(dir.path(), "desktop", 200, None, None, None).unwrap();
        assert!(!relay_arming(dir.path()).contains_key("desktop"));
        record_report(
            dir.path(),
            "desktop",
            205,
            None,
            Some("armed_in_process"),
            Some("node"),
        )
        .unwrap();
        record_report(
            dir.path(),
            "desktop",
            210,
            None,
            Some(&"x".repeat(65)),
            Some("node"),
        )
        .unwrap();
        assert!(!relay_arming(dir.path()).contains_key("desktop"));
        assert_eq!(
            relay_arming(dir.path())
                .get("cli")
                .map(|r| r.runtime.as_str()),
            Some("bun"),
            "another surface's evidence is kept"
        );

        record_report(
            dir.path(),
            "desktop",
            300,
            None,
            Some("not_armed"),
            Some("node"),
        )
        .unwrap();
        assert_eq!(
            relay_arming(dir.path())["desktop"].relay_arming,
            "not_armed"
        );
        assert_eq!(relay_arming(dir.path())["desktop"].at_ms, 300);

        let seen = read_seen(dir.path());
        assert!(
            !seen.contains_key(RELAY_KEY),
            "the arming map is not a surface"
        );
        assert_eq!(seen.get("desktop"), Some(&300));
    }

    #[test]
    fn surfaces_map_per_c3() {
        assert_eq!(surface_of(Some("cli")), "cli");
        assert_eq!(surface_of(Some("acp")), "acp");
        assert_eq!(surface_of(Some("desktop")), "desktop");
        assert_eq!(surface_of(Some("web")), "unknown");
        assert_eq!(surface_of(None), "unknown");
    }
}