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

81 lines
3.9 KiB
Markdown

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