use crate::git_watch::SessionGit;
use std::collections::BTreeSet;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
pub const TIMEOUT: Duration = Duration::from_secs(15 * 60);
pub type Due = (String, String, String);
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
}
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()),
}
}
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()
}
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)]
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}");
}
}