docs: M6b implementation plan
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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
//! `[consult]` in `brokerd.toml`. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::config::{Config, ConfigError};
|
||||
use brokerd::consult::{DEFAULT_TEMPERATURE_MILLI, DEFAULT_TIMEOUT_MS};
|
||||
use tmp::TempDir;
|
||||
|
||||
fn load(text: &str) -> Result<Config, ConfigError> {
|
||||
let dir = TempDir::new("cfg-consult");
|
||||
let path = dir.write("brokerd.toml", text);
|
||||
Config::load(&path)
|
||||
}
|
||||
|
||||
const TABLE: &str = r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn no_consult_table_means_none() {
|
||||
let c = load("").unwrap();
|
||||
assert!(c.consult.is_none());
|
||||
assert!(c.secrets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consult_table_loads_defaults() {
|
||||
let c = load(TABLE).unwrap();
|
||||
let consult = c.consult.expect("consult");
|
||||
assert_eq!(consult.url, "http://127.0.0.1:9/v1/chat/completions");
|
||||
assert_eq!(consult.model, "test-model");
|
||||
assert_eq!(consult.secret, "consult");
|
||||
assert_eq!(consult.temperature_milli, DEFAULT_TEMPERATURE_MILLI);
|
||||
assert_eq!(consult.timeout_ms, DEFAULT_TIMEOUT_MS);
|
||||
assert!((consult.temperature() - 0.6).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_above_zero_point_six_is_invalid() {
|
||||
let text = format!("{TABLE}temperature = 0.7\n");
|
||||
match load(&text) {
|
||||
Err(ConfigError::Parse(_, err)) => {
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("0.6"), "{msg}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_loopback_url_is_invalid() {
|
||||
let text = r#"
|
||||
[consult]
|
||||
url = "https://example.com/v1/chat/completions"
|
||||
model = "x"
|
||||
secret = "consult"
|
||||
"#;
|
||||
match load(text) {
|
||||
Err(ConfigError::Invalid(_, why)) => {
|
||||
assert!(
|
||||
why.contains("http://127.0.0.1") || why.contains("localhost"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_consult_keys_are_errors() {
|
||||
let text = format!("{TABLE}vendor = \"x\"\n");
|
||||
assert!(matches!(load(&text), Err(ConfigError::Parse(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_spec_loads_under_secrets() {
|
||||
let text = format!("{TABLE}\n[secrets.consult]\nenv = \"CONSULT_TOKEN\"\n");
|
||||
let c = load(&text).unwrap();
|
||||
assert!(c.secrets.contains_key("consult"));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! An allowed `consult`: fake HTTP 200, audit body is the allowlisted JSON, the tool result is
|
||||
//! the answer text. Do not edit.
|
||||
|
||||
#[path = "support/client.rs"]
|
||||
mod client;
|
||||
#[path = "support/rig.rs"]
|
||||
mod rig;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/sink.rs"]
|
||||
mod sink;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::consult::{self, Consult, ConsultRuntime, ConsultTransport};
|
||||
use bxcore::secrets::Secret;
|
||||
use bxcore::{AuditEvent, DataClass, DecisionRecord, ResultStatus, ToolRequest, ToolResponse};
|
||||
use client::call;
|
||||
use rig::{Rig, request};
|
||||
|
||||
struct SharedFake {
|
||||
reply: Vec<u8>,
|
||||
seen: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ConsultTransport for SharedFake {
|
||||
fn post(
|
||||
&self,
|
||||
_url: &str,
|
||||
_bearer: &str,
|
||||
body: &[u8],
|
||||
_timeout: Duration,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
*self.seen.lock().unwrap() = Some(body.to_vec());
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn consult_cfg() -> Consult {
|
||||
Consult {
|
||||
url: "http://127.0.0.1:9/v1/chat/completions".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
temperature_milli: 600,
|
||||
timeout_ms: 1_000,
|
||||
secret: "consult".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_consult(rig: &Rig) {
|
||||
rig.grant(
|
||||
"consult",
|
||||
"tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"private\"\n",
|
||||
);
|
||||
}
|
||||
|
||||
fn question_req(question: &str) -> ToolRequest {
|
||||
request(
|
||||
"s1",
|
||||
1,
|
||||
"consult",
|
||||
&format!(
|
||||
r#"{{"question":{}}}"#,
|
||||
serde_json::to_string(question).unwrap()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn broker_with_fake(
|
||||
rig: &Rig,
|
||||
cfg: Consult,
|
||||
reply: Vec<u8>,
|
||||
seen: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
) -> Arc<brokerd::broker::Broker> {
|
||||
let runtime = ConsultRuntime::new(
|
||||
cfg,
|
||||
Secret::new("token".to_string()),
|
||||
SharedFake { reply, seen },
|
||||
);
|
||||
Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime),
|
||||
rig.lines.sink(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowed_consult_posts_only_the_question_and_returns_the_answer() {
|
||||
let mut rig = Rig::new("consult-allow");
|
||||
let cfg = consult_cfg();
|
||||
rig.cfg.consult = Some(cfg.clone());
|
||||
grant_consult(&rig);
|
||||
let tag = concat!("<", "think", ">");
|
||||
let raw_answer = format!("blue {tag} sky");
|
||||
let reply = serde_json::json!({
|
||||
"id": "ignored",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": raw_answer}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1}
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let broker = broker_with_fake(&rig, cfg.clone(), reply, Arc::clone(&recorded));
|
||||
let question = "what colour is the sky?";
|
||||
let frames = call(&broker, question_req(question));
|
||||
let want_body = consult::wire_body(Some(&cfg), &format!(r#"{{"question":"{question}"}}"#));
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Result {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated,
|
||||
}) => {
|
||||
assert!(content.contains("blue"), "{content}");
|
||||
assert!(content.contains("sky"), "{content}");
|
||||
assert!(!content.contains(tag), "{content}");
|
||||
assert!(content.contains("<"), "{content}");
|
||||
assert_eq!(*class, DataClass::Private);
|
||||
assert!(*untrusted);
|
||||
assert!(!*truncated);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let posted = recorded.lock().unwrap().clone().expect("posted");
|
||||
assert_eq!(posted, want_body.as_bytes());
|
||||
match rig.events().as_slice() {
|
||||
[
|
||||
AuditEvent::Decision {
|
||||
outcome,
|
||||
body: Some(body),
|
||||
tool,
|
||||
..
|
||||
},
|
||||
AuditEvent::Result {
|
||||
status: ResultStatus::Result,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*outcome, DecisionRecord::Allowed {});
|
||||
assert_eq!(tool, "consult");
|
||||
assert_eq!(body, &want_body);
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(v["model"], "test-model");
|
||||
assert_eq!(v["temperature"], 0.6);
|
||||
assert_eq!(v["messages"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["messages"][0]["role"], "user");
|
||||
assert_eq!(v["messages"][0]["content"], question);
|
||||
assert!(v.get("tools").is_none());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body is only `question`, even when the caller had earlier user text in its own session.
|
||||
#[test]
|
||||
fn the_consult_body_is_only_the_question() {
|
||||
let mut rig = Rig::new("consult-nohist");
|
||||
let cfg = consult_cfg();
|
||||
rig.cfg.consult = Some(cfg.clone());
|
||||
grant_consult(&rig);
|
||||
let reply = serde_json::json!({
|
||||
"choices": [{"message": {"content": "ok"}}]
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let broker = broker_with_fake(&rig, cfg, reply, Arc::clone(&recorded));
|
||||
let arguments = r#"{"question":"only this"}"#;
|
||||
let _ = call(&broker, request("s1", 4, "consult", arguments));
|
||||
let posted = recorded.lock().unwrap().clone().expect("posted");
|
||||
let v: serde_json::Value = serde_json::from_slice(&posted).unwrap();
|
||||
assert_eq!(v["messages"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["messages"][0]["content"], "only this");
|
||||
match &rig.events()[0] {
|
||||
AuditEvent::Decision {
|
||||
body: Some(body), ..
|
||||
} => {
|
||||
assert_eq!(body.as_bytes(), posted.as_slice());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Consult denials: not configured, taint too high, unknown keys. Each records the body. Do not edit.
|
||||
|
||||
#[path = "support/client.rs"]
|
||||
mod client;
|
||||
#[path = "support/rig.rs"]
|
||||
mod rig;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/sink.rs"]
|
||||
mod sink;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use brokerd::consult::{self, Consult};
|
||||
use brokerd::policy::{Label, SessionState};
|
||||
use bxcore::{
|
||||
AuditEvent, DataClass, DecisionRecord, DenyReason, SessionId, ToolRequest, ToolResponse,
|
||||
};
|
||||
use client::{Serve, call};
|
||||
use rig::{Rig, request};
|
||||
use runtime::Recording;
|
||||
|
||||
fn consult_req(session: &str, call: u64, arguments: &str) -> ToolRequest {
|
||||
request(session, call, "consult", arguments)
|
||||
}
|
||||
|
||||
fn consult_cfg() -> Consult {
|
||||
Consult {
|
||||
url: "http://127.0.0.1:9/v1/chat/completions".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
temperature_milli: 600,
|
||||
timeout_ms: 1_000,
|
||||
secret: "consult".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_consult(rig: &Rig, max_taint: &str) {
|
||||
rig.grant(
|
||||
"consult",
|
||||
&format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"{max_taint}\"\n"),
|
||||
);
|
||||
}
|
||||
|
||||
fn decision_body(events: &[AuditEvent]) -> Option<&str> {
|
||||
match events.first() {
|
||||
Some(AuditEvent::Decision { body, .. }) => body.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_consult_table_is_denied_with_the_runbook_and_records_the_body() {
|
||||
let rig = Rig::new("consult-noconfig");
|
||||
let rt = Recording::answering("no");
|
||||
let broker = rig.broker(&rt);
|
||||
let question = r#"{"question":"what colour is the sky?"}"#;
|
||||
let frames = call(&broker, consult_req("s1", 1, question));
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => {
|
||||
assert_eq!(*reason, DenyReason::InvalidArguments);
|
||||
let detail = detail.as_deref().unwrap_or("");
|
||||
assert!(detail.contains("consult-not-configured"), "{detail}");
|
||||
assert!(detail.contains("docs/runbook.md"), "{detail}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let events = rig.events();
|
||||
let body = decision_body(&events).expect("body");
|
||||
let want = consult::wire_body(None, question);
|
||||
assert_eq!(body, want);
|
||||
assert!(body.contains("what colour is the sky?"), "{body}");
|
||||
assert!(!body.contains("tools"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_session_is_taint_too_high_and_records_the_body() {
|
||||
let mut rig = Rig::new("consult-taint");
|
||||
rig.cfg.consult = Some(consult_cfg());
|
||||
grant_consult(&rig, "private");
|
||||
let sid = SessionId::new("s1").unwrap();
|
||||
rig.state()
|
||||
.raise(
|
||||
&sid,
|
||||
SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: false,
|
||||
},
|
||||
Label {
|
||||
class: DataClass::Secret,
|
||||
untrusted: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let rt = Recording::answering("no");
|
||||
let broker = Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime::Shared(Arc::clone(&rt))),
|
||||
rig.lines.sink(),
|
||||
));
|
||||
let question = r#"{"question":"summarise the notes"}"#;
|
||||
let frames = call(&broker, consult_req("s1", 2, question));
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => {
|
||||
assert_eq!(*reason, DenyReason::TaintTooHigh);
|
||||
assert_eq!(*detail, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match rig.events().as_slice() {
|
||||
[
|
||||
AuditEvent::Decision {
|
||||
outcome,
|
||||
body: Some(body),
|
||||
taint,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(
|
||||
*outcome,
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::TaintTooHigh
|
||||
}
|
||||
);
|
||||
assert_eq!(*taint, DataClass::Secret);
|
||||
let want = consult::wire_body(rig.cfg.consult.as_ref(), question);
|
||||
assert_eq!(body, &want);
|
||||
assert!(body.contains("\"model\":\"test-model\""), "{body}");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(body).unwrap()["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_field_is_invalid_arguments() {
|
||||
let mut rig = Rig::new("consult-unknown");
|
||||
rig.cfg.consult = Some(consult_cfg());
|
||||
grant_consult(&rig, "secret");
|
||||
let rt = Recording::answering("no");
|
||||
let broker = Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime::Shared(Arc::clone(&rt))),
|
||||
rig.lines.sink(),
|
||||
));
|
||||
let frames = call(
|
||||
&broker,
|
||||
consult_req("s1", 3, r#"{"question":"hi","history":["old"]}"#),
|
||||
);
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, .. }) => {
|
||||
assert_eq!(*reason, DenyReason::InvalidArguments);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `[consult]` without a readable secret: `brokerd` does not start. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
use brokerd::config::Config;
|
||||
use brokerd::consult::{self, SECRET_POINTER};
|
||||
use tmp::TempDir;
|
||||
|
||||
#[test]
|
||||
fn a_missing_secrets_entry_is_consult_secret_unreadable() {
|
||||
let dir = TempDir::new("consult-secret-missing");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let err = consult::load_secret(&cfg, &|_| None).unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains(SECRET_POINTER), "{text}");
|
||||
assert!(text.contains("consult"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_env_secret_names_both_pointers() {
|
||||
let dir = TempDir::new("consult-secret-env");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
|
||||
[secrets.consult]
|
||||
env = "CONSULT_TOKEN_FOR_THIS_TEST_UNSET"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let err = consult::load_secret(&cfg, &|_| None).unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains(SECRET_POINTER), "{text}");
|
||||
assert!(
|
||||
text.contains("docs/runbook.md#secret-unavailable"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_env_secret_loads() {
|
||||
let dir = TempDir::new("consult-secret-ok");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
|
||||
[secrets.consult]
|
||||
env = "CONSULT_TOKEN_FOR_THIS_TEST_SET"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let loaded = consult::load_secret(&cfg, &|k| {
|
||||
if k == "CONSULT_TOKEN_FOR_THIS_TEST_SET" {
|
||||
Some(OsString::from("bearer-value"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.expect("loaded");
|
||||
assert_eq!(loaded.secret.expose(), "bearer-value");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A `consult` grant: empty constraints, no paths or hosts. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::args::{ToolArgs, ToolName, parse};
|
||||
use brokerd::grants::load;
|
||||
use tmp::TempDir;
|
||||
|
||||
fn body(rest: &str) -> String {
|
||||
format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"private\"\n{rest}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_parses_and_is_not_a_container_tool() {
|
||||
assert_eq!(ToolName::parse("consult"), Some(ToolName::Consult));
|
||||
assert_eq!(ToolName::Consult.as_str(), "consult");
|
||||
assert!(!ToolName::Consult.is_memory());
|
||||
assert!(
|
||||
!ToolName::ALL.contains(&ToolName::Consult),
|
||||
"ALL is the container tools"
|
||||
);
|
||||
let args = parse(ToolName::Consult, r#"{"question":"hello"}"#).unwrap();
|
||||
assert_eq!(args.tool(), ToolName::Consult);
|
||||
assert_eq!(
|
||||
args,
|
||||
ToolArgs::Consult {
|
||||
question: "hello".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(args.canonical_json(), r#"{"question":"hello"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_invalid_arguments() {
|
||||
let err = parse(ToolName::Consult, r#"{"question":"hello","history":[]}"#).unwrap_err();
|
||||
let detail = err.detail();
|
||||
assert!(
|
||||
detail.contains("unknown") || detail.contains("history"),
|
||||
"{detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consult_grant_with_empty_constraints_loads() {
|
||||
let dir = TempDir::new("grants-consult");
|
||||
dir.write("consult.toml", &body(""));
|
||||
let set = load(dir.path()).unwrap();
|
||||
assert_eq!(set.grants()[0].grant.tool, "consult");
|
||||
assert!(set.grants()[0].grant.untrusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_does_not_take_paths_or_hosts() {
|
||||
let dir = TempDir::new("grants-consult-paths");
|
||||
dir.write("consult.toml", &body("[constraints]\npaths = [\"/tmp\"]\n"));
|
||||
let problems = load(dir.path()).expect_err("paths");
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|p| p.problem.contains("does not take paths")),
|
||||
"{problems:?}"
|
||||
);
|
||||
let dir = TempDir::new("grants-consult-hosts");
|
||||
dir.write(
|
||||
"consult.toml",
|
||||
&body("[constraints]\nhosts = [\"example.com\"]\n"),
|
||||
);
|
||||
let problems = load(dir.path()).expect_err("hosts");
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|p| p.problem.contains("does not take hosts")),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! How long `BrokerPort` waits for `consult`: 180 s, or `[broker] timeout_ms` if that is longer.
|
||||
//! Do not edit.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use loopd::broker_port::{BrokerPort, CONSULT_WAIT};
|
||||
|
||||
fn quiet(timeout: Duration) -> BrokerPort {
|
||||
BrokerPort::with_log("unused.sock".into(), timeout, Box::new(|_| {}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_waits_one_hundred_and_eighty_seconds() {
|
||||
assert_eq!(CONSULT_WAIT, Duration::from_secs(180));
|
||||
let port = BrokerPort::new("unused.sock".into(), Duration::from_secs(120));
|
||||
assert_eq!(port.timeout_for("consult"), CONSULT_WAIT);
|
||||
assert_eq!(port.timeout_for("recall"), Duration::from_secs(120));
|
||||
let port = BrokerPort::new("unused.sock".into(), Duration::from_secs(300));
|
||||
assert_eq!(port.timeout_for("consult"), Duration::from_secs(300));
|
||||
let port = quiet(Duration::from_secs(120));
|
||||
assert_eq!(port.timeout_for("consult"), CONSULT_WAIT, "with_log as new");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `consult` in the M5c registry, not core. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use loopd::baseline::Baseline;
|
||||
use loopd::consult::{self, CONSULT};
|
||||
use loopd::tools::Registry;
|
||||
use std::path::Path;
|
||||
use support::Home;
|
||||
|
||||
fn core_names(registry: &Registry) -> Vec<String> {
|
||||
registry
|
||||
.core_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_is_in_m5c_and_is_not_core() {
|
||||
let r = Registry::m5c();
|
||||
let entry = r.get(CONSULT).expect("consult");
|
||||
assert!(!entry.core);
|
||||
assert_eq!(entry.schema, consult::schema());
|
||||
assert_eq!(
|
||||
core_names(&r),
|
||||
[
|
||||
"clock",
|
||||
"recall",
|
||||
"memory",
|
||||
"ask_memory",
|
||||
"find_tool",
|
||||
"call_tool"
|
||||
]
|
||||
);
|
||||
assert!(r.find("consult").iter().any(|s| s.name == CONSULT));
|
||||
assert!(r.find("remote").iter().any(|s| s.name == CONSULT));
|
||||
assert!(r.get("subagent").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_from_m5c_does_not_put_consult_in_the_tools_array() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let baseline = Baseline::assemble(&cfg, &Registry::m5c()).unwrap();
|
||||
let names: Vec<&str> = baseline.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"clock",
|
||||
"recall",
|
||||
"memory",
|
||||
"ask_memory",
|
||||
"find_tool",
|
||||
"call_tool"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
## consult-not-configured
|
||||
|
||||
**What you see.** A `consult` call is denied as invalid-arguments. The tool result names this
|
||||
entry:
|
||||
|
||||
```text
|
||||
Denied: the arguments are not valid for this tool: consult is not configured; see docs/runbook.md#consult-not-configured.
|
||||
```
|
||||
|
||||
**Why.** `brokerd.toml` has no `[consult]` table. Every `consult` call is denied until the owner
|
||||
writes one. The audit line still holds the JSON body that would have been sent.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
grep -n '\[consult\]' "$BOXMAKER_HOME/../brokerd.toml" /etc/boxmaker/brokerd.toml 2>/dev/null
|
||||
```
|
||||
|
||||
**Fix.** Add a `[consult]` table with `url`, `model` and `secret`, a matching `[secrets.<name>]`
|
||||
entry, and a grant whose `tool` is `consult`. Restart `brokerd`. M6's URL is loopback only.
|
||||
|
||||
**Check.** A `consult` from a session whose taint is at or below the grant's `max_taint` returns
|
||||
an answer, and the audit line's `body` matches the bytes posted.
|
||||
|
||||
## consult-secret-unreadable
|
||||
|
||||
**What you see.** `brokerd` does not start. The line names the secret and this entry, and may
|
||||
also name `secret-unavailable` or `secret-in-a-file`.
|
||||
|
||||
**Why.** `[consult]` names a secret that is missing from `[secrets]`, whose spec is not exactly
|
||||
one of credential/env/file, or that the secret store cannot read (missing, too open, or not
|
||||
UTF-8).
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
# the name in [consult] secret = "…"
|
||||
ls -l <the file or credential path>
|
||||
```
|
||||
|
||||
**Fix.** Put the bearer in the store the spec names, mode `0600` for a file, owned by the
|
||||
user `brokerd` runs as. Restart `brokerd`.
|
||||
|
||||
**Check.** `brokerd serve --config <path>` prints the serving lines and no secret error.
|
||||
Reference in New Issue
Block a user