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