openlatch-client 0.6.15

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The Windows launcher shims, placed: one `<command>.exe` per [`ProxyDelivery::EnvFile`] command
//! under `<ol_dir>\shims\`, each a hard link to the running `openlatch.exe` (a copy across
//! volumes), with that folder first in the user `Path` (`core::user_path`).
//!
//! The launcher behaviour itself is `hooks::cli_shim`. Placing is the wiring supervisor's, every
//! pass, behind `cfg!(windows)` and `supervision::owns_machine_supervision()` ([`wanted`]) — the
//! same machine-global gate as the shell-profile line; `uninstall` takes both out. The shims exist
//! for every command whether or not its CLI is installed, so a CLI installed later is already
//! shadowed in every shell opened after install.
//!
//! [`ProxyDelivery::EnvFile`]: crate::hooks::binding::ProxyDelivery::EnvFile

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

use crate::core::user_path::{self, PathSink};
use crate::hooks::bindings::ENV_FILE_DELIVERIES;

/// The shims folder's name under `<ol_dir>`.
pub const SHIMS_DIR: &str = "shims";

/// The tamper field a missing or stale shim is reported under.
pub const FIELD_SHIM: &str = "cli_shim";
/// The tamper field a shims folder no longer first in the user `Path` is reported under.
pub const FIELD_USER_PATH: &str = "user_path";

pub fn shims_dir(ol_dir: &Path) -> PathBuf {
    ol_dir.join(SHIMS_DIR)
}

/// Every command a shim is placed for, in [`ENV_FILE_DELIVERIES`] order.
pub fn shim_commands() -> impl Iterator<Item = &'static str> {
    ENV_FILE_DELIVERIES
        .iter()
        .flat_map(|d| d.commands.iter().map(|c| c.name))
}

pub fn shim_path(dir: &Path, cmd: &str) -> PathBuf {
    dir.join(format!("{cmd}.exe"))
}

/// Whether this install places the shims: Windows, and the machine's own install (an isolated
/// instance never edits the user `Path`).
pub fn wanted(owns_machine: bool) -> bool {
    cfg!(windows) && owns_machine
}

/// Two names for the same binary: a hard link shares its length and write time, and a copy is
/// given the source's write time ([`place`]). A self-update replaces `exe`, so every shim then
/// differs and is re-linked.
fn same_binary(a: &Path, b: &Path) -> bool {
    match (std::fs::metadata(a), std::fs::metadata(b)) {
        (Ok(a), Ok(b)) => a.len() == b.len() && a.modified().ok() == b.modified().ok(),
        _ => false,
    }
}

/// The commands whose shim is absent or no longer the current `exe`.
pub fn stale_shims(ol_dir: &Path, exe: &Path) -> Vec<&'static str> {
    let dir = shims_dir(ol_dir);
    shim_commands()
        .filter(|cmd| !same_binary(&shim_path(&dir, cmd), exe))
        .collect()
}

/// `exe` at `target`: linked (copied across volumes) beside it first, then renamed in — through
/// [`crate::core::update::place_launcher`] when a shim is already there, since a running one can
/// be renamed but not overwritten.
fn place(exe: &Path, target: &Path) -> std::io::Result<()> {
    let staged = target.with_extension("exe.new");
    let _ = std::fs::remove_file(&staged);
    if std::fs::hard_link(exe, &staged).is_err() {
        std::fs::copy(exe, &staged)?;
        if let Ok(modified) = std::fs::metadata(exe).and_then(|m| m.modified()) {
            std::fs::File::options()
                .write(true)
                .open(&staged)
                .and_then(|f| f.set_modified(modified))?;
        }
    }
    if target.exists() {
        let placed = crate::core::update::place_launcher(&staged, target)?;
        // Still running shims keep their `.bak` until they exit; the next pass retries.
        if let Some(bak) = placed.bak {
            let _ = std::fs::remove_file(bak);
        }
        Ok(())
    } else {
        std::fs::rename(&staged, target)
    }
}

/// Place every missing or stale shim. Returns the commands placed.
pub fn ensure_shims(ol_dir: &Path, exe: &Path) -> Result<Vec<&'static str>, String> {
    let dir = shims_dir(ol_dir);
    let stale = stale_shims(ol_dir, exe);
    if stale.is_empty() {
        return Ok(stale);
    }
    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    for cmd in &stale {
        let target = shim_path(&dir, cmd);
        place(exe, &target).map_err(|e| format!("{}: {e}", target.display()))?;
    }
    Ok(stale)
}

/// Remove the shims folder. `Ok(true)` when it existed.
pub fn remove_shims(ol_dir: &Path) -> Result<bool, String> {
    let dir = shims_dir(ol_dir);
    match std::fs::remove_dir_all(&dir) {
        Ok(()) => Ok(true),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
        Err(e) => Err(format!("{}: {e}", dir.display())),
    }
}

/// What one [`ensure_all`] pass changed.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Placed {
    pub shims: Vec<&'static str>,
    pub user_path: bool,
}

/// Every shim current, and the shims folder first in the user `Path`. Shims first: the folder is
/// never put on `Path` before the launchers in it exist.
pub fn ensure_all(ol_dir: &Path, exe: &Path, sink: &dyn PathSink) -> Result<Placed, String> {
    let shims = ensure_shims(ol_dir, exe)?;
    let user_path = user_path::ensure_first(sink, &shims_dir(ol_dir))?;
    Ok(Placed { shims, user_path })
}

