openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Where install remembers the endpoint an agent was pointed at *before* we
//! wired it to the model boundary — so uninstall can put it back.
//!
//! Until this existed the wiring was a one-way door on both conventions: the
//! writer overwrote `ANTHROPIC_BASE_URL` unconditionally and the remover only
//! knew how to delete the key, so a customer whose Claude Code pointed at a
//! corporate gateway lost it on install and never got it back. The headers on
//! the same path were always additive in both directions; only the base URL
//! was careless. This makes the two consistent, and gives Codex's
//! `model_provider` the same guarantee from the start.
//!
//! # Why a sibling file, and not a field on `StateEntry`
//!
//! [`crate::core::hook_state::StateEntry`] carries a `hook_event` and is
//! upserted **once per hook event** — twelve rows per agent. A prior endpoint
//! is a per-**agent** fact, so an additive field there would write it twelve
//! times with no defined tie-break, and the re-install rule below would then
//! depend on which of the twelve rows happened to be read.
//!
//! # The re-install rule, which the caller owns
//!
//! Recording unconditionally is wrong. On a re-install the value on disk is
//! already ours, so a second install would overwrite the recorded prior with
//! our own value — and uninstall would then "restore" a pointer at a provider
//! table it has just deleted. Callers therefore record **only** when the
//! current value is not already ours; see `hooks::write_boundary_config`.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};

/// One agent's record.
///
/// A struct rather than a bare `Option<String>` on the wire so the file can
/// grow a second per-agent fact without a format break. `prior: null` is a
/// meaningful value — *the agent named no endpoint before we wired it* — and
/// is not the same as having no entry at all.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Entry {
    /// The endpoint the agent named before OpenLatch wired it, or `null` when
    /// it named none.
    prior: Option<String>,
}

/// The whole file: agent wire type → entry.
type Records = BTreeMap<String, Entry>;

/// `$OPENLATCH_DIR/boundary-endpoints.json`.
///
/// Through [`crate::config::openlatch_dir`], **never a literal `~/.openlatch`**:
/// that function is the seam every isolated instance depends on, and hardcoding
/// the home path would make a sandboxed run rewrite the developer's real
/// record.
fn record_path() -> PathBuf {
    crate::config::openlatch_dir().join("boundary-endpoints.json")
}

/// Read the file, distinguishing "there is none" from "it could not be read".
///
/// `Ok(None)` means the file is absent, which is the ordinary first-install
/// state. `Err` means it exists and could not be read or parsed, and the two
/// callers want opposite things from that:
///
/// * A TEARDOWN must not be blocked by it. The worst case there is that a prior
///   endpoint goes unrestored, and refusing to unwire would leave the agent
///   pointed at a listener that is going away — so `take` degrades to "no
///   record" and says so in a warning.
/// * A WRITE must not proceed over it. `record` used to inherit the same
///   degradation, which meant one unreadable byte turned into "the journal is
///   empty" and the next `store` overwrote the OTHER agent's restoration
///   record with a single entry. Losing an unrelated agent's prior endpoint to
///   our own parse failure is not a degradation, it is data loss.
fn load() -> Result<Option<Records>, OlError> {
    let raw = match std::fs::read_to_string(record_path()) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(OlError::new(
                ERR_STATE_FILE_CORRUPT,
                format!("cannot read {}: {e}", record_path().display()),
            ))
        }
    };
    serde_json::from_str(&raw).map(Some).map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_CORRUPT,
            format!("{} is not valid JSON: {e}", record_path().display()),
        )
    })
}

/// Write the file back, owner-only.
///
/// Through the shared [`crate::fs_secure::restrict_to_owner`] helper rather
/// than a `cfg` branch of its own — the record names a customer's internal
/// gateway host, which is not something to leave world-readable under an
/// `OPENLATCH_DIR` that points somewhere with a permissive ACL.
fn store(records: &Records) -> Result<(), OlError> {
    let path = record_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot create the OpenLatch directory: {e}"),
            )
        })?;
    }
    let content = serde_json::to_string_pretty(records).map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot serialize the boundary endpoint record: {e}"),
        )
    })?;
    let tmp = path.with_extension("json.openlatch-tmp");
    std::fs::write(&tmp, &content).map_err(|e| {
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot write the boundary endpoint record: {e}"),
        )
    })?;
    std::fs::rename(&tmp, &path).map_err(|e| {
        let _ = std::fs::remove_file(&tmp);
        OlError::new(
            ERR_STATE_FILE_WRITE_FAILED,
            format!("Cannot replace the boundary endpoint record: {e}"),
        )
    })?;
    let _ = crate::fs_secure::restrict_to_owner(&path);
    Ok(())
}

/// Record what `agent` pointed at before we wired it.
///
/// `prior` is `None` when the agent named no endpoint at all — a value worth
/// storing, because uninstall then knows to *remove* the key rather than leave
/// ours behind.
///
/// **Only ever called when the value on disk is not already ours.** See the
/// module doc: recording on a re-install destroys the real prior.
pub fn record(agent: &str, prior: Option<String>) -> Result<(), OlError> {
    // PROPAGATE a read failure rather than degrading to an empty journal. The
    // degradation is right for `take` and wrong here: `store` below writes the
    // whole map back, so treating an unreadable file as empty would replace
    // the OTHER agent's restoration record with a single entry. Losing an
    // unrelated agent's prior endpoint to our own parse failure is data loss,
    // not a graceful degradation.
    let mut records = load()?.unwrap_or_default();
    records.insert(agent.to_string(), Entry { prior });
    store(&records)
}

