Skip to main content

speccoder_runtime/
person.rs

1//! Commands run for a person from an interface that confirmed them.
2//!
3//! - [`run`]: the person's own command, a fix speccoder printed (`npm
4//!   install`, `git add … && git commit …`). It runs in the project folder,
5//!   in the OS sandbox with the network on and without the variables
6//!   speccoder treats as secret, for ten minutes at most.
7//! - [`speccoder`]: one of speccoder's own commands that has no action of
8//!   its own (`speccoder run --ignore-checklists`): this binary, with no
9//!   time limit, its result read back as the job's.
10//!
11//! Each runs in a process group of its own: a stop ends it and everything
12//! it started, and the job always ends. Output is masked and streamed line
13//! by line. Which argvs may run is the interface's to prove (the web host
14//! runs only the ones it issued); this module never decides that.
15
16use std::io::{BufRead, BufReader};
17use std::path::Path;
18use std::process::{Child, Command, ExitStatus, Stdio};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::mpsc;
22use std::time::{Duration, Instant};
23
24use speccoder_core::{CommandResult, CommandStatus};
25
26/// How long the person's own command may run.
27pub const TIMEOUT: Duration = Duration::from_secs(600);
28
29/// How long a stopped group gets to end before it is killed.
30const GRACE: Duration = Duration::from_secs(5);
31
32/// How long output may still arrive once the command itself exited (a
33/// background process it left holds the pipes open).
34const DRAIN: Duration = Duration::from_millis(500);
35
36/// Which stream a line came from.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Stream {
39    /// Standard output.
40    Out,
41    /// Standard error.
42    Err,
43}
44
45impl Stream {
46    /// `stdout` or `stderr`.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Out => "stdout",
51            Self::Err => "stderr",
52        }
53    }
54}
55
56/// The argvs as a shell reads them.
57#[must_use]
58pub fn shown(argvs: &[Vec<String>]) -> String {
59    speccoder_core::step::render(argvs)
60}
61
62/// How a command ended.
63enum End {
64    Exited(ExitStatus),
65    Stopped,
66    OutOfTime,
67    Broken(String),
68}
69
70/// Signals the child's process group.
71fn signal(child: &Child, sig: nix::sys::signal::Signal) {
72    if let Ok(pid) = i32::try_from(child.id()) {
73        let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(pid), sig);
74    }
75}
76
77/// Ends the group: `first` (a request to stop), then a kill after the grace.
78fn end_group(child: &mut Child, first: nix::sys::signal::Signal) {
79    signal(child, first);
80    let until = Instant::now() + GRACE;
81    while Instant::now() < until {
82        if matches!(child.try_wait(), Ok(Some(_))) {
83            break;
84        }
85        std::thread::sleep(Duration::from_millis(50));
86    }
87    signal(child, nix::sys::signal::Signal::SIGKILL);
88    let _ = child.wait();
89}
90
91/// Starts `cmd` in a group of its own, its output piped.
92fn spawn(mut cmd: Command, root: &Path) -> std::io::Result<Child> {
93    cmd.current_dir(root)
94        .stdin(Stdio::null())
95        .stdout(Stdio::piped())
96        .stderr(Stdio::piped());
97    std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
98    cmd.spawn()
99}
100
101/// Follows `child` to its end: lines to `line`, a stop or `deadline` end
102/// the group (`stop` the signal asked first). Readers are never waited
103/// for past the end: what a background process holds open is let go.
104fn follow(
105    child: &mut Child,
106    cancel: &Arc<AtomicBool>,
107    deadline: Option<Instant>,
108    stop: nix::sys::signal::Signal,
109    line: &mut dyn FnMut(Stream, &str),
110) -> End {
111    let (tx, rx) = mpsc::channel::<(Stream, String)>();
112    for (stream, pipe) in [
113        (
114            Stream::Out,
115            child
116                .stdout
117                .take()
118                .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
119        ),
120        (
121            Stream::Err,
122            child
123                .stderr
124                .take()
125                .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
126        ),
127    ] {
128        if let Some(pipe) = pipe {
129            let tx = tx.clone();
130            std::thread::spawn(move || {
131                for l in BufReader::new(pipe).lines().map_while(Result::ok) {
132                    if tx.send((stream, l)).is_err() {
133                        break;
134                    }
135                }
136            });
137        }
138    }
139    drop(tx);
140    let mut exited: Option<(ExitStatus, Instant)> = None;
141    let mut open = true;
142    loop {
143        if open {
144            match rx.recv_timeout(Duration::from_millis(50)) {
145                Ok((s, l)) => line(s, &crate::secrets::mask(&l)),
146                Err(mpsc::RecvTimeoutError::Timeout) => {}
147                Err(mpsc::RecvTimeoutError::Disconnected) => open = false,
148            }
149        } else {
150            std::thread::sleep(Duration::from_millis(50));
151        }
152        // Every pipe closed and it exited: that is the end.
153        if !open && let Some((s, _)) = exited {
154            return End::Exited(s);
155        }
156        if exited.is_none() {
157            match child.try_wait() {
158                Ok(Some(s)) => exited = Some((s, Instant::now())),
159                Ok(None) => {}
160                Err(e) => {
161                    end_group(child, nix::sys::signal::Signal::SIGKILL);
162                    return End::Broken(e.to_string());
163                }
164            }
165        }
166        if let Some((s, at)) = exited
167            && at.elapsed() >= DRAIN
168        {
169            // It exited; what it left running in its group goes with it.
170            signal(child, nix::sys::signal::Signal::SIGKILL);
171            return End::Exited(s);
172        }
173        if cancel.load(Ordering::SeqCst) {
174            end_group(child, stop);
175            return End::Stopped;
176        }
177        if deadline.is_some_and(|d| Instant::now() >= d) {
178            end_group(child, nix::sys::signal::Signal::SIGTERM);
179            return End::OutOfTime;
180        }
181    }
182}
183
184/// Environment variables, in order.
185type Env = Vec<(String, String)>;
186
187/// The program `name` names: the first executable of that name in an
188/// absolute `PATH` entry outside the project — never one the repository
189/// could bring (`.`, `node_modules/.bin`, a folder of the project).
190fn program(
191    name: &str,
192    root: &Path,
193    env: &[(String, String)],
194) -> Result<std::path::PathBuf, String> {
195    if name.contains('/') {
196        return Err(format!("`{name}` is a path, not a program on PATH"));
197    }
198    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
199    let path = env
200        .iter()
201        .find(|(k, _)| k == "PATH")
202        .map_or("/usr/bin:/bin", |(_, v)| v.as_str());
203    path.split(':')
204        .map(Path::new)
205        .filter(|d| d.is_absolute())
206        .filter_map(|d| std::fs::canonicalize(d).ok())
207        .filter(|d| !d.starts_with(&root))
208        .find_map(|d| {
209            crate::sandbox::resolve_executable(
210                name,
211                &d,
212                &[("PATH".to_owned(), d.display().to_string())],
213            )
214        })
215        .filter(|p| !p.starts_with(&root))
216        .ok_or_else(|| format!("`{name}` is not on PATH outside the project"))
217}
218
219/// How one argv starts: confined by speccoder's OS sandbox — writes to the
220/// project (its `.git` among it), the package caches and a private
221/// temporary folder, credentials unreadable, the network on (a fix is an
222/// install, most often) — unless it is one of the few fixes that write
223/// outside the project by their nature, or there is no sandbox here. The
224/// person's environment, without the variables speccoder treats as secret.
225fn confined(
226    argv: &[String],
227    root: &Path,
228    env: &[(String, String)],
229) -> Result<(Vec<String>, Env), String> {
230    let (name, args) = argv.split_first().ok_or("an empty command")?;
231    let mut full = vec![program(name, root, env)?.display().to_string()];
232    full.extend(args.iter().cloned());
233    let mut env: Vec<(String, String)> = env
234        .iter()
235        .filter(|(k, _)| !crate::verify::process::is_secret(k))
236        .cloned()
237        .collect();
238    if speccoder_core::step::outside(argv).is_some() {
239        return Ok((full, env));
240    }
241    let Ok(backend) = crate::sandbox::backend() else {
242        return Ok((full, env));
243    };
244    let home = env
245        .iter()
246        .find(|(k, _)| k == "HOME")
247        .map(|(_, v)| std::path::PathBuf::from(v));
248    let profile =
249        crate::sandbox::Profile::new(root, &crate::sandbox::Config::default(), home.as_deref())
250            .with_network(crate::sandbox::Network::On);
251    std::fs::create_dir_all(&profile.tmp)
252        .map_err(|e| format!("the sandbox's temporary folder: {e}"))?;
253    let tmp = profile.tmp.display().to_string();
254    env.retain(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"));
255    env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
256    Ok((crate::sandbox::wrap(&profile, &backend, &full)?, env))
257}
258
259/// Whether the person's commands run in an OS sandbox here.
260#[must_use]
261pub fn sandboxed() -> bool {
262    crate::sandbox::backend().is_ok()
263}
264
265/// Runs the person's own `argvs` in `root`, one after the other while each
266/// succeeds, each line of output handed to `line` (masked). Ends early, with
267/// everything it started, when `cancel` turns true or after `timeout`.
268#[must_use]
269pub fn run(
270    root: &Path,
271    argvs: &[Vec<String>],
272    timeout: Duration,
273    cancel: &Arc<AtomicBool>,
274    line: &dyn Fn(Stream, &str),
275) -> CommandResult {
276    let env: Vec<(String, String)> = std::env::vars().collect();
277    run_in(root, argvs, &env, timeout, cancel, line)
278}
279
280fn run_in(
281    root: &Path,
282    argvs: &[Vec<String>],
283    env: &[(String, String)],
284    timeout: Duration,
285    cancel: &Arc<AtomicBool>,
286    line: &dyn Fn(Stream, &str),
287) -> CommandResult {
288    let what = shown(argvs);
289    let failed = |message: String| {
290        CommandResult::new("fix", CommandStatus::Error, &crate::secrets::mask(&message))
291            .with_code("FIX-FAILED")
292    };
293    let deadline = Instant::now() + timeout;
294    for argv in argvs {
295        let one = shown(std::slice::from_ref(argv));
296        let (start, vars) = match confined(argv, root, env) {
297            Ok(x) => x,
298            Err(e) => return failed(format!("`{one}` could not start: {e}")),
299        };
300        let Some((program, args)) = start.split_first() else {
301            return failed("an empty command".to_owned());
302        };
303        let mut cmd = Command::new(program);
304        cmd.args(args).env_clear().envs(vars);
305        let mut child = match spawn(cmd, root) {
306            Ok(c) => c,
307            Err(e) => return failed(format!("`{one}` could not start: {e}")),
308        };
309        match follow(
310            &mut child,
311            cancel,
312            Some(deadline),
313            nix::sys::signal::Signal::SIGTERM,
314            &mut |s, l| line(s, l),
315        ) {
316            End::Exited(s) if s.success() => {}
317            End::Exited(s) => {
318                let code = s
319                    .code()
320                    .map_or_else(|| "a signal".to_owned(), |c| c.to_string());
321                return failed(format!("`{one}` exited with {code}"));
322            }
323            End::Stopped => return failed(format!("`{what}` was stopped")),
324            End::OutOfTime => return failed(format!("`{what}` ran out of time")),
325            End::Broken(e) => return failed(format!("`{what}`: {e}")),
326        }
327    }
328    CommandResult::ok("fix", &crate::secrets::mask(&format!("`{what}` finished")))
329}
330
331/// Runs speccoder itself with `args` in `root`, as a job: no time limit,
332/// a stop asks it to stop (as ctrl-c would) before it is killed. Its
333/// standard error goes to `line`; its result, read from `--json`, is the
334/// job's. `Err`: it gave none (it could not start, or was stopped first).
335///
336/// # Errors
337///
338/// No result came back.
339pub fn speccoder(
340    root: &Path,
341    args: &[String],
342    cancel: &Arc<AtomicBool>,
343    line: &dyn Fn(Stream, &str),
344) -> Result<serde_json::Value, String> {
345    let exe =
346        std::env::current_exe().map_err(|e| format!("speccoder could not find itself: {e}"))?;
347    let mut cmd = Command::new(exe);
348    cmd.args(args).arg("--json");
349    let mut child = spawn(cmd, root).map_err(|e| format!("speccoder could not start: {e}"))?;
350    let mut out = String::new();
351    let end = follow(
352        &mut child,
353        cancel,
354        None,
355        nix::sys::signal::Signal::SIGINT,
356        &mut |s, l| match s {
357            Stream::Out => {
358                out.push_str(l);
359                out.push('\n');
360            }
361            Stream::Err => line(s, l),
362        },
363    );
364    let result = serde_json::from_str::<serde_json::Value>(out.trim()).ok();
365    match (end, result) {
366        (_, Some(v)) if v.is_object() => Ok(v),
367        (End::Stopped, _) => Err("it was stopped".to_owned()),
368        (End::Broken(e), _) => Err(e),
369        _ => Err("it gave no result".to_owned()),
370    }
371}
372
373/// Opens a terminal of the person's own at `root` (macOS: Terminal;
374/// elsewhere `$TERMINAL`, else `x-terminal-emulator`).
375///
376/// # Errors
377///
378/// No terminal could be started.
379pub fn open_terminal(root: &Path) -> Result<(), String> {
380    let started = if cfg!(target_os = "macos") {
381        Command::new("open")
382            .args(["-a", "Terminal"])
383            .arg(root)
384            .status()
385            .is_ok_and(|s| s.success())
386    } else {
387        let program = std::env::var("TERMINAL")
388            .ok()
389            .filter(|t| !t.trim().is_empty())
390            .unwrap_or_else(|| "x-terminal-emulator".to_owned());
391        Command::new(program)
392            .current_dir(root)
393            .stdin(Stdio::null())
394            .stdout(Stdio::null())
395            .stderr(Stdio::null())
396            .spawn()
397            .is_ok()
398    };
399    if started {
400        Ok(())
401    } else {
402        Err("no terminal could be opened here".to_owned())
403    }
404}
405
406#[cfg(test)]
407#[cfg(unix)]
408mod tests {
409    use std::sync::Mutex;
410
411    use super::*;
412
413    fn s(v: &[&str]) -> Vec<String> {
414        v.iter().map(|w| (*w).to_owned()).collect()
415    }
416
417    #[test]
418    fn it_runs_in_the_folder_streams_and_stops_at_the_first_failure() {
419        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
420        let lines = Mutex::new(Vec::new());
421        let r = run(
422            dir.path(),
423            &[
424                s(&["sh", "-c", "echo here; pwd; echo oops >&2"]),
425                s(&["false"]),
426                s(&["touch", "never"]),
427            ],
428            TIMEOUT,
429            &Arc::new(AtomicBool::new(false)),
430            &|stream, l| {
431                lines
432                    .lock()
433                    .unwrap_or_else(|e| e.into_inner())
434                    .push((stream, l.to_owned()))
435            },
436        );
437        assert_eq!(r.status(), CommandStatus::Error);
438        assert_eq!(r.code().map(|c| c.name), Some("FIX-FAILED"));
439        assert_eq!(r.message(), "`false` exited with 1");
440        assert!(!dir.path().join("never").exists());
441        let lines = lines.into_inner().unwrap_or_default();
442        assert!(
443            lines.contains(&(Stream::Out, "here".to_owned())),
444            "{lines:?}"
445        );
446        assert!(
447            lines.contains(&(Stream::Err, "oops".to_owned())),
448            "{lines:?}"
449        );
450        let canonical = std::fs::canonicalize(dir.path()).unwrap_or_default();
451        assert!(
452            lines
453                .iter()
454                .any(|(_, l)| Path::new(l) == canonical || Path::new(l) == dir.path()),
455            "{lines:?}"
456        );
457        let ok = run(
458            dir.path(),
459            &[s(&["true"])],
460            TIMEOUT,
461            &Arc::new(AtomicBool::new(false)),
462            &|_, _| {},
463        );
464        assert_eq!(
465            (ok.status(), ok.message()),
466            (CommandStatus::Ok, "`true` finished")
467        );
468    }
469
470    #[test]
471    fn it_can_be_stopped_and_runs_out_of_time() {
472        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
473        let cancel = Arc::new(AtomicBool::new(false));
474        let flag = Arc::clone(&cancel);
475        std::thread::spawn(move || {
476            std::thread::sleep(Duration::from_millis(200));
477            flag.store(true, Ordering::SeqCst);
478        });
479        let started = Instant::now();
480        let r = run(
481            dir.path(),
482            &[s(&["sleep", "30"])],
483            TIMEOUT,
484            &cancel,
485            &|_, _| {},
486        );
487        assert_eq!(r.message(), "`sleep 30` was stopped");
488        assert!(started.elapsed() < Duration::from_secs(10));
489        let r = run(
490            dir.path(),
491            &[s(&["sleep", "30"])],
492            Duration::from_millis(300),
493            &Arc::new(AtomicBool::new(false)),
494            &|_, _| {},
495        );
496        assert_eq!(r.message(), "`sleep 30` ran out of time");
497    }
498
499    #[test]
500    fn its_output_is_masked() {
501        crate::secrets::register("PERSON_TEST_TOKEN", "person-run-Qx7 value");
502        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
503        let lines = Mutex::new(Vec::new());
504        let _ = run(
505            dir.path(),
506            &[s(&["echo", "token person-run-Qx7 value"])],
507            TIMEOUT,
508            &Arc::new(AtomicBool::new(false)),
509            &|_, l| {
510                lines
511                    .lock()
512                    .unwrap_or_else(|e| e.into_inner())
513                    .push(l.to_owned())
514            },
515        );
516        let lines = lines.into_inner().unwrap_or_default();
517        assert_eq!(lines, ["token [redacted:PERSON_TEST_TOKEN]"]);
518    }
519
520    #[test]
521    fn what_it_shows_is_what_a_person_types() {
522        assert_eq!(
523            shown(&[
524                s(&["git", "add", "speccoder.toml"]),
525                s(&["git", "commit", "-m", "speccoder: config"])
526            ]),
527            "git add speccoder.toml && git commit -m 'speccoder: config'"
528        );
529    }
530
531    fn alive(pid: &str) -> bool {
532        std::process::Command::new("kill")
533            .args(["-0", pid])
534            .status()
535            .is_ok_and(|s| s.success())
536    }
537
538    #[test]
539    fn a_stop_ends_everything_it_started_and_the_job_ends() {
540        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
541        let cancel = Arc::new(AtomicBool::new(false));
542        let flag = Arc::clone(&cancel);
543        std::thread::spawn(move || {
544            std::thread::sleep(Duration::from_millis(400));
545            flag.store(true, Ordering::SeqCst);
546        });
547        let pids = Mutex::new(Vec::new());
548        let started = Instant::now();
549        let r = run(
550            dir.path(),
551            &[s(&["sh", "-c", "sleep 30 & echo $!; sleep 30"])],
552            TIMEOUT,
553            &cancel,
554            &|_, l| {
555                pids.lock()
556                    .unwrap_or_else(|e| e.into_inner())
557                    .push(l.to_owned())
558            },
559        );
560        assert!(r.message().ends_with("was stopped"), "{}", r.message());
561        assert!(
562            started.elapsed() < Duration::from_secs(10),
563            "{:?}",
564            started.elapsed()
565        );
566        let pids = pids.into_inner().unwrap_or_default();
567        let background = pids.first().cloned().unwrap_or_default();
568        assert!(!background.is_empty());
569        std::thread::sleep(Duration::from_millis(200));
570        assert!(!alive(&background), "{background} survived the stop");
571    }
572
573    #[test]
574    fn it_runs_confined_to_the_project_where_there_is_a_sandbox() {
575        if !sandboxed() {
576            return;
577        }
578        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
579        let outside = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
580        let escaped = outside.path().join("escaped");
581        let r = run(
582            dir.path(),
583            &[s(&[
584                "sh",
585                "-c",
586                &format!("touch inside && touch '{}'", escaped.display()),
587            ])],
588            TIMEOUT,
589            &Arc::new(AtomicBool::new(false)),
590            &|_, _| {},
591        );
592        assert_eq!(r.status(), CommandStatus::Error, "{}", r.message());
593        assert!(dir.path().join("inside").exists());
594        assert!(!escaped.exists(), "a write outside the project got through");
595    }
596
597    #[test]
598    fn its_program_is_never_one_the_project_brings() {
599        use std::os::unix::fs::PermissionsExt as _;
600        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
601        let bin = dir.path().join("node_modules/.bin");
602        std::fs::create_dir_all(&bin).unwrap_or_else(|e| panic!("{e}"));
603        let fake = bin.join("true");
604        std::fs::write(&fake, "#!/bin/sh\ntouch planted\n").unwrap_or_else(|e| panic!("{e}"));
605        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755))
606            .unwrap_or_else(|e| panic!("{e}"));
607        std::fs::copy(&fake, dir.path().join("true")).unwrap_or_else(|e| panic!("{e}"));
608        let env = vec![(
609            "PATH".to_owned(),
610            format!(".:node_modules/.bin:{}:/usr/bin:/bin", bin.display()),
611        )];
612        let r = run_in(
613            dir.path(),
614            &[s(&["true"])],
615            &env,
616            TIMEOUT,
617            &Arc::new(AtomicBool::new(false)),
618            &|_, _| {},
619        );
620        assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
621        assert!(!dir.path().join("planted").exists());
622        // Only the project's own: refused.
623        let only = vec![("PATH".to_owned(), format!(".:{}", bin.display()))];
624        let r = run_in(
625            dir.path(),
626            &[s(&["true"])],
627            &only,
628            TIMEOUT,
629            &Arc::new(AtomicBool::new(false)),
630            &|_, _| {},
631        );
632        assert!(
633            r.message().contains("not on PATH outside the project"),
634            "{}",
635            r.message()
636        );
637        assert!(!dir.path().join("planted").exists());
638    }
639
640    #[test]
641    fn what_it_leaves_running_does_not_hold_the_job() {
642        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
643        let started = Instant::now();
644        let r = run(
645            dir.path(),
646            &[s(&["sh", "-c", "sleep 30 & echo started"])],
647            TIMEOUT,
648            &Arc::new(AtomicBool::new(false)),
649            &|_, _| {},
650        );
651        assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
652        assert!(
653            started.elapsed() < Duration::from_secs(10),
654            "{:?}",
655            started.elapsed()
656        );
657    }
658}