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>
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
cwdisargs.cwd, orDEFAULT_CWDwhen it isNone. IfPath::new(cwd).is_dir()is false →Outcome::tool_error(format!("shell: {cwd}: no such directory")).- Make one pipe for both output streams:
let (mut reader, writer) = std::io::pipe()?, and a second write end withwriter.try_clone(). (std::io::pipeis stable since Rust 1.87.) A failure here →tool_error(format!("shell: cannot make a pipe: {e}")). Command::new(SHELL).arg("-c").arg(&args.command).current_dir(cwd), standard inputStdio::null(), standard output the first write end, standard error the second. Spawn it. A failure →tool_error(format!("shell: cannot start {SHELL}: {e}")).- Drop the
Commandright after spawning (drop(command)): it still holds the write ends, and while any write end is open, reading never reaches the end. - Read
readerto the end in a loop with an 8 KiB buffer. Keep the firstMAX_OUTPUTbytes; 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. RetryErrorKind::Interrupted; stop on any other error. No indexing that can panic: usebuf.get(..n)and the like. child.wait(). A failure →tool_error(format!("shell: cannot wait for the command: {e}")).- 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}]")ifstatus.code()isSome, elseformat!("\n[killed by signal {n}]")ifstatus.signal()isSome(std::os::unix::process::ExitStatusExt), else"\n[ended without a status]".
- if something was dropped:
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, thencp 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.rsand thelib.rschanges. Runcargo 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 shellreports 8 passed five times running;make gateprintsgate: ok.
Stop and report if
- A test hangs: that is almost always step 4 (a write end still open). Report it if dropping the
Commanddoes not fix it.