A draft spec for the owner's review and 13 offline tasks with their given tests: shared tool arguments and host rules in proto, the sealed fetch target (M3a finding 14), the toolkit tools and SOCKS5 egress proxy, and brokerd's [runner], podman argument lists, runtime and proxy lifecycle. Each task's tests were run against a reference at that task's end state (560 to 638 tests, clippy clean); the reference is not in the repository. Adds the runner-unavailable runbook entry and tip T23 (ETXTBSY in script tests). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
81 lines
3.9 KiB
Markdown
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 && 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.
|