hatchery-node 0.2.77

Hatchery node: the complete node kit over the gate4agent transport (PTY/inline sessions, file browser, local git, worktrees, mailbox, browser station, session restore)
Documentation
//! Task checks the node runs itself. When a session's task worktree (`task-<id>`) has a new
//! commit and a clean tree, the node asks core for the task's check command
//! (`task.check_spec`), runs it in that worktree and reports the exit code
//! (`task.check_run`, signed by the node's key). Core takes the receipt as proof, not the
//! agent's word; the same path serves PTY and ACP sessions.
//!
//! The command runs code from the agent's tree (tests, build scripts), so it runs only through
//! `exec-as` with the node's run-as user, exactly like the sessions; a node without `--run-as`
//! (and Windows) does not run checks. The check sees only [`ENV_KEEP`] of the node's variables,
//! gets no stdin, its output is dropped, and its process group is killed at the timeout.

use crate::git_watch::SessionGit;
use std::collections::BTreeSet;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

/// Longest a check may run; past it the check fails (exit code -1).
pub const TIMEOUT: Duration = Duration::from_secs(15 * 60);

/// A worktree whose head wants a check: `(workspace, root, head)`.
pub type Due = (String, String, String);

/// Task worktrees with a new commit and a clean tree whose head was not checked yet.
pub fn due(sessions: &[SessionGit], seen: &BTreeSet<(String, String)>) -> Vec<Due> {
    let mut out: Vec<Due> = Vec::new();
    for s in sessions {
        let key = (s.workspace_id.clone(), s.head.clone());
        if s.workspace_id.starts_with("task-") && s.dirty == 0 && s.ahead > 0 && !seen.contains(&key) && !out.iter().any(|d| d.0 == key.0 && d.2 == key.1) {
            out.push((s.workspace_id.clone(), s.root.clone(), s.head.clone()));
        }
    }
    out
}

/// The program and arguments that run `command` through the shell (and the run-as user).
pub fn argv(command: &str, run_as: Option<&str>, node_exe: &str) -> (String, Vec<String>) {
    #[cfg(windows)]
    let shell = vec!["cmd".to_owned(), "/C".to_owned(), command.to_owned()];
    #[cfg(not(windows))]
    let shell = vec!["sh".to_owned(), "-c".to_owned(), command.to_owned()];
    match run_as {
        Some(user) => {
            let mut args = vec!["exec-as".to_owned(), user.to_owned(), "--".to_owned()];
            args.extend(shell);
            (node_exe.to_owned(), args)
        }
        None => (shell[0].clone(), shell[1..].to_vec()),
    }
}

/// The only node variables a check sees: tool paths and locale, never the node's own
/// settings (core address, token, m4a keys, provider credentials).
pub const ENV_KEEP: &[&str] = &["PATH", "HOME", "LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP", "CARGO_HOME", "RUSTUP_HOME", "SYSTEMROOT", "COMSPEC", "PATHEXT"];

pub fn kept_env<I: IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>>(vars: I) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
    vars.into_iter().filter(|(k, _)| ENV_KEEP.iter().any(|keep| k.to_str() == Some(keep))).collect()
}

/// Run `command` in `root`: `Some((exit_code, duration_ms))`, -1 for a timeout or a check that
/// could not start. `None`: not run, the node has no `--run-as` user (a check is a command from
/// core and never runs as the node's own user).
pub fn run(root: &Path, command: &str, timeout: Duration) -> Option<(i64, u64)> {
    #[cfg(unix)]
    let run_as = crate::isolation::run_as();
    #[cfg(not(unix))]
    let run_as: Option<&str> = None;
    run_as?;
    let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
    let (program, args) = argv(command, run_as, &exe);
    Some(run_argv(root, &program, &args, timeout))
}

