toolkit: shell
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
//! The programs that run inside tool containers.
|
||||
|
||||
use proto::tools::{ReadFileArgs, WriteFileArgs};
|
||||
use proto::tools::{ReadFileArgs, ShellArgs, WriteFileArgs};
|
||||
|
||||
pub mod files;
|
||||
pub mod input;
|
||||
pub mod shell;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Exit {
|
||||
@@ -77,6 +78,13 @@ pub fn run(name: &str, stdin: &mut dyn std::io::Read) -> Outcome {
|
||||
};
|
||||
files::write_file(&args)
|
||||
}
|
||||
"shell" => {
|
||||
let args = match input::parse::<ShellArgs>(name, &text) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return Outcome::misuse(e),
|
||||
};
|
||||
shell::shell(&args)
|
||||
}
|
||||
_ => Outcome::misuse(format!("toolkit: unknown tool {name:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The third tool, `shell`: run one command under `/bin/sh -c` in a directory and report its
|
||||
//! output (stdout and stderr together, in order) followed by how the command ended. The command's
|
||||
//! own exit status is part of the result, not `toolkit`'s.
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use proto::tools::ShellArgs;
|
||||
|
||||
use crate::Outcome;
|
||||
|
||||
pub const SHELL: &str = "/bin/sh";
|
||||
pub const DEFAULT_CWD: &str = "/tmp";
|
||||
pub const MAX_OUTPUT: usize = 1024 * 1024;
|
||||
|
||||
pub fn shell(args: &ShellArgs) -> Outcome {
|
||||
let cwd = match args.cwd.as_deref() {
|
||||
Some(c) => c,
|
||||
None => DEFAULT_CWD,
|
||||
};
|
||||
if !Path::new(cwd).is_dir() {
|
||||
return Outcome::tool_error(format!("shell: {cwd}: no such directory"));
|
||||
}
|
||||
|
||||
let (mut reader, writer) = match io::pipe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Outcome::tool_error(format!("shell: cannot make a pipe: {e}")),
|
||||
};
|
||||
let writer2 = match writer.try_clone() {
|
||||
Ok(w) => w,
|
||||
Err(e) => return Outcome::tool_error(format!("shell: cannot make a pipe: {e}")),
|
||||
};
|
||||
|
||||
let mut command = Command::new(SHELL);
|
||||
command.arg("-c").arg(&args.command).current_dir(cwd);
|
||||
command.stdin(Stdio::null());
|
||||
command.stdout(writer);
|
||||
command.stderr(writer2);
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Outcome::tool_error(format!("shell: cannot start {SHELL}: {e}")),
|
||||
};
|
||||
// The Command still owns the write ends; while they are open reading never reaches EOF.
|
||||
drop(command);
|
||||
|
||||
let mut buf = [0u8; 8 * 1024];
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let mut dropped = false;
|
||||
loop {
|
||||
let n = match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Outcome::tool_error(format!("shell: cannot read the command: {e}")),
|
||||
};
|
||||
if out.len() >= MAX_OUTPUT {
|
||||
// Past the limit: keep draining so the command never blocks on a full pipe.
|
||||
dropped = true;
|
||||
continue;
|
||||
}
|
||||
let chunk = match buf.get(..n) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let room = MAX_OUTPUT - out.len();
|
||||
if chunk.len() <= room {
|
||||
out.extend_from_slice(chunk);
|
||||
} else {
|
||||
out.extend_from_slice(chunk.get(..room).unwrap_or_default());
|
||||
dropped = true;
|
||||
}
|
||||
}
|
||||
|
||||
let status = match child.wait() {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Outcome::tool_error(format!("shell: cannot wait for the command: {e}")),
|
||||
};
|
||||
|
||||
let mut text = String::from_utf8_lossy(&out).into_owned();
|
||||
if dropped {
|
||||
text.push_str(&format!("\n[output after {MAX_OUTPUT} bytes dropped]"));
|
||||
}
|
||||
match status.code() {
|
||||
Some(code) => text.push_str(&format!("\n[exit {code}]")),
|
||||
None => match status.signal() {
|
||||
Some(sig) => text.push_str(&format!("\n[killed by signal {sig}]")),
|
||||
None => text.push_str("\n[ended without a status]"),
|
||||
},
|
||||
}
|
||||
Outcome::done(text)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `toolkit shell`, run as `brokerd` runs it, with the host's `/bin/sh`. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use proto::tools::ShellArgs;
|
||||
use support::{TempDir, json, toolkit};
|
||||
use toolkit::shell::MAX_OUTPUT;
|
||||
|
||||
fn sh(command: &str, cwd: Option<&str>) -> support::Ran {
|
||||
let args = ShellArgs {
|
||||
command: command.to_string(),
|
||||
cwd: cwd.map(str::to_string),
|
||||
};
|
||||
toolkit(&["shell"], &json(&args))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_and_errors_come_back_in_order_then_the_exit_status() {
|
||||
let ran = sh("echo one; echo two >&2; echo three", None);
|
||||
assert_eq!(ran.code, 0);
|
||||
assert_eq!(ran.stdout, "one\ntwo\nthree\n\n[exit 0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_command_is_still_a_result_with_its_status() {
|
||||
let ran = sh("echo nope; exit 3", None);
|
||||
assert_eq!((ran.code, ran.stdout.as_str()), (0, "nope\n\n[exit 3]"));
|
||||
let ran = sh("true", None);
|
||||
assert_eq!(ran.stdout, "\n[exit 0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_killed_by_a_signal_says_so() {
|
||||
let ran = sh("kill -9 $$", None);
|
||||
assert_eq!(
|
||||
(ran.code, ran.stdout.as_str()),
|
||||
(0, "\n[killed by signal 9]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_runs_in_cwd_or_in_tmp() {
|
||||
let dir = TempDir::new("cwd");
|
||||
let here = dir.path().to_str().unwrap();
|
||||
let ran = sh("pwd", Some(here));
|
||||
assert_eq!(ran.stdout, format!("{here}\n\n[exit 0]"));
|
||||
let ran = sh("pwd", None);
|
||||
assert_eq!(ran.stdout, "/tmp\n\n[exit 0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_cwd_is_exit_1() {
|
||||
let ran = sh("pwd", Some("/no/such/dir"));
|
||||
assert_eq!(
|
||||
(ran.code, ran.stdout.as_str()),
|
||||
(1, "shell: /no/such/dir: no such directory")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_input_is_empty() {
|
||||
let ran = sh("cat; echo done", None);
|
||||
assert_eq!(ran.stdout, "done\n\n[exit 0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_past_the_limit_is_dropped_and_the_command_still_finishes() {
|
||||
let ran = sh(
|
||||
&format!(
|
||||
"head -c {} /dev/zero | tr '\\0' a; echo; echo end >&2; exit 4",
|
||||
MAX_OUTPUT + 5000
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert_eq!(ran.code, 0);
|
||||
let expected_tail = format!("\n[output after {MAX_OUTPUT} bytes dropped]\n[exit 4]");
|
||||
assert!(
|
||||
ran.stdout.ends_with(&expected_tail),
|
||||
"{}",
|
||||
&ran.stdout[ran.stdout.len() - 80..]
|
||||
);
|
||||
assert_eq!(ran.stdout.len(), MAX_OUTPUT + expected_tail.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_that_is_not_utf8_is_replaced_not_refused() {
|
||||
let ran = sh("printf 'a\\377b'", None);
|
||||
assert_eq!(ran.stdout, "a\u{fffd}b\n[exit 0]");
|
||||
}
|
||||
Reference in New Issue
Block a user