openlatch-client 0.6.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
//! openlatch-cline-runtime: set ONE variable, run ONE runtime, relay the IPC channel. std + windows-sys only;
//! links nothing from the `openlatch` lib (like openlatch-hook, it must stay tiny). Built on every target so it is
//! compiled and clippy-checked everywhere, but only STAGED on Windows (Cline I-5, INDEX D-06).
//!
//! Why it exists: Cline spawns its plugin-sandbox runtime with NO shell, and Node ≥ 18.20.2 refuses a
//! `.cmd`/`.bat` that way (EINVAL), so the Windows wrapper has to be a real `.exe`.
//!
//! Config: `<exe dir>/<exe stem>.target`, UTF-8, two lines:  runtime=<absolute path>\n  env=<NAME>=<VALUE>\n
//! Only NAME ∈ {BUN_BE_BUN, ELECTRON_RUN_AS_NODE} and VALUE == "1" are accepted (anything else → exit 70).
//!
//! Windows algorithm (the whole point):
//!   1. GetStartupInfoW(&mut si) — our own STARTUPINFO, which carries Node's CRT fd table in cbReserved2/lpReserved2
//!      (libuv passes the IPC pipe as fd 3 this way; NODE_CHANNEL_FD=3 is already in our env).
//!   2. Build the child env = our env + NAME=VALUE (Unicode block, sorted as CreateProcessW requires).
//!   3. CreateProcessW(runtime, "\"runtime\" <our args re-quoted>", bInheritHandles=TRUE,
//!      CREATE_UNICODE_ENVIRONMENT, si with cbReserved2/lpReserved2 COPIED from step 1) — std::process::Command cannot
//!      pass lpReserved2, which is why this is raw windows-sys.
//!   4. WaitForSingleObject(INFINITE); GetExitCodeProcess; exit with it.
//!
//! Unix build: exec(2) the runtime with the variable set (CommandExt::exec) — used only by the CI test, never staged.

use std::ffi::OsString;
use std::path::PathBuf;

/// A malformed or missing `.target` (EX_SOFTWARE-adjacent: the launcher was staged wrong).
const EXIT_CONFIG: i32 = 70;
/// The runtime could not be started.
const EXIT_SPAWN: i32 = 71;

/// The only variables the launcher will set.
const ALLOWED_VARS: [&str; 2] = ["BUN_BE_BUN", "ELECTRON_RUN_AS_NODE"];
/// The only value it sets them to (`parse_target` refuses any other).
const VAR_VALUE: &str = "1";

struct Target {
    runtime: PathBuf,
    name: String,
}

/// Parse the `.target` body. Pure, so its rules are testable on every OS.
fn parse_target(body: &str) -> Option<Target> {
    let mut runtime: Option<PathBuf> = None;
    let mut env: Option<(String, String)> = None;
    for line in body.lines() {
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            continue;
        }
        if let Some(path) = line.strip_prefix("runtime=") {
            if runtime.is_some() {
                return None;
            }
            runtime = Some(PathBuf::from(path));
        } else {
            let assign = line.strip_prefix("env=")?;
            if env.is_some() {
                return None;
            }
            let (name, value) = assign.split_once('=')?;
            env = Some((name.to_string(), value.to_string()));
        }
    }
    let runtime = runtime?;
    let (name, value) = env?;
    if !runtime.is_absolute() || !ALLOWED_VARS.contains(&name.as_str()) || value != VAR_VALUE {
        return None;
    }
    Some(Target { runtime, name })
}

fn load_target() -> Option<Target> {
    let exe = std::env::current_exe().ok()?;
    let config = exe.with_extension("target");
    let body = std::fs::read_to_string(config).ok()?;
    parse_target(&body)
}

fn main() {
    let Some(target) = load_target() else {
        eprintln!("openlatch-cline-runtime: missing or invalid .target beside the launcher");
        std::process::exit(EXIT_CONFIG);
    };
    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
    std::process::exit(run(&target, &args));
}

#[cfg(unix)]
fn run(target: &Target, args: &[OsString]) -> i32 {
    use std::os::unix::process::CommandExt;
    // exec(2) only returns on failure.
    let err = std::process::Command::new(&target.runtime)
        .args(args)
        .env(&target.name, VAR_VALUE)
        .exec();
    eprintln!(
        "openlatch-cline-runtime: cannot run {}: {err}",
        target.runtime.display()
    );
    EXIT_SPAWN
}

