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
//! Each agent's surfaces that read the relay wiring only when they start
//! ([`crate::model_relay::readiness::SurfaceDecl`]), by where their executables live.
//!
//! **`picks_up_live` is `false` everywhere until a live test records otherwise.** A running
//! Claude Code session, Codex thread, Cursor or VS Code window and terminal are all assumed to keep
//! the environment and settings they started with; flipping one to `true` needs the test that
//! proved it, named beside the flag. A process found nowhere here is never asked to restart.

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

use crate::hooks::cline_app::process::ExeProcess;
use crate::model_relay::readiness::{Restart, SurfaceDecl};

/// An Electron app's main process: the one whose settings and certificate list every window
/// inherits ([`crate::hooks::cursor::is_main_process`]).
fn electron_main(p: &ExeProcess) -> bool {
    crate::hooks::cursor::is_main_process(p)
}

/// Every process of a CLI.
fn any(_: &ExeProcess) -> bool {
    true
}

/// A Claude Code session, never its background helpers: the PTY host (`--bg-pty-host`) and the
/// daemon (`claude daemon …`) outlive every session and carry no model call of their own.
fn claude_session(p: &ExeProcess) -> bool {
    let line = p.args.join(" ");
    let first = p.args.get(1).map(String::as_str);
    !line.contains("--bg-pty-host") && first != Some("daemon")
}

/// The `.app` bundle holding a macOS binary at `<bundle>/Contents/MacOS/<exe>`, else the binary's
/// own directory.
fn app_root(binary: &Path) -> Option<PathBuf> {
    binary
        .ancestors()
        .find(|a| a.extension().is_some_and(|e| e == "app"))
        .or_else(|| binary.parent())
        .map(Path::to_path_buf)
}

/// Claude Code: a terminal `claude`, and the sessions Claude's desktop app spawns from its own copy.
/// Both read `env.ANTHROPIC_BASE_URL` from `settings.json` when the session starts.
pub(crate) fn claude_code(home: &Path) -> Vec<SurfaceDecl> {
    let mut roots = vec![home.join(".local").join("share").join("claude")];
    if cfg!(windows) {
        roots.push(home.join(".local").join("bin"));
    }
    if cfg!(target_os = "macos") {
        roots.push(
            home.join("Library")
                .join("Application Support")
                .join("Claude")
                .join("claude-code"),
        );
    }
    if let Some(data) = dirs::data_dir().filter(|_| cfg!(windows)) {
        roots.push(data.join("Claude").join("claude-code"));
    }
    vec![SurfaceDecl {
        name: "Claude Code session",
        roots,
        counts: claude_session,
        picks_up_live: false,
        restart: Restart::NewSession,
    }]
}

/// Codex: the desktop app's own CLI (the app-server every thread runs on) and a terminal `codex`.
/// Both read `config.toml`'s provider table when they start.
pub(crate) fn codex(home: &Path) -> Vec<SurfaceDecl> {
    let mut app = Vec::new();
    if cfg!(target_os = "macos") {
        for root in [PathBuf::from("/Applications"), home.join("Applications")] {
            // portability-ok: macOS app root, reached only when cfg!(target_os = "macos")
            app.push(root.join("ChatGPT.app"));
            app.push(root.join("Codex.app"));
        }
    }
    if let Some(local) = dirs::data_local_dir().filter(|_| cfg!(windows)) {
        app.push(local.join("OpenAI").join("Codex"));
    }
    let mut cli = vec![home.join(".codex").join("packages")];
    let path = crate::core::user_path::effective_path();
    if let Some(found) = crate::hooks::opencode::find_on_path(Some(path.as_os_str()), "codex") {
        // An npm install's launcher is a script beside `node_modules`; the binary it runs lives in
        // the package.
        let real = std::fs::canonicalize(&found).unwrap_or(found);
        cli.extend(
            real.ancestors()
                .find(|a| a.ends_with(Path::new("@openai").join("codex")))
                .map(Path::to_path_buf)
                .or_else(|| real.parent().map(Path::to_path_buf)),
        );
    }
    vec![
        SurfaceDecl {
            name: "Codex app",
            roots: app,
            counts: any,
            picks_up_live: false,
            restart: Restart::QuitAndReopen,
        },
        SurfaceDecl {
            name: "Codex session",
            roots: cli,
            counts: any,
            picks_up_live: false,
            restart: Restart::NewSession,
        },
    ]
}

