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 path resolution and detection (AGT-5 C-1).
//!
//! The **only** place the OpenCode config root is resolved. The binding, the
//! plugin writer, the attestation and the config-monitor manifest all ask here.
//!
//! The root is exactly OpenCode's own resolution — `$XDG_CONFIG_HOME/opencode`,
//! else `~/.config/opencode`, on every OS — so the folder we write is always the
//! one OpenCode loads plugins from. There is deliberately no OpenLatch-specific
//! override: OpenCode's own `XDG_*` variables are the isolation seams, as
//! `CODEX_HOME` is for Codex. `OPENCODE_CONFIG_DIR` only *adds* a directory to
//! OpenCode's search and the global one is always loaded, so it is ignored.
//!
//! Detection is the CLI or the Desktop application being installed. A leftover
//! config root alone is **not** a detection.

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

/// OpenCode's own seam for its config root. Isolated instances set it.
pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";

/// Every seam an isolated instance sets for OpenCode: config, data, state and
/// cache. The client reads only the first; the sandbox tool and the E2E gate
/// require all four, because OpenCode itself writes into each.
pub const SEAMS: &[&str] = &[
    "XDG_CONFIG_HOME",
    "XDG_DATA_HOME",
    "XDG_STATE_HOME",
    "XDG_CACHE_HOME",
];

/// The directory name under the config home.
const DIR_NAME: &str = "opencode";

/// The plugins folder we write. OpenCode also reads `plugin/`; we always write
/// `plugins/`.
pub const PLUGINS_DIR_NAME: &str = "plugins";

/// Our plugin file's name inside [`PLUGINS_DIR_NAME`].
pub const PLUGIN_FILE_NAME: &str = "openlatch.js";

/// The OpenCode version this build supports from (C-5 `cli.supported`).
pub const MIN_SUPPORTED_VERSION: (u64, u64, u64) = (1, 17, 0);

/// How the config root was located (C-5 `config_root.located`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Located {
    /// `~/.config/opencode`.
    Default,
    /// `$XDG_CONFIG_HOME/opencode`.
    Xdg,
}

impl Located {
    /// The wire token: `default` or `xdg`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Xdg => "xdg",
        }
    }
}

fn xdg_config_home() -> Option<PathBuf> {
    xdg_config_home_from(
        std::env::var_os(XDG_CONFIG_HOME_ENV),
        std::env::current_dir().ok().as_deref(),
    )
}

/// OpenCode (via xdg-basedir) uses `XDG_CONFIG_HOME` as given, so a relative
/// value is the folder under the cwd OpenCode starts in. We resolve it the same
/// way, against our own cwd, and keep the absolute result: `init` run from that
/// folder writes where OpenCode loads, and the state row records an absolute
/// path the daemon can heal from any cwd. Empty reads as unset; a relative value
/// with no cwd to resolve against is unusable and reads as unset too.
pub(crate) fn xdg_config_home_from(
    value: Option<std::ffi::OsString>,
    cwd: Option<&Path>,
) -> Option<PathBuf> {
    let path = PathBuf::from(value.filter(|v| !v.is_empty())?);
    if path.is_absolute() {
        return Some(path);
    }
    Some(cwd?.join(path))
}

fn default_root() -> Option<PathBuf> {
    dirs::home_dir().map(|home| home.join(".config").join(DIR_NAME))
}

/// The config root and how it was located.
#[must_use]
pub fn root_located() -> Option<(PathBuf, Located)> {
    root_located_in(xdg_config_home(), dirs::home_dir().as_deref())
}

pub(crate) fn root_located_in(
    xdg: Option<PathBuf>,
    home: Option<&Path>,
) -> Option<(PathBuf, Located)> {
    if let Some(xdg) = xdg {
        return Some((xdg.join(DIR_NAME), Located::Xdg));
    }
    Some((home?.join(".config").join(DIR_NAME), Located::Default))
}

/// The config root: `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`.
#[must_use]
pub fn root() -> Option<PathBuf> {
    root_located().map(|(p, _)| p)
}

/// `<root>/plugins`.
#[must_use]
pub fn plugins_dir(root: &Path) -> PathBuf {
    root.join(PLUGINS_DIR_NAME)
}

/// `<root>/plugins/openlatch.js`.
#[must_use]
pub fn plugin_path(root: &Path) -> PathBuf {
    plugins_dir(root).join(PLUGIN_FILE_NAME)
}

