Comments cite nix modules, scripts and crate source files constantly,
and nothing evaluates a comment — so when a file moves, the reference
rots silently and `nix flake check` stays green. A reader following one
finds nothing and cannot tell whether the file was renamed, deleted, or
never existed.
Seven such references, each repointed at the file that actually holds
the thing the sentence is about rather than at the directory the old
name became:
hive-c0re/src/agent_config/limits.rs hive-agent/src/mcp.rs
-> hive-agent-mcp/src/mcp/mod.rs
hive-agent-mcp/src/mcp/mod.rs hive-c0re/src/limits.rs
-> hive-c0re/src/agent_config/limits.rs
(and the module path in the doc
comment above it, which was stale
in the same way)
hive-c0re/src/forge/mod.rs hive-c0re/src/knowledge.rs
-> hive-c0re/src/workers/knowledge.rs
nix/host-modules/hive-c0re/options.nix hive-c0re/src/hive_stats.rs
-> hive-c0re/src/stats/hive_stats.rs
nix/packages/default.nix nix/host-modules/hive-c0re.nix
-> .../hive-c0re/options.nix
nix/agent-modules/network.nix nix/host-modules/hive-gateway.nix
-> .../hive-gateway/dnsmasq.nix
frontend/README.md nix/modules/frontend.nix
-> nix/packages/frontend.nix
The two `limits.rs` comments are a matched pair: each names the other's
old path, so the "keep in sync" instruction they exist to carry pointed
both ways at nothing.
Where a flat module became a directory the target is the file that
declares the named thing, not `default.nix` by reflex — the
`preBuildAgentTemplates` option is declared in `options.nix`, and the
DHCP pool that sentence is about lives in `dnsmasq.nix`.
Comments only; no behaviour change. Refs #3923, which is about whether a
gate should cover this class at all — that question is unanswered and
this does not close it.
176 lines
7.1 KiB
Rust
176 lines
7.1 KiB
Rust
//! Wire-protocol size limits shared across the agent + manager
|
|
//! sockets. Caps on inline message bodies stop a single chatty agent
|
|
//! (or a misbehaving extra-MCP server) from flooding the broker
|
|
//! sqlite with megabyte-sized rows that then bloat every recipient's
|
|
//! wake-prompt context. Anything genuinely larger should be written
|
|
//! to a state file and the path sent as the body.
|
|
//!
|
|
//! Reminders get a separate auto-file escape hatch (see
|
|
//! `socket_server::handle_remind`) so callers don't have to think
|
|
//! about it — oversized reminder bodies get persisted to disk
|
|
//! transparently and the inbox sees a pointer.
|
|
|
|
/// Per-message body cap. Applies to `send` bodies and the stored
|
|
/// inline form of a reminder. 4 KiB
|
|
/// catches the bulk of conversational overflow (status reports,
|
|
/// bullet-list summaries, short proposals) while staying small
|
|
/// enough that a backed-up inbox of ~10 unread messages only adds
|
|
/// ~40 KiB to the recipient's wake-prompt context. Genuinely
|
|
/// long-form artifacts (audit reports, full diffs, transcripts)
|
|
/// still belong in a state file — the error message on overflow
|
|
/// points callers at that escape hatch.
|
|
pub const MESSAGE_MAX_BYTES: usize = 4096;
|
|
|
|
/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a
|
|
/// caller-ready error string (caller wraps in
|
|
/// `Response::Err`) on failure.
|
|
///
|
|
/// `label` shows up in the error message verbatim — pass a short
|
|
/// noun like `"send"` or `"broadcast"` so the model can tell which
|
|
/// call got rejected.
|
|
pub fn check_size(label: &str, body: &str) -> Result<(), String> {
|
|
if body.len() > MESSAGE_MAX_BYTES {
|
|
Err(format!(
|
|
"{label} body too long ({} bytes, max {MESSAGE_MAX_BYTES}); write the \
|
|
payload to a file under your `/agents/<you>/state/` dir and send the \
|
|
path as the body instead",
|
|
body.len()
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Per-status soft cap. `set_status` renders as a short chip on the
|
|
/// dashboard agent card — the front-end truncates long strings to
|
|
/// keep the row layout intact, so a multi-paragraph "session report"
|
|
/// is wasted bytes that just bloat the rescan emit + container view
|
|
/// payload. Cap at 200 chars to fit the chip plus a little
|
|
/// descriptive padding without forcing the operator to read a
|
|
/// scrolling chunk.
|
|
/// NOTE: `hive-agent-mcp/src/mcp/mod.rs::write_status_file` mirrors this constant
|
|
/// client-side so invalid text is caught before the file is written.
|
|
/// Keep in sync if this value changes.
|
|
pub const STATUS_MAX_CHARS: usize = 200;
|
|
|
|
/// Validate a `set_status` payload. Single-line + bounded so
|
|
/// callers can't dump multi-paragraph session reports into the
|
|
/// dashboard chip. Whitespace trim is done by the caller before the
|
|
/// store-to-disk step — we run validation on the trimmed form so
|
|
/// surrounding whitespace doesn't push a borderline-legal status
|
|
/// past the cap.
|
|
///
|
|
/// Empty / all-whitespace input is accepted: the call site treats
|
|
/// that as "clear the status" and removes the on-disk sentinel. Tests
|
|
/// + caller cover both directions.
|
|
///
|
|
/// Returns a caller-ready error string suitable for surfacing in the
|
|
/// `*Response::Err` shape.
|
|
pub fn check_status_text(text: &str) -> Result<(), String> {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
// Empty = clear-status sentinel; nothing to validate.
|
|
return Ok(());
|
|
}
|
|
// Newline / carriage-return: status is a single-line chip on the
|
|
// dashboard. Multi-line session reports should go to a state file.
|
|
if trimmed.contains('\n') || trimmed.contains('\r') {
|
|
return Err(
|
|
"set_status text must be a single line — write multi-line context to \
|
|
a file under your `/agents/<you>/state/` dir and reference that path \
|
|
from the chip instead"
|
|
.to_owned(),
|
|
);
|
|
}
|
|
let len = trimmed.chars().count();
|
|
if len > STATUS_MAX_CHARS {
|
|
return Err(format!(
|
|
"set_status text too long ({len} chars, max {STATUS_MAX_CHARS}); the \
|
|
dashboard chip truncates anything longer, so trim to a short summary \
|
|
and write the detail to `/agents/<you>/state/<file>` instead"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn accepts_short_body() {
|
|
assert!(check_size("send", "hello").is_ok());
|
|
assert!(check_size("send", &"x".repeat(MESSAGE_MAX_BYTES)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_oversize_body() {
|
|
let err = check_size("send", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
|
|
assert!(err.contains("send body too long"));
|
|
assert!(err.contains(&format!("max {MESSAGE_MAX_BYTES}")));
|
|
}
|
|
|
|
#[test]
|
|
fn label_threads_through() {
|
|
let err = check_size("broadcast", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err();
|
|
assert!(err.starts_with("broadcast body too long"));
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_accepts_short_single_line() {
|
|
assert!(check_status_text("idle").is_ok());
|
|
assert!(check_status_text("processing matrix messages").is_ok());
|
|
// Boundary: exactly STATUS_MAX_CHARS chars trimmed is still
|
|
// accepted; one more rejects.
|
|
let max = "a".repeat(STATUS_MAX_CHARS);
|
|
assert!(check_status_text(&max).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_accepts_empty_and_whitespace() {
|
|
// Empty + whitespace-only are the "clear status" sentinel and
|
|
// bypass the rest of the checks.
|
|
assert!(check_status_text("").is_ok());
|
|
assert!(check_status_text(" ").is_ok());
|
|
assert!(check_status_text("\n\t ").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_rejects_multi_line() {
|
|
let err = check_status_text("line one\nline two").unwrap_err();
|
|
assert!(err.contains("single line"), "err = {err}");
|
|
// Carriage return alone also rejects (windows linebreak / CR-only).
|
|
assert!(check_status_text("a\rb").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_rejects_oversize() {
|
|
let too_long = "a".repeat(STATUS_MAX_CHARS + 1);
|
|
let err = check_status_text(&too_long).unwrap_err();
|
|
assert!(err.contains("too long"), "err = {err}");
|
|
assert!(err.contains(&format!("max {STATUS_MAX_CHARS}")));
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_counts_chars_not_bytes() {
|
|
// Multi-byte chars (emoji, accented letters) count once each
|
|
// per char — chars().count() not byte len. STATUS_MAX_CHARS
|
|
// worth of 4-byte chars is still legal.
|
|
let emoji = "💜".repeat(STATUS_MAX_CHARS);
|
|
assert!(
|
|
check_status_text(&emoji).is_ok(),
|
|
"{STATUS_MAX_CHARS} emoji should fit"
|
|
);
|
|
let too_many = "💜".repeat(STATUS_MAX_CHARS + 1);
|
|
assert!(check_status_text(&too_many).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn check_status_validates_post_trim() {
|
|
// Leading/trailing whitespace is trimmed before the length
|
|
// check — a borderline-legal payload with spaces around it
|
|
// still passes.
|
|
let padded = format!(" {} ", "a".repeat(STATUS_MAX_CHARS));
|
|
assert!(check_status_text(&padded).is_ok());
|
|
}
|
|
}
|