1use crate::git_watch::SessionGit;
13use std::collections::BTreeSet;
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant};
17
18pub const TIMEOUT: Duration = Duration::from_secs(15 * 60);
20
21pub type Due = (String, String, String);
23
24pub 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
36pub 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
52pub 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
60pub 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 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}