#[cfg(windows)]
fn run(target: &Target, args: &[OsString]) -> i32 {
    windows::run(target, args)
}

#[cfg(not(any(unix, windows)))]
fn run(_target: &Target, _args: &[OsString]) -> i32 {
    EXIT_SPAWN
}

/// Quote one argument the way `CommandLineToArgvW` (and the MSVC CRT) reads it back.
#[cfg_attr(not(windows), allow(dead_code))]
fn quote_windows_arg(arg: &str) -> String {
    if !arg.is_empty() && !arg.contains([' ', '\t', '\n', '\u{b}', '"']) {
        return arg.to_string();
    }
    let mut out = String::with_capacity(arg.len() + 2);
    out.push('"');
    let mut backslashes = 0usize;
    for c in arg.chars() {
        match c {
            '\\' => backslashes += 1,
            '"' => {
                out.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
                out.push('"');
                backslashes = 0;
            }
            _ => {
                out.extend(std::iter::repeat_n('\\', backslashes));
                out.push(c);
                backslashes = 0;
            }
        }
    }
    out.extend(std::iter::repeat_n('\\', backslashes * 2));
    out.push('"');
    out
}

/// Our environment plus `name=value` (replacing any existing `name`, case-insensitively), sorted
/// case-insensitively as CreateProcessW requires. Pure over its input.
#[cfg_attr(not(windows), allow(dead_code))]
fn child_env(
    ours: impl IntoIterator<Item = (String, String)>,
    name: &str,
    value: &str,
) -> Vec<(String, String)> {
    let mut env: Vec<(String, String)> = ours
        .into_iter()
        .filter(|(k, _)| !k.eq_ignore_ascii_case(name))
        .collect();
    env.push((name.to_string(), value.to_string()));
    env.sort_by_key(|(k, _)| k.to_uppercase());
    env
}

#[cfg(windows)]
mod windows {
    use std::ffi::OsString;
    use std::os::windows::ffi::OsStrExt;

    use windows_sys::Win32::Foundation::{CloseHandle, TRUE};
    use windows_sys::Win32::System::Threading::{
        CreateProcessW, GetExitCodeProcess, GetStartupInfoW, WaitForSingleObject,
        CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTUPINFOW,
    };

    use super::{child_env, quote_windows_arg, Target, EXIT_SPAWN, VAR_VALUE};

    fn wide(s: &std::ffi::OsStr) -> Vec<u16> {
        s.encode_wide().chain(std::iter::once(0)).collect()
    }

