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>
6.5 KiB
M3b task 04: the toolkit program, read_file and write_file
Branch: m3b (run git switch m3b; git status --short must be empty, otherwise stop)
Commit subject: toolkit: the tool program, read_file and write_file
Goal
toolkit is the one binary inside a tool container. brokerd will run toolkit <tool> with the
tool's arguments as JSON on standard input (the proto::tools structs from task 01). toolkit
prints what the model should see on standard output and exits 0 (done), 1 (the tool could not do
it, for a reason the model should read) or 2 (it was run wrongly). It decides nothing: whether the
call may run was decided before the container existed. Spec section 4.
This task makes the program and its first two tools. shell and http_fetch come in tasks 05 and
06; until then they are unknown tools (exit 2).
Files
- Copy:
crates/toolkit/tests/support/mod.rs,crates/toolkit/tests/files.rs - Create:
crates/toolkit/src/input.rs,crates/toolkit/src/files.rs - Replace:
crates/toolkit/src/lib.rs,crates/toolkit/src/main.rs(both are stubs today) - Modify:
crates/toolkit/Cargo.toml,docs/dependencies.md,docs/implementer-log.md
Cargo.toml: under [dependencies], after proto.workspace = true, add
serde.workspace = true and serde_json.workspace = true, each on its own line.
docs/dependencies.md: in the serde and serde_json rows, the "Used by" column becomes
`proto`, `brokerd`, `toolkit`.
Interfaces
lib.rs (module doc: the programs that run inside tool containers):
pub mod files;
pub mod input;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exit { Done, ToolError, Misuse }
impl Exit {
/// 0, 1, 2. No `as` cast: a `match`.
pub fn code(self) -> u8;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome { pub exit: Exit, pub stdout: String, pub stderr: String }
impl Outcome {
pub fn done(stdout: String) -> Outcome; // Done, stderr empty
pub fn tool_error(stdout: String) -> Outcome; // ToolError, stderr empty
pub fn misuse(stderr: String) -> Outcome; // Misuse, stdout EMPTY: the model sees nothing
}
/// Run tool `name` with the arguments on `stdin`.
pub fn run(name: &str, stdin: &mut dyn std::io::Read) -> Outcome;
run:
input::read_input(stdin); an errore→Outcome::misuse(format!("toolkit: {e}")).- By
name:"read_file"→input::parse::<ReadFileArgs>(name, &text)thenfiles::read_file(&args);"write_file"→ the same withWriteFileArgsandfiles::write_file. A parse errore(already a whole line) →Outcome::misuse(e). - Any other name →
Outcome::misuse(format!("toolkit: unknown tool {name:?}")).
The input is read before the name is looked at, for every name.
input.rs:
pub const MAX_INPUT: usize = 2 * 1024 * 1024;
#[derive(Debug)]
pub enum InputError { TooLarge, NotUtf8, Io(std::io::Error) }
// Display, by hand:
// TooLarge → "the arguments are larger than 2097152 bytes" (write it with {MAX_INPUT})
// NotUtf8 → "the arguments are not UTF-8"
// Io(e) → "cannot read the arguments: {e}"
/// All of `stdin`, at most MAX_INPUT bytes, as UTF-8. Read at most MAX_INPUT + 1 bytes
/// (`Read::take`); more than MAX_INPUT is TooLarge.
pub fn read_input(stdin: &mut dyn std::io::Read) -> Result<String, InputError>;
/// Parse `text` as the arguments of tool `name`; the error is the whole line for stderr:
/// "toolkit: {name}: the arguments do not parse: {serde's error}".
pub fn parse<T: serde::de::DeserializeOwned>(name: &str, text: &str) -> Result<T, String>;
files.rs:
pub const MAX_READ: usize = 1024 * 1024;
pub fn read_file(args: &ReadFileArgs) -> Outcome;
pub fn write_file(args: &WriteFileArgs) -> Outcome;
read_file: every exit
Every failure is Outcome::tool_error(format!("read_file: {path}: {why}")), one line, no newline
at the end.
std::fs::metadata(path)fails withErrorKind::NotFound→ why =no such file.- It fails any other way → why = the error's
Displaytext. - It is a directory → why =
is a directory. - Opening fails → why = the error's text.
- Read at most
MAX_READ + 1bytes (take). Reading fails → the error's text. More thanMAX_READbytes → why =larger than 1048576 bytes(write it with{MAX_READ}). ExactlyMAX_READis fine. - Not UTF-8 → why =
not UTF-8 text. - Otherwise
Outcome::done(text): the content exactly, nothing added.
write_file: every exit
Every failure is Outcome::tool_error(format!("write_file: {path}: {why}")).
- The path's parent is not an existing directory (
Path::parentisNone, or notis_dir()) → why =the directory does not exist. No directory is ever created. - The path is a directory → why =
is a directory. std::fs::write(path, content)fails → why = the error's text.- Otherwise
Outcome::done(format!("wrote {} bytes to {path}", content.len()))(bytes, not characters).
main.rs
Read the arguments with std::env::args_os() (never args(), which panics on non-UTF-8). Skip the
program name. If there is exactly one argument, it is the tool name (to_str(), or "" if it is
not UTF-8); otherwise the name is "", which run answers as an unknown tool. Call
toolkit::run(name, &mut std::io::stdin().lock()), write stdout to standard output and stderr
to standard error with write_all (ignore their errors), and return
ExitCode::from(outcome.exit.code()).
Steps
- 1. Copy.
git switch m3b, thenmkdir -p crates/toolkit/tests/supportandcp docs/plans/M3b/files/crates/toolkit/tests/support/mod.rs crates/toolkit/tests/support/andcp docs/plans/M3b/files/crates/toolkit/tests/files.rs crates/toolkit/tests/ - 2. See it fail.
cargo test -p toolkit --test files. Expected: it does not compile. - 3. Write the code and the two manifest and doc changes. Run
cargo fmt --all. - 4. See it pass.
cargo test -p toolkit --test files. Expected: 7 passed. One of them returns early and passes if you are root; you should not be. - 5. Walk the exits. Point at the line of your code for each numbered exit above.
- 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/toolkit docs/dependencies.md docs/implementer-log.md && git commit
Done when
cargo test -p toolkit --test filesreports 7 passed;make gateprintsgate: ok.
Stop and report if
- A test wants a misuse to print anything on standard output.
- A test wants
write_fileto create a directory.