bxctl: escape error details, time out on admin.sock, AdminError::Io

M3a review findings 9, 10, 12, 13. A retrying error and every error detail
can carry the inference server's body, so they are escaped like model text.
Admin requests wait at most 30 s, so a stuck brokerd cannot hang bxctl or a
chat turn. A failed write is AdminError::Io and stops handle_pending instead
of being answered with another write. The usage line says what audit verify
checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:09:53 -07:00
co-authored by Claude Opus 5.5
parent 70d582acf5
commit a75a5453e9
5 changed files with 132 additions and 8 deletions
+27 -4
View File
@@ -12,10 +12,27 @@ use proto::{
use crate::chat::code_name;
use crate::escape::escape_json_text;
// Ask brokerd for one message and read one answer.
/// How long `bxctl` waits for `brokerd` to take or answer an admin request.
pub const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
// Ask brokerd for one message and read one answer, waiting at most `ADMIN_TIMEOUT`.
pub fn request(socket: &Path, msg: Message) -> Result<Message, AdminError> {
request_with_timeout(socket, msg, ADMIN_TIMEOUT)
}
/// `request` with another limit on each read and write, so a `brokerd` that accepts and never
/// answers cannot hang `bxctl`, or a `chat` turn waiting on an approval.
pub fn request_with_timeout(
socket: &Path,
msg: Message,
timeout: std::time::Duration,
) -> Result<Message, AdminError> {
let mut stream =
UnixStream::connect(socket).map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?;
stream
.set_read_timeout(Some(timeout))
.and_then(|()| stream.set_write_timeout(Some(timeout)))
.map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?;
let env = Envelope {
v: PROTOCOL_VERSION,
id: 1,
@@ -51,6 +68,8 @@ pub enum AdminError {
Frame(FrameError),
Refused(WireError),
Protocol(String),
/// Writing the output failed; the caller stops rather than write again.
Io(std::io::Error),
}
impl std::fmt::Display for AdminError {
@@ -58,16 +77,20 @@ impl std::fmt::Display for AdminError {
match self {
AdminError::Connect(p, e) => write!(f, "cannot reach brokerd at {}: {e}", p.display()),
AdminError::Frame(e) => write!(f, "{e}"),
AdminError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
// The detail may carry text from the inference server or a tool: escape it.
AdminError::Refused(w) => {
write!(f, "{}: {}", code_name(w.code), escape_json_text(&w.detail))
}
AdminError::Protocol(s) => write!(f, "{s}"),
AdminError::Io(e) => write!(f, "{e}"),
}
}
}
// An error writing the output is reported like any other failure, with the message preserved.
// An error writing the output is its own kind, so the caller can stop instead of writing again.
impl From<std::io::Error> for AdminError {
fn from(e: std::io::Error) -> Self {
AdminError::Protocol(e.to_string())
AdminError::Io(e)
}
}
+9 -3
View File
@@ -10,7 +10,7 @@ use proto::{
};
use crate::admin;
use crate::escape::escape_model_text;
use crate::escape::{escape_json_text, escape_model_text};
#[derive(Debug)]
pub enum ChatError {
@@ -25,7 +25,10 @@ impl std::fmt::Display for ChatError {
match self {
ChatError::Connect(e) => write!(f, "{e}"),
ChatError::Frame(e) => write!(f, "{e}"),
ChatError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
// The detail may carry the inference server's body: escape it like model text.
ChatError::Refused(w) => {
write!(f, "{}: {}", code_name(w.code), escape_json_text(&w.detail))
}
ChatError::Protocol(s) => write!(f, "{s}"),
}
}
@@ -188,7 +191,8 @@ impl Printer {
self.close_dimmed(out)?;
writeln!(
out,
"[retrying: attempt {attempt} in {after_ms} ms: {error}]"
"[retrying: attempt {attempt} in {after_ms} ms: {}]",
escape_json_text(error)
)?;
}
TurnEvent::ThinkingCapped { tokens } => {
@@ -302,6 +306,8 @@ pub fn handle_pending(
};
match result {
Ok(_) => Ok(()),
// The output itself failed: writing another line to it would fail the same way.
Err(admin::AdminError::Io(e)) => Err(e),
Err(e) => {
writeln!(out, "approval {approval}: {e}")?;
Ok(())
+1 -1
View File
@@ -25,7 +25,7 @@ usage: bxctl <command> [options]
Check that the grants load.
bxctl audit verify [--home DIR]
Verify the audit log against the grants.";
Check the audit log's hash chain, reading the files directly.";
// The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin
// socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`.