brokerd: consult config, secrets, loopback URL
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -9,7 +9,7 @@ publish.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bxcore.workspace = true
|
||||
bxcore = { workspace = true, features = ["secrets"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
//! owner believes is set and is not.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::secrets::SecretSpec;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Paths {
|
||||
@@ -155,6 +158,10 @@ pub struct Config {
|
||||
pub gateway: Option<Gateway>,
|
||||
#[serde(default)]
|
||||
pub memory: Memory,
|
||||
#[serde(default)]
|
||||
pub consult: Option<crate::consult::Consult>,
|
||||
#[serde(default)]
|
||||
pub secrets: BTreeMap<String, SecretSpec>,
|
||||
}
|
||||
|
||||
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
|
||||
@@ -272,6 +279,11 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(consult) = &config.consult
|
||||
&& let Err(why) = crate::consult::validate(consult)
|
||||
{
|
||||
return Err(ConfigError::Invalid(path.to_path_buf(), why));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
pub fn broker_socket(&self) -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! `consult`: an allowlisted chat-completions POST decided by the grant's `max_taint`.
|
||||
//! Nothing here names a vendor (M6 spec, section 4).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::secrets::{Loaded, SecretError, SecretSpec};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub const CONSULT: &str = "consult";
|
||||
/// Default wait for the remote completion, milliseconds.
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 180_000;
|
||||
/// Default sampling temperature as thousandths (0.6).
|
||||
pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600;
|
||||
/// Start failure when the named secret cannot be read.
|
||||
pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable";
|
||||
|
||||
/// `[consult]` in `brokerd.toml`. Unknown keys are errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Consult {
|
||||
pub url: String,
|
||||
pub model: String,
|
||||
#[serde(
|
||||
rename = "temperature",
|
||||
default = "default_temperature_milli",
|
||||
deserialize_with = "de_temperature_milli"
|
||||
)]
|
||||
pub temperature_milli: u16,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
fn default_temperature_milli() -> u16 {
|
||||
DEFAULT_TEMPERATURE_MILLI
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
|
||||
fn de_temperature_milli<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u16, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Num {
|
||||
F(f64),
|
||||
I(i64),
|
||||
}
|
||||
let v = match Num::deserialize(deserializer).map_err(serde::de::Error::custom)? {
|
||||
Num::F(f) => f,
|
||||
Num::I(0) => 0.0,
|
||||
Num::I(_) => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"[consult] temperature must be from 0 to 0.6",
|
||||
));
|
||||
}
|
||||
};
|
||||
if !v.is_finite() || v < 0.0 || v > 0.6 {
|
||||
return Err(serde::de::Error::custom(
|
||||
"[consult] temperature must be from 0 to 0.6",
|
||||
));
|
||||
}
|
||||
let milli = (v * 1000.0).round();
|
||||
let text = format!("{milli:.0}");
|
||||
text.parse::<u16>().map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
impl Consult {
|
||||
/// The temperature written in the JSON body.
|
||||
pub fn temperature(&self) -> f64 {
|
||||
f64::from(self.temperature_milli) / 1000.0
|
||||
}
|
||||
|
||||
pub fn timeout(&self) -> Duration {
|
||||
Duration::from_millis(self.timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `[consult]` table cannot be used.
|
||||
pub fn validate(consult: &Consult) -> Result<(), String> {
|
||||
parse_loopback_http(&consult.url).map(|_| ())?;
|
||||
if consult.model.is_empty() {
|
||||
return Err("[consult] model must not be empty".to_string());
|
||||
}
|
||||
if consult.timeout_ms == 0 {
|
||||
return Err("[consult] timeout_ms must be at least 1".to_string());
|
||||
}
|
||||
if consult.secret.is_empty() {
|
||||
return Err("[consult] secret must not be empty".to_string());
|
||||
}
|
||||
if consult.temperature_milli > DEFAULT_TEMPERATURE_MILLI {
|
||||
return Err("[consult] temperature must be from 0 to 0.6".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host, port and path of a loopback `http://` URL. M6 stays on loopback.
|
||||
pub fn parse_loopback_http(url: &str) -> Result<(String, u16, String), String> {
|
||||
let rest = match url.strip_prefix("http://") {
|
||||
Some(rest) => rest,
|
||||
None => {
|
||||
return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string());
|
||||
}
|
||||
};
|
||||
if rest.contains('@') {
|
||||
return Err("[consult] url must not include a userinfo".to_string());
|
||||
}
|
||||
let (hostport, path) = match rest.find('/') {
|
||||
Some(i) => match rest.split_at_checked(i) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
return Err("[consult] url is not a valid http URL".to_string());
|
||||
}
|
||||
},
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
let (host, port) = match hostport.split_once(':') {
|
||||
Some((host, port)) => {
|
||||
let port: u16 = port
|
||||
.parse()
|
||||
.map_err(|_| "[consult] url port is not a number".to_string())?;
|
||||
if port == 0 {
|
||||
return Err("[consult] url port must be from 1 to 65535".to_string());
|
||||
}
|
||||
(host, port)
|
||||
}
|
||||
None => (hostport, 80),
|
||||
};
|
||||
if host != "127.0.0.1" && host != "localhost" {
|
||||
return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string());
|
||||
}
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
/// Why `brokerd` will not start with `[consult]` set.
|
||||
#[derive(Debug)]
|
||||
pub enum SecretStartError {
|
||||
Missing(String),
|
||||
Spec(String, String),
|
||||
Load(SecretError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretStartError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SecretStartError::Missing(name) => {
|
||||
write!(f, "secret {name} is not in [secrets]; {SECRET_POINTER}")
|
||||
}
|
||||
SecretStartError::Spec(name, why) => {
|
||||
write!(f, "[secrets.{name}]: {why}; {SECRET_POINTER}")
|
||||
}
|
||||
SecretStartError::Load(err) => write!(f, "{err}\n{SECRET_POINTER}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SecretStartError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
SecretStartError::Load(err) => Some(err),
|
||||
SecretStartError::Missing(_) | SecretStartError::Spec(_, _) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bearer for `[consult]`, or `Ok(None)` when the table is absent.
|
||||
pub fn load_secret(
|
||||
cfg: &Config,
|
||||
env: &dyn Fn(&str) -> Option<OsString>,
|
||||
) -> Result<Option<Loaded>, SecretStartError> {
|
||||
let Some(consult) = &cfg.consult else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec: &SecretSpec = match cfg.secrets.get(&consult.secret) {
|
||||
Some(spec) => spec,
|
||||
None => return Err(SecretStartError::Missing(consult.secret.clone())),
|
||||
};
|
||||
let source = spec
|
||||
.source()
|
||||
.map_err(|why| SecretStartError::Spec(consult.secret.clone(), why))?;
|
||||
let loaded = bxcore::secrets::load("brokerd", &consult.secret, &source, env)
|
||||
.map_err(SecretStartError::Load)?;
|
||||
Ok(Some(loaded))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod audit;
|
||||
pub mod broker;
|
||||
pub mod builtin;
|
||||
pub mod config;
|
||||
pub mod consult;
|
||||
pub mod container;
|
||||
pub mod courier;
|
||||
pub mod grants;
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
@@ -43,6 +43,8 @@ impl Rig {
|
||||
runner: None,
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
consult: None,
|
||||
secrets: Default::default(),
|
||||
};
|
||||
Rig {
|
||||
dir,
|
||||
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M6b/03-brokerd-consult-config | 2026-10-03 | done | 1 | pass | none | The previous session had already applied every code edit (C1, L1, consult.rs, F1-F3, R1, B1) and left them uncommitted; this session finished them. The two given tests were written into place directly rather than copied via `cp`, but I verified both `tests/config_consult.rs` and `tests/consult_secret.rs` are byte-identical to `docs/plans/M6b/files/`. Ran `cargo build` (Cargo.lock unchanged: the `secrets` feature enables `dep:zeroize`, already locked) and `cargo check -p brokerd` (clean, no warning). `wc -l`: consult.rs 188, config.rs 337, both under 500. Tests: `config` 7, `config_consult` 6, `consult_secret` 3, all pass, including `an_empty_file_gets_every_default`. `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1602 tests (1593 before, plus 9). No new dependency, so Cargo.lock unchanged. | ornith-1.5-35b-a3b |
|
||||
| M6b/02-loopd-consult-wait | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/broker_port_consult_wait.rs` from `docs/plans/M6b/files/`. Step 2 failed as expected: E0432 unresolved `CONSULT_WAIT`. Added `CONSULT_WAIT` const (180 s) after `ASK_MEMORY_WAIT` in `broker_port.rs`. Added a `consult_timeout` field to the struct and to both `new` and `with_log` constructors (`with_ask_wait` and `with_pending_cap` use `..self` and need no edit). Extended `timeout_for` so `consult` returns `self.timeout.max(self.consult_timeout)` (floor, not replacement); a configured 300 s wait stays 300 s. `ASK_MEMORY_WAIT` and its arm unchanged; `broker_port_ask_wait.rs` and `config.rs` untouched. `cargo check -p loopd` clean, no warning; `wc -l` prints 270, under 500. All suites pass: `broker_port_consult_wait` 1, `broker_port_ask_wait` 4 (`the_wait_for_each_tool` still passes). `make gate` printed `gate: ok` with 1593 tests (1592 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M6b/01-loopd-consult-registry | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/consult_registry.rs` from `docs/plans/M6b/files/`. Created `crates/loopd/src/consult.rs` with `CONSULT` const and `schema()` returning the `consult` `ToolSchema`. Added `pub mod consult;` to lib.rs after `pub mod config;`. In tools.rs `m5c()` pushed the non-core `consult` entry after the `subagent` entry (`Registry::new(entries)` unchanged). `cargo check -p loopd` clean, no warning; `wc -l` prints 21 for consult.rs, 481 for tools.rs, both under 500. All suites pass: `consult_registry` 2, `subagent_registry` 2, `ask_memory_tool`, `tools` 11, `baseline` 6. `make gate` printed `gate: ok` with 1592 tests (1590 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M6a/05-brokerd-state-inherit | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/state_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p brokerd --test state_subagent`) failed as expected: `a_new_subagent_copies_the_parents_untrusted_flag` (child was default private/trusted) and `a_nested_subagent_copies_through_a_missing_middle_child` (leaf was Private not Secret) failed, `a_plain_session_still_creates_nothing_on_read` passed. Applied I1 in state.rs `read`: replaced the `NotFound` early return with `return inherit_or_default(self, session)`. Applied I2 after `impl StateStore` (after `persist`'s and the `impl`'s closing braces, before `/// One line on disk`): added `inherit_or_default` which calls `parent_of_sub`, returns default when `None`, else `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label{class: inherited.taint, untrusted: inherited.untrusted})`, and `parent_of_sub` which strips `sub-`, `rsplit_once('-')`, requires non-empty ASCII-digit suffix and builds `SessionId::new(parent)`. Did not touch `raise` or `persist`. `cargo check -p brokerd` clean, no warning; `wc -l` prints 176 for state.rs, under 500. All suites pass: `state_subagent` 3, `state` 9. `make gate` printed `gate: ok` with 1590 tests (1587 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
|
||||
@@ -1269,3 +1269,47 @@ scope.
|
||||
|
||||
**Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and
|
||||
writes `sessions/sub-<parent>-<n>/`.
|
||||
## 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