Files
boxmaker/docs/plans/M3b/05-toolkit-shell.md
T
kyleandClaude Opus 5.5 5e55fe4c66 M3b plan: every task's git add includes Cargo.lock
Task 04 added dependencies to toolkit and its git add line left out the lock
file, so the driver stopped on an unclean tree. The lock change is folded into
task 04's commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 00:00:12 -07:00

3.9 KiB

M3b task 05: toolkit shell

Branch: m3b (run git switch m3b; git status --short must be empty, otherwise stop) Commit subject: toolkit: shell

Goal

toolkit shell runs one command under /bin/sh -c, in the call's cwd or in /tmp, and prints its output (standard output and standard error together, in the order written) followed by how it ended. The command's own exit status is part of the result, not toolkit's: toolkit exits 0 whenever the command ran. Spec section 4.

Files

  • Copy: crates/toolkit/tests/shell.rs
  • Create: crates/toolkit/src/shell.rs
  • Modify: crates/toolkit/src/lib.rs, docs/implementer-log.md

Interfaces

pub const SHELL: &str = "/bin/sh";
pub const DEFAULT_CWD: &str = "/tmp";
pub const MAX_OUTPUT: usize = 1024 * 1024;

pub fn shell(args: &proto::tools::ShellArgs) -> crate::Outcome;

In lib.rs: add pub mod shell; and, in run, a "shell" arm before the unknown-tool arm, parsing ShellArgs exactly as the other two tools do and calling shell::shell(&args).

shell: every step and exit

  1. cwd is args.cwd, or DEFAULT_CWD when it is None. If Path::new(cwd).is_dir() is false → Outcome::tool_error(format!("shell: {cwd}: no such directory")).
  2. Make one pipe for both output streams: let (mut reader, writer) = std::io::pipe()?, and a second write end with writer.try_clone(). (std::io::pipe is stable since Rust 1.87.) A failure here → tool_error(format!("shell: cannot make a pipe: {e}")).
  3. Command::new(SHELL).arg("-c").arg(&args.command).current_dir(cwd), standard input Stdio::null(), standard output the first write end, standard error the second. Spawn it. A failure → tool_error(format!("shell: cannot start {SHELL}: {e}")).
  4. Drop the Command right after spawning (drop(command)): it still holds the write ends, and while any write end is open, reading never reaches the end.
  5. Read reader to the end in a loop with an 8 KiB buffer. Keep the first MAX_OUTPUT bytes; keep reading after that and throw the rest away, remembering that something was dropped. Do not stop reading: the command would block on a full pipe. Retry ErrorKind::Interrupted; stop on any other error. No indexing that can panic: use buf.get(..n) and the like.
  6. child.wait(). A failure → tool_error(format!("shell: cannot wait for the command: {e}")).
  7. The text is the kept bytes decoded with String::from_utf8_lossy (invalid bytes become U+FFFD, never an error). Then append, in this order:
    • if something was dropped: format!("\n[output after {MAX_OUTPUT} bytes dropped]");
    • format!("\n[exit {code}]") if status.code() is Some, else format!("\n[killed by signal {n}]") if status.signal() is Some (std::os::unix::process::ExitStatusExt), else "\n[ended without a status]".
  8. Outcome::done(text).

A command that leaves a background process holding the pipe (sleep 100 &) makes step 5 wait for it; that is expected, and brokerd's time limit ends it.

Steps

  • 1. Copy. git switch m3b, then cp docs/plans/M3b/files/crates/toolkit/tests/shell.rs crates/toolkit/tests/
  • 2. See it fail. cargo test -p toolkit --test shell. Expected: it does not compile.
  • 3. Write shell.rs and the lib.rs changes. Run cargo fmt --all.
  • 4. See it pass. cargo test -p toolkit --test shell. Expected: 8 passed. Run it five times; it must pass every time.
  • 5. Walk the steps. Point at the line for steps 4 and 5 in particular.
  • 6. Run the gate. make gate. Expected last line: gate: ok.
  • 7. Log and commit. git add crates/toolkit docs/implementer-log.md Cargo.lock && git commit

Done when

  • cargo test -p toolkit --test shell reports 8 passed five times running; make gate prints gate: ok.

Stop and report if

  • A test hangs: that is almost always step 4 (a write end still open). Report it if dropping the Command does not fix it.