/// True when the root resolves to the machine user's default location.
#[must_use]
pub fn config_is_machine_global() -> bool {
    let (Some(resolved), Some(default)) = (root(), default_root()) else {
        return true;
    };
    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    canonical(&resolved) == canonical(&default)
}

/// The seam a fixture sets so nothing it installs reaches the developer's own
/// `~/.config/opencode`: a config home that is never created.
#[must_use]
pub fn absent_seam(root: &Path) -> (&'static str, PathBuf) {
    (XDG_CONFIG_HOME_ENV, root.join("absent-opencode-config"))
}

/// Which OpenCode surfaces are installed on this host.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Installed {
    /// The CLI binary, when found.
    pub cli: Option<PathBuf>,
    /// The Desktop application, when found.
    pub desktop: Option<PathBuf>,
}

impl Installed {
    /// Either surface is present.
    #[must_use]
    pub fn any(&self) -> bool {
        self.cli.is_some() || self.desktop.is_some()
    }
}

/// Detect OpenCode on this host. `None` when neither the CLI nor the Desktop
/// application is installed — a leftover config root is not a detection.
#[must_use]
pub fn detect() -> Option<PathBuf> {
    installed().any().then(root).flatten()
}

/// The installed surfaces, from the real environment.
#[must_use]
pub fn installed() -> Installed {
    match dirs::home_dir() {
        Some(home) => installed_in(&home, std::env::var_os("PATH").as_deref()),
        None => Installed::default(),
    }
}