/// Read `agent`'s record WITHOUT removing it.
///
/// The consuming [`take`] cannot be used before a rewrite that might fail: it
/// deletes the entry first, so a failed rename would leave the agent pointed
/// at us with its real prior endpoint gone for good. Callers peek, rewrite,
/// and only then [`forget`].
pub fn peek(agent: &str) -> Option<Option<String>> {
    match load() {
        Ok(Some(records)) => records.get(agent).map(|e| e.prior.clone()),
        Ok(None) => None,
        Err(e) => {
            tracing::warn!(
                agent,
                code = %e.code,
                error = %e.message,
                "boundary endpoint record unreadable — treating as no prior rather than \
                 blocking the teardown"
            );
            None
        }
    }
}

/// Drop `agent`'s record, after the rewrite that consumed it succeeded.
///
/// Best effort by design: the customer's file is already correct by the time
/// this runs, and failing the teardown over the bookkeeping would be the tail
/// wagging the dog. A surviving record is harmless — the next uninstall finds
/// the pointer is no longer ours and leaves it alone.
pub fn forget(agent: &str) {
    match load() {
        Ok(Some(mut records)) => {
            if records.remove(agent).is_some() {
                if let Err(e) = store(&records) {
                    tracing::warn!(agent, error = %e.message, "could not clear the record");
                }
            }
        }
        Ok(None) => {}
        Err(e) => tracing::warn!(agent, error = %e.message, "could not clear the record"),
    }
}

/// Take `agent`'s record, removing it.
///
/// Three outcomes, and collapsing any two of them loses something:
///
/// - `Some(Some(v))` — restore `v`.
/// - `Some(None)` — the agent named no endpoint before us; remove ours.
/// - `None` — no record at all. Nothing to restore.
///
/// Removing the entry is what makes uninstall **idempotent**: it runs two or
/// three times per `openlatch uninstall` (the command itself, `run_stop`'s
/// net, and the daemon's own teardown), and a record that survived the first
/// call would have the second one restore a pointer that is already back.
///
/// The file itself is left in place even when it empties out.
pub fn take(agent: &str) -> Option<Option<String>> {
    // Degrades on an unreadable journal, deliberately and loudly: a teardown
    // must not be blocked by bookkeeping. See `load`'s doc for why `record`
    // does the opposite.
    let mut records = match load() {
        Ok(Some(r)) => r,
        Ok(None) => return None,
        Err(e) => {
            tracing::warn!(
                agent,
                code = %e.code,
                error = %e.message,
                "boundary endpoint record unreadable — no prior will be restored"
            );
            return None;
        }
    };
    let entry = records.remove(agent)?;
    if let Err(e) = store(&records) {
        // Best effort: the caller has the value it needs and the restore is
        // the point. A record that could not be cleared is re-read on the next
        // uninstall pass, where the ownership guard has already stopped it.
        tracing::warn!(
            code = %e.code,
            error = %e.message,
            agent,
            "could not clear the recorded prior model endpoint"
        );
    }
    Some(entry.prior)
}

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

    /// Redirect `OPENLATCH_DIR` at a tempdir for the body of `f`, under **the**
    /// lock for that variable.
    fn with_openlatch_dir<T>(f: impl FnOnce() -> T) -> T {
        let _guard = crate::config::OPENLATCH_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::tempdir().expect("tempdir");
        let prev = std::env::var_os("OPENLATCH_DIR");
        std::env::set_var("OPENLATCH_DIR", tmp.path());
        let out = f();
        match prev {
            Some(v) => std::env::set_var("OPENLATCH_DIR", v),
            None => std::env::remove_var("OPENLATCH_DIR"),
        }
        out
    }

    /// The three outcomes are three outcomes. `Some(None)` says "there was no
    /// endpoint"; `None` says "we never looked" — and the remover acts on them
    /// differently.
    #[test]
    fn take_distinguishes_an_absent_record_from_a_recorded_absence() {
        with_openlatch_dir(|| {
            assert_eq!(take("claude-code"), None, "nothing recorded yet");

            record("claude-code", None).expect("record");
            assert_eq!(take("claude-code"), Some(None), "a recorded absence");
            assert_eq!(take("claude-code"), None, "take removes the entry");

            record("codex-cli", Some("corporate-gateway".into())).expect("record");
            assert_eq!(take("codex-cli"), Some(Some("corporate-gateway".into())));
            assert_eq!(take("codex-cli"), None, "and removes that one too");
        });
    }

    /// Two agents, two independent records: taking one must not disturb the
    /// other, which is the whole reason this is keyed per agent.
    #[test]
    fn records_are_per_agent() {
        with_openlatch_dir(|| {
            record("claude-code", Some("https://gw.example".into())).expect("record");
            record("codex-cli", Some("corporate-gateway".into())).expect("record");

            assert_eq!(take("claude-code"), Some(Some("https://gw.example".into())));
            assert_eq!(
                take("codex-cli"),
                Some(Some("corporate-gateway".into())),
                "the other agent's record must survive the first take"
            );
        });
    }
}