fn run_argv(root: &Path, program: &str, args: &[String], timeout: Duration) -> (i64, u64) {
    let started = Instant::now();
    let mut cmd = Command::new(program);
    cmd.args(args).current_dir(root).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
    cmd.env_clear().envs(kept_env(std::env::vars_os()));
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        cmd.process_group(0);
    }
    let Ok(mut child) = cmd.spawn() else { return (-1, 0) };
    loop {
        match child.try_wait() {
            Ok(Some(status)) => return (status.code().map_or(-1, i64::from), started.elapsed().as_millis() as u64),
            Ok(None) if started.elapsed() < timeout => std::thread::sleep(Duration::from_millis(200)),
            _ => {
                #[cfg(unix)]
                // SAFETY: signals the check's own process group (created above), nothing else.
                unsafe {
                    libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
                }
                let _ = child.kill();
                let _ = child.wait();
                return (-1, started.elapsed().as_millis() as u64);
            }
        }
    }
}

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

    fn s(ws: &str, head: &str, dirty: u32, ahead: u32) -> SessionGit {
        SessionGit { workspace_id: ws.into(), instance_id: 1, provider: "claude".into(), branch: ws.into(), head: head.into(), dirty, ahead, root: format!("/r/{ws}"), nick: None, m4a_key: None }
    }

    #[test]
    fn check_env_keeps_only_tool_paths() {
        let v = |k: &str| (std::ffi::OsString::from(k), std::ffi::OsString::from("v"));
        let kept = kept_env([v("PATH"), v("HOME"), v("GATE4AGENT_SERVER_TOKEN"), v("M4A_PRIVATE_KEY_SEED_B64"), v("ANTHROPIC_API_KEY"), v("CARGO_HOME")]);
        let names: Vec<_> = kept.iter().map(|(k, _)| k.to_str().unwrap().to_owned()).collect();
        assert_eq!(names, ["PATH", "HOME", "CARGO_HOME"]);
    }

    #[test]
    fn only_new_clean_commits_in_task_worktrees() {
        let seen = BTreeSet::from([("task-1".to_string(), "h1".to_string())]);
        let sessions = [s("task-1", "h1", 0, 1), s("task-1", "h2", 0, 1), s("task-1", "h2", 0, 1), s("task-2", "h", 3, 1), s("task-3", "h", 0, 0), s("primary", "h", 0, 2)];
        assert_eq!(due(&sessions, &seen), vec![("task-1".to_string(), "/r/task-1".to_string(), "h2".to_string())]);
    }

    #[test]
    fn argv_goes_through_exec_as_with_a_run_as_user() {
        let (p, a) = argv("cargo test -q", Some("agent"), "/bin/node");
        assert_eq!((p.as_str(), a[..3].to_vec()), ("/bin/node", vec!["exec-as".to_string(), "agent".into(), "--".into()]));
        assert_eq!(a.last().unwrap(), "cargo test -q");
        let (p, a) = argv("x", None, "/bin/node");
        assert!(p == "sh" || p == "cmd");
        assert_eq!(a.last().unwrap(), "x");
    }

    #[cfg(unix)]
    #[test]
    fn no_run_as_user_no_check() {
        if crate::isolation::run_as().is_none() {
            assert_eq!(run(std::path::Path::new("."), "true", TIMEOUT), None);
        }
    }

    #[cfg(unix)]
    #[test]
    fn runs_in_the_worktree_and_times_out() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("ok"), "").unwrap();
        let sh = |c: &str, t: Duration| {
            let (p, a) = argv(c, None, "");
            run_argv(dir.path(), &p, &a, t)
        };
        assert_eq!(sh("test -f ok", TIMEOUT).0, 0);
        assert_eq!(sh("exit 3", TIMEOUT).0, 3);
        std::env::set_var("CHECK_RUNNER_TEST_SECRET", "x");
        assert_eq!(sh("test -z \"$CHECK_RUNNER_TEST_SECRET\"", TIMEOUT).0, 0, "node variables do not reach the check");
        let (code, ms) = sh("sleep 5", Duration::from_millis(300));
        assert!(code == -1 && ms < 3000, "{code} {ms}");
    }
}