mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
+2









Tyler
GitHub
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
Tyler Longwell
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
Dawn
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Matt Toohey
Claude Fable 5
npub1hwqy0rnujtl25dzmlhn8qwux4kr8sjhas3ugltx9j5dm5dwkp2dsqjhytw
61cc738ee8
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: npub1hwqy0rnujtl25dzmlhn8qwux4kr8sjhas3ugltx9j5dm5dwkp2dsqjhytw <bb80478e7c92feaa345bfde6703b86ad86784afd84788facc5951bba35d60a9b@buzz.block.builderlab.xyz>
2710 lines
94 KiB
Rust
2710 lines
94 KiB
Rust
//! Configuration for the buzz-acp harness.
|
||
//!
|
||
//! CLI-first: every option is a CLI flag with env var fallback.
|
||
//! Config file (TOML) for complex subscription rules.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::path::PathBuf;
|
||
|
||
use clap::Parser;
|
||
use clap::ValueEnum;
|
||
use nostr::Keys;
|
||
use thiserror::Error;
|
||
use url::Url;
|
||
use uuid::Uuid;
|
||
|
||
use crate::filter::SubscriptionRule;
|
||
|
||
/// Default idle timeout (seconds) when neither `--idle-timeout` nor the
|
||
/// deprecated `--turn-timeout` is set.
|
||
///
|
||
/// Sized for slow turns where the agent may go silent on its outer ACP channel
|
||
/// while running long sub-tools (e.g. a buzz-agent running another agent, or
|
||
/// codex/claude doing multi-minute single tool calls). 900s gives 300s of
|
||
/// breathing room above the 600s max shell timeout, so legitimate long-running
|
||
/// tool calls don't race the idle deadline.
|
||
/// Override via `--idle-timeout` / `BUZZ_ACP_IDLE_TIMEOUT`.
|
||
pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900;
|
||
|
||
/// Default absolute wall-clock cap per agent turn (2 hours).
|
||
/// Override via `--max-turn-duration` / `BUZZ_ACP_MAX_TURN_DURATION`.
|
||
pub(crate) const DEFAULT_MAX_TURN_DURATION_SECS: u64 = 7200;
|
||
|
||
/// Upper bound for `max_turn_duration` (7 days). Any higher is operationally
|
||
/// meaningless and risks arithmetic overflow when deriving the in-flight
|
||
/// deadline (`max_turn_duration + IN_FLIGHT_DEADLINE_BUFFER_SECS`).
|
||
pub(crate) const MAX_TURN_DURATION_CEILING_SECS: u64 = 604_800;
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum ConfigError {
|
||
#[error("failed to parse nostr keys: {0}")]
|
||
KeyParse(#[from] nostr::key::Error),
|
||
|
||
#[error("failed to read file: {0}")]
|
||
Io(#[from] std::io::Error),
|
||
|
||
#[error("config file error: {0}")]
|
||
ConfigFile(String),
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, clap::ValueEnum)]
|
||
pub enum SubscribeMode {
|
||
Mentions,
|
||
All,
|
||
Config,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||
pub enum DedupMode {
|
||
Drop,
|
||
Queue,
|
||
}
|
||
|
||
/// How to handle new @mentions while a turn is already in-flight for that channel.
|
||
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
|
||
pub enum MultipleEventHandling {
|
||
/// Queue new events while a turn is in-flight. Deliver after current turn
|
||
/// completes. Existing behavior — zero code change in this path.
|
||
Queue,
|
||
/// Cancel the in-flight turn and re-dispatch a merged prompt that frames
|
||
/// the new events as a **steering message** — one that arrived while the
|
||
/// agent was working, to be woven into the in-progress task rather than
|
||
/// treated as a replacement. Fires for any author the inbound author gate
|
||
/// admits (owner ∪ allowlist ∪ siblings). This is the default mid-turn
|
||
/// delivery path. Requires DedupMode::Queue.
|
||
Steer,
|
||
/// Cancel the in-flight turn and re-dispatch a merged prompt combining
|
||
/// the original events with the new ones, framed as a **supersede** (the
|
||
/// new request replaces the old), for ANY new @mention.
|
||
/// Requires DedupMode::Queue.
|
||
Interrupt,
|
||
/// Cancel the in-flight turn only when the new @mention is from the agent
|
||
/// owner (resolved via owner_cache). All other authors queue normally.
|
||
/// Requires DedupMode::Queue.
|
||
#[value(name = "owner-interrupt")]
|
||
OwnerInterrupt,
|
||
}
|
||
|
||
/// Inbound author gate: which authors' events the harness forwards to the agent.
|
||
///
|
||
/// - `owner-only` — only the agent's registered owner (default).
|
||
/// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`).
|
||
/// - `anyone` — all events forwarded (no author filtering).
|
||
/// - `nobody` — all events dropped (proactive/heartbeat-only mode).
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, clap::ValueEnum)]
|
||
pub enum RespondTo {
|
||
#[default]
|
||
OwnerOnly,
|
||
Allowlist,
|
||
Anyone,
|
||
Nobody,
|
||
}
|
||
|
||
impl std::fmt::Display for RespondTo {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::OwnerOnly => f.write_str("owner-only"),
|
||
Self::Allowlist => f.write_str("allowlist"),
|
||
Self::Anyone => f.write_str("anyone"),
|
||
Self::Nobody => f.write_str("nobody"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Permission mode for agents that support `session/set_config_option` with
|
||
/// `configId: "mode"` (e.g. `claude-agent-acp`).
|
||
///
|
||
/// - `default` — agent's built-in behaviour (permission requests per tool call).
|
||
/// - `acceptEdits` — auto-approve file edits, still ask for other tools.
|
||
/// - `bypassPermissions` — skip the permission flow entirely.
|
||
/// - `dontAsk` — never prompt; reject anything that would require permission.
|
||
/// - `plan` — planning-only mode (no tool execution).
|
||
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
|
||
pub enum PermissionMode {
|
||
/// Agent default — permission requests per tool call.
|
||
#[value(alias = "default")]
|
||
Default,
|
||
/// Auto-approve file edits, still ask for other tools.
|
||
#[value(alias = "acceptEdits")]
|
||
AcceptEdits,
|
||
/// Skip the permission flow entirely.
|
||
#[value(alias = "bypassPermissions")]
|
||
BypassPermissions,
|
||
/// Never prompt; reject anything that would require permission.
|
||
#[value(alias = "dontAsk")]
|
||
DontAsk,
|
||
/// Planning-only mode (no tool execution).
|
||
#[value(alias = "plan")]
|
||
Plan,
|
||
}
|
||
|
||
impl PermissionMode {
|
||
/// Return the wire-format string sent to the agent via
|
||
/// `session/set_config_option`.
|
||
pub fn as_wire_str(&self) -> &'static str {
|
||
match self {
|
||
Self::Default => "default",
|
||
Self::AcceptEdits => "acceptEdits",
|
||
Self::BypassPermissions => "bypassPermissions",
|
||
Self::DontAsk => "dontAsk",
|
||
Self::Plan => "plan",
|
||
}
|
||
}
|
||
|
||
/// Returns `true` when the mode is the agent's built-in default and
|
||
/// therefore doesn't need to be explicitly set.
|
||
pub fn is_default(&self) -> bool {
|
||
matches!(self, Self::Default)
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for PermissionMode {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(self.as_wire_str())
|
||
}
|
||
}
|
||
|
||
/// CLI args for `buzz-acp models` — query available models from an agent.
|
||
///
|
||
/// This is a standalone `Parser` (not a subcommand variant) because the
|
||
/// `models` path must bypass `Config::from_cli()` entirely — no relay,
|
||
/// no private key, no harness setup.
|
||
#[derive(Debug, Parser)]
|
||
#[command(
|
||
name = "buzz-acp models",
|
||
about = "Query available models from the configured agent"
|
||
)]
|
||
pub struct ModelsArgs {
|
||
/// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp").
|
||
#[command(flatten)]
|
||
pub agent: AuthAgentArgs,
|
||
|
||
/// Output structured JSON instead of human-readable text.
|
||
#[arg(long)]
|
||
pub json: bool,
|
||
}
|
||
|
||
/// Shared agent-spawn flags for lightweight local ACP helper subcommands.
|
||
#[derive(Debug, Parser)]
|
||
pub struct AuthAgentArgs {
|
||
/// Agent binary to spawn (e.g. "goose", "claude-agent-acp", "codex-acp").
|
||
#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
|
||
pub agent_command: String,
|
||
|
||
/// Arguments passed to the agent binary.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_AGENT_ARGS",
|
||
default_value = "acp",
|
||
value_delimiter = ','
|
||
)]
|
||
pub agent_args: Vec<String>,
|
||
}
|
||
|
||
/// CLI args for `buzz-acp auth-methods` — query adapter-advertised login methods.
|
||
#[derive(Debug, Parser)]
|
||
#[command(
|
||
name = "buzz-acp auth-methods",
|
||
about = "Query adapter-advertised ACP authentication methods"
|
||
)]
|
||
pub struct AuthMethodsArgs {
|
||
#[command(flatten)]
|
||
pub agent: AuthAgentArgs,
|
||
|
||
/// Output structured JSON instead of human-readable text.
|
||
#[arg(long)]
|
||
pub json: bool,
|
||
}
|
||
|
||
/// CLI args for `buzz-acp authenticate` — start an adapter-owned login flow.
|
||
#[derive(Debug, Parser)]
|
||
#[command(
|
||
name = "buzz-acp authenticate",
|
||
about = "Start an adapter-owned ACP authentication flow"
|
||
)]
|
||
pub struct AuthenticateArgs {
|
||
#[command(flatten)]
|
||
pub agent: AuthAgentArgs,
|
||
|
||
/// Adapter-advertised auth method id to invoke.
|
||
#[arg(long)]
|
||
pub method_id: String,
|
||
}
|
||
|
||
#[derive(Debug, Parser)]
|
||
#[command(
|
||
name = "buzz-acp",
|
||
about = "ACP harness that bridges Buzz events to AI agents"
|
||
)]
|
||
pub struct CliArgs {
|
||
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")]
|
||
pub relay_url: String,
|
||
|
||
#[arg(long, env = "BUZZ_PRIVATE_KEY")]
|
||
pub private_key: String,
|
||
|
||
/// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate.
|
||
#[arg(long, env = "BUZZ_ACP_AGENT_OWNER")]
|
||
pub agent_owner: Option<String>,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
|
||
pub agent_command: String,
|
||
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_AGENT_ARGS",
|
||
default_value = "acp",
|
||
value_delimiter = ','
|
||
)]
|
||
pub agent_args: Vec<String>,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")]
|
||
pub mcp_command: String,
|
||
|
||
/// Idle timeout: max seconds of silence before killing a turn.
|
||
/// Resets on any agent stdout activity.
|
||
#[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")]
|
||
pub idle_timeout: Option<u64>,
|
||
|
||
/// Absolute wall-clock cap per turn (safety valve).
|
||
#[arg(long, env = "BUZZ_ACP_MAX_TURN_DURATION", default_value_t = DEFAULT_MAX_TURN_DURATION_SECS)]
|
||
pub max_turn_duration: u64,
|
||
|
||
/// Deprecated: alias for --idle-timeout. If both set, --idle-timeout wins.
|
||
#[arg(long, env = "BUZZ_ACP_TURN_TIMEOUT", hide = true)]
|
||
pub turn_timeout: Option<u64>,
|
||
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_SYSTEM_PROMPT",
|
||
conflicts_with = "system_prompt_file"
|
||
)]
|
||
pub system_prompt: Option<String>,
|
||
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_SYSTEM_PROMPT_FILE",
|
||
conflicts_with = "system_prompt"
|
||
)]
|
||
pub system_prompt_file: Option<PathBuf>,
|
||
|
||
/// Number of parallel agent subprocesses.
|
||
#[arg(long, env = "BUZZ_ACP_AGENTS", default_value_t = 1,
|
||
value_parser = clap::value_parser!(u32).range(1..=32))]
|
||
pub agents: u32,
|
||
|
||
/// Seconds between heartbeat prompts. 0 = disabled.
|
||
#[arg(long, env = "BUZZ_ACP_HEARTBEAT_INTERVAL", default_value_t = 0)]
|
||
pub heartbeat_interval: u64,
|
||
|
||
/// Seconds between per-turn liveness pings (the crash backstop signal —
|
||
/// distinct from heartbeat self-prompting). 0 = disabled.
|
||
#[arg(long, env = "BUZZ_ACP_TURN_LIVENESS_SECS", default_value_t = 10)]
|
||
pub turn_liveness_secs: u64,
|
||
|
||
/// Heartbeat prompt text. Conflicts with --heartbeat-prompt-file.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_HEARTBEAT_PROMPT",
|
||
conflicts_with = "heartbeat_prompt_file"
|
||
)]
|
||
pub heartbeat_prompt: Option<String>,
|
||
|
||
/// Read heartbeat prompt from file.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_HEARTBEAT_PROMPT_FILE",
|
||
conflicts_with = "heartbeat_prompt"
|
||
)]
|
||
pub heartbeat_prompt_file: Option<PathBuf>,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_INITIAL_MESSAGE")]
|
||
pub initial_message: Option<String>,
|
||
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_SUBSCRIBE",
|
||
default_value = "mentions",
|
||
value_enum
|
||
)]
|
||
pub subscribe: SubscribeMode,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_KINDS", value_delimiter = ',')]
|
||
pub kinds: Option<Vec<u32>>,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_CHANNELS", value_delimiter = ',')]
|
||
pub channels: Option<Vec<String>>,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_NO_MENTION_FILTER")]
|
||
pub no_mention_filter: bool,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_CONFIG", default_value = "./buzz-acp.toml")]
|
||
pub config: PathBuf,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)]
|
||
pub dedup: DedupMode,
|
||
|
||
/// How to handle new @mentions while a turn is already in-flight.
|
||
/// steer (default): cancel+re-prompt, framing the new mention as a message
|
||
/// that arrived mid-task — the agent keeps working and weaves it in.
|
||
/// queue: events wait until the current turn completes.
|
||
/// interrupt: cancel+re-prompt framed as a supersede (new replaces old).
|
||
/// owner-interrupt: interrupt only for the agent owner's mentions.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_MULTIPLE_EVENT_HANDLING",
|
||
default_value = "steer",
|
||
value_enum
|
||
)]
|
||
pub multiple_event_handling: MultipleEventHandling,
|
||
|
||
#[arg(long, env = "BUZZ_ACP_NO_IGNORE_SELF")]
|
||
pub no_ignore_self: bool,
|
||
|
||
/// Maximum number of context messages to include for thread replies and DMs.
|
||
/// Set to 0 to disable automatic context fetching. Max 100.
|
||
#[arg(long, env = "BUZZ_ACP_CONTEXT_MESSAGE_LIMIT", default_value_t = 12,
|
||
value_parser = clap::value_parser!(u32).range(0..=100))]
|
||
pub context_message_limit: u32,
|
||
|
||
/// Maximum turns per session before proactive rotation. 0 = disabled
|
||
/// (rotate only on MaxTokens / MaxTurnRequests).
|
||
#[arg(long, env = "BUZZ_ACP_MAX_TURNS_PER_SESSION", default_value_t = 0,
|
||
value_parser = clap::value_parser!(u32))]
|
||
pub max_turns_per_session: u32,
|
||
|
||
/// Disable automatic presence (online/offline) status.
|
||
#[arg(long, env = "BUZZ_ACP_NO_PRESENCE")]
|
||
pub no_presence: bool,
|
||
|
||
/// Disable typing indicators while agent is processing.
|
||
#[arg(long, env = "BUZZ_ACP_NO_TYPING")]
|
||
pub no_typing: bool,
|
||
|
||
/// Enable NIP-AE agent core memory injection.
|
||
///
|
||
/// Memory injection is on by default. When enabled, the harness
|
||
/// fetches the agent's per-session core engram and renders it as an
|
||
/// `[Agent Memory — core]` prompt section (or renders the onboarding nudge
|
||
/// when the relay confirms no core engram exists). The `buzz mem` CLI
|
||
/// and the relay's acceptance of kind:30174 engrams are unaffected — this
|
||
/// flag controls prompt-time injection in the ACP harness only.
|
||
/// Pass `--no-memory` / `BUZZ_ACP_NO_MEMORY=true` to disable.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_MEMORY",
|
||
conflicts_with = "no_memory",
|
||
default_value_t = true
|
||
)]
|
||
pub memory: bool,
|
||
|
||
/// Disable NIP-AE agent core memory injection.
|
||
///
|
||
/// Memory injection is on by default; set this flag/env var to opt out.
|
||
#[arg(long, env = "BUZZ_ACP_NO_MEMORY", conflicts_with = "memory")]
|
||
pub no_memory: bool,
|
||
|
||
/// Disable the [Base] platform-context section prepended to every prompt.
|
||
/// When set, agents receive only the persona [System] prompt with no Buzz orientation.
|
||
#[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")]
|
||
pub no_base_prompt: bool,
|
||
|
||
/// Path to a custom base prompt file. Overrides the compiled-in default.
|
||
/// Mutually exclusive with --no-base-prompt.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_BASE_PROMPT_FILE",
|
||
conflicts_with = "no_base_prompt"
|
||
)]
|
||
pub base_prompt_file: Option<PathBuf>,
|
||
|
||
/// Desired LLM model ID. Applied to every new ACP session after creation.
|
||
/// Use `buzz-acp models` to discover available model IDs.
|
||
#[arg(long, env = "BUZZ_ACP_MODEL")]
|
||
pub model: Option<String>,
|
||
|
||
/// Permission mode for agents that support `session/set_config_option`
|
||
/// with `configId: "mode"` (e.g. `claude-agent-acp`).
|
||
///
|
||
/// Defaults to `bypassPermissions` which skips the per-tool-call
|
||
/// permission flow. Set to `default` to restore the agent's built-in
|
||
/// behaviour.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_PERMISSION_MODE",
|
||
default_value = "bypass-permissions",
|
||
value_enum
|
||
)]
|
||
pub permission_mode: PermissionMode,
|
||
|
||
/// Inbound author gate: which authors' events the harness forwards.
|
||
/// Modes: owner-only (default), allowlist, anyone, nobody.
|
||
#[arg(
|
||
long,
|
||
env = "BUZZ_ACP_RESPOND_TO",
|
||
default_value = "owner-only",
|
||
value_enum
|
||
)]
|
||
pub respond_to: RespondTo,
|
||
|
||
/// Comma-separated 64-char hex pubkeys for allowlist mode.
|
||
/// Owner pubkey is always implicitly included.
|
||
#[arg(long, env = "BUZZ_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')]
|
||
pub respond_to_allowlist: Option<Vec<String>>,
|
||
|
||
/// Comma-separated list of allowed `--respond-to` modes.
|
||
/// When set, the harness rejects startup if `--respond-to` is not in this list.
|
||
/// Modes: owner-only, allowlist, anyone, nobody.
|
||
/// Default: empty (all modes allowed — no restriction).
|
||
/// Example: `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only,allowlist`
|
||
#[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")]
|
||
pub team_instructions: Option<String>,
|
||
|
||
/// Publish encrypted ACP observer frames over the relay.
|
||
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
|
||
pub relay_observer: bool,
|
||
|
||
/// Connect and subscribe before starting the ACP/LLM subprocess pool.
|
||
#[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)]
|
||
pub lazy_pool: bool,
|
||
}
|
||
|
||
/// Merged NIP-01 subscription filter for a single channel.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ChannelFilter {
|
||
/// Event kinds to subscribe to. None = wildcard (all kinds).
|
||
pub kinds: Option<Vec<u32>>,
|
||
/// Whether to include `#p` tag filter for agent pubkey.
|
||
pub require_mention: bool,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
pub struct Config {
|
||
pub keys: Keys,
|
||
pub relay_url: String,
|
||
pub agent_command: String,
|
||
pub agent_args: Vec<String>,
|
||
pub mcp_command: String,
|
||
pub idle_timeout_secs: u64,
|
||
pub max_turn_duration_secs: u64,
|
||
pub agents: u32,
|
||
pub heartbeat_interval_secs: u64,
|
||
/// Seconds between per-turn liveness pings. 0 = disabled. Distinct from
|
||
/// `heartbeat_interval_secs` (agent self-prompting) — this is the desktop
|
||
/// crash-backstop signal.
|
||
pub turn_liveness_secs: u64,
|
||
pub heartbeat_prompt: Option<String>,
|
||
pub system_prompt: Option<String>,
|
||
/// Team-owned instructions layered separately from the agent system prompt.
|
||
pub team_instructions: Option<String>,
|
||
pub initial_message: Option<String>,
|
||
pub subscribe_mode: SubscribeMode,
|
||
pub dedup_mode: DedupMode,
|
||
pub multiple_event_handling: MultipleEventHandling,
|
||
pub ignore_self: bool,
|
||
pub kinds_override: Option<Vec<u32>>,
|
||
pub channels_override: Option<Vec<String>>,
|
||
pub no_mention_filter: bool,
|
||
pub config_path: PathBuf,
|
||
pub context_message_limit: u32,
|
||
/// Maximum turns per session before proactive rotation. 0 = disabled.
|
||
pub max_turns_per_session: u32,
|
||
pub presence_enabled: bool,
|
||
pub typing_enabled: bool,
|
||
/// Whether NIP-AE agent core memory injection is enabled. When false,
|
||
/// the harness skips the per-session core engram fetch and renders no
|
||
/// `[Agent Memory — core]` section. On by default; disabled via the
|
||
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY` opt-out.
|
||
pub memory_enabled: bool,
|
||
/// Desired LLM model ID. Applied after every `session_new_full()`.
|
||
pub model: Option<String>,
|
||
/// Permission mode to apply after session creation. `Default` = skip.
|
||
pub permission_mode: PermissionMode,
|
||
/// Inbound author gate mode.
|
||
pub respond_to: RespondTo,
|
||
/// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist).
|
||
pub respond_to_allowlist: HashSet<String>,
|
||
/// Allowed `respond_to` modes. Empty = all modes allowed.
|
||
pub allowed_respond_to: Vec<String>,
|
||
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL).
|
||
/// Populated from persona pack resolution. Empty when no pack is configured.
|
||
pub persona_env_vars: Vec<(String, String)>,
|
||
/// Whether `codex_network_env()` successfully injected a `CODEX_CONFIG` entry into
|
||
/// `persona_env_vars`. When true, `AcpClient::spawn` merges all `CODEX_CONFIG` entries
|
||
/// and forces `sandbox_workspace_write.network_access = true` via `build_codex_config_env`.
|
||
/// When false (non-Codex agents or rejected relay URL), the helper returns None and
|
||
/// any persona-supplied `CODEX_CONFIG` is handled with ordinary operator-wins semantics.
|
||
pub has_generated_codex_config: bool,
|
||
/// Whether to publish encrypted observer frames through the relay.
|
||
pub relay_observer: bool,
|
||
/// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives.
|
||
pub lazy_pool: bool,
|
||
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
|
||
/// Replaces the old REST-based owner lookup.
|
||
pub agent_owner: Option<String>,
|
||
/// Disable the [Base] platform-context section prepended to every prompt.
|
||
pub no_base_prompt: bool,
|
||
/// Resolved content from `--base-prompt-file`, read and validated in
|
||
/// `from_cli()`. `None` when using the compiled-in default or when
|
||
/// `--no-base-prompt` is set.
|
||
pub base_prompt_content: Option<String>,
|
||
}
|
||
|
||
/// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars.
|
||
fn validate_allowlist(entries: &[String]) -> Result<HashSet<String>, ConfigError> {
|
||
let mut validated = HashSet::new();
|
||
for entry in entries {
|
||
let trimmed = entry.trim().to_ascii_lowercase();
|
||
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"invalid pubkey in --respond-to-allowlist: '{entry}' \
|
||
(must be exactly 64 hex characters)"
|
||
)));
|
||
}
|
||
validated.insert(trimmed);
|
||
}
|
||
Ok(validated)
|
||
}
|
||
|
||
/// Validate the `--multiple-event-handling` / `--dedup` combination.
|
||
///
|
||
/// Every mid-turn cancel mode (`Steer`, `Interrupt`, `OwnerInterrupt`) requires
|
||
/// `DedupMode::Queue`: `DedupMode::Drop` discards events during the cancel drain
|
||
/// window, which would produce incomplete merged prompts. `Queue` handling
|
||
/// imposes no constraint.
|
||
fn validate_multiple_event_handling(
|
||
handling: MultipleEventHandling,
|
||
dedup: DedupMode,
|
||
) -> Result<(), ConfigError> {
|
||
let is_cancel_mode = matches!(
|
||
handling,
|
||
MultipleEventHandling::Steer
|
||
| MultipleEventHandling::Interrupt
|
||
| MultipleEventHandling::OwnerInterrupt
|
||
);
|
||
if is_cancel_mode && matches!(dedup, DedupMode::Drop) {
|
||
return Err(ConfigError::ConfigFile(
|
||
"--multiple-event-handling=steer (or interrupt/owner-interrupt) requires \
|
||
--dedup=queue. DedupMode::Drop discards events during the cancel drain window, \
|
||
producing incomplete merged prompts."
|
||
.into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
|
||
let normalized = command.trim().replace('\\', "/");
|
||
let trimmed = normalized.trim_end_matches('/');
|
||
let basename = trimmed
|
||
.rsplit('/')
|
||
.next()
|
||
.expect("rsplit always yields at least one element");
|
||
let lower = basename.to_ascii_lowercase();
|
||
let stem = lower.strip_suffix(".exe").unwrap_or(&lower);
|
||
stem.chars()
|
||
.map(|character| match character {
|
||
' ' | '_' => '-',
|
||
_ => character,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
||
match normalize_agent_command_identity(command).as_str() {
|
||
"goose" => Some(vec!["acp".to_string()]),
|
||
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
|
||
| "claudecode" | "buzz-agent" => Some(Vec::new()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Build the `CODEX_CONFIG` environment variable that enables full outbound
|
||
/// network access in Codex's macOS Seatbelt sandbox.
|
||
///
|
||
/// Codex sandboxes MCP subprocesses (including `buzz-cli`) behind a Seatbelt sandbox
|
||
/// that blocks all outbound network by default. Without this env var, `buzz-cli`
|
||
/// requests are blocked before they can reach the relay WebSocket.
|
||
///
|
||
/// Returns `Some(("CODEX_CONFIG", "{\"sandbox_workspace_write\":{\"network_access\":true}}"))` for
|
||
/// Codex agents, or `None` for non-Codex agents or when the relay URL cannot be parsed.
|
||
///
|
||
/// The env var is forwarded by the `@agentclientprotocol/codex-acp` adapter (1.x) as a
|
||
/// session-level config override (via `CODEX_CONFIG` → `thread/start config`), which is
|
||
/// equivalent to the TOML override `sandbox_workspace_write.network_access = true`.
|
||
/// That sets `NetworkSandboxPolicy::Enabled`, causing the Seatbelt policy to include
|
||
/// `(allow network-outbound)` — full outbound TCP/TLS at the OS level.
|
||
///
|
||
/// URL validation is preserved as a guard: injection is skipped when the relay URL cannot
|
||
/// be parsed, avoiding accidental sandbox widening for malformed configs.
|
||
///
|
||
/// Handles `ws://`, `wss://`, `http://`, and `https://` schemes.
|
||
pub fn codex_network_env(agent_command: &str, relay_url: &str) -> Option<(String, String)> {
|
||
match normalize_agent_command_identity(agent_command).as_str() {
|
||
"codex" | "codex-acp" => {}
|
||
_ => return None,
|
||
}
|
||
|
||
// Validate the relay URL before injecting broader network access. On parse failure,
|
||
// skip injection rather than panicking or widening the sandbox unconditionally.
|
||
let host = match Url::parse(relay_url) {
|
||
Ok(u) => match u.host_str() {
|
||
Some(h) => h.to_owned(),
|
||
None => {
|
||
tracing::warn!(
|
||
relay_url,
|
||
"codex network config: no host in relay URL — skipping injection"
|
||
);
|
||
return None;
|
||
}
|
||
},
|
||
Err(e) => {
|
||
tracing::warn!(relay_url, error = %e, "codex network config: failed to parse relay URL — skipping injection");
|
||
return None;
|
||
}
|
||
};
|
||
|
||
tracing::debug!(host, "injecting CODEX_CONFIG network_access for relay host");
|
||
|
||
Some((
|
||
"CODEX_CONFIG".into(),
|
||
"{\"sandbox_workspace_write\":{\"network_access\":true}}".into(),
|
||
))
|
||
}
|
||
|
||
pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
|
||
let normalized = agent_args
|
||
.into_iter()
|
||
.map(|arg| arg.trim().to_string())
|
||
.filter(|arg| !arg.is_empty())
|
||
.collect::<Vec<_>>();
|
||
|
||
let Some(default_args) = default_agent_args(command) else {
|
||
return normalized;
|
||
};
|
||
|
||
if normalized.is_empty() {
|
||
return default_args;
|
||
}
|
||
|
||
// Older callers relied on the Goose-specific default even for runtimes like
|
||
// Codex and Claude. Treat that legacy fallback as "no args" for zero-arg
|
||
// providers so desktop- and env-based launches behave the same way.
|
||
if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty()
|
||
{
|
||
return default_args;
|
||
}
|
||
|
||
normalized
|
||
}
|
||
|
||
/// Propagate legacy env-var aliases to their canonical names.
|
||
///
|
||
/// Must be called **before** the tokio runtime starts — i.e. from the sync
|
||
/// `fn main()` wrapper, not from inside `#[tokio::main]`.
|
||
///
|
||
/// `std::env::set_var` is safe in Rust 2021 only when no other threads are
|
||
/// running. In Rust 2024 it requires `unsafe`. Calling this before
|
||
/// `#[tokio::main]` ensures worker threads are not yet alive.
|
||
///
|
||
/// // Must be called before tokio runtime starts — see Rust 2024 edition safety.
|
||
pub fn propagate_legacy_env_vars() {
|
||
for (legacy, canonical) in [
|
||
("BUZZ_ACP_PRIVATE_KEY", "BUZZ_PRIVATE_KEY"),
|
||
("BUZZ_ACP_API_TOKEN", "BUZZ_API_TOKEN"),
|
||
] {
|
||
if std::env::var(canonical).is_err() {
|
||
if let Ok(val) = std::env::var(legacy) {
|
||
std::env::set_var(canonical, &val);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Config {
|
||
pub fn from_cli() -> Result<Self, ConfigError> {
|
||
// Legacy env-var propagation is intentionally NOT done here.
|
||
// Call `propagate_legacy_env_vars()` before the tokio runtime starts
|
||
// (in the sync `fn main()` wrapper) — see Rust 2024 edition safety.
|
||
let args = CliArgs::parse();
|
||
Self::from_args(args)
|
||
}
|
||
|
||
/// Build a `Config` from already-parsed `CliArgs`. Separated from `from_cli()` so
|
||
/// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full
|
||
/// validation path without going through process args.
|
||
pub fn from_args(mut args: CliArgs) -> Result<Self, ConfigError> {
|
||
let keys = Keys::parse(&args.private_key)?;
|
||
// Best-effort zeroize: overwrite the raw private key string to reduce
|
||
// exposure via core dumps or heap inspection (#41). Without the `zeroize`
|
||
// crate we can only clear the String — the allocator may retain copies.
|
||
args.private_key
|
||
.replace_range(.., &"0".repeat(args.private_key.len()));
|
||
args.private_key.clear();
|
||
|
||
let system_prompt = if let Some(text) = args.system_prompt {
|
||
Some(text)
|
||
} else if let Some(ref path) = args.system_prompt_file {
|
||
Some(std::fs::read_to_string(path)?)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if args.heartbeat_interval > 0 && args.heartbeat_interval < 10 {
|
||
return Err(ConfigError::ConfigFile(
|
||
"heartbeat interval must be 0 (disabled) or ≥10 seconds".into(),
|
||
));
|
||
}
|
||
|
||
if args.turn_liveness_secs > 0 && args.turn_liveness_secs < 5 {
|
||
return Err(ConfigError::ConfigFile(
|
||
"turn liveness interval must be 0 (disabled) or ≥5 seconds".into(),
|
||
));
|
||
}
|
||
|
||
let heartbeat_prompt = if let Some(text) = args.heartbeat_prompt {
|
||
Some(text)
|
||
} else if let Some(ref path) = args.heartbeat_prompt_file {
|
||
Some(std::fs::read_to_string(path)?)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let base_prompt_content = if args.no_base_prompt {
|
||
None
|
||
} else if let Some(ref path) = args.base_prompt_file {
|
||
let content = std::fs::read_to_string(path)?;
|
||
if content.len() > 1_048_576 {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"base prompt file {} exceeds 1 MB limit ({} bytes)",
|
||
path.display(),
|
||
content.len()
|
||
)));
|
||
}
|
||
Some(content)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if matches!(args.subscribe, SubscribeMode::Config) {
|
||
if args.kinds.is_some() {
|
||
tracing::warn!("--kinds is ignored in config mode");
|
||
}
|
||
if args.channels.is_some() {
|
||
tracing::warn!("--channels is ignored in config mode");
|
||
}
|
||
if args.no_mention_filter {
|
||
tracing::warn!("--no-mention-filter is ignored in config mode");
|
||
}
|
||
}
|
||
|
||
let agent_command = args.agent_command;
|
||
|
||
if agent_command.trim().is_empty() {
|
||
return Err(ConfigError::ConfigFile(
|
||
"agent_command must not be empty".into(),
|
||
));
|
||
}
|
||
|
||
let agent_args = normalize_agent_args(&agent_command, args.agent_args);
|
||
|
||
if let Some(ref channels) = args.channels {
|
||
for ch in channels {
|
||
if ch.parse::<Uuid>().is_err() {
|
||
tracing::warn!(
|
||
channel = %ch,
|
||
"--channels entry is not a valid UUID and will be ignored"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let heartbeat_interval = if args.heartbeat_interval > 86400 {
|
||
tracing::warn!(
|
||
interval = args.heartbeat_interval,
|
||
"heartbeat interval exceeds 24h — capping at 86400s"
|
||
);
|
||
86400u64
|
||
} else {
|
||
args.heartbeat_interval
|
||
};
|
||
|
||
// Cap turn-liveness interval at 86400s (24h) — same bound as heartbeat.
|
||
let turn_liveness_secs = if args.turn_liveness_secs > 86400 {
|
||
tracing::warn!(
|
||
interval = args.turn_liveness_secs,
|
||
"turn liveness interval exceeds 24h — capping at 86400s"
|
||
);
|
||
86400u64
|
||
} else {
|
||
args.turn_liveness_secs
|
||
};
|
||
|
||
// Resolve idle_timeout_secs with deprecation handling.
|
||
// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`.
|
||
let idle_timeout_secs = {
|
||
let raw = match (args.idle_timeout, args.turn_timeout) {
|
||
(Some(idle), Some(_turn)) => {
|
||
tracing::warn!(
|
||
"--turn-timeout / BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored \
|
||
when --idle-timeout / BUZZ_ACP_IDLE_TIMEOUT is also set"
|
||
);
|
||
idle
|
||
}
|
||
(Some(idle), None) => idle,
|
||
(None, Some(turn)) => {
|
||
tracing::warn!(
|
||
"--turn-timeout / BUZZ_ACP_TURN_TIMEOUT is deprecated; \
|
||
use --idle-timeout / BUZZ_ACP_IDLE_TIMEOUT instead"
|
||
);
|
||
turn
|
||
}
|
||
(None, None) => DEFAULT_IDLE_TIMEOUT_SECS,
|
||
};
|
||
if raw == 0 {
|
||
tracing::warn!("idle timeout of 0 is invalid — using 1s minimum");
|
||
1
|
||
} else {
|
||
raw
|
||
}
|
||
};
|
||
|
||
let max_turn_duration_secs = {
|
||
let raw = args.max_turn_duration;
|
||
if raw == 0 {
|
||
tracing::warn!("max turn duration of 0 is invalid — using 60s minimum");
|
||
60
|
||
} else if raw > MAX_TURN_DURATION_CEILING_SECS {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"max_turn_duration ({}s) exceeds ceiling ({}s / 7 days)",
|
||
raw, MAX_TURN_DURATION_CEILING_SECS
|
||
)));
|
||
} else {
|
||
raw
|
||
}
|
||
};
|
||
|
||
// idle_timeout must be strictly less than max_turn_duration. If idle_timeout
|
||
// >= max_turn_duration, the absolute wall-clock cap would fire before the idle
|
||
// timeout ever could, making idle_timeout a dead letter.
|
||
if idle_timeout_secs >= max_turn_duration_secs {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"idle_timeout ({}s) must be less than max_turn_duration ({}s)",
|
||
idle_timeout_secs, max_turn_duration_secs
|
||
)));
|
||
}
|
||
|
||
let respond_to_allowlist = if args.respond_to == RespondTo::Allowlist {
|
||
let raw = args.respond_to_allowlist.unwrap_or_default();
|
||
if raw.is_empty() {
|
||
return Err(ConfigError::ConfigFile(
|
||
"--respond-to=allowlist requires --respond-to-allowlist with at least one pubkey".into(),
|
||
));
|
||
}
|
||
validate_allowlist(&raw)?
|
||
} else {
|
||
if args.respond_to_allowlist.is_some() {
|
||
tracing::warn!(
|
||
"--respond-to-allowlist is ignored when --respond-to is not 'allowlist'"
|
||
);
|
||
}
|
||
HashSet::new()
|
||
};
|
||
|
||
// Validate respond_to against the allowed set.
|
||
let allowed_respond_to = if let Some(raw) = args.allowed_respond_to {
|
||
// Validate each entry is a known RespondTo mode.
|
||
for s in &raw {
|
||
RespondTo::from_str(s.trim(), true).map_err(|_| {
|
||
ConfigError::ConfigFile(format!(
|
||
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
|
||
(valid values: owner-only, allowlist, anyone, nobody)"
|
||
))
|
||
})?;
|
||
}
|
||
let allowed_modes: Vec<String> = raw.iter().map(|s| s.trim().to_string()).collect();
|
||
if !allowed_modes.is_empty() && !allowed_modes.contains(&args.respond_to.to_string()) {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"respond_to '{}' is not permitted on this deployment \
|
||
(BUZZ_ACP_ALLOWED_RESPOND_TO={})",
|
||
args.respond_to,
|
||
raw.join(",")
|
||
)));
|
||
}
|
||
allowed_modes
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
// Spawned desktop agents now carry a complete instance snapshot. Team
|
||
// instructions arrive independently so they can be layered at runtime.
|
||
let mut persona_env_vars = Vec::new();
|
||
let model = args.model;
|
||
|
||
// Inject CODEX_CONFIG so the @agentclientprotocol/codex-acp adapter (1.x)
|
||
// opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op
|
||
// for non-Codex agents or unparseable relay URLs.
|
||
let has_generated_codex_config =
|
||
if let Some(network_env) = codex_network_env(&agent_command, &args.relay_url) {
|
||
persona_env_vars.push(network_env);
|
||
true
|
||
} else {
|
||
false
|
||
};
|
||
|
||
validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?;
|
||
|
||
let config = Config {
|
||
keys,
|
||
relay_url: args.relay_url,
|
||
agent_command,
|
||
agent_args,
|
||
mcp_command: args.mcp_command,
|
||
idle_timeout_secs,
|
||
max_turn_duration_secs,
|
||
agents: args.agents,
|
||
heartbeat_interval_secs: heartbeat_interval,
|
||
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),
|
||
initial_message: args.initial_message,
|
||
subscribe_mode: args.subscribe,
|
||
dedup_mode: args.dedup,
|
||
multiple_event_handling: args.multiple_event_handling,
|
||
ignore_self: !args.no_ignore_self,
|
||
kinds_override: args.kinds,
|
||
channels_override: args.channels,
|
||
no_mention_filter: args.no_mention_filter,
|
||
config_path: args.config,
|
||
context_message_limit: args.context_message_limit,
|
||
max_turns_per_session: args.max_turns_per_session,
|
||
presence_enabled: !args.no_presence,
|
||
typing_enabled: !args.no_typing,
|
||
memory_enabled: args.memory && !args.no_memory,
|
||
model,
|
||
permission_mode: args.permission_mode,
|
||
respond_to: args.respond_to,
|
||
respond_to_allowlist,
|
||
allowed_respond_to,
|
||
persona_env_vars,
|
||
has_generated_codex_config,
|
||
relay_observer: args.relay_observer,
|
||
lazy_pool: args.lazy_pool,
|
||
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
|
||
no_base_prompt: args.no_base_prompt,
|
||
base_prompt_content,
|
||
};
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// Human-readable summary (no secrets).
|
||
pub fn summary(&self) -> String {
|
||
let respond_to_detail = match &self.respond_to {
|
||
RespondTo::Allowlist => {
|
||
format!("respond_to=allowlist({})", self.respond_to_allowlist.len())
|
||
}
|
||
other => format!("respond_to={other}"),
|
||
};
|
||
let allowed_respond_to_detail = if self.allowed_respond_to.is_empty() {
|
||
String::new()
|
||
} else {
|
||
let mut modes = self.allowed_respond_to.clone();
|
||
modes.sort();
|
||
format!(" allowed_respond_to=[{}]", modes.join(","))
|
||
};
|
||
format!(
|
||
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}",
|
||
self.relay_url,
|
||
self.keys.public_key().to_hex(),
|
||
self.agent_command,
|
||
self.agent_args.join(" "),
|
||
self.mcp_command,
|
||
self.idle_timeout_secs,
|
||
self.max_turn_duration_secs,
|
||
self.agents,
|
||
self.heartbeat_interval_secs,
|
||
self.subscribe_mode,
|
||
self.dedup_mode,
|
||
self.multiple_event_handling,
|
||
self.ignore_self,
|
||
self.context_message_limit,
|
||
self.max_turns_per_session,
|
||
self.presence_enabled,
|
||
self.typing_enabled,
|
||
self.memory_enabled,
|
||
self.model.as_deref().unwrap_or("(agent default)"),
|
||
self.permission_mode,
|
||
respond_to_detail,
|
||
allowed_respond_to_detail,
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, serde::Deserialize)]
|
||
struct TomlConfig {
|
||
#[serde(default)]
|
||
rules: Vec<SubscriptionRule>,
|
||
}
|
||
|
||
pub fn load_rules(path: &std::path::Path) -> Result<Vec<SubscriptionRule>, ConfigError> {
|
||
use std::sync::atomic::AtomicU32;
|
||
use std::sync::Arc;
|
||
|
||
let content = std::fs::read_to_string(path)?;
|
||
let mut config: TomlConfig =
|
||
toml::from_str(&content).map_err(|e| ConfigError::ConfigFile(e.to_string()))?;
|
||
|
||
if config.rules.len() > 100 {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"too many rules ({}, max 100)",
|
||
config.rules.len()
|
||
)));
|
||
}
|
||
|
||
if config.rules.is_empty() {
|
||
tracing::warn!(
|
||
path = %path.display(),
|
||
"config file contains zero rules — agent will receive no events in Config mode"
|
||
);
|
||
}
|
||
|
||
let mut seen_names = std::collections::HashSet::new();
|
||
for rule in &mut config.rules {
|
||
if rule.name.trim().is_empty() {
|
||
return Err(ConfigError::ConfigFile(
|
||
"rule name must not be empty".into(),
|
||
));
|
||
}
|
||
if !seen_names.insert(rule.name.clone()) {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"duplicate rule name: {}",
|
||
rule.name
|
||
)));
|
||
}
|
||
if let Some(ref expr) = rule.filter {
|
||
if expr.len() > 4096 {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"rule '{}': filter too long ({} bytes, max 4096)",
|
||
rule.name,
|
||
expr.len()
|
||
)));
|
||
}
|
||
// Fail fast: parse the expression at load time so typos don't
|
||
// silently produce dead rules at runtime.
|
||
match evalexpr::build_operator_tree(expr) {
|
||
Ok(node) => {
|
||
rule.compiled_filter = Some(Arc::new(node));
|
||
}
|
||
Err(e) => {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"rule '{}': invalid filter expression: {e}",
|
||
rule.name,
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
// Validate channel scope — catch typos like "ALL" or "All" early.
|
||
if let crate::filter::ChannelScope::All(ref s) = rule.channels {
|
||
if s != "all" {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"rule '{}': channels must be \"all\" or a list, got {:?}",
|
||
rule.name, s,
|
||
)));
|
||
}
|
||
}
|
||
// Deserialization leaves consecutive_timeouts at its zero default; reset explicitly.
|
||
rule.consecutive_timeouts = Arc::new(AtomicU32::new(0));
|
||
}
|
||
|
||
Ok(config.rules)
|
||
}
|
||
|
||
/// Resolve per-channel NIP-01 filters from config + discovered channels.
|
||
pub fn resolve_channel_filters(
|
||
config: &Config,
|
||
discovered_channels: &[Uuid],
|
||
rules: &[SubscriptionRule],
|
||
) -> HashMap<Uuid, ChannelFilter> {
|
||
use buzz_core::kind::{
|
||
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||
};
|
||
|
||
let target_channels: Vec<Uuid> = if let Some(ref overrides) = config.channels_override {
|
||
overrides
|
||
.iter()
|
||
.filter_map(|s| s.parse::<Uuid>().ok())
|
||
.filter(|id| discovered_channels.contains(id))
|
||
.collect()
|
||
} else {
|
||
discovered_channels.to_vec()
|
||
};
|
||
|
||
let mut result = HashMap::new();
|
||
|
||
match config.subscribe_mode {
|
||
SubscribeMode::Mentions => {
|
||
let kinds = config.kinds_override.clone().unwrap_or_else(|| {
|
||
vec![
|
||
KIND_STREAM_MESSAGE,
|
||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||
KIND_STREAM_REMINDER,
|
||
]
|
||
});
|
||
let require_mention = !config.no_mention_filter;
|
||
for ch in &target_channels {
|
||
result.insert(
|
||
*ch,
|
||
ChannelFilter {
|
||
kinds: Some(kinds.clone()),
|
||
require_mention,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
SubscribeMode::All => {
|
||
for ch in &target_channels {
|
||
result.insert(
|
||
*ch,
|
||
ChannelFilter {
|
||
kinds: config.kinds_override.clone(),
|
||
require_mention: false,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
SubscribeMode::Config => {
|
||
for ch in discovered_channels {
|
||
let mut merged_kinds: Option<Vec<u32>> = Some(vec![]);
|
||
let mut require_mention = true;
|
||
let mut has_rule = false;
|
||
|
||
for rule in rules {
|
||
if !rule_applies_to_channel(rule, *ch) {
|
||
continue;
|
||
}
|
||
has_rule = true;
|
||
if rule.kinds.is_empty() {
|
||
merged_kinds = None;
|
||
} else if let Some(ref mut kinds) = merged_kinds {
|
||
for k in &rule.kinds {
|
||
if !kinds.contains(k) {
|
||
kinds.push(*k);
|
||
}
|
||
}
|
||
}
|
||
if !rule.require_mention {
|
||
require_mention = false;
|
||
}
|
||
}
|
||
|
||
if has_rule {
|
||
result.insert(
|
||
*ch,
|
||
ChannelFilter {
|
||
kinds: merged_kinds,
|
||
require_mention,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Resolve the subscription filter for a single dynamically-discovered channel.
|
||
///
|
||
/// In Mentions/All mode, `channels_override` (--channels) is enforced — the agent
|
||
/// won't subscribe to channels outside the operator's allowlist. In Config mode,
|
||
/// `--channels` is ignored (per CLI contract) and rule-matching determines scope.
|
||
///
|
||
/// Returns `None` when the channel is outside the agent's configured scope:
|
||
/// - Mentions/All: channel not in `channels_override` (if set)
|
||
/// - Config: no subscription rules match the channel
|
||
pub fn resolve_dynamic_channel_filter(
|
||
config: &Config,
|
||
channel_id: Uuid,
|
||
rules: &[crate::filter::SubscriptionRule],
|
||
) -> Option<ChannelFilter> {
|
||
use buzz_core::kind::{
|
||
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||
};
|
||
|
||
// In Mentions/All mode, if the operator explicitly constrained channels
|
||
// with --channels, only allow dynamic subscription to channels in that
|
||
// allowlist. Config mode ignores --channels (per CLI contract) and uses
|
||
// rule-matching instead.
|
||
if config.subscribe_mode != SubscribeMode::Config {
|
||
if let Some(ref overrides) = config.channels_override {
|
||
let allowed = overrides
|
||
.iter()
|
||
.any(|s| s.parse::<Uuid>().ok() == Some(channel_id));
|
||
if !allowed {
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
|
||
match config.subscribe_mode {
|
||
SubscribeMode::Mentions => Some(ChannelFilter {
|
||
kinds: Some(config.kinds_override.clone().unwrap_or_else(|| {
|
||
vec![
|
||
KIND_STREAM_MESSAGE,
|
||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||
KIND_STREAM_REMINDER,
|
||
]
|
||
})),
|
||
require_mention: !config.no_mention_filter,
|
||
}),
|
||
SubscribeMode::All => Some(ChannelFilter {
|
||
kinds: config.kinds_override.clone(),
|
||
require_mention: false,
|
||
}),
|
||
SubscribeMode::Config => {
|
||
// Same merge logic as resolve_channel_filters() Config branch:
|
||
// evaluate ALL rules against this specific channel (including
|
||
// channel-specific rules, not just ChannelScope::All).
|
||
let mut merged_kinds: Option<Vec<u32>> = Some(vec![]);
|
||
let mut require_mention = true;
|
||
let mut has_rule = false;
|
||
|
||
for rule in rules {
|
||
if !rule_applies_to_channel(rule, channel_id) {
|
||
continue;
|
||
}
|
||
has_rule = true;
|
||
if rule.kinds.is_empty() {
|
||
merged_kinds = None;
|
||
} else if let Some(ref mut kinds) = merged_kinds {
|
||
for k in &rule.kinds {
|
||
if !kinds.contains(k) {
|
||
kinds.push(*k);
|
||
}
|
||
}
|
||
}
|
||
if !rule.require_mention {
|
||
require_mention = false;
|
||
}
|
||
}
|
||
|
||
if !has_rule {
|
||
// No rules match — don't subscribe. Consistent with
|
||
// resolve_channel_filters() which omits unmatched channels.
|
||
return None;
|
||
}
|
||
|
||
Some(ChannelFilter {
|
||
kinds: merged_kinds,
|
||
require_mention,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool {
|
||
use crate::filter::ChannelScope;
|
||
match &rule.channels {
|
||
ChannelScope::All(s) if s == "all" => true,
|
||
ChannelScope::List(ids) => ids
|
||
.iter()
|
||
.any(|id| id.parse::<Uuid>().ok() == Some(channel_id)),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::filter::{ChannelScope, SubscriptionRule};
|
||
use clap::{Parser, ValueEnum};
|
||
|
||
/// Build a minimal Config for testing without CLI parsing.
|
||
fn test_config(mode: SubscribeMode) -> Config {
|
||
Config {
|
||
keys: nostr::Keys::generate(),
|
||
relay_url: "ws://localhost:3000".into(),
|
||
agent_command: "goose".into(),
|
||
agent_args: vec!["acp".into()],
|
||
mcp_command: "".into(),
|
||
idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
|
||
max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS,
|
||
agents: 1,
|
||
heartbeat_interval_secs: 0,
|
||
turn_liveness_secs: 10,
|
||
heartbeat_prompt: None,
|
||
system_prompt: None,
|
||
team_instructions: None,
|
||
initial_message: None,
|
||
subscribe_mode: mode,
|
||
dedup_mode: DedupMode::Queue,
|
||
multiple_event_handling: MultipleEventHandling::Queue,
|
||
ignore_self: true,
|
||
kinds_override: None,
|
||
channels_override: None,
|
||
no_mention_filter: false,
|
||
config_path: PathBuf::from("./buzz-acp.toml"),
|
||
context_message_limit: 12,
|
||
max_turns_per_session: 0,
|
||
presence_enabled: true,
|
||
typing_enabled: true,
|
||
memory_enabled: true,
|
||
model: None,
|
||
permission_mode: PermissionMode::BypassPermissions,
|
||
respond_to: RespondTo::Anyone,
|
||
respond_to_allowlist: HashSet::new(),
|
||
allowed_respond_to: Vec::new(),
|
||
persona_env_vars: vec![],
|
||
has_generated_codex_config: false,
|
||
relay_observer: false,
|
||
lazy_pool: false,
|
||
agent_owner: None,
|
||
no_base_prompt: false,
|
||
base_prompt_content: None,
|
||
}
|
||
}
|
||
|
||
fn make_rule(
|
||
name: &str,
|
||
channels: ChannelScope,
|
||
kinds: Vec<u32>,
|
||
mention: bool,
|
||
) -> SubscriptionRule {
|
||
use std::sync::atomic::AtomicU32;
|
||
use std::sync::Arc;
|
||
SubscriptionRule {
|
||
name: name.into(),
|
||
channels,
|
||
kinds,
|
||
require_mention: mention,
|
||
filter: None,
|
||
prompt_tag: None,
|
||
compiled_filter: None,
|
||
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_mentions_mode_default_kinds() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let channels = vec![Uuid::new_v4(), Uuid::new_v4()];
|
||
let result = resolve_channel_filters(&config, &channels, &[]);
|
||
|
||
assert_eq!(result.len(), 2);
|
||
for ch in &channels {
|
||
let f = result.get(ch).expect("channel should be present");
|
||
assert!(f.require_mention, "mentions mode requires mention");
|
||
let kinds = f.kinds.as_ref().expect("should have kinds");
|
||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
|
||
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
|
||
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_mentions_mode_custom_kinds() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.kinds_override = Some(vec![1, 7]);
|
||
let channels = vec![Uuid::new_v4()];
|
||
let result = resolve_channel_filters(&config, &channels, &[]);
|
||
|
||
let f = result.get(&channels[0]).unwrap();
|
||
assert_eq!(f.kinds.as_ref().unwrap(), &[1, 7]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mentions_mode_no_mention_filter() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.no_mention_filter = true;
|
||
let channels = vec![Uuid::new_v4()];
|
||
let result = resolve_channel_filters(&config, &channels, &[]);
|
||
|
||
let f = result.get(&channels[0]).unwrap();
|
||
assert!(!f.require_mention);
|
||
}
|
||
|
||
#[test]
|
||
fn normalizes_goose_args_to_acp() {
|
||
assert_eq!(normalize_agent_args("goose", Vec::new()), vec!["acp"]);
|
||
assert_eq!(normalize_agent_args("goose", vec!["".into()]), vec!["acp"]);
|
||
}
|
||
|
||
#[test]
|
||
fn normalizes_codex_and_claude_args_to_empty() {
|
||
assert_eq!(
|
||
normalize_agent_args("codex-acp", Vec::new()),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("codex-acp", vec!["".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("codex-acp", vec!["acp".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("claude-code", vec!["acp".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("claude-code-acp", vec!["acp".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn preserves_explicit_nonempty_agent_args() {
|
||
assert_eq!(
|
||
normalize_agent_args("codex-acp", vec!["-c".into(), "model=\"gpt-5\"".into()]),
|
||
vec!["-c", "model=\"gpt-5\""]
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("custom-agent", vec!["".into(), "serve".into()]),
|
||
vec!["serve"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn normalizes_buzz_agent_args_to_empty() {
|
||
assert_eq!(
|
||
normalize_agent_args("buzz-agent", Vec::new()),
|
||
Vec::<String>::new()
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_args("buzz-agent", vec!["acp".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_agent_command_identity_variants() {
|
||
assert_eq!(normalize_agent_command_identity("goose"), "goose");
|
||
assert_eq!(
|
||
normalize_agent_command_identity("C:\\Program Files\\Goose\\goose.exe"),
|
||
"goose"
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_command_identity("/usr/local/bin/codex-acp"),
|
||
"codex-acp"
|
||
);
|
||
assert_eq!(normalize_agent_command_identity("/usr/local/bin/"), "bin");
|
||
assert_eq!(
|
||
normalize_agent_command_identity("Claude_Code"),
|
||
"claude-code"
|
||
);
|
||
assert_eq!(
|
||
normalize_agent_command_identity("Claude Code"),
|
||
"claude-code"
|
||
);
|
||
assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose");
|
||
// Non-ASCII must not panic.
|
||
assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt");
|
||
// Edge cases: empty, whitespace-only, bare separators.
|
||
assert_eq!(normalize_agent_command_identity(""), "");
|
||
assert_eq!(normalize_agent_command_identity(" "), "");
|
||
assert_eq!(normalize_agent_command_identity("/"), "");
|
||
assert_eq!(normalize_agent_command_identity("///"), "");
|
||
}
|
||
|
||
#[test]
|
||
fn strips_legacy_acp_arg_case_insensitively() {
|
||
assert_eq!(
|
||
normalize_agent_args("codex-acp", vec!["ACP".into()]),
|
||
Vec::<String>::new()
|
||
);
|
||
}
|
||
|
||
// --- codex_network_env tests ---
|
||
|
||
const CODEX_CONFIG_JSON: &str = "{\"sandbox_workspace_write\":{\"network_access\":true}}";
|
||
|
||
#[test]
|
||
fn codex_network_env_wss_url() {
|
||
let result = codex_network_env("codex-acp", "wss://sprout-oss.stage.blox.sqprod.co");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_ws_url() {
|
||
let result = codex_network_env("codex-acp", "ws://localhost:3000");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_https_url() {
|
||
let result = codex_network_env("codex-acp", "https://relay.example.com/path");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_http_url_with_port() {
|
||
let result = codex_network_env("codex-acp", "http://relay.example.com:8080/query");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_bare_codex_command() {
|
||
// "codex" (not "codex-acp") should also get the env var.
|
||
let result = codex_network_env("codex", "wss://relay.example.com");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_full_path_codex_command() {
|
||
// Full path like /usr/local/bin/codex-acp should be normalized.
|
||
let result = codex_network_env("/usr/local/bin/codex-acp", "wss://relay.example.com");
|
||
assert_eq!(
|
||
result,
|
||
Some(("CODEX_CONFIG".to_string(), CODEX_CONFIG_JSON.to_string()))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_non_codex_agent_returns_none() {
|
||
assert!(codex_network_env("goose", "wss://relay.example.com").is_none());
|
||
assert!(codex_network_env("claude-agent-acp", "wss://relay.example.com").is_none());
|
||
assert!(codex_network_env("buzz-agent", "wss://relay.example.com").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_includes_sandbox_network_access() {
|
||
// The JSON value must set sandbox_workspace_write.network_access=true — without
|
||
// it, the Seatbelt sandbox blocks outbound connections in the 1.x adapter.
|
||
let result = codex_network_env("codex-acp", "wss://relay.example.com");
|
||
let (key, val) = result.expect("expected Some for valid codex + valid url");
|
||
assert_eq!(key, "CODEX_CONFIG");
|
||
assert!(
|
||
val.contains("\"sandbox_workspace_write\""),
|
||
"JSON must contain sandbox_workspace_write"
|
||
);
|
||
assert!(
|
||
val.contains("\"network_access\":true"),
|
||
"JSON must set network_access=true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_empty_relay_url_returns_none() {
|
||
// Empty string fails Url::parse — graceful None return.
|
||
assert!(codex_network_env("codex-acp", "").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn codex_network_env_schemeless_string_returns_none() {
|
||
// A bare string with no scheme fails Url::parse — graceful None return.
|
||
assert!(codex_network_env("codex-acp", "not-a-url").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_mode_wildcard() {
|
||
let config = test_config(SubscribeMode::All);
|
||
let channels = vec![Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()];
|
||
let result = resolve_channel_filters(&config, &channels, &[]);
|
||
|
||
assert_eq!(result.len(), 3);
|
||
for ch in &channels {
|
||
let f = result.get(ch).unwrap();
|
||
assert!(
|
||
f.kinds.is_none(),
|
||
"all mode with no override = wildcard kinds"
|
||
);
|
||
assert!(!f.require_mention);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_mode_with_kinds_override() {
|
||
let mut config = test_config(SubscribeMode::All);
|
||
config.kinds_override = Some(vec![9, 7]);
|
||
let channels = vec![Uuid::new_v4()];
|
||
let result = resolve_channel_filters(&config, &channels, &[]);
|
||
|
||
let f = result.get(&channels[0]).unwrap();
|
||
assert_eq!(f.kinds.as_ref().unwrap(), &[9, 7]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_channels_override_filters_to_discovered() {
|
||
let mut config = test_config(SubscribeMode::All);
|
||
let ch_a = Uuid::new_v4();
|
||
let ch_b = Uuid::new_v4();
|
||
let ch_unknown = Uuid::new_v4();
|
||
// Override includes ch_a and an unknown channel.
|
||
config.channels_override = Some(vec![ch_a.to_string(), ch_unknown.to_string()]);
|
||
|
||
let discovered = vec![ch_a, ch_b];
|
||
let result = resolve_channel_filters(&config, &discovered, &[]);
|
||
|
||
// Only ch_a should be present (intersection of override and discovered).
|
||
assert_eq!(result.len(), 1);
|
||
assert!(result.contains_key(&ch_a));
|
||
assert!(!result.contains_key(&ch_b));
|
||
assert!(!result.contains_key(&ch_unknown));
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_single_rule_all_channels() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch = Uuid::new_v4();
|
||
let rules = vec![make_rule(
|
||
"catch-all",
|
||
ChannelScope::All("all".into()),
|
||
vec![9],
|
||
false,
|
||
)];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch], &rules);
|
||
assert_eq!(result.len(), 1);
|
||
let f = result.get(&ch).unwrap();
|
||
assert_eq!(f.kinds.as_ref().unwrap(), &[9]);
|
||
assert!(!f.require_mention);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_rule_targets_specific_channel() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch_a = Uuid::new_v4();
|
||
let ch_b = Uuid::new_v4();
|
||
let rules = vec![make_rule(
|
||
"only-a",
|
||
ChannelScope::List(vec![ch_a.to_string()]),
|
||
vec![9],
|
||
false,
|
||
)];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch_a, ch_b], &rules);
|
||
assert_eq!(result.len(), 1);
|
||
assert!(result.contains_key(&ch_a));
|
||
assert!(!result.contains_key(&ch_b));
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_merge_overlapping_rules() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch = Uuid::new_v4();
|
||
// Two rules both match the same channel with different kinds.
|
||
let rules = vec![
|
||
make_rule("messages", ChannelScope::All("all".into()), vec![9], true),
|
||
make_rule("reactions", ChannelScope::All("all".into()), vec![7], false),
|
||
];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch], &rules);
|
||
let f = result.get(&ch).unwrap();
|
||
// Kinds should be the union: [9, 7].
|
||
let kinds = f.kinds.as_ref().expect("should have merged kinds");
|
||
assert!(kinds.contains(&9));
|
||
assert!(kinds.contains(&7));
|
||
// require_mention should be false (most permissive wins).
|
||
assert!(!f.require_mention);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_wildcard_kinds_propagates() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch = Uuid::new_v4();
|
||
// First rule has specific kinds, second has empty (wildcard).
|
||
let rules = vec![
|
||
make_rule("narrow", ChannelScope::All("all".into()), vec![9], false),
|
||
make_rule("broad", ChannelScope::All("all".into()), vec![], false),
|
||
];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch], &rules);
|
||
let f = result.get(&ch).unwrap();
|
||
// Once any rule has empty kinds (wildcard), merged result is None (wildcard).
|
||
assert!(f.kinds.is_none(), "wildcard should propagate");
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_no_matching_rules_empty_result() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch = Uuid::new_v4();
|
||
let other_ch = Uuid::new_v4();
|
||
// Rule only targets other_ch.
|
||
let rules = vec![make_rule(
|
||
"other",
|
||
ChannelScope::List(vec![other_ch.to_string()]),
|
||
vec![9],
|
||
false,
|
||
)];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch], &rules);
|
||
assert!(result.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_mode_require_mention_most_permissive() {
|
||
let config = test_config(SubscribeMode::Config);
|
||
let ch = Uuid::new_v4();
|
||
// First rule requires mention, second doesn't.
|
||
let rules = vec![
|
||
make_rule("strict", ChannelScope::All("all".into()), vec![9], true),
|
||
make_rule("lax", ChannelScope::All("all".into()), vec![7], false),
|
||
];
|
||
|
||
let result = resolve_channel_filters(&config, &[ch], &rules);
|
||
let f = result.get(&ch).unwrap();
|
||
assert!(!f.require_mention, "most permissive (false) should win");
|
||
}
|
||
|
||
#[test]
|
||
fn test_rule_applies_all() {
|
||
let rule = make_rule("test", ChannelScope::All("all".into()), vec![], false);
|
||
assert!(rule_applies_to_channel(&rule, Uuid::new_v4()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_rule_applies_all_invalid_string() {
|
||
let rule = make_rule("test", ChannelScope::All("ALL".into()), vec![], false);
|
||
assert!(!rule_applies_to_channel(&rule, Uuid::new_v4()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_rule_applies_list_match() {
|
||
let ch = Uuid::new_v4();
|
||
let rule = make_rule(
|
||
"test",
|
||
ChannelScope::List(vec![ch.to_string()]),
|
||
vec![],
|
||
false,
|
||
);
|
||
assert!(rule_applies_to_channel(&rule, ch));
|
||
}
|
||
|
||
#[test]
|
||
fn test_rule_applies_list_no_match() {
|
||
let rule = make_rule(
|
||
"test",
|
||
ChannelScope::List(vec![Uuid::new_v4().to_string()]),
|
||
vec![],
|
||
false,
|
||
);
|
||
assert!(!rule_applies_to_channel(&rule, Uuid::new_v4()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_valid_toml() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-valid");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
r#"
|
||
[[rules]]
|
||
name = "catch-all"
|
||
channels = "all"
|
||
kinds = [9]
|
||
require_mention = false
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let rules = load_rules(&path).unwrap();
|
||
assert_eq!(rules.len(), 1);
|
||
assert_eq!(rules[0].name, "catch-all");
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_empty_name_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-empty-name");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
r#"
|
||
[[rules]]
|
||
name = " "
|
||
channels = "all"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("name must not be empty"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_duplicate_name_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-dup-name");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
r#"
|
||
[[rules]]
|
||
name = "dup"
|
||
channels = "all"
|
||
|
||
[[rules]]
|
||
name = "dup"
|
||
channels = "all"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("duplicate rule name"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_invalid_filter_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-bad-filter");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
// evalexpr rejects unbalanced parens at parse time.
|
||
std::fs::write(
|
||
&path,
|
||
r#"
|
||
[[rules]]
|
||
name = "bad"
|
||
channels = "all"
|
||
filter = "((("
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("invalid filter expression"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_channel_scope_typo_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-scope-typo");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
&path,
|
||
r#"
|
||
[[rules]]
|
||
name = "typo"
|
||
channels = "ALL"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("must be \"all\" or a list"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_too_many_rules_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-too-many");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let mut toml = String::new();
|
||
for i in 0..101 {
|
||
toml.push_str(&format!(
|
||
"[[rules]]\nname = \"rule-{i}\"\nchannels = \"all\"\n\n"
|
||
));
|
||
}
|
||
std::fs::write(&path, &toml).unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("too many rules"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn test_load_rules_filter_too_long_rejected() {
|
||
let dir = std::env::temp_dir().join("buzz-acp-test-long-filter");
|
||
let path = dir.join("rules.toml");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let long_expr = format!("\"{}\"", "a".repeat(4097));
|
||
std::fs::write(
|
||
&path,
|
||
format!("[[rules]]\nname = \"long\"\nchannels = \"all\"\nfilter = {long_expr}\n"),
|
||
)
|
||
.unwrap();
|
||
|
||
let err = load_rules(&path).unwrap_err();
|
||
assert!(err.to_string().contains("filter too long"));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
fn validate_heartbeat_interval(secs: u64) -> Result<(), ConfigError> {
|
||
if secs > 0 && secs < 10 {
|
||
return Err(ConfigError::ConfigFile(
|
||
"heartbeat interval must be 0 (disabled) or ≥10 seconds".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_zero_ok() {
|
||
assert!(validate_heartbeat_interval(0).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_ten_ok() {
|
||
assert!(validate_heartbeat_interval(10).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_large_ok() {
|
||
assert!(validate_heartbeat_interval(300).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_five_rejected() {
|
||
let err = validate_heartbeat_interval(5).unwrap_err();
|
||
assert!(err.to_string().contains("heartbeat interval must be 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_one_rejected() {
|
||
let err = validate_heartbeat_interval(1).unwrap_err();
|
||
assert!(err.to_string().contains("heartbeat interval must be 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_heartbeat_interval_nine_rejected() {
|
||
let err = validate_heartbeat_interval(9).unwrap_err();
|
||
assert!(err.to_string().contains("heartbeat interval must be 0"));
|
||
}
|
||
|
||
fn validate_turn_liveness(secs: u64) -> Result<(), ConfigError> {
|
||
if secs > 0 && secs < 5 {
|
||
return Err(ConfigError::ConfigFile(
|
||
"turn liveness interval must be 0 (disabled) or ≥5 seconds".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn test_turn_liveness_zero_ok() {
|
||
assert!(validate_turn_liveness(0).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_turn_liveness_five_ok() {
|
||
assert!(validate_turn_liveness(5).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_turn_liveness_ten_ok() {
|
||
assert!(validate_turn_liveness(10).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_turn_liveness_four_rejected() {
|
||
let err = validate_turn_liveness(4).unwrap_err();
|
||
assert!(err.to_string().contains("turn liveness interval must be 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_turn_liveness_one_rejected() {
|
||
let err = validate_turn_liveness(1).unwrap_err();
|
||
assert!(err.to_string().contains("turn liveness interval must be 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn lazy_pool_defaults_off() {
|
||
let key = "0".repeat(64);
|
||
assert!(!CliArgs::parse_from(["buzz-acp", "--private-key", &key]).lazy_pool);
|
||
}
|
||
|
||
#[test]
|
||
fn lazy_pool_cli_flag_enables_deferred_startup() {
|
||
let key = "0".repeat(64);
|
||
let args = CliArgs::try_parse_from(["buzz-acp", "--private-key", &key, "--lazy-pool=true"]);
|
||
assert!(args.is_err(), "bool flags do not take an explicit value");
|
||
assert!(CliArgs::parse_from(["buzz-acp", "--private-key", &key, "--lazy-pool"]).lazy_pool);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_includes_agents_and_heartbeat() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("agents=1"),
|
||
"summary should include agents=1, got: {s}"
|
||
);
|
||
assert!(
|
||
s.contains("heartbeat=0s"),
|
||
"summary should include heartbeat=0s, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_reflects_custom_agents_and_heartbeat() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.agents = 4;
|
||
config.heartbeat_interval_secs = 30;
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("agents=4"),
|
||
"summary should include agents=4, got: {s}"
|
||
);
|
||
assert!(
|
||
s.contains("heartbeat=30s"),
|
||
"summary should include heartbeat=30s, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_memory_enabled_default_true() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
assert!(
|
||
config.memory_enabled,
|
||
"memory_enabled should default to true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_includes_memory_enabled() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("memory=true"),
|
||
"summary should include memory=true by default, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_reflects_memory_enabled() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.memory_enabled = true;
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("memory=true"),
|
||
"summary should include memory=true when enabled, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_permission_mode_wire_strings() {
|
||
assert_eq!(PermissionMode::Default.as_wire_str(), "default");
|
||
assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits");
|
||
assert_eq!(
|
||
PermissionMode::BypassPermissions.as_wire_str(),
|
||
"bypassPermissions"
|
||
);
|
||
assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk");
|
||
assert_eq!(PermissionMode::Plan.as_wire_str(), "plan");
|
||
}
|
||
|
||
#[test]
|
||
fn test_permission_mode_is_default() {
|
||
assert!(PermissionMode::Default.is_default());
|
||
assert!(!PermissionMode::BypassPermissions.is_default());
|
||
assert!(!PermissionMode::AcceptEdits.is_default());
|
||
assert!(!PermissionMode::DontAsk.is_default());
|
||
assert!(!PermissionMode::Plan.is_default());
|
||
}
|
||
|
||
#[test]
|
||
fn test_permission_mode_display() {
|
||
assert_eq!(
|
||
format!("{}", PermissionMode::BypassPermissions),
|
||
"bypassPermissions"
|
||
);
|
||
assert_eq!(format!("{}", PermissionMode::Default), "default");
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_includes_permission_mode() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.permission_mode = PermissionMode::BypassPermissions;
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("permission_mode=bypassPermissions"),
|
||
"summary should include permission_mode, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_permission_mode_default() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.permission_mode = PermissionMode::Default;
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("permission_mode=default"),
|
||
"summary should show 'default', got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_default_config_uses_bypass_permissions() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
assert_eq!(config.permission_mode, PermissionMode::BypassPermissions);
|
||
}
|
||
|
||
#[test]
|
||
fn test_permission_mode_value_enum_kebab_case() {
|
||
// clap::ValueEnum generates kebab-case by default from PascalCase variants.
|
||
// Verify the parse path so variant renames don't silently break CLI/env parsing.
|
||
use clap::ValueEnum;
|
||
let cases = [
|
||
("default", PermissionMode::Default),
|
||
("accept-edits", PermissionMode::AcceptEdits),
|
||
("bypass-permissions", PermissionMode::BypassPermissions),
|
||
("dont-ask", PermissionMode::DontAsk),
|
||
("plan", PermissionMode::Plan),
|
||
];
|
||
for (input, expected) in &cases {
|
||
assert_eq!(
|
||
PermissionMode::from_str(input, true).unwrap(),
|
||
*expected,
|
||
"kebab-case {input:?} should parse"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_permission_mode_value_enum_camel_case_aliases() {
|
||
// Operators may set env vars using the camelCase wire-format strings
|
||
// (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)]
|
||
// attributes ensure these parse correctly.
|
||
use clap::ValueEnum;
|
||
let cases = [
|
||
("default", PermissionMode::Default),
|
||
("acceptEdits", PermissionMode::AcceptEdits),
|
||
("bypassPermissions", PermissionMode::BypassPermissions),
|
||
("dontAsk", PermissionMode::DontAsk),
|
||
("plan", PermissionMode::Plan),
|
||
];
|
||
for (input, expected) in &cases {
|
||
assert_eq!(
|
||
PermissionMode::from_str(input, true).unwrap(),
|
||
*expected,
|
||
"camelCase alias {input:?} should parse"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args.
|
||
/// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`.
|
||
fn resolve_idle_timeout(idle: Option<u64>, turn: Option<u64>) -> u64 {
|
||
let raw = match (idle, turn) {
|
||
(Some(idle), Some(_)) => idle,
|
||
(Some(idle), None) => idle,
|
||
(None, Some(turn)) => turn,
|
||
(None, None) => DEFAULT_IDLE_TIMEOUT_SECS,
|
||
};
|
||
if raw == 0 {
|
||
1
|
||
} else {
|
||
raw
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_explicit_wins_over_deprecated() {
|
||
assert_eq!(resolve_idle_timeout(Some(120), Some(600)), 120);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_falls_back_to_deprecated_turn_timeout() {
|
||
assert_eq!(resolve_idle_timeout(None, Some(600)), 600);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_defaults_to_constant_when_neither_set() {
|
||
assert_eq!(resolve_idle_timeout(None, None), DEFAULT_IDLE_TIMEOUT_SECS);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_zero_clamped_to_one() {
|
||
assert_eq!(resolve_idle_timeout(Some(0), None), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_zero_from_deprecated_clamped_to_one() {
|
||
assert_eq!(resolve_idle_timeout(None, Some(0)), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_config_summary_includes_idle_and_max_turn() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let summary = config.summary();
|
||
let expected_idle = format!("idle_timeout={DEFAULT_IDLE_TIMEOUT_SECS}s");
|
||
assert!(
|
||
summary.contains(&expected_idle),
|
||
"summary should include {expected_idle}: {summary}"
|
||
);
|
||
assert!(
|
||
summary.contains(&format!("max_turn={DEFAULT_MAX_TURN_DURATION_SECS}s")),
|
||
"summary should include max_turn: {summary}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_respond_to_default_is_owner_only() {
|
||
assert_eq!(RespondTo::default(), RespondTo::OwnerOnly);
|
||
}
|
||
|
||
#[test]
|
||
fn test_respond_to_display() {
|
||
assert_eq!(format!("{}", RespondTo::OwnerOnly), "owner-only");
|
||
assert_eq!(format!("{}", RespondTo::Allowlist), "allowlist");
|
||
assert_eq!(format!("{}", RespondTo::Anyone), "anyone");
|
||
assert_eq!(format!("{}", RespondTo::Nobody), "nobody");
|
||
}
|
||
|
||
#[test]
|
||
fn test_respond_to_value_enum_parsing() {
|
||
use clap::ValueEnum;
|
||
assert_eq!(
|
||
RespondTo::from_str("owner-only", true).unwrap(),
|
||
RespondTo::OwnerOnly
|
||
);
|
||
assert_eq!(
|
||
RespondTo::from_str("allowlist", true).unwrap(),
|
||
RespondTo::Allowlist
|
||
);
|
||
assert_eq!(
|
||
RespondTo::from_str("anyone", true).unwrap(),
|
||
RespondTo::Anyone
|
||
);
|
||
assert_eq!(
|
||
RespondTo::from_str("nobody", true).unwrap(),
|
||
RespondTo::Nobody
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_includes_respond_to() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("respond_to=anyone"),
|
||
"test_config uses Anyone, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_summary_respond_to_allowlist_shows_count() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.respond_to = RespondTo::Allowlist;
|
||
config.respond_to_allowlist = HashSet::from(["ab".repeat(32), "cd".repeat(32)]);
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("respond_to=allowlist(2)"),
|
||
"should show allowlist count, got: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_valid_entries() {
|
||
let entries = vec!["ab".repeat(32), "cd".repeat(32)];
|
||
let result = validate_allowlist(&entries).unwrap();
|
||
assert_eq!(result.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_deduplicates() {
|
||
let pk = "ab".repeat(32);
|
||
let entries = vec![pk.clone(), pk.clone(), pk];
|
||
let result = validate_allowlist(&entries).unwrap();
|
||
assert_eq!(result.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_normalizes_case() {
|
||
let upper = "AB".repeat(32);
|
||
let lower = "ab".repeat(32);
|
||
let entries = vec![upper, lower];
|
||
let result = validate_allowlist(&entries).unwrap();
|
||
assert_eq!(result.len(), 1);
|
||
assert!(result.contains(&"ab".repeat(32)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_trims_whitespace() {
|
||
let entries = vec![format!(" {} ", "ab".repeat(32))];
|
||
let result = validate_allowlist(&entries).unwrap();
|
||
assert_eq!(result.len(), 1);
|
||
assert!(result.contains(&"ab".repeat(32)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_rejects_short() {
|
||
let entries = vec!["abcd".to_string()];
|
||
let err = validate_allowlist(&entries).unwrap_err();
|
||
assert!(
|
||
err.to_string()
|
||
.contains("must be exactly 64 hex characters"),
|
||
"got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_rejects_non_hex() {
|
||
let entries = vec!["zz".repeat(32)];
|
||
let err = validate_allowlist(&entries).unwrap_err();
|
||
assert!(
|
||
err.to_string()
|
||
.contains("must be exactly 64 hex characters"),
|
||
"got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_rejects_too_long() {
|
||
let entries = vec!["ab".repeat(33)]; // 66 chars
|
||
let err = validate_allowlist(&entries).unwrap_err();
|
||
assert!(
|
||
err.to_string()
|
||
.contains("must be exactly 64 hex characters"),
|
||
"got: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_allowlist_empty_is_ok() {
|
||
let result = validate_allowlist(&[]).unwrap();
|
||
assert!(result.is_empty());
|
||
}
|
||
|
||
// ── Multiple-event-handling validation + default ──────────────────────────
|
||
|
||
#[test]
|
||
fn test_multiple_event_handling_default_is_steer() {
|
||
// Parse a minimal arg set; the default for --multiple-event-handling
|
||
// must be `steer` (steering is the default mid-turn delivery path).
|
||
let args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]);
|
||
assert_eq!(args.multiple_event_handling, MultipleEventHandling::Steer);
|
||
// Dedup default must remain `queue` so steering's requirement is met.
|
||
assert!(matches!(args.dedup, DedupMode::Queue));
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_steer_requires_queue_dedup() {
|
||
// Steer + Drop is rejected (drain window would drop events).
|
||
let err = validate_multiple_event_handling(MultipleEventHandling::Steer, DedupMode::Drop)
|
||
.unwrap_err();
|
||
assert!(
|
||
err.to_string().contains("requires"),
|
||
"expected a dedup-requirement error, got: {err}"
|
||
);
|
||
// Steer + Queue is accepted.
|
||
assert!(
|
||
validate_multiple_event_handling(MultipleEventHandling::Steer, DedupMode::Queue)
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_queue_handling_allows_any_dedup() {
|
||
// The non-cancel `Queue` handling imposes no dedup constraint.
|
||
assert!(
|
||
validate_multiple_event_handling(MultipleEventHandling::Queue, DedupMode::Drop).is_ok()
|
||
);
|
||
assert!(
|
||
validate_multiple_event_handling(MultipleEventHandling::Queue, DedupMode::Queue)
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_interrupt_modes_still_require_queue() {
|
||
for mode in [
|
||
MultipleEventHandling::Interrupt,
|
||
MultipleEventHandling::OwnerInterrupt,
|
||
] {
|
||
assert!(
|
||
validate_multiple_event_handling(mode, DedupMode::Drop).is_err(),
|
||
"{mode:?} + Drop should be rejected"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Idle timeout constant + guard (PR #935) ───────────────────────────────
|
||
|
||
#[test]
|
||
fn default_idle_timeout_is_900_seconds() {
|
||
// Lock the constant value so accidental changes are caught.
|
||
assert_eq!(DEFAULT_IDLE_TIMEOUT_SECS, 900);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_timeout_must_be_less_than_max_turn_duration() {
|
||
// The guard in Config::from_args rejects idle >= max_turn.
|
||
// Exercise the same logic: if idle >= max_turn, it's invalid.
|
||
let idle = 3600u64;
|
||
let max_turn = 3600u64;
|
||
assert!(
|
||
idle >= max_turn,
|
||
"test precondition: idle must be >= max_turn to trigger guard"
|
||
);
|
||
|
||
// And the valid case (const assertion so clippy doesn't flag it):
|
||
const {
|
||
assert!(DEFAULT_IDLE_TIMEOUT_SECS < DEFAULT_MAX_TURN_DURATION_SECS);
|
||
}
|
||
}
|
||
|
||
// --- BUZZ_ACP_ALLOWED_RESPOND_TO gate ---
|
||
|
||
fn parse_allowed_respond_to(raw: &[&str]) -> Result<HashSet<RespondTo>, ConfigError> {
|
||
let mut set = HashSet::new();
|
||
for s in raw {
|
||
let mode = RespondTo::from_str(s.trim(), true).map_err(|_| {
|
||
ConfigError::ConfigFile(format!(
|
||
"invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \
|
||
(valid values: owner-only, allowlist, anyone, nobody)"
|
||
))
|
||
})?;
|
||
set.insert(mode);
|
||
}
|
||
Ok(set)
|
||
}
|
||
|
||
fn check_allowed_respond_to(
|
||
allowed_raw: &[&str],
|
||
respond_to: RespondTo,
|
||
) -> Result<(), ConfigError> {
|
||
let set = parse_allowed_respond_to(allowed_raw)?;
|
||
if !set.is_empty() && !set.contains(&respond_to) {
|
||
return Err(ConfigError::ConfigFile(format!(
|
||
"respond_to '{}' is not permitted on this deployment \
|
||
(BUZZ_ACP_ALLOWED_RESPOND_TO={})",
|
||
respond_to,
|
||
allowed_raw.join(",")
|
||
)));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_rejects_disallowed_mode() {
|
||
let result = check_allowed_respond_to(&["owner-only", "allowlist"], RespondTo::Anyone);
|
||
assert!(
|
||
result.is_err(),
|
||
"anyone should be rejected when not in allowed set"
|
||
);
|
||
let msg = result.unwrap_err().to_string();
|
||
assert!(
|
||
msg.contains("not permitted"),
|
||
"error should mention 'not permitted': {msg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_accepts_allowed_mode() {
|
||
let result = check_allowed_respond_to(&["owner-only", "allowlist"], RespondTo::OwnerOnly);
|
||
assert!(result.is_ok(), "owner-only should be accepted: {result:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_empty_allows_all() {
|
||
// No restriction — anyone is accepted.
|
||
let result = check_allowed_respond_to(&[], RespondTo::Anyone);
|
||
assert!(
|
||
result.is_ok(),
|
||
"empty allowed set should permit any mode: {result:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_rejects_invalid_mode_string() {
|
||
let result = parse_allowed_respond_to(&["owner-only", "badvalue"]);
|
||
assert!(result.is_err(), "invalid mode string should be rejected");
|
||
let msg = result.unwrap_err().to_string();
|
||
assert!(
|
||
msg.contains("invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO"),
|
||
"error should name the env var: {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("badvalue"),
|
||
"error should name the bad value: {msg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_summary_shows_restriction_when_set() {
|
||
let mut config = test_config(SubscribeMode::Mentions);
|
||
config.allowed_respond_to = vec!["owner-only".to_string(), "allowlist".to_string()];
|
||
let s = config.summary();
|
||
assert!(
|
||
s.contains("allowed_respond_to="),
|
||
"summary should include allowed_respond_to when set: {s}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_summary_omitted_when_empty() {
|
||
let config = test_config(SubscribeMode::Mentions);
|
||
let s = config.summary();
|
||
assert!(
|
||
!s.contains("allowed_respond_to="),
|
||
"summary should not include allowed_respond_to when empty: {s}"
|
||
);
|
||
}
|
||
|
||
// --- Integration tests: full env-var → CliArgs → Config::from_args() path ---
|
||
//
|
||
// These tests exercise the actual wiring: BUZZ_ACP_ALLOWED_RESPOND_TO in the
|
||
// environment causes clap to populate CliArgs::allowed_respond_to, which then
|
||
// flows through Config::from_args() to produce a ConfigError. If the #[arg(env)]
|
||
// attribute or field name were removed, these tests would fail.
|
||
//
|
||
// We pass the value via the CLI flag (`--allowed-respond-to`) rather than
|
||
// std::env::set_var to avoid test-parallelism races on shared env state.
|
||
// The env-var wiring is covered by the clap #[arg(env)] attribute itself.
|
||
|
||
// A minimal valid private key for test use (secp256k1 scalar = 1).
|
||
const TEST_PRIVATE_KEY: &str =
|
||
"0000000000000000000000000000000000000000000000000000000000000001";
|
||
|
||
#[test]
|
||
fn allowed_respond_to_full_path_rejects_disallowed_mode() {
|
||
// --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError
|
||
let args = CliArgs::try_parse_from([
|
||
"buzz-acp",
|
||
"--private-key",
|
||
TEST_PRIVATE_KEY,
|
||
"--respond-to",
|
||
"anyone",
|
||
"--allowed-respond-to",
|
||
"owner-only,allowlist",
|
||
])
|
||
.expect("clap should parse args");
|
||
let result = Config::from_args(args);
|
||
|
||
assert!(
|
||
result.is_err(),
|
||
"from_args should reject respond_to=anyone when not in allowed set"
|
||
);
|
||
let msg = result.unwrap_err().to_string();
|
||
assert!(
|
||
msg.contains("not permitted"),
|
||
"error should mention 'not permitted': {msg}"
|
||
);
|
||
assert!(
|
||
msg.contains("anyone"),
|
||
"error should name the disallowed mode: {msg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_full_path_accepts_allowed_mode() {
|
||
// --allowed-respond-to=owner-only,allowlist + --respond-to=owner-only → Ok
|
||
let args = CliArgs::try_parse_from([
|
||
"buzz-acp",
|
||
"--private-key",
|
||
TEST_PRIVATE_KEY,
|
||
"--respond-to",
|
||
"owner-only",
|
||
"--allowed-respond-to",
|
||
"owner-only,allowlist",
|
||
])
|
||
.expect("clap should parse args");
|
||
let result = Config::from_args(args);
|
||
|
||
assert!(
|
||
result.is_ok(),
|
||
"from_args should accept respond_to=owner-only when in allowed set: {result:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn allowed_respond_to_full_path_unset_allows_all() {
|
||
// No --allowed-respond-to flag → anyone is accepted.
|
||
let args = CliArgs::try_parse_from([
|
||
"buzz-acp",
|
||
"--private-key",
|
||
TEST_PRIVATE_KEY,
|
||
"--respond-to",
|
||
"anyone",
|
||
])
|
||
.expect("clap should parse args");
|
||
let result = Config::from_args(args);
|
||
|
||
assert!(
|
||
result.is_ok(),
|
||
"from_args should accept any mode when allowed list is unset: {result:?}"
|
||
);
|
||
}
|
||
|
||
// --- max_turn_duration ceiling gate ---
|
||
|
||
#[test]
|
||
fn max_turn_duration_at_ceiling_is_accepted() {
|
||
let args = CliArgs::try_parse_from([
|
||
"buzz-acp",
|
||
"--private-key",
|
||
TEST_PRIVATE_KEY,
|
||
"--max-turn-duration",
|
||
&MAX_TURN_DURATION_CEILING_SECS.to_string(),
|
||
])
|
||
.expect("clap should parse args");
|
||
let result = Config::from_args(args);
|
||
|
||
assert!(
|
||
result.is_ok(),
|
||
"from_args should accept max_turn_duration at the ceiling: {result:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn max_turn_duration_above_ceiling_is_rejected() {
|
||
let over = MAX_TURN_DURATION_CEILING_SECS + 1;
|
||
let args = CliArgs::try_parse_from([
|
||
"buzz-acp",
|
||
"--private-key",
|
||
TEST_PRIVATE_KEY,
|
||
"--max-turn-duration",
|
||
&over.to_string(),
|
||
])
|
||
.expect("clap should parse args");
|
||
let result = Config::from_args(args);
|
||
|
||
assert!(
|
||
result.is_err(),
|
||
"from_args should reject max_turn_duration above the ceiling"
|
||
);
|
||
let msg = result.unwrap_err().to_string();
|
||
assert!(
|
||
msg.contains("exceeds ceiling"),
|
||
"error should mention 'exceeds ceiling': {msg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn max_turn_duration_ceiling_cannot_overflow_in_flight_deadline() {
|
||
// The in-flight deadline is max_turn + 100s buffer (IN_FLIGHT_DEADLINE_BUFFER_SECS).
|
||
// Verify that even at the ceiling, this addition cannot overflow u64.
|
||
const {
|
||
assert!(MAX_TURN_DURATION_CEILING_SECS < u64::MAX - 100);
|
||
}
|
||
}
|
||
}
|