Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,38 @@ All configuration is via environment variables (or CLI flags — every env var h

**Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks.

### Managed identity and team instructions

Two trusted values are normally supplied by Buzz Desktop after it resolves the
managed agent's owner and team. They are deliberately separate from the
user-editable environment variables on an agent:

| Variable | Supplied by | Description |
|----------|-------------|-------------|
| `BUZZ_AUTH_TAG` | Buzz Desktop or a trusted backend provider | JSON-encoded NIP-OA owner attestation. The harness uses it to resolve the owner and forwards it to the `buzz` CLI. It is delegated authority, not general deploy, database, or infrastructure access. |
| `BUZZ_ACP_TEAM_INSTRUCTIONS` | Buzz Desktop or the service operator | Inline team-owned instruction text, layered after the system prompt and before agent memory. |
| `BUZZ_ACP_TEAM_INSTRUCTIONS_FILE` | Service operator | Path to a file containing team-owned instructions. Mutually exclusive with the inline value. |

Buzz Desktop strips user-supplied values for reserved identity keys such as
`BUZZ_PRIVATE_KEY` and `BUZZ_AUTH_TAG`, then injects the values it owns when it
starts the harness. Adding either key in the agent environment-variable editor
does not override that boundary.

For a remote or manually supervised harness, deliver `BUZZ_AUTH_TAG` only
through the trusted provisioning channel. Do not mint it on the agent host from
the owner's private key, put it on a process command line, commit it, or print it
to logs. Store it with the same protections as the agent private key and replace
the old value when rotating the authorization.

Team instructions are not secret-bearing storage. For a durable file-backed
source, set `BUZZ_ACP_TEAM_INSTRUCTIONS_FILE=/path/to/instructions.md`; setting
the inline `BUZZ_ACP_TEAM_INSTRUCTIONS` variable to that path would inject the
literal path into the prompt. The harness validates the file at startup, caps it
at 64 KiB, and re-reads it for every new ACP session. Legacy adapters that cannot
accept a system prompt at session creation re-read it before every prompt. If
the file becomes unreadable or oversized, the affected prompt fails closed
without sending partial or stale instructions to the adapter.

### Parallel Agents & Heartbeat

| Flag | Env Var | Default | Description |
Expand Down
152 changes: 144 additions & 8 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ pub enum ConfigError {
ConfigFile(String),
}

const MAX_TEAM_INSTRUCTIONS_BYTES: usize = 65_536;

fn normalize_team_instructions(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}

/// Read and bound a file-backed team-instruction source.
///
/// This is reused by the pool when a new ACP session is created so a
/// long-running harness can pick up durable instruction changes without a
/// process restart.
pub(crate) fn read_team_instructions_file(
path: &std::path::Path,
) -> Result<Option<String>, ConfigError> {
use std::io::Read;

let file = std::fs::File::open(path)?;
let mut content = Vec::with_capacity(MAX_TEAM_INSTRUCTIONS_BYTES.min(8192));
file.take((MAX_TEAM_INSTRUCTIONS_BYTES + 1) as u64)
.read_to_end(&mut content)?;
if content.len() > MAX_TEAM_INSTRUCTIONS_BYTES {
return Err(ConfigError::ConfigFile(format!(
"team instructions file {} exceeds 64 KiB limit",
path.display(),
)));
}
let content = String::from_utf8(content).map_err(|_| {
ConfigError::ConfigFile(format!(
"team instructions file {} is not valid UTF-8",
path.display()
))
})?;
Ok(normalize_team_instructions(content))
}

#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
pub enum SubscribeMode {
Mentions,
Expand Down Expand Up @@ -466,10 +502,22 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')]
pub allowed_respond_to: Option<Vec<String>>,

/// Team-owned instructions layered after `[System]` and before agent memory.
#[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")]
/// Inline team-owned instructions layered after `[System]` and before agent memory.
#[arg(
long,
env = "BUZZ_ACP_TEAM_INSTRUCTIONS",
conflicts_with = "team_instructions_file"
)]
pub team_instructions: Option<String>,

/// Read team-owned instructions from a file.
#[arg(
long,
env = "BUZZ_ACP_TEAM_INSTRUCTIONS_FILE",
conflicts_with = "team_instructions"
)]
pub team_instructions_file: Option<PathBuf>,

