Six closed tasks for consult: registry, CONSULT_WAIT, [consult]/[secrets], ToolName and grants, Decision body, loopback HTTP. Reference is the git bundle ~/boxmaker-refs/m6b-ref.bundle at 5fb05f0. Gate after task 06 is 1611. Co-Authored-By: Grok 4.6 <noreply@x.ai>
16 KiB
M6b task 06: allowlisted POST; audit body equals the bytes
Branch: m6b. Check it: git branch --show-current prints m6b and git status --short
is empty; otherwise stop. Never switch branch.
Commit subject: brokerd: consult posts the allowlisted body
Log row: the Task cell is M6b/06-brokerd-consult-http.
First action. Run this before any Read of crate source. Do not ls
crates/brokerd/tests/. Do not Read podman.rs, docs/implementer-log.md (except the
first 12 lines at the log step). The given test injects an in-process fake; do not open a
socket in the tests and do not reach the network (T27).
cp docs/plans/M6b/files/crates/brokerd/tests/consult_allow.rs crates/brokerd/tests/
Goal
An allowed consult POSTs the allowlisted JSON (wire_body) through ConsultTransport.
Tests inject a fake; production uses loopback HTTP/1.1 (HttpTransport). The audit
body equals the posted bytes. messages has one user item and no tools. The tool
result is choices[0].message.content, tag-neutralised, then capped. ConsultStack
sends Consult to that runtime and everything else to inner, so existing Routed
tests stay unedited. main loads the secret after Config::load and wraps Routed
when [consult] and the secret are both present.
Every place that changes
| Place | What happens there |
|---|---|
crates/brokerd/src/consult.rs |
ConsultTransport, ConsultRuntime, ConsultStack, parse_message_content |
crates/brokerd/src/consult_http.rs |
new: HttpTransport |
crates/brokerd/src/lib.rs |
pub mod consult_http; |
crates/brokerd/src/main.rs |
load_secret; wrap Routed in ConsultStack |
crates/brokerd/tests/consult_allow.rs |
new, given |
| everything else | no change: not ledger, not handle's decide path |
Files
- Copy, from
docs/plans/M6b/files/(the same path under it): the new testcrates/brokerd/tests/consult_allow.rs. - Create:
crates/brokerd/src/consult_http.rs(the module below). - Modify:
crates/brokerd/src/consult.rs,crates/brokerd/src/lib.rs,crates/brokerd/src/main.rs,docs/implementer-log.md. - Do not touch: any other file.
The given test holds mod runtime even though it does not name Recording:
support/client.rs imports crate::runtime. Leave that module in the test.
Facts you need
Runtime::run_labelleddefault callsrun. Implement both:runforwards torun_labelledand drops the label. Implementing onlyrun_labelledrecurses.- No
choices[0]index.as_array().and_then(|a| a.first()). A missing content is"the response has no content". - Neutralise then cap.
bxcore::sanitize::neutralisethencap_bytes(..., QUESTION_CAP). The given test builds the think tag withconcat!("<", "think", ">")and asserts<in the result. Do not write that tag whole anywhere (T33). - Label.
run_labelledreturns(RunOutput { content, truncated }, None). Result class comes from the grant, as today. - HTTP/1.1.
TcpStreamto the loopback host:port fromparse_loopback_http. Headers:POST {path} HTTP/1.1,Host,Authorization: Bearer {bearer},Content-Type: application/json,Content-Length,Connection: close. Response cap 262_144 bytes. Status must contain200or end with200. HTTPS is later. load_secretinmainafterConfig::load, before the instance lock. Print{e}and exit 1 onErr. Print awarningifLoadedhas one. Role is already"brokerd"insideload_secret.- Wrap after
Routed.ConsultStack { consult: ConsultRuntime::new(consult, loaded.secret, HttpTransport), inner: runtime }when bothcfg.consultand the loaded secret areSome. Otherwise keepruntime. - The items the edits use. This table is complete.
Item Signature Runtime::runrun(&self, spec: &RunSpec) -> Result<RunOutput, RunError>Runtime::run_labelledrun_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError>RunSpec::tooltool(&self) -> ToolNameRunSpec::argumentsarguments(&self) -> &ToolArgsRunError::UnavailableUnavailable(String)RunOutput{ content: String, truncated: bool }Secret::newnew(text: String) -> SecretSecret::exposeexpose(&self) -> &strLoaded{ secret: Secret, warning: Option<String> }bxcore::sanitize::neutraliseneutralise(s: &str) -> StringConsult::temperaturetemperature(&self) -> f64Consult::timeouttimeout(&self) -> Durationparse_loopback_httpparse_loopback_http(url: &str) -> Result<(String, u16, String), String>TcpStream::connectconnect<A: ToSocketAddrs>(addr: A) -> io::Result<TcpStream>TcpStream::set_read_timeoutset_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>TcpStream::set_write_timeoutset_write_timeout(&self, dur: Option<Duration>) -> io::Result<()>
The edits, exactly
Each quoted "before" block occurs exactly once after task 05.
crates/brokerd/src/consult.rs
I1. The imports
use bxcore::secrets::{Loaded, SecretError, SecretSpec};
use serde::{Deserialize, Deserializer, Serialize};
use crate::config::Config;
become
use bxcore::secrets::{Loaded, Secret, SecretError, SecretSpec};
use serde::{Deserialize, Deserializer, Serialize};
use crate::args::ToolArgs;
use crate::config::Config;
use crate::policy::Label;
use crate::runner::{RunError, RunOutput, RunSpec, Runtime};
I2. After load_secret (after its closing }) append:
/// One POST of the allowlisted body. Tests inject a fake; production uses loopback HTTP/1.1.
pub trait ConsultTransport: Send + Sync {
fn post(
&self,
url: &str,
bearer: &str,
body: &[u8],
timeout: Duration,
) -> Result<Vec<u8>, String>;
}
/// Runs an allowed `consult` through `transport`.
pub struct ConsultRuntime<T> {
consult: Consult,
secret: Secret,
transport: T,
}
impl<T: ConsultTransport> ConsultRuntime<T> {
pub fn new(consult: Consult, secret: Secret, transport: T) -> ConsultRuntime<T> {
ConsultRuntime {
consult,
secret,
transport,
}
}
}
impl<T: ConsultTransport> Runtime for ConsultRuntime<T> {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.run_labelled(spec).map(|(output, _)| output)
}
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
let question = match spec.arguments() {
ToolArgs::Consult { question } => question.as_str(),
_ => {
return Err(RunError::Unavailable(
"consult was routed a different tool".to_string(),
));
}
};
let body = wire_body_parts(&self.consult.model, self.consult.temperature(), question);
let bytes = self
.transport
.post(
&self.consult.url,
self.secret.expose(),
body.as_bytes(),
self.consult.timeout(),
)
.map_err(RunError::Unavailable)?;
let content = parse_message_content(&bytes).map_err(RunError::Unavailable)?;
let neutralised = bxcore::sanitize::neutralise(&content);
let (content, truncated) = cap_bytes(&neutralised, QUESTION_CAP);
Ok((RunOutput { content, truncated }, None))
}
}
/// `choices[0].message.content`, ignoring unknown fields.
pub fn parse_message_content(bytes: &[u8]) -> Result<String, String> {
let value: serde_json::Value =
serde_json::from_slice(bytes).map_err(|e| format!("consult response is not JSON: {e}"))?;
match value
.get("choices")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|c| c.get("message"))
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
{
Some(content) => Ok(content.to_string()),
None => Err("the response has no content".to_string()),
}
}
/// `consult` first, everything else to `inner`.
pub struct ConsultStack {
pub consult: Box<dyn Runtime>,
pub inner: Box<dyn Runtime>,
}
impl Runtime for ConsultStack {
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
self.run_labelled(spec).map(|(output, _)| output)
}
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
if spec.tool() == crate::args::ToolName::Consult {
self.consult.run_labelled(spec)
} else {
self.inner.run_labelled(spec)
}
}
}
crates/brokerd/src/consult_http.rs
Write this module.
//! Loopback HTTP/1.1 POST for `consult`. HTTPS is a later milestone.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, Instant};
use crate::consult::{ConsultTransport, parse_loopback_http};
/// Largest response body accepted, in bytes.
pub const MAX_RESPONSE: usize = 262_144;
/// Production transport: one TCP connection to the configured loopback URL.
pub struct HttpTransport;
impl ConsultTransport for HttpTransport {
fn post(
&self,
url: &str,
bearer: &str,
body: &[u8],
timeout: Duration,
) -> Result<Vec<u8>, String> {
post_http(url, bearer, body, timeout)
}
}
fn post_http(url: &str, bearer: &str, body: &[u8], timeout: Duration) -> Result<Vec<u8>, String> {
let (host, port, path) = parse_loopback_http(url)?;
let addr = format!("{host}:{port}");
let mut stream =
TcpStream::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| format!("cannot set read timeout: {e}"))?;
stream
.set_write_timeout(Some(timeout))
.map_err(|e| format!("cannot set write timeout: {e}"))?;
let host_header = if port == 80 {
host
} else {
format!("{host}:{port}")
};
let length = body.len();
let header = format!(
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nAuthorization: Bearer {bearer}\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n"
);
stream
.write_all(header.as_bytes())
.map_err(|e| format!("cannot write consult request: {e}"))?;
stream
.write_all(body)
.map_err(|e| format!("cannot write consult request: {e}"))?;
stream
.flush()
.map_err(|e| format!("cannot write consult request: {e}"))?;
read_response(&mut stream, timeout)
}
fn read_response(stream: &mut TcpStream, timeout: Duration) -> Result<Vec<u8>, String> {
let until = Instant::now()
.checked_add(timeout)
.ok_or_else(|| "the consult timeout is too large".to_string())?;
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
if Instant::now() >= until {
return Err("consult timed out".to_string());
}
let left = until.saturating_duration_since(Instant::now());
stream
.set_read_timeout(Some(left))
.map_err(|e| format!("cannot set read timeout: {e}"))?;
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > MAX_RESPONSE {
return Err("consult response is too large".to_string());
}
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
return Err("consult timed out".to_string());
}
Err(e) => return Err(format!("cannot read consult response: {e}")),
}
}
split_http_body(&buf)
}
fn split_http_body(buf: &[u8]) -> Result<Vec<u8>, String> {
const SEP: &[u8] = b"\r\n\r\n";
let sep = buf
.windows(SEP.len())
.position(|w| w == SEP)
.ok_or_else(|| "consult response has no header terminator".to_string())?;
let (head, rest) = match buf.split_at_checked(sep) {
Some(pair) => pair,
None => return Err("consult response is truncated".to_string()),
};
let body = match rest.get(SEP.len()..) {
Some(body) => body,
None => return Err("consult response is truncated".to_string()),
};
let head = std::str::from_utf8(head)
.map_err(|_| "consult response headers are not UTF-8".to_string())?;
let status_line = head.lines().next().unwrap_or("");
if !status_line.contains(" 200 ") && !status_line.ends_with(" 200") {
return Err(format!("consult HTTP status is not 200: {status_line}"));
}
Ok(body.to_vec())
}
crates/brokerd/src/lib.rs
L1. After pub mod consult; add pub mod consult_http;. rustfmt decides the final
order.
crates/brokerd/src/main.rs
S1. After use brokerd::config::Config; add:
use brokerd::consult::{self, ConsultRuntime, ConsultStack};
use brokerd::consult_http::HttpTransport;
S2. Immediately after the Config::load match (after its closing };) and before
the instance-lock comment, add:
let consult_secret = match consult::load_secret(&cfg, &|k| std::env::var_os(k)) {
Ok(loaded) => loaded,
Err(e) => {
eprintln!("brokerd: {e}");
return ExitCode::from(1);
}
};
if let Some(loaded) = &consult_secret
&& let Some(warning) = &loaded.warning
{
eprintln!("{warning}");
}
S3. After the Routed { ... }); block, before let started = match serve::start, add:
let runtime: Box<dyn Runtime> = match (cfg.consult.clone(), consult_secret) {
(Some(consult), Some(loaded)) => Box::new(ConsultStack {
consult: Box::new(ConsultRuntime::new(consult, loaded.secret, HttpTransport)),
inner: runtime,
}),
_ => runtime,
};
Steps
-
1. Copy the given test.
cp docs/plans/M6b/files/crates/brokerd/tests/consult_allow.rs crates/brokerd/tests/ -
2. See it fail.
cargo test -p brokerd --test consult_allow, timeout 600000 ms: the target does not compile (E0432unresolvedConsultRuntime/ConsultTransport). -
3. The edits. I1, I2, write
consult_http.rs, L1, S1–S3.cargo check -p brokerdprints no error and no warning.wc -l crates/brokerd/src/consult.rs,consult_http.rs,main.rs,broker.rseach ≤ 500. -
4. The tests.
cargo test -p brokerd --test consult_allow --test consult_deny --test memory_port --test memory_tools: every test passes, 2 inconsult_allow. The memory-port tests still pass (ConsultStackis not in those tests;Routedis unedited). -
5. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1611 (1609 before, plus 2). Aftergate: ok, do not add uptest result:lines. -
6. Log and commit. Task cell
M6b/06-brokerd-consult-http. Read only the first 12 lines ofdocs/implementer-log.md; insert the row under the header. Stage withgit add crates/brokerd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1611 tests.git diff HEAD~1 --statlists onlycrates/brokerd/src/consult.rs,crates/brokerd/src/consult_http.rs,crates/brokerd/src/lib.rs,crates/brokerd/src/main.rs,crates/brokerd/tests/consult_allow.rsand the log.
Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; a
test opens a TCP port or sleeps; you write a chat-template tag whole;
cargo checkwarns;consult.rsexceeds 500 lines; a memory-port test fails; a test outside the named files fails in the gate (git stash -u, rerun,git stash pop, report both).
What the given tests pin
An allowed consult POSTs bytes equal to wire_body; the audit body is those bytes;
messages has length 1, role user, the question, no tools; the tool result contains
blue and sky and < and not the think tag; class private, untrusted true. A
second test with a different question still posts only that question.