/// The user `Path` entry, then the shims folder. `Ok(true)` when either was there.
pub fn remove_all(ol_dir: &Path, sink: &dyn PathSink) -> Result<bool, String> {
    let path = user_path::remove(sink, &shims_dir(ol_dir))?;
    let shims = remove_shims(ol_dir)?;
    Ok(path || shims)
}

/// The tamper fields lost since the last pass: [`FIELD_SHIM`] for an absent or stale shim,
/// [`FIELD_USER_PATH`] for a shims folder no longer first in the user `Path`.
pub fn drift(ol_dir: &Path, exe: &Path, sink: &dyn PathSink) -> Vec<&'static str> {
    let mut lost = Vec::new();
    if !stale_shims(ol_dir, exe).is_empty() {
        lost.push(FIELD_SHIM);
    }
    let first = sink
        .read()
        .ok()
        .flatten()
        .is_some_and(|p| user_path::is_first(&p, &shims_dir(ol_dir).display().to_string()));
    if !first {
        lost.push(FIELD_USER_PATH);
    }
    lost
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use std::sync::Mutex;

    #[derive(Default)]
    pub(crate) struct RecordingPath {
        pub value: Mutex<Option<String>>,
    }

    impl PathSink for RecordingPath {
        fn read(&self) -> Result<Option<String>, String> {
            Ok(self.value.lock().unwrap().clone())
        }
        fn write(&self, value: &str) -> Result<(), String> {
            *self.value.lock().unwrap() = Some(value.to_string());
            Ok(())
        }
    }

    fn fake_exe(dir: &Path, body: &[u8]) -> PathBuf {
        let exe = dir.join("openlatch.exe");
        std::fs::write(&exe, body).unwrap();
        exe
    }

    #[test]
    fn a_shim_is_placed_for_every_env_file_command() {
        let cmds: Vec<_> = shim_commands().collect();
        for cmd in ["cursor-agent", "agent", "opencode", "cline"] {
            assert!(cmds.contains(&cmd), "{cmd} has no shim");
        }
    }

    #[test]
    fn ensure_places_links_once_and_relinks_after_an_update() {
        let tmp = tempfile::tempdir().unwrap();
        let (ol, bin) = (tmp.path().join("ol"), tmp.path().join("bin"));
        std::fs::create_dir_all(&bin).unwrap();
        let exe = fake_exe(&bin, b"v1");

        assert_eq!(
            ensure_shims(&ol, &exe).unwrap().len(),
            shim_commands().count()
        );
        assert!(ensure_shims(&ol, &exe).unwrap().is_empty(), "idempotent");
        let shim = shim_path(&shims_dir(&ol), "opencode");
        assert_eq!(std::fs::read(&shim).unwrap(), b"v1");

        // A self-update replaces the file at `exe`.
        std::fs::remove_file(&exe).unwrap();
        let exe = fake_exe(&bin, b"v2-longer");
        assert_eq!(stale_shims(&ol, &exe).len(), shim_commands().count());
        ensure_shims(&ol, &exe).unwrap();
        assert_eq!(std::fs::read(&shim).unwrap(), b"v2-longer");
        assert!(!shim.with_extension("bak").exists());
    }

    #[test]
    fn ensure_all_places_shims_then_puts_the_folder_first() {
        let tmp = tempfile::tempdir().unwrap();
        let ol = tmp.path().join("ol");
        let exe = fake_exe(tmp.path(), b"x");
        let sink = RecordingPath::default();
        *sink.value.lock().unwrap() = Some("C:\\tools".into());

        let placed = ensure_all(&ol, &exe, &sink).unwrap();
        assert!(placed.user_path);
        assert!(drift(&ol, &exe, &sink).is_empty());
        assert_eq!(ensure_all(&ol, &exe, &sink).unwrap(), Placed::default());

        // A later installer puts its entry in front, and a shim is deleted.
        *sink.value.lock().unwrap() = Some(format!("C:\\new;{}", shims_dir(&ol).display()));
        std::fs::remove_file(shim_path(&shims_dir(&ol), "cline")).unwrap();
        assert_eq!(drift(&ol, &exe, &sink), vec![FIELD_SHIM, FIELD_USER_PATH]);
        let placed = ensure_all(&ol, &exe, &sink).unwrap();
        assert_eq!(placed.shims, vec!["cline"]);
        assert!(placed.user_path);
        assert!(drift(&ol, &exe, &sink).is_empty());
    }

    #[test]
    fn remove_all_takes_out_the_entry_and_the_folder() {
        let tmp = tempfile::tempdir().unwrap();
        let ol = tmp.path().join("ol");
        let exe = fake_exe(tmp.path(), b"x");
        let sink = RecordingPath::default();
        *sink.value.lock().unwrap() = Some("C:\\tools".into());
        ensure_all(&ol, &exe, &sink).unwrap();

        assert!(remove_all(&ol, &sink).unwrap());
        assert_eq!(sink.read().unwrap().as_deref(), Some("C:\\tools"));
        assert!(!shims_dir(&ol).exists());
        assert!(!remove_all(&ol, &sink).unwrap(), "idempotent");
    }

    #[test]
    fn the_gate_needs_windows_and_the_machine_install() {
        assert!(!wanted(false));
        assert_eq!(wanted(true), cfg!(windows));
    }
}