/// Publish encrypted ACP observer frames over the relay.
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,
Expand Down Expand Up @@ -507,6 +555,8 @@ pub struct Config {
pub system_prompt: Option<String>,
/// Team-owned instructions layered separately from the agent system prompt.
pub team_instructions: Option<String>,
/// Validated file source, re-read when the harness creates a new ACP session.
pub team_instructions_file: Option<PathBuf>,
pub initial_message: Option<String>,
pub subscribe_mode: SubscribeMode,
pub dedup_mode: DedupMode,
Expand Down Expand Up @@ -845,6 +895,15 @@ impl Config {
None
};

let team_instructions_file = args.team_instructions_file.clone();
let team_instructions = if let Some(text) = args.team_instructions {
normalize_team_instructions(text)
} else if let Some(ref path) = args.team_instructions_file {
read_team_instructions_file(path)?
} else {
None
};

let base_prompt_content = if args.no_base_prompt {
None
} else if let Some(ref path) = args.base_prompt_file {
Expand Down Expand Up @@ -1042,12 +1101,8 @@ impl Config {
turn_liveness_secs,
heartbeat_prompt,
system_prompt,
team_instructions: args
.team_instructions
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
team_instructions,
team_instructions_file,
initial_message: args.initial_message,
subscribe_mode: args.subscribe,
dedup_mode: args.dedup,
Expand Down Expand Up @@ -1421,6 +1476,7 @@ mod tests {
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
team_instructions_file: None,
initial_message: None,
subscribe_mode: mode,
dedup_mode: DedupMode::Queue,
Expand Down Expand Up @@ -2660,6 +2716,86 @@ channels = "ALL"
const TEST_PRIVATE_KEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";

#[test]
fn team_instructions_file_loads_and_trims_content() {
let path = std::env::temp_dir().join(format!(
"buzz-acp-team-instructions-{}.md",
uuid::Uuid::new_v4()
));
std::fs::write(&path, "\n Add members when creating a channel. \n").unwrap();

let args = CliArgs::try_parse_from([
"buzz-acp",
"--private-key",
TEST_PRIVATE_KEY,
"--team-instructions-file",
path.to_str().unwrap(),
])
.expect("clap should parse the file option");
let config = Config::from_args(args).expect("team instruction file should load");
std::fs::remove_file(path).unwrap();

assert_eq!(
config.team_instructions.as_deref(),
Some("Add members when creating a channel.")
);
}

#[test]
fn team_instructions_file_conflicts_with_inline_value() {
let result = CliArgs::try_parse_from([
"buzz-acp",
"--private-key",
TEST_PRIVATE_KEY,
"--team-instructions",
"inline",
"--team-instructions-file",
"/tmp/team-instructions.md",
]);

assert!(result.is_err(), "clap must reject two instruction sources");
}

#[test]
fn missing_team_instructions_file_fails_startup() {
let path = std::env::temp_dir().join(format!(
"buzz-acp-missing-team-instructions-{}.md",
uuid::Uuid::new_v4()
));
let args = CliArgs::try_parse_from([
"buzz-acp",
"--private-key",
TEST_PRIVATE_KEY,
"--team-instructions-file",
path.to_str().unwrap(),
])
.expect("clap should parse the file option");

let error = Config::from_args(args).unwrap_err().to_string();
assert!(error.contains("failed to read file"), "got: {error}");
}

#[test]
fn oversized_team_instructions_file_fails_startup() {
let path = std::env::temp_dir().join(format!(
"buzz-acp-oversized-team-instructions-{}.md",
uuid::Uuid::new_v4()
));
std::fs::write(&path, vec![b'x'; MAX_TEAM_INSTRUCTIONS_BYTES + 1]).unwrap();
let args = CliArgs::try_parse_from([
"buzz-acp",
"--private-key",
TEST_PRIVATE_KEY,
"--team-instructions-file",
path.to_str().unwrap(),
])
.expect("clap should parse the file option");

let error = Config::from_args(args).unwrap_err().to_string();
std::fs::remove_file(path).unwrap();
assert!(error.contains("exceeds 64 KiB limit"), "got: {error}");
}

#[test]
fn allowed_respond_to_full_path_rejects_disallowed_mode() {
// --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,7 @@ async fn tokio_main() -> Result<()> {
system_prompt: config.system_prompt.clone(),
session_title: config.session_title.clone(),
team_instructions: config.team_instructions.clone(),
team_instructions_file: config.team_instructions_file.clone(),
base_prompt: if config.no_base_prompt {
None
} else if let Some(content) = base_prompt_content {
Expand Down Expand Up @@ -4971,6 +4972,7 @@ mod build_mcp_servers_tests {
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
team_instructions_file: None,
initial_message: None,
subscribe_mode: config::SubscribeMode::All,
dedup_mode: config::DedupMode::Queue,
Expand Down Expand Up @@ -5192,6 +5194,7 @@ mod error_outcome_emission_tests {
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
team_instructions_file: None,
initial_message: None,
subscribe_mode: config::SubscribeMode::All,
dedup_mode: config::DedupMode::Queue,
Expand Down
Loading