Skip to main content

hatchery_node/
check_runner.rs

1//! Task checks the node runs itself. When a session's task worktree (`task-<id>`) has a new
2//! commit and a clean tree, the node asks core for the task's check command
3//! (`task.check_spec`), runs it in that worktree and reports the exit code
4//! (`task.check_run`, signed by the node's key). Core takes the receipt as proof, not the
5//! agent's word; the same path serves PTY and ACP sessions.
6//!
7//! The command runs code from the agent's tree (tests, build scripts), so it runs only through
8//! `exec-as` with the node's run-as user, exactly like the sessions; a node without `--run-as`
9//! (and Windows) does not run checks. The check sees only [`ENV_KEEP`] of the node's variables,
10//! gets no stdin, its output is dropped, and its process group is killed at the timeout.
11
12use crate::git_watch::SessionGit;
13use std::collections::BTreeSet;
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant};
17
18/// Longest a check may run; past it the check fails (exit code -1).
19pub const TIMEOUT: Duration = Duration::from_secs(15 * 60);
20
21/// A worktree whose head wants a check: `(workspace, root, head)`.
22pub type Due = (String, String, String);
23
24/// Task worktrees with a new commit and a clean tree whose head was not checked yet.
25pub fn due(sessions: &[SessionGit], seen: &BTreeSet<(String, String)>) -> Vec<Due> {
26    let mut out: Vec<Due> = Vec::new();
27    for s in sessions {
28        let key = (s.workspace_id.clone(), s.head.clone());
29        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) {
30            out.push((s.workspace_id.clone(), s.root.clone(), s.head.clone()));
31        }
32    }
33    out
34}
35
36/// The program and arguments that run `command` through the shell (and the run-as user).
37pub fn argv(command: &str, run_as: Option<&str>, node_exe: &str) -> (String, Vec<String>) {
38    #[cfg(windows)]
39    let shell = vec!["cmd".to_owned(), "/C".to_owned(), command.to_owned()];
40    #[cfg(not(windows))]
41    let shell = vec!["sh".to_owned(), "-c".to_owned(), command.to_owned()];
42    match run_as {
43        Some(user) => {
44            let mut args = vec!["exec-as".to_owned(), user.to_owned(), "--".to_owned()];
45            args.extend(shell);
46            (node_exe.to_owned(), args)
47        }
48        None => (shell[0].clone(), shell[1..].to_vec()),
49    }
50}
51
52/// The only node variables a check sees: tool paths and locale, never the node's own
53/// settings (core address, token, m4a keys, provider credentials).
54pub const ENV_KEEP: &[&str] = &["PATH", "HOME", "LANG", "LC_ALL", "TERM", "TMPDIR", "TEMP", "TMP", "CARGO_HOME", "RUSTUP_HOME", "SYSTEMROOT", "COMSPEC", "PATHEXT"];
55
56pub fn kept_env<I: IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>>(vars: I) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
57    vars.into_iter().filter(|(k, _)| ENV_KEEP.iter().any(|keep| k.to_str() == Some(keep))).collect()
58}
59
60/// Run `command` in `root`: `Some((exit_code, duration_ms))`, -1 for a timeout or a check that
61/// could not start. `None`: not run, the node has no `--run-as` user (a check is a command from
62/// core and never runs as the node's own user).
63pub fn run(root: &Path, command: &str, timeout: Duration) -> Option<(i64, u64)> {
64    #[cfg(unix)]
65    let run_as = crate::isolation::run_as();
66    #[cfg(not(unix))]
67    let run_as: Option<&str> = None;
68    run_as?;
69    let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
70    let (program, args) = argv(command, run_as, &exe);
71    Some(run_argv(root, &program, &args, timeout))
72}
73
74fn run_argv(root: &Path, program: &str, args: &[String], timeout: Duration) -> (i64, u64) {
75    let started = Instant::now();
76    let mut cmd = Command::new(program);
77    cmd.args(args).current_dir(root).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
78    cmd.env_clear().envs(kept_env(std::env::vars_os()));
79    #[cfg(unix)]
80    {
81        use std::os::unix::process::CommandExt;
82        cmd.process_group(0);
83    }
84    let Ok(mut child) = cmd.spawn() else { return (-1, 0) };
85    loop {
86        match child.try_wait() {
87            Ok(Some(status)) => return (status.code().map_or(-1, i64::from), started.elapsed().as_millis() as u64),
88            Ok(None) if started.elapsed() < timeout => std::thread::sleep(Duration::from_millis(200)),
89            _ => {
90                #[cfg(unix)]
91                // SAFETY: signals the check's own process group (created above), nothing else.
92                unsafe {
93                    libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL);
94                }
95                let _ = child.kill();
96                let _ = child.wait();
97                return (-1, started.elapsed().as_millis() as u64);
98            }
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    fn s(ws: &str, head: &str, dirty: u32, ahead: u32) -> SessionGit {
108        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 }
109    }
110
111    #[test]
112    fn check_env_keeps_only_tool_paths() {
113        let v = |k: &str| (std::ffi::OsString::from(k), std::ffi::OsString::from("v"));
114        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")]);
115        let names: Vec<_> = kept.iter().map(|(k, _)| k.to_str().unwrap().to_owned()).collect();
116        assert_eq!(names, ["PATH", "HOME", "CARGO_HOME"]);
117    }
118
119    #[test]
120    fn only_new_clean_commits_in_task_worktrees() {
121        let seen = BTreeSet::from([("task-1".to_string(), "h1".to_string())]);
122        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)];
123        assert_eq!(due(&sessions, &seen), vec![("task-1".to_string(), "/r/task-1".to_string(), "h2".to_string())]);
124    }
125
126    #[test]
127    fn argv_goes_through_exec_as_with_a_run_as_user() {
128        let (p, a) = argv("cargo test -q", Some("agent"), "/bin/node");
129        assert_eq!((p.as_str(), a[..3].to_vec()), ("/bin/node", vec!["exec-as".to_string(), "agent".into(), "--".into()]));
130        assert_eq!(a.last().unwrap(), "cargo test -q");
131        let (p, a) = argv("x", None, "/bin/node");
132        assert!(p == "sh" || p == "cmd");
133        assert_eq!(a.last().unwrap(), "x");
134    }
135
136    #[cfg(unix)]
137    #[test]
138    fn no_run_as_user_no_check() {
139        if crate::isolation::run_as().is_none() {
140            assert_eq!(run(std::path::Path::new("."), "true", TIMEOUT), None);
141        }
142    }
143
144    #[cfg(unix)]
145    #[test]
146    fn runs_in_the_worktree_and_times_out() {
147        let dir = tempfile::tempdir().unwrap();
148        std::fs::write(dir.path().join("ok"), "").unwrap();
149        let sh = |c: &str, t: Duration| {
150            let (p, a) = argv(c, None, "");
151            run_argv(dir.path(), &p, &a, t)
152        };
153        assert_eq!(sh("test -f ok", TIMEOUT).0, 0);
154        assert_eq!(sh("exit 3", TIMEOUT).0, 3);
155        std::env::set_var("CHECK_RUNNER_TEST_SECRET", "x");
156        assert_eq!(sh("test -z \"$CHECK_RUNNER_TEST_SECRET\"", TIMEOUT).0, 0, "node variables do not reach the check");
157        let (code, ms) = sh("sleep 5", Duration::from_millis(300));
158        assert!(code == -1 && ms < 3000, "{code} {ms}");
159    }
160}