/// Cursor: the IDE's main process (its `http.proxy` and its certificate list are read once per
/// launch — a window reload is not enough, proven on ol-win11) and `cursor-agent`.
pub(crate) fn cursor(home: &Path) -> Vec<SurfaceDecl> {
    vec![
        SurfaceDecl {
            name: "Cursor",
            roots: crate::hooks::cursor::ide_roots(home),
            counts: electron_main,
            picks_up_live: false,
            restart: Restart::QuitAndReopen,
        },
        SurfaceDecl {
            name: "Cursor CLI session",
            roots: crate::hooks::cursor::cli_install_dirs(home),
            counts: any,
            picks_up_live: false,
            restart: Restart::NewSession,
        },
    ]
}

/// Cline: VS Code's main process, for the Cline extension (`http.proxy` and the certificate list,
/// as for Cursor). Cline.app's hub is judged from its own finding, not from here; the `cline` CLI
/// is a Node script, which no install directory identifies.
pub(crate) fn cline(home: &Path) -> Vec<SurfaceDecl> {
    let roots = crate::hooks::cline_hosts::vscode_binary_candidates(
        None,
        Some(home),
        false,
        dirs::data_local_dir().as_deref(),
        std::env::var_os("ProgramFiles")
            .map(PathBuf::from)
            .as_deref(),
    )
    .iter()
    .filter_map(|b| app_root(b))
    .collect();
    vec![SurfaceDecl {
        name: "VS Code",
        roots,
        counts: electron_main,
        picks_up_live: false,
        restart: Restart::QuitAndReopen,
    }]
}

/// OpenCode: a terminal `opencode`, whose wrapper sets the proxy when it starts. The desktop app
/// is not delivered the relay at all, so it is not a surface here.
pub(crate) fn opencode(home: &Path) -> Vec<SurfaceDecl> {
    let path = crate::core::user_path::effective_path();
    let roots = crate::hooks::opencode::installed_in(home, Some(path.as_os_str()))
        .cli
        .map(|p| std::fs::canonicalize(&p).unwrap_or(p))
        .and_then(|p| p.parent().map(Path::to_path_buf))
        .into_iter()
        .collect();
    vec![SurfaceDecl {
        name: "OpenCode session",
        roots,
        counts: any,
        picks_up_live: false,
        restart: Restart::NewSession,
    }]
}

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

    fn proc(args: &[&str]) -> ExeProcess {
        ExeProcess {
            exe: PathBuf::from("/x/claude"),
            args: args.iter().map(|a| a.to_string()).collect(),
            started: None,
        }
    }

    #[test]
    fn a_claude_session_counts_and_its_helpers_do_not() {
        assert!(claude_session(&proc(&["claude"])));
        assert!(claude_session(&proc(&["claude", "--resume", "x"])));
        assert!(!claude_session(&proc(&[
            "claude",
            "--bg-pty-host",
            "/tmp/s.sock"
        ])));
        assert!(!claude_session(&proc(&["claude", "daemon", "run"])));
    }

    #[test]
    fn a_bundle_binary_roots_at_its_app() {
        assert_eq!(
            app_root(Path::new(
                "/Applications/Visual Studio Code.app/Contents/MacOS/Code"
            )),
            Some(PathBuf::from("/Applications/Visual Studio Code.app"))
        );
        assert_eq!(
            app_root(Path::new("/opt/visual-studio-code/code")),
            Some(PathBuf::from("/opt/visual-studio-code"))
        );
    }

    #[test]
    fn nothing_picks_up_live_until_a_live_test_says_so() {
        let home = Path::new("/home/u");
        for d in claude_code(home)
            .into_iter()
            .chain(codex(home))
            .chain(cursor(home))
            .chain(cline(home))
            .chain(opencode(home))
        {
            assert!(!d.picks_up_live, "{} needs its live test first", d.name);
        }
    }
}