pub(crate) fn installed_in(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Installed {
    Installed {
        cli: cli_location(home, path_var),
        desktop: desktop_locations(home).into_iter().find(|p| p.exists()),
    }
}

/// The CLI: `opencode` on an absolute `PATH` entry (npm and Homebrew installs),
/// else the official install script's target `~/.opencode/bin/opencode`.
fn cli_location(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
    if let Some(found) = find_on_path(path_var, "opencode") {
        return Some(found);
    }
    let name = if cfg!(windows) {
        "opencode.exe"
    } else {
        "opencode"
    };
    let script = home.join(".opencode").join("bin").join(name);
    script.is_file().then_some(script)
}

fn find_on_path(path_var: Option<&std::ffi::OsStr>, name: &str) -> Option<PathBuf> {
    let path_var = path_var?;
    let names: Vec<String> = if cfg!(windows) {
        ["exe", "cmd", "ps1"]
            .iter()
            .map(|ext| format!("{name}.{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    };
    std::env::split_paths(path_var)
        .filter(|dir| dir.is_absolute())
        .flat_map(|dir| names.iter().map(move |n| dir.join(n)))
        .find(|p| p.is_file())
}

/// Where the Desktop application (Electron; product `OpenCode`, app id
/// `ai.opencode.desktop`) installs. Read for presence and bundle metadata only —
/// never launched.
pub(crate) fn desktop_locations(home: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    if cfg!(target_os = "macos") {
        out.push(PathBuf::from("/Applications").join("OpenCode.app")); // portability-ok: macOS bundle root, reached only when cfg!(target_os = "macos")
        out.push(home.join("Applications").join("OpenCode.app"));
    }
    if cfg!(windows) {
        if let Some(local) = dirs::data_local_dir() {
            let programs = local.join("Programs");
            out.push(programs.join("OpenCode").join("OpenCode.exe"));
            out.push(programs.join("opencode").join("OpenCode.exe"));
            out.push(programs.join("ai.opencode.desktop").join("OpenCode.exe"));
        }
    }
    if cfg!(target_os = "linux") {
        out.push(PathBuf::from("/usr/bin").join("ai.opencode.desktop")); // portability-ok: Linux package binary, reached only when cfg!(target_os = "linux")
        out.push(
            PathBuf::from("/usr/share/applications").join("ai.opencode.desktop.desktop"), // portability-ok: Linux desktop entry, reached only when cfg!(target_os = "linux")
        );
        out.push(
            home.join(".local")
                .join("share")
                .join("applications")
                .join("ai.opencode.desktop.desktop"),
        );
    }
    out
}

/// OpenCode's managed-config directory (C-7): `/Library/Application
/// Support/opencode` on macOS, `/etc/opencode` on Linux, `%ProgramData%\opencode`
/// on Windows. Read as inventory, never written. `None` where it cannot be named.
#[must_use]
pub fn managed_dir() -> Option<PathBuf> {
    if cfg!(target_os = "macos") {
        return Some(PathBuf::from("/Library/Application Support/opencode")); // portability-ok: macOS managed-config root, reached only when cfg!(target_os = "macos")
    }
    if cfg!(windows) {
        return std::env::var_os("ProgramData").map(|d| PathBuf::from(d).join("opencode"));
    }
    Some(PathBuf::from("/etc/opencode")) // portability-ok: Linux managed-config root, reached on Linux and other Unix only
}

/// Parse `opencode --version` output (`1.18.35`, possibly prefixed `v`).
#[must_use]
pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
    let token = text.split_whitespace().find(|t| {
        t.trim_start_matches('v')
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_digit())
    })?;
    let core = token
        .trim_start_matches('v')
        .split(['-', '+'])
        .next()
        .unwrap_or_default();
    let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
    Some((
        parts.next()??,
        parts.next()??,
        parts.next().flatten().unwrap_or(0),
    ))
}

/// Whether a version meets [`MIN_SUPPORTED_VERSION`].
#[must_use]
pub fn version_supported(version: (u64, u64, u64)) -> bool {
    version >= MIN_SUPPORTED_VERSION
}

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

    #[test]
    fn a_relative_xdg_config_home_resolves_against_the_cwd_as_opencode_does() {
        let cwd = std::env::temp_dir().join("work-repo");
        let absolute = std::env::temp_dir().join("abs-cfg");
        assert_eq!(
            xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
            Some(cwd.join("agent-config"))
        );
        assert_eq!(
            xdg_config_home_from(Some(absolute.clone().into_os_string()), Some(&cwd)),
            Some(absolute)
        );
        assert_eq!(xdg_config_home_from(Some("".into()), Some(&cwd)), None);
        assert_eq!(xdg_config_home_from(None, Some(&cwd)), None);
        assert_eq!(xdg_config_home_from(Some("rel".into()), None), None);
        let (root, located) = root_located_in(
            xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
            Some(Path::new("/h")),
        )
        .unwrap();
        assert_eq!(root, cwd.join("agent-config").join("opencode"));
        assert_eq!(located, Located::Xdg);
    }

    #[test]
    fn root_is_xdg_when_set_else_home_config() {
        let (p, l) = root_located_in(Some(PathBuf::from("/x/cfg")), Some(Path::new("/h"))).unwrap();
        assert_eq!(p, PathBuf::from("/x/cfg").join("opencode"));
        assert_eq!(l, Located::Xdg);
        let (p, l) = root_located_in(None, Some(Path::new("/h"))).unwrap();
        assert_eq!(p, Path::new("/h").join(".config").join("opencode"));
        assert_eq!(l, Located::Default);
    }

    #[test]
    fn plugin_path_is_plugins_openlatch_js() {
        let p = plugin_path(Path::new("/r"));
        assert_eq!(p, Path::new("/r").join("plugins").join("openlatch.js"));
    }

    #[test]
    fn a_leftover_config_root_alone_is_not_a_detection() {
        let home = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
        assert!(!installed_in(home.path(), None).any());
    }

    #[test]
    fn the_install_script_target_is_a_detection() {
        let home = tempfile::tempdir().unwrap();
        let bin = home.path().join(".opencode").join("bin");
        std::fs::create_dir_all(&bin).unwrap();
        let name = if cfg!(windows) {
            "opencode.exe"
        } else {
            "opencode"
        };
        std::fs::write(bin.join(name), "").unwrap();
        assert!(installed_in(home.path(), None).cli.is_some());
    }

    #[test]
    fn a_cli_on_path_is_a_detection() {
        let home = tempfile::tempdir().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let name = if cfg!(windows) {
            "opencode.exe"
        } else {
            "opencode"
        };
        std::fs::write(dir.path().join(name), "").unwrap();
        let path = std::env::join_paths([dir.path()]).unwrap();
        assert_eq!(
            installed_in(home.path(), Some(&path)).cli,
            Some(dir.path().join(name))
        );
    }

    #[test]
    fn versions_parse_and_compare_against_the_floor() {
        assert_eq!(parse_version("1.18.35\n"), Some((1, 18, 35)));
        assert_eq!(parse_version("opencode v1.17.0"), Some((1, 17, 0)));
        assert_eq!(parse_version("0.0.0-dev-202610"), Some((0, 0, 0)));
        assert_eq!(parse_version("garbage"), None);
        assert!(version_supported((1, 17, 0)));
        assert!(version_supported((1, 18, 35)));
        assert!(!version_supported((1, 16, 9)));
    }
}