# 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 ```rust 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.