    pub(super) fn run(target: &Target, args: &[OsString]) -> i32 {
        // 1. Our own STARTUPINFO: Node's CRT fd table (the IPC pipe is fd 3) rides in
        //    cbReserved2/lpReserved2, and the handles it names are inheritable in this process.
        let mut ours = STARTUPINFOW {
            cb: std::mem::size_of::<STARTUPINFOW>() as u32,
            ..Default::default()
        };
        // SAFETY: `ours` is a live, correctly sized STARTUPINFOW out-parameter.
        unsafe { GetStartupInfoW(&mut ours) };
        let si = STARTUPINFOW {
            cb: std::mem::size_of::<STARTUPINFOW>() as u32,
            dwFlags: ours.dwFlags,
            wShowWindow: ours.wShowWindow,
            cbReserved2: ours.cbReserved2,
            lpReserved2: ours.lpReserved2,
            hStdInput: ours.hStdInput,
            hStdOutput: ours.hStdOutput,
            hStdError: ours.hStdError,
            ..Default::default()
        };

        // 2. The environment block: KEY=VALUE\0 ... \0, UTF-16.
        let env = child_env(
            std::env::vars_os().map(|(k, v)| {
                (
                    k.to_string_lossy().into_owned(),
                    v.to_string_lossy().into_owned(),
                )
            }),
            &target.name,
            VAR_VALUE,
        );
        let mut block: Vec<u16> = Vec::new();
        for (k, v) in &env {
            block.extend(std::ffi::OsStr::new(&format!("{k}={v}")).encode_wide());
            block.push(0);
        }
        block.push(0);

        // 3. The command line: the quoted runtime, then our own arguments re-quoted.
        let runtime = target.runtime.as_os_str();
        let mut cmdline = quote_windows_arg(&runtime.to_string_lossy());
        for arg in args {
            cmdline.push(' ');
            cmdline.push_str(&quote_windows_arg(&arg.to_string_lossy()));
        }
        let mut cmdline = wide(std::ffi::OsStr::new(&cmdline));
        let application = wide(runtime);

        let mut pi = PROCESS_INFORMATION {
            hProcess: std::ptr::null_mut(),
            hThread: std::ptr::null_mut(),
            dwProcessId: 0,
            dwThreadId: 0,
        };
        // SAFETY: every buffer is NUL-terminated UTF-16 alive for the call, `cmdline` is mutable as
        // CreateProcessW requires, the environment block is double-NUL-terminated UTF-16 matching
        // CREATE_UNICODE_ENVIRONMENT, and `si`/`pi` are live, correctly sized structs.
        let created = unsafe {
            CreateProcessW(
                application.as_ptr(),
                cmdline.as_mut_ptr(),
                std::ptr::null(),
                std::ptr::null(),
                TRUE,
                CREATE_UNICODE_ENVIRONMENT,
                block.as_ptr().cast(),
                std::ptr::null(),
                &si,
                &mut pi,
            )
        };
        if created == 0 {
            eprintln!(
                "openlatch-cline-runtime: cannot run {}: {}",
                target.runtime.display(),
                std::io::Error::last_os_error()
            );
            return EXIT_SPAWN;
        }

        // 4. Wait, and exit with the child's code.
        let mut code: u32 = EXIT_SPAWN as u32;
        // SAFETY: `pi.hProcess`/`pi.hThread` are the live handles CreateProcessW returned; each is
        // closed exactly once, after its last use.
        unsafe {
            WaitForSingleObject(pi.hProcess, INFINITE);
            GetExitCodeProcess(pi.hProcess, &mut code);
            CloseHandle(pi.hThread);
            CloseHandle(pi.hProcess);
        }
        code as i32
    }
}

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

    #[test]
    fn target_accepts_exactly_one_runtime_and_one_allowed_variable() {
        let abs = if cfg!(windows) {
            r"C:\VS Code\Code.exe"
        } else {
            "/opt/code/code"
        };
        let ok =
            parse_target(&format!("runtime={abs}\nenv=ELECTRON_RUN_AS_NODE=1\n")).expect("valid");
        assert_eq!(ok.runtime, PathBuf::from(abs));
        assert_eq!(ok.name, "ELECTRON_RUN_AS_NODE");
        assert!(parse_target(&format!("runtime={abs}\r\nenv=BUN_BE_BUN=1\r\n")).is_some());

        for bad in [
            format!("runtime={abs}\nenv=PATH=1\n"),
            format!("runtime={abs}\nenv=BUN_BE_BUN=0\n"),
            format!("runtime={abs}\n"),
            "env=BUN_BE_BUN=1\n".to_string(),
            "runtime=relative/code\nenv=BUN_BE_BUN=1\n".to_string(),
            format!("runtime={abs}\nenv=BUN_BE_BUN=1\nextra=1\n"),
            format!("runtime={abs}\nruntime={abs}\nenv=BUN_BE_BUN=1\n"),
        ] {
            assert!(parse_target(&bad).is_none(), "{bad:?}");
        }
    }

    #[test]
    fn windows_arguments_round_trip_the_crt_rules() {
        assert_eq!(quote_windows_arg("plain"), "plain");
        assert_eq!(quote_windows_arg(""), "\"\"");
        assert_eq!(quote_windows_arg("a b"), "\"a b\"");
        assert_eq!(quote_windows_arg(r#"a"b"#), r#""a\"b""#);
        assert_eq!(
            quote_windows_arg(r"C:\dir with space\"),
            r#""C:\dir with space\\""#
        );
        assert_eq!(quote_windows_arg(r"C:\no\space"), r"C:\no\space");
    }

    #[test]
    fn child_env_replaces_and_sorts() {
        let env = child_env(
            [
                ("Path".to_string(), "x".to_string()),
                ("electron_run_as_node".to_string(), "0".to_string()),
                ("APPDATA".to_string(), "y".to_string()),
            ],
            "ELECTRON_RUN_AS_NODE",
            "1",
        );
        assert_eq!(
            env,
            vec![
                ("APPDATA".to_string(), "y".to_string()),
                ("ELECTRON_RUN_AS_NODE".to_string(), "1".to_string()),
                ("Path".to_string(), "x".to_string()),
            ]
        );
    }
}