From f8e89e929cd0c4654b8485225fc7c08d7391eab4 Mon Sep 17 00:00:00 2001 From: Daniel Schwartz Date: Tue, 28 Jul 2026 23:52:05 -0700 Subject: [PATCH 1/2] docs(acp): explain trusted managed-agent environment Signed-off-by: Daniel Schwartz --- crates/buzz-acp/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..8795214a24 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -119,6 +119,33 @@ 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. The value is the instruction text itself, not a filename. | + +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. + +`BUZZ_ACP_TEAM_INSTRUCTIONS` is not secret-bearing storage. Operators using a +file as their source of truth must read that file and pass its contents to the +harness; setting the variable to `/path/to/instructions.md` would inject that +literal path into the prompt. + ### Parallel Agents & Heartbeat | Flag | Env Var | Default | Description | From 9dac34c974d49be9983719b3584cf21be40ba210 Mon Sep 17 00:00:00 2001 From: Daniel Schwartz Date: Wed, 29 Jul 2026 00:06:29 -0700 Subject: [PATCH 2/2] feat(acp): reload team instructions from file Signed-off-by: Daniel Schwartz --- crates/buzz-acp/README.md | 15 ++-- crates/buzz-acp/src/config.rs | 152 ++++++++++++++++++++++++++++++++-- crates/buzz-acp/src/lib.rs | 3 + crates/buzz-acp/src/pool.rs | 139 ++++++++++++++++++++++++++++++- 4 files changed, 294 insertions(+), 15 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 8795214a24..809b92fd8e 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -128,7 +128,8 @@ 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. The value is the instruction text itself, not a filename. | +| `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 @@ -141,10 +142,14 @@ the owner's private key, put it on a process command line, commit it, or print i to logs. Store it with the same protections as the agent private key and replace the old value when rotating the authorization. -`BUZZ_ACP_TEAM_INSTRUCTIONS` is not secret-bearing storage. Operators using a -file as their source of truth must read that file and pass its contents to the -harness; setting the variable to `/path/to/instructions.md` would inject that -literal path into the prompt. +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 diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..a669d7665b 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -47,6 +47,42 @@ pub enum ConfigError { ConfigFile(String), } +const MAX_TEAM_INSTRUCTIONS_BYTES: usize = 65_536; + +fn normalize_team_instructions(value: String) -> Option { + 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, 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, @@ -466,10 +502,22 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] pub allowed_respond_to: Option>, - /// 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, + /// Read team-owned instructions from a file. + #[arg( + long, + env = "BUZZ_ACP_TEAM_INSTRUCTIONS_FILE", + conflicts_with = "team_instructions" + )] + pub team_instructions_file: Option, + /// Publish encrypted ACP observer frames over the relay. #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, @@ -507,6 +555,8 @@ pub struct Config { pub system_prompt: Option, /// Team-owned instructions layered separately from the agent system prompt. pub team_instructions: Option, + /// Validated file source, re-read when the harness creates a new ACP session. + pub team_instructions_file: Option, pub initial_message: Option, pub subscribe_mode: SubscribeMode, pub dedup_mode: DedupMode, @@ -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 { @@ -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, @@ -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, @@ -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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..cc7cd2fb00 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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 { @@ -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, @@ -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, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..38b619ee0b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -494,6 +494,10 @@ pub struct PromptContext { /// on `session/new`. Never part of the prompt. pub session_title: Option, pub team_instructions: Option, + /// File-backed instructions are re-read for every new ACP session (and + /// before legacy per-turn prompt framing) so updates do not require a + /// harness restart. + pub team_instructions_file: Option, pub heartbeat_prompt: Option, /// Base prompt content, or `None` if `--no-base-prompt` was passed. /// @@ -841,6 +845,18 @@ async fn resolve_new_session_channel_context( (is_dm, title_channel) } +fn current_team_instructions(ctx: &PromptContext) -> Result, AcpError> { + match ctx.team_instructions_file.as_deref() { + Some(path) => crate::config::read_team_instructions_file(path).map_err(|error| { + AcpError::Protocol(format!( + "failed to reload team instructions file {}: {error}", + path.display() + )) + }), + None => Ok(ctx.team_instructions.clone()), + } +} + /// Create a new ACP session via `session_new_full()`, populate model capabilities /// on the agent (first session only), and apply `desired_model` if set. /// @@ -861,11 +877,12 @@ async fn create_session_and_apply_model( // its own `[Agent Memory — core]` header, and canvas carries its own // `[Channel Canvas]` header; both are appended with a blank-line separator. let is_goose = agent.agent_name == "goose"; + let team_instructions = current_team_instructions(ctx)?; let combined_system_prompt = with_canvas( with_core( with_team( framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), - ctx.team_instructions.as_deref(), + team_instructions.as_deref(), ), agent_core, ), @@ -1784,6 +1801,28 @@ pub async fn run_prompt_task( // (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context // follows as a second block. let mut slash_command: Option = None; + let legacy_team_instructions = if agent.has_system_prompt_support() { + None + } else { + match current_team_instructions(&ctx) { + Ok(instructions) => instructions, + Err(error) => { + tracing::error!( + target: "pool::prompt", + "refusing prompt with unavailable team instructions: {error}" + ); + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(error), + None, + ); + return; + } + } + }; let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. @@ -1837,7 +1876,7 @@ pub async fn run_prompt_task( has_system_prompt_support: agent.has_system_prompt_support(), base_prompt: ctx.base_prompt, system_prompt: ctx.system_prompt.as_deref(), - team_instructions: ctx.team_instructions.as_deref(), + team_instructions: legacy_team_instructions.as_deref(), agent_canvas: agent_canvas.as_deref(), }, ) @@ -5344,6 +5383,7 @@ mod tests { system_prompt: None, session_title: None, team_instructions: None, + team_instructions_file: None, heartbeat_prompt: None, base_prompt: None, cwd: ".".to_string(), @@ -5373,6 +5413,101 @@ mod tests { } } + #[test] + fn team_instructions_file_is_reloaded_after_context_creation() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-reload-team-instructions-{}.md", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, "first rule").unwrap(); + let mut ctx = make_prompt_context_no_owner(); + ctx.team_instructions = Some("startup snapshot".into()); + ctx.team_instructions_file = Some(path.clone()); + + assert_eq!( + current_team_instructions(&ctx).unwrap().as_deref(), + Some("first rule") + ); + std::fs::write(&path, "second rule").unwrap(); + assert_eq!( + current_team_instructions(&ctx).unwrap().as_deref(), + Some("second rule") + ); + + std::fs::remove_file(path).unwrap(); + } + + #[tokio::test] + async fn legacy_prompt_refuses_when_team_instructions_file_disappears() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-missing-live-team-instructions-{}.md", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, "required rule").unwrap(); + let mut ctx = make_prompt_context_no_owner(); + ctx.team_instructions_file = Some(path.clone()); + std::fs::remove_file(&path).unwrap(); + + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); + let state = SessionState { + heartbeat_session: Some("existing-session".into()), + ..SessionState::default() + }; + let agent = OwnedAgent { + index: 0, + acp, + state, + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "legacy-test".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + let (result_tx, mut result_rx) = tokio::sync::mpsc::unbounded_channel(); + + run_prompt_task( + agent, + None, + Some("must not reach the adapter".into()), + Arc::new(ctx), + result_tx, + None, + "missing-team-instructions".into(), + ) + .await; + + let result = result_rx.recv().await.expect("prompt result"); + match result.outcome { + PromptOutcome::Error(error) => { + assert!( + error + .to_string() + .contains("failed to reload team instructions file"), + "got: {error}" + ); + } + other => panic!( + "missing team instructions must refuse the prompt, got {}", + match other { + PromptOutcome::AgentExited => "AgentExited", + PromptOutcome::Timeout(_) => "Timeout", + PromptOutcome::CancelDrainTimeout(_) => "CancelDrainTimeout", + PromptOutcome::Cancelled => "Cancelled", + PromptOutcome::Ok(_) => "Ok", + PromptOutcome::Error(_) => unreachable!(), + } + ), + } + } + // ── render_canvas_section ──────────────────────────────────────────────── #[test]