openlatch-client 0.6.13

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. `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)
}

/// Record a load report for `surface` at `at_ms`, with the effective plugin
/// list when the report carried one. A report older than the one already kept
/// for its surface changes nothing: the newest wins whatever order they land in.
///
/// # Errors
///
/// The underlying write error.
pub fn record_report(
    ol_dir: &Path,
    surface: &str,
    at_ms: i64,
    plugins: Option<&[String]>,
) -> 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));
    }
    crate::fs_secure::write_json_state(&path, &file)
}

/// 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)).unwrap();
        record_report(dir.path(), "cli", 400, Some(&[])).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));
    }

    #[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");
    }
}