diff --git a/.env.example b/.env.example index b9bfcada0e..c65a8c19e0 100644 --- a/.env.example +++ b/.env.example @@ -160,6 +160,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent). # BUZZ_ACP_MCP_COMMAND= +# Path to an optional version 1 JSON file defining additional stdio MCP servers. +# This file may contain credentials. Keep it out of Git and restrict it to +# its owner. +# BUZZ_ACP_MCP_CONFIG=/absolute/path/to/mcp-servers.json + # Number of parallel agent subprocesses (1–32). # BUZZ_ACP_AGENTS=1 diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..d75c96bd87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,6 +800,7 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" name = "buzz-acp" version = "0.1.0" dependencies = [ + "aho-corasick", "anyhow", "base64 0.22.1", "buzz-core", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..37c6a4146d 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { workspace = true } # Serialization serde = { workspace = true } serde_json = { workspace = true } +aho-corasick = "1.1" # IDs uuid = { workspace = true } diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..198645f67c 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -111,6 +111,7 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | +| `BUZZ_ACP_MCP_CONFIG` | no | `""` (empty) | Path to a version 1 JSON file defining additional stdio MCP servers. | | `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | | `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). | | `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). | @@ -119,6 +120,61 @@ All configuration is via environment variables (or CLI flags — every env var h **Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks. +### Multiple MCP servers + +Use `--mcp-config ` or `BUZZ_ACP_MCP_CONFIG` to add named stdio MCP +servers: + +```json +{ + "version": 1, + "servers": [ + { + "name": "analytics", + "transport": "stdio", + "command": "/opt/mcp/analytics-server", + "args": ["--stdio"], + "env": { + "ANALYTICS_TOKEN": "replace-me" + } + } + ] +} +``` + +The JSON is strict. The only top-level fields are `version` and `servers`. +Each server has `name`, `transport`, `command`, `args`, and `env`. Version 1 +supports the `stdio` transport. Server names must be unique, contain 1 to 128 +ASCII bytes using only letters, digits, `_`, or `-`, and cannot contain `__`. +Names are checked across both structured entries and the legacy server. + +The config file is limited to 64 KiB. A harness can have at most 16 MCP +servers in total, including the server from `BUZZ_ACP_MCP_COMMAND`. An +unreadable file, malformed JSON, an unsupported version, an unknown field, or +an invalid server entry stops startup. Buzz does not silently drop a server. + +`BUZZ_ACP_MCP_COMMAND` keeps its current behavior. It defines one privileged +Buzz companion and receives the relay URL and Buzz identity credentials. +For a structured server, Buzz puts only the values listed in its `env` object +into the ACP `env` list. Protected Buzz identity and authentication keys are +rejected. Buzz sends the list to the ACP adapter in `session/new`, and the +adapter controls the MCP processes. Treat the adapter as a credential broker +and use one you trust. + +The adapter still inherits the harness environment so its shell tools can use +the `buzz` CLI. Some adapters may propagate inherited variables to MCP child +processes. Per-server `env` entries are explicit configuration, not a process +isolation boundary. Use a separate account, container, or credential-brokered +service when the MCP process must not inherit adapter credentials. + +If the JSON contains secrets, keep it outside Git and restrict the file to its +owner. On Unix: + +```bash +chmod 600 /absolute/path/to/mcp-servers.json +buzz-acp --mcp-config /absolute/path/to/mcp-servers.json +``` + ### Parallel Agents & Heartbeat | Flag | Env Var | Default | Description | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..99881b67b5 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -8,7 +8,9 @@ //! 4. [`AcpClient::session_prompt_with_idle_timeout`] — send prompt with idle/hard deadline, return stop reason //! 5. [`AcpClient::session_cancel`] / [`AcpClient::cancel_with_cleanup`] — cancel in-flight turn +use aho_corasick::AhoCorasick; use futures_util::StreamExt; +use std::borrow::Cow; use tokio::io::AsyncWriteExt; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; @@ -20,11 +22,13 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +const REDACTED_ENV_VALUE: &str = "[REDACTED]"; + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. /// All four fields are **required** by the schema (`args` and `env` may be empty arrays). -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Clone, serde::Serialize)] pub struct McpServer { pub name: String, pub command: String, @@ -32,13 +36,35 @@ pub struct McpServer { pub env: Vec, } +impl std::fmt::Debug for McpServer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("McpServer") + .field("name", &self.name) + .field("command", &self.command) + .field("arg_count", &self.args.len()) + .field("env", &self.env) + .finish() + } +} + /// A single environment variable for an MCP server. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Clone, serde::Serialize)] pub struct EnvVar { pub name: String, pub value: String, } +impl std::fmt::Debug for EnvVar { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnvVar") + .field("name", &self.name) + .field("value", &REDACTED_ENV_VALUE) + .finish() + } +} + /// Stop reason returned by `session/prompt` when the agent finishes a turn. /// /// Maps to the `stopReason` field in the `SessionPromptResponse`. @@ -112,15 +138,121 @@ pub enum AcpError { /// preserving the numeric code. When the `message` field is missing or /// non-string, fall back to the full JSON object so provider-specific /// detail (e.g. a `data` field) is not lost. -fn agent_error_from_json(error: &serde_json::Value) -> AcpError { +fn agent_error_from_json( + error: &serde_json::Value, + sensitive_value_matcher: Option<&AhoCorasick>, +) -> AcpError { let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); - let message = match error.get("message").and_then(|m| m.as_str()) { + let redacted_error = redact_wire_value(error, sensitive_value_matcher); + let message = match redacted_error.get("message").and_then(|m| m.as_str()) { Some(m) => m.to_string(), - None => error.to_string(), + None => redacted_error.to_string(), }; AcpError::AgentError { code, message } } +fn contains_serialized_json_key(text: &str, key: &str) -> bool { + text.match_indices(key).any(|(start, _)| { + let bytes = text.as_bytes(); + if start == 0 || bytes[start - 1] != b'"' { + return false; + } + + let mut cursor = start + key.len(); + while bytes.get(cursor) == Some(&b'\\') { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'"') { + return false; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cursor += 1; + } + bytes.get(cursor) == Some(&b':') + }) +} + +fn redact_wire_text<'a>( + text: &'a str, + sensitive_value_matcher: Option<&AhoCorasick>, +) -> Cow<'a, str> { + let looks_like_serialized_mcp_config = contains_serialized_json_key(text, "mcpServers") + && contains_serialized_json_key(text, "env") + && contains_serialized_json_key(text, "value"); + if looks_like_serialized_mcp_config { + return Cow::Borrowed(REDACTED_ENV_VALUE); + } + + let Some(matcher) = sensitive_value_matcher else { + return Cow::Borrowed(text); + }; + if matcher.find(text).is_some() { + // Suppress the whole diagnostic. Partial replacement can expand a + // bounded 10 MB adapter line many times over when a configured value + // is short or common. + return Cow::Borrowed(REDACTED_ENV_VALUE); + } + Cow::Borrowed(text) +} + +/// Return a logging-safe copy of an ACP wire value. +/// +/// MCP environment values are needed by the adapter on the real wire, but +/// must not reach tracing or observer frames. +fn redact_wire_value( + value: &serde_json::Value, + sensitive_value_matcher: Option<&AhoCorasick>, +) -> serde_json::Value { + fn redact_in_place( + value: &mut serde_json::Value, + sensitive_value_matcher: Option<&AhoCorasick>, + ) { + match value { + serde_json::Value::Array(values) => { + for value in values { + redact_in_place(value, sensitive_value_matcher); + } + } + serde_json::Value::Object(fields) => { + for (key, value) in fields { + if key == "env" { + if let serde_json::Value::Array(entries) = value { + for entry in entries { + if let serde_json::Value::Object(env_var) = entry { + if env_var.contains_key("value") { + env_var.insert( + "value".to_string(), + serde_json::Value::String( + REDACTED_ENV_VALUE.to_string(), + ), + ); + } + } + } + } + } + redact_in_place(value, sensitive_value_matcher); + } + } + serde_json::Value::String(text) => { + let observed = redact_wire_text(text, sensitive_value_matcher); + if observed != text.as_str() { + *text = observed.into_owned(); + } + } + _ => {} + } + } + + let mut redacted = value.clone(); + redact_in_place(&mut redacted, sensitive_value_matcher); + redacted +} + fn build_initialize_params() -> serde_json::Value { serde_json::json!({ "protocolVersion": 2, @@ -172,6 +304,13 @@ pub struct AcpClient { observer_agent_index: Option, /// Best-effort context attached to raw ACP wire events. observer_context: ObserverContext, + /// Non-empty MCP environment values ever sent to this adapter. + /// + /// Values remain registered across sessions so a delayed adapter diagnostic + /// cannot disclose a credential from an earlier `session/new`. + sensitive_mcp_env_values: Vec, + /// Single-pass matcher rebuilt only when a session introduces a new value. + sensitive_mcp_env_matcher: Option, /// Most recently observed `_meta.goose.activeRunId` from a /// `session/update` notification of kind `session_info_update`. /// @@ -512,7 +651,6 @@ impl AcpClient { if let Some(merged) = codex_config_value { cmd.env("CODEX_CONFIG", merged); } - // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -546,6 +684,8 @@ impl AcpClient { observer: None, observer_agent_index: None, observer_context: ObserverContext::default(), + sensitive_mcp_env_values: Vec::new(), + sensitive_mcp_env_matcher: None, active_run_id: None, steering_supported: false, steer_rx: None, @@ -574,8 +714,58 @@ impl AcpClient { self.observer_agent_index } + fn register_sensitive_mcp_env_values(&mut self, servers: &[McpServer]) -> Result<(), AcpError> { + let mut changed = false; + for value in servers + .iter() + .flat_map(|server| server.env.iter().map(|entry| &entry.value)) + .filter(|value| !value.is_empty()) + { + if !self + .sensitive_mcp_env_values + .iter() + .any(|registered| registered == value) + { + self.sensitive_mcp_env_values.push(value.clone()); + changed = true; + } + } + if !changed { + return Ok(()); + } + + self.sensitive_mcp_env_values + .sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right))); + self.sensitive_mcp_env_matcher = Some( + AhoCorasick::new(&self.sensitive_mcp_env_values).map_err(|_| { + AcpError::Protocol("failed to initialize MCP credential redaction".to_string()) + })?, + ); + Ok(()) + } + + fn redact_wire_text<'a>(&self, text: &'a str) -> Cow<'a, str> { + redact_wire_text(text, self.sensitive_mcp_env_matcher.as_ref()) + } + + fn redact_wire_value(&self, value: &serde_json::Value) -> serde_json::Value { + redact_wire_value(value, self.sensitive_mcp_env_matcher.as_ref()) + } + + fn agent_error_from_json(&self, error: &serde_json::Value) -> AcpError { + agent_error_from_json(error, self.sensitive_mcp_env_matcher.as_ref()) + } + /// Emit a semantic event to the local observer feed, if enabled. pub fn observe(&self, kind: impl Into, payload: serde_json::Value) { + if self.observer.is_none() { + return; + } + self.emit_observer(kind, self.redact_wire_value(&payload)); + } + + /// Emit an event whose payload is already a logging-safe copy. + fn emit_observer(&self, kind: impl Into, payload: serde_json::Value) { if let Some(observer) = &self.observer { observer.emit( kind, @@ -604,7 +794,8 @@ impl AcpClient { .pointer("/_meta/steering/supported") .and_then(|v| v.as_bool()) .unwrap_or(false); - tracing::debug!(target: "acp::init", "initialize response: {result}"); + let observed_result = self.redact_wire_value(&result); + tracing::debug!(target: "acp::init", "initialize response: {observed_result}"); Ok(result) } @@ -642,6 +833,7 @@ impl AcpClient { system_prompt: Option>, session_title: Option<&str>, ) -> Result { + self.register_sensitive_mcp_env_values(&mcp_servers)?; let mut params = serde_json::json!({ "cwd": cwd, "mcpServers": mcp_servers, @@ -665,7 +857,8 @@ impl AcpClient { .as_str() .ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))? .to_owned(); - tracing::info!(target: "acp::session", "session created: {session_id}"); + let observed_session_id = self.redact_wire_text(&session_id); + tracing::info!(target: "acp::session", "session created: {observed_session_id}"); Ok(SessionNewResponse { session_id, raw: result, @@ -788,7 +981,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; @@ -1009,9 +1201,10 @@ impl AcpClient { if !self.permission_responded { let response = permission_response_cancelled(&perm_id); self.write_ndjson(&response).await?; + let observed_permission_id = self.redact_wire_value(&perm_id); tracing::debug!( target: "acp::cancel", - "responded cancelled to pending permission id={perm_id}" + "responded cancelled to pending permission id={observed_permission_id}" ); } self.pending_permission_id = None; @@ -1020,7 +1213,8 @@ impl AcpClient { // Step 2: send session/cancel notification (no id) self.session_cancel(session_id).await?; - tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}"); + let observed_session_id = self.redact_wire_text(session_id); + tracing::info!(target: "acp::cancel", "sent session/cancel for {observed_session_id}"); // Use a fixed 30s idle timeout during cleanup — the cancel notification // needs time to propagate and the agent may go silent while winding down. // The separate hard_deadline bounds agents that keep producing output @@ -1047,6 +1241,8 @@ impl AcpClient { /// (e.g., it's stuck or dead), the write would otherwise block forever. async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + let observed_value = self.redact_wire_value(value); + tracing::debug!(target: "acp::wire", "→ {observed_value}"); let line = serde_json::to_string(value)?; tokio::time::timeout(WRITE_TIMEOUT, async { self.stdin.write_all(line.as_bytes()).await?; @@ -1057,10 +1253,43 @@ impl AcpClient { .await .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? .map_err(AcpError::Io)?; - self.observe("acp_write", value.clone()); + self.emit_observer("acp_write", observed_value); Ok(()) } + /// Parse one non-empty agent stdout line and emit only a safe copy. + /// + /// Parse failures expose the line length and parser error, never the raw + /// line. Successful messages retain their raw value for protocol handling. + fn parse_inbound_line(&self, line: &str) -> Option { + match serde_json::from_str(line) { + Ok(msg) => { + let observed_value = self.redact_wire_value(&msg); + tracing::debug!(target: "acp::wire", "← {observed_value}"); + self.emit_observer("acp_read", observed_value); + Some(msg) + } + Err(error) => { + let line_length = line.len(); + let error = error.to_string(); + self.observe( + "acp_parse_error", + serde_json::json!({ + "lineLength": line_length, + "error": error, + }), + ); + tracing::warn!( + target: "acp::wire", + line_length, + error = %error, + "failed to parse agent stdout as JSON; skipping" + ); + None + } + } + } + /// Default timeout for non-prompt RPCs (initialize, session/new, etc.). const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -1088,8 +1317,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); - // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits // inside timeout(), so we sequence them with early-return on timeout. @@ -1153,7 +1380,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ (notification) {}", &serde_json::to_string(&msg).unwrap_or_default()); self.write_ndjson(&msg).await?; Ok(()) } @@ -1194,27 +1420,10 @@ impl AcpClient { continue; } - // Only log and reset idle after we have a valid non-empty line. - tracing::debug!(target: "acp::wire", "← {trimmed}"); - - let msg: serde_json::Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - self.observe( - "acp_parse_error", - serde_json::json!({ - "line": trimmed, - "error": e.to_string(), - }), - ); - tracing::warn!( - target: "acp::wire", - "failed to parse line as JSON: {e} — skipping" - ); - continue; - } + let msg = match self.parse_inbound_line(trimmed) { + Some(msg) => msg, + None => continue, }; - self.observe("acp_read", msg.clone()); // Check if this is a response to our expected request (has matching id // AND no `method` field — a `method` field means it's an agent-initiated @@ -1222,7 +1431,7 @@ impl AcpClient { if let Some(id) = msg.get("id") { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { - return Err(agent_error_from_json(error)); + return Err(self.agent_error_from_json(error)); } return Ok(msg["result"].clone()); } @@ -1254,7 +1463,11 @@ impl AcpClient { // agent process is dead and continuing would hang. self.write_ndjson(&err_resp).await?; } - tracing::debug!(target: "acp::wire", "ignoring unknown method: {other}"); + let observed_method = self.redact_wire_text(other); + tracing::debug!( + target: "acp::wire", + "ignoring unknown method: {observed_method}" + ); } } } @@ -1439,11 +1652,6 @@ impl AcpClient { "method": method, "params": params, }); - tracing::debug!( - target: "acp::wire", - "→ {}", - serde_json::to_string(&msg).unwrap_or_default() - ); match self.write_ndjson(&msg).await { Ok(()) => { pending_steer = Some((id, transport, req.ack_tx)); @@ -1518,26 +1726,10 @@ impl AcpClient { continue; } - tracing::debug!(target: "acp::wire", "← {trimmed}"); - - let msg: serde_json::Value = match serde_json::from_str(trimmed) { - Ok(v) => v, - Err(e) => { - self.observe( - "acp_parse_error", - serde_json::json!({ - "line": trimmed, - "error": e.to_string(), - }), - ); - tracing::warn!( - target: "acp::wire", - "failed to parse line as JSON: {e} — skipping" - ); - continue; - } + let msg = match self.parse_inbound_line(trimmed) { + Some(msg) => msg, + None => continue, }; - self.observe("acp_read", msg.clone()); let activity_now = Instant::now(); idle_deadline = activity_now + idle_timeout; @@ -1563,7 +1755,7 @@ impl AcpClient { .get("code") .and_then(|c| c.as_i64()) .unwrap_or(-1); - let message = error.to_string(); + let message = self.redact_wire_value(error).to_string(); crate::pool::SteerAck::Err( crate::pool::SteerError::AgentError { code, message }, ) @@ -1633,6 +1825,8 @@ impl AcpClient { Some(serde_json::Value::String(s)) => s.clone(), Some(other) => other.to_string(), }; + let reported = + self.redact_wire_text(&reported).into_owned(); tracing::warn!( "steer rejected: {ACP_STEER_METHOD} returned \ unrecognized outcome {reported} — releasing \ @@ -1656,7 +1850,7 @@ impl AcpClient { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } - return Err(agent_error_from_json(error)); + return Err(self.agent_error_from_json(error)); } if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = @@ -1698,7 +1892,11 @@ impl AcpClient { // agent process is dead and continuing would hang. self.write_ndjson(&err_resp).await?; } - tracing::debug!(target: "acp::wire", "ignoring unknown method: {other}"); + let observed_method = self.redact_wire_text(other); + tracing::debug!( + target: "acp::wire", + "ignoring unknown method: {observed_method}" + ); } } } @@ -1731,6 +1929,7 @@ impl AcpClient { match update_type { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { + let text = self.redact_wire_text(text); tracing::info!(target: "acp::stream", "{text}"); } false @@ -1744,6 +1943,8 @@ impl AcpClient { .get("kind") .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let title = self.redact_wire_text(title); + let kind = self.redact_wire_text(kind); tracing::info!(target: "acp::tool", "tool_call: {title} ({kind})"); true } @@ -1753,6 +1954,8 @@ impl AcpClient { .and_then(|v| v.as_str()) .unwrap_or("?"); let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let tool_id = self.redact_wire_text(tool_id); + let status = self.redact_wire_text(status); tracing::info!(target: "acp::tool", "tool_call_update: {tool_id} → {status}"); false } @@ -1762,6 +1965,7 @@ impl AcpClient { } "agent_thought_chunk" => { if let Some(text) = update["content"]["text"].as_str() { + let text = self.redact_wire_text(text); tracing::debug!(target: "acp::thought", "{text}"); } false @@ -1769,9 +1973,14 @@ impl AcpClient { "available_commands_update" => { // Advertised slash commands (ACP slash-commands extension). // Logged for observability; UI surfacing is a follow-up. - let names: Vec<&str> = update["availableCommands"] + let names: Vec> = update["availableCommands"] .as_array() - .map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect()) + .map(|cmds| { + cmds.iter() + .filter_map(|c| c["name"].as_str()) + .map(|name| self.redact_wire_text(name)) + .collect() + }) .unwrap_or_default(); tracing::info!( target: "acp::update", @@ -1798,9 +2007,10 @@ impl AcpClient { if let Some(goose_meta) = meta { match goose_meta.get("activeRunId") { Some(serde_json::Value::String(run_id)) => { + let observed_run_id = self.redact_wire_text(run_id); tracing::debug!( target: "acp::update", - "session_info_update: activeRunId={run_id}" + "session_info_update: activeRunId={observed_run_id}" ); self.active_run_id = Some(run_id.clone()); } @@ -1819,6 +2029,7 @@ impl AcpClient { } "keepalive" => false, other => { + let other = self.redact_wire_text(other); tracing::debug!(target: "acp::update", "session/update: {other}"); false } @@ -1846,9 +2057,10 @@ impl AcpClient { match serde_json::from_value::(params.clone()) { Ok(notif) => { if let GooseSessionUpdateVariant::UsageUpdate(payload) = ¬if.update { + let observed_session_id = self.redact_wire_text(¬if.session_id); tracing::debug!( target: "acp::usage", - session_id = %notif.session_id, + session_id = %observed_session_id, input = payload.accumulated_input_tokens, output = payload.accumulated_output_tokens, // A subset of `input`, logged so downstream accounting can @@ -1862,9 +2074,11 @@ impl AcpClient { } } Err(e) => { + let error = e.to_string(); + let observed_error = self.redact_wire_text(&error); tracing::debug!( target: "acp::usage", - "_goose/unstable/session/update: deserialization error: {e}" + "_goose/unstable/session/update: deserialization error: {observed_error}" ); } } @@ -1895,9 +2109,10 @@ impl AcpClient { .as_array() .ok_or_else(|| AcpError::Protocol("permission request missing options".into()))?; + let observed_id = self.redact_wire_value(&id); tracing::debug!( target: "acp::permission", - "session/request_permission id={id}, {} options", + "session/request_permission id={observed_id}, {} options", options.len() ); @@ -1910,16 +2125,17 @@ impl AcpClient { let option_id = opt["optionId"] .as_str() .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; + let observed_option_id = self.redact_wire_text(option_id); tracing::info!( target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" + "auto-approving permission id={observed_id} with allow_once optionId={observed_option_id:?}" ); permission_response_selected(&id, option_id) } else { // No allow_once — fall back to reject_once. tracing::warn!( target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" + "no allow_once option found in permission request id={observed_id}, falling back to reject_once" ); let reject = options .iter() @@ -1961,8 +2177,10 @@ impl AcpClient { let raw = result["stopReason"].as_str().ok_or_else(|| { AcpError::Protocol("session/prompt response missing stopReason".into()) })?; - StopReason::from_str(raw) - .ok_or_else(|| AcpError::Protocol(format!("unknown stopReason: {raw:?}"))) + StopReason::from_str(raw).ok_or_else(|| { + let observed = self.redact_wire_text(raw); + AcpError::Protocol(format!("unknown stopReason: {observed:?}")) + }) } } @@ -2256,6 +2474,17 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + fn sensitive_matcher(values: &[String]) -> Option { + let mut patterns = values + .iter() + .map(String::as_str) + .filter(|value| !value.is_empty()) + .collect::>(); + patterns.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right))); + patterns.dedup(); + (!patterns.is_empty()).then(|| AhoCorasick::new(patterns).unwrap()) + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); @@ -2459,6 +2688,160 @@ mod tests { ); } + #[test] + fn mcp_debug_redacts_environment_and_argument_values() { + let env_secret = "debug-env-secret-must-not-render"; + let arg_secret = "debug-arg-secret-must-not-render"; + let rendered = format!( + "{:?}", + McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec!["--token".into(), arg_secret.into()], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: env_secret.into(), + }], + } + ); + + assert!(rendered.contains("ANALYTICS_TOKEN")); + assert!(rendered.contains("arg_count: 2")); + assert!(rendered.contains(REDACTED_ENV_VALUE)); + assert!(!rendered.contains(env_secret)); + assert!(!rendered.contains(arg_secret)); + } + + #[test] + fn wire_redaction_covers_nested_env_values_without_changing_source() { + let source = serde_json::json!({ + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "env": [ + {"name": "ANALYTICS_TOKEN", "value": "secret-one"}, + {"name": "EMPTY_VALUE", "value": ""} + ] + }], + "nested": { + "env": [{"name": "OTHER_TOKEN", "value": "secret-two"}] + }, + "ordinary": {"value": "keep-me"} + } + }); + + let redacted = redact_wire_value(&source, None); + + assert_eq!( + source["params"]["mcpServers"][0]["env"][0]["value"], "secret-one", + "the source value sent on the wire must stay unchanged" + ); + assert_eq!( + redacted["params"]["mcpServers"][0]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["mcpServers"][0]["env"][1]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["nested"]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + assert_eq!( + redacted["params"]["ordinary"]["value"], "keep-me", + "value fields outside env arrays must remain visible" + ); + } + + #[test] + fn wire_redaction_suppresses_serialized_mcp_configs_without_broad_string_matching() { + let secret = "quote\" slash\\ newline\n snowman \u{2603}"; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "command": "analytics-mcp", + "args": [], + "env": [{"name": "ANALYTICS_TOKEN", "value": secret}] + }] + } + }) + .to_string(); + let mut serialized_levels = vec![request]; + for _ in 0..3 { + let next = serde_json::to_string( + serialized_levels + .last() + .expect("at least one serialized request"), + ) + .expect("serialize request again"); + serialized_levels.push(next); + } + let source = serde_json::json!({ + "echoes": serialized_levels + .iter() + .map(|request| format!("adapter rejected {request}; check configuration")) + .collect::>(), + "ordinary": r#"invalid {"environment":"prod","value":"x"}"#, + "nearMiss": r#"invalid {"mcpServers":[],"envValue":"prod","value":"x"}"#, + "unrelated": "ordinary adapter error" + }); + let original_source = source.clone(); + + let redacted = redact_wire_value(&source, None); + + for echo in redacted["echoes"].as_array().expect("redacted echoes") { + assert_eq!(echo, REDACTED_ENV_VALUE); + } + assert_eq!(redacted["ordinary"], source["ordinary"]); + assert_eq!(redacted["nearMiss"], source["nearMiss"]); + assert_eq!(redacted["unrelated"], source["unrelated"]); + assert_eq!( + source, original_source, + "the protocol value must stay unchanged" + ); + } + + #[test] + fn wire_redaction_suppresses_plain_sensitive_echoes_without_changing_source() { + let shorter = "token"; + let longer = "token-with-suffix"; + let source = serde_json::json!({ + "error": { + "message": format!("[adapter] rejected {longer}; retry with {shorter}") + }, + "ordinary": "safe diagnostic" + }); + let original_source = source.clone(); + let sensitive_values = vec![ + longer.to_string(), + shorter.to_string(), + "[".to_string(), + String::new(), + longer.to_string(), + ]; + + let matcher = sensitive_matcher(&sensitive_values); + let redacted = redact_wire_value(&source, matcher.as_ref()); + + let message = redacted["error"]["message"] + .as_str() + .expect("redacted error message"); + assert!(!message.contains(longer)); + assert!(!message.contains(shorter)); + assert_eq!(message, REDACTED_ENV_VALUE); + assert_eq!(redacted["ordinary"], source["ordinary"]); + assert_eq!( + source, original_source, + "the protocol value must stay unchanged" + ); + } + #[test] fn session_prompt_request_format() { let prompt_text = "[Buzz @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; @@ -2885,6 +3268,74 @@ mod tests { .expect("failed to spawn test script") } + fn assert_safe_parse_error_event(observer: &ObserverHandle, raw_line: &str) { + let event = observer + .snapshot() + .into_iter() + .find(|event| event.kind == "acp_parse_error") + .expect("parse error observer event"); + assert_eq!( + event.payload["lineLength"].as_u64(), + Some(raw_line.len() as u64) + ); + assert!(event.payload["error"].is_string()); + assert!( + event.payload.get("line").is_none(), + "the raw line field must not exist" + ); + assert!( + !event.payload.to_string().contains(raw_line), + "the raw malformed line must not reach the observer" + ); + } + + #[tokio::test] + async fn regular_read_loop_reports_malformed_json_without_raw_line() { + let raw_line = "regular-loop-sensitive-malformed-json"; + let script = format!( + "read -t 2 _REQ\nprintf '%s\\n' '{raw_line}'\nprintf '%s\\n' \ + '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"ok\":true}}}}'\nsleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + + let result = client + .send_request("test/request", serde_json::json!({})) + .await + .expect("valid response after malformed line"); + assert_eq!(result["ok"], true); + assert_safe_parse_error_event(&observer, raw_line); + client.shutdown().await; + } + + #[tokio::test] + async fn idle_read_loop_reports_malformed_json_without_raw_line() { + let raw_line = "idle-loop-sensitive-malformed-json"; + let script = format!( + "printf '%s\\n' '{raw_line}'\nprintf '%s\\n' \ + '{{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{{\"ok\":true}}}}'\nsleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + let max_duration = std::time::Duration::from_secs(5); + + let result = client + .read_until_response_with_idle_timeout( + "test", + 999, + std::time::Duration::from_secs(1), + tokio::time::Instant::now() + max_duration, + max_duration, + ) + .await + .expect("valid idle-loop response after malformed line"); + assert_eq!(result["ok"], true); + assert_safe_parse_error_event(&observer, raw_line); + client.shutdown().await; + } + /// Spawn a probe script whose file name carries a runtime identity (e.g. /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` means the child did not receive the var. @@ -3322,6 +3773,417 @@ mod tests { ); } + #[tokio::test] + async fn session_new_sends_real_mcp_env_but_observer_only_sees_redacted_values() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_secret_test","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + let secret = "mcp-secret-must-not-reach-observer"; + let response = client + .session_new_full( + "/tmp", + vec![McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec!["--stdio".into()], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: secret.into(), + }], + }], + None, + None, + ) + .await + .expect("session/new should succeed"); + + assert_eq!( + response.raw["_receivedRequest"]["params"]["mcpServers"][0]["env"][0]["value"], secret, + "the adapter must receive the real MCP environment value" + ); + + let events = observer.snapshot(); + let session_write = events + .iter() + .find(|event| event.kind == "acp_write" && event.payload["method"] == "session/new") + .expect("session/new write observer event"); + assert_eq!( + session_write.payload["params"]["mcpServers"][0]["env"][0]["value"], + REDACTED_ENV_VALUE + ); + + let echoed_read = events + .iter() + .find(|event| { + event.kind == "acp_read" + && event.payload["result"]["sessionId"] == "ses_secret_test" + }) + .expect("session/new response observer event"); + assert_eq!( + echoed_read.payload["result"]["_receivedRequest"]["params"]["mcpServers"][0]["env"][0] + ["value"], + REDACTED_ENV_VALUE + ); + + let serialized_events = + serde_json::to_string(&events).expect("serialize observer snapshot"); + assert!( + !serialized_events.contains(secret), + "no observer frame may contain the real MCP environment value" + ); + client.shutdown().await; + } + + #[tokio::test] + async fn session_new_error_cannot_echo_serialized_mcp_config() { + let secret = "adapter-echo-secret"; + let embedded_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "analytics", + "command": "analytics-mcp", + "args": [], + "env": [{"name": "ANALYTICS_TOKEN", "value": secret}] + }] + } + }) + .to_string(); + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32055, + "message": format!("adapter rejected {embedded_request}") + } + }) + .to_string(); + let script = format!( + "read -t 2 _init\n\ + printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"protocolVersion\":2,\"agentCapabilities\":{{}}}}}}'\n\ + read -t 2 _session\n\ + printf '%s\\n' '{error_response}'\n\ + sleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + let result = client + .session_new_full( + "/tmp", + vec![McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec![], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: secret.into(), + }], + }], + None, + None, + ) + .await; + + match result { + Err(AcpError::AgentError { code, message }) => { + assert_eq!(code, -32055); + assert_eq!(message, REDACTED_ENV_VALUE); + assert!(!message.contains(secret)); + } + Err(other) => panic!("expected redacted AgentError, got {other:?}"), + Ok(_) => panic!("expected session/new to return an error"), + } + + client.observe( + "adapter_diagnostic", + serde_json::json!({"message": embedded_request}), + ); + let serialized_events = + serde_json::to_string(&observer.snapshot()).expect("serialize observer snapshot"); + assert!(!serialized_events.contains(secret)); + assert!(serialized_events.contains(REDACTED_ENV_VALUE)); + client.shutdown().await; + } + + #[tokio::test] + async fn plain_mcp_secret_echo_is_redacted_and_old_session_values_are_retained() { + let first_secret = "first-session-adapter-secret"; + let second_secret = "second-session-adapter-secret"; + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32056, + "message": format!("adapter failed while using {first_secret}") + } + }) + .to_string(); + let script = format!( + "read -t 2 _init\n\ + printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{{\"protocolVersion\":2,\"agentCapabilities\":{{}}}}}}'\n\ + read -t 2 _first_session\n\ + printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{\"sessionId\":\"ses_first\"}}}}'\n\ + read -t 2 _second_session\n\ + printf '%s\\n' '{error_response}'\n\ + sleep 1" + ); + let mut client = spawn_script(&script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + let server_with_secret = |value: &str| McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec![], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: value.into(), + }], + }; + + client + .session_new_full("/tmp", vec![server_with_secret(first_secret)], None, None) + .await + .expect("first session should succeed"); + let result = client + .session_new_full("/tmp", vec![server_with_secret(second_secret)], None, None) + .await; + + match result { + Err(AcpError::AgentError { code, message }) => { + assert_eq!(code, -32056); + assert_eq!(message, REDACTED_ENV_VALUE); + assert!(!message.contains(first_secret)); + } + Err(other) => panic!("expected redacted AgentError, got {other:?}"), + Ok(_) => panic!("expected second session/new to return an error"), + } + + assert_eq!(client.sensitive_mcp_env_values.len(), 2); + assert!( + client + .sensitive_mcp_env_values + .iter() + .any(|value| value == first_secret), + "credentials from earlier sessions must remain registered" + ); + assert!(client + .sensitive_mcp_env_values + .iter() + .any(|value| value == second_secret)); + let serialized_events = + serde_json::to_string(&observer.snapshot()).expect("serialize observer snapshot"); + assert!(!serialized_events.contains(first_secret)); + assert!(!serialized_events.contains(second_secret)); + assert!(serialized_events.contains(REDACTED_ENV_VALUE)); + client.shutdown().await; + } + + #[tokio::test] + async fn semantic_traces_redact_plain_mcp_secret_echoes() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct TraceCapture(Arc>>); + + struct TraceWriter(Arc>>); + + impl Write for TraceWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("trace buffer lock") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TraceCapture { + type Writer = TraceWriter; + + fn make_writer(&'a self) -> Self::Writer { + TraceWriter(self.0.clone()) + } + } + + let secret = "semantic-trace-secret"; + let plain_echo = format!("adapter diagnostic included {secret}"); + let update = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": plain_echo} + } + } + }); + let mut client = spawn_inert_client().await; + client + .register_sensitive_mcp_env_values(&[McpServer { + name: "analytics".into(), + command: "analytics-mcp".into(), + args: vec![], + env: vec![EnvVar { + name: "ANALYTICS_TOKEN".into(), + value: secret.into(), + }], + }]) + .expect("credential redactor should initialize"); + let trace = TraceCapture::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer(trace.clone()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + let _ = client.handle_session_update(&update); + client.handle_goose_usage_update(&serde_json::json!({ + "params": { + "sessionId": plain_echo, + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 10, + "accumulatedOutputTokens": 5, + "accumulatedCachedInputTokens": null, + "accumulatedCost": null + } + } + })); + client.handle_goose_usage_update(&serde_json::json!({ + "params": { + "sessionId": "ordinary-session", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": plain_echo, + "accumulatedOutputTokens": 5, + "accumulatedCachedInputTokens": null, + "accumulatedCost": null + } + } + })); + }); + + let output = String::from_utf8(trace.0.lock().expect("trace buffer lock").clone()) + .expect("trace output should be UTF-8"); + assert!(!output.contains(secret)); + assert!(output.contains(REDACTED_ENV_VALUE)); + client.shutdown().await; + } + + #[tokio::test] + async fn structured_mcp_servers_survive_repeated_sessions_and_adapter_restart() { + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":2,"agentCapabilities":{}}}' + read -t 2 FIRST + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_first","_receivedRequest":'"$FIRST"'}}' + read -t 2 SECOND + echo '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_second","_receivedRequest":'"$SECOND"'}}' + sleep 1 + "#; + let servers = vec![ + McpServer { + name: "analytics".into(), + command: "/opt/MCP Servers/analytics,prod".into(), + args: vec!["--stdio".into(), "literal value".into()], + env: vec![EnvVar { + name: "ANALYTICS_ENDPOINT".into(), + value: "https://example.test/a=b".into(), + }], + }, + McpServer { + name: "search".into(), + command: "/opt/search-mcp".into(), + args: vec![], + env: vec![], + }, + ]; + + for _restart in 0..2 { + let mut client = spawn_script(script).await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + client + .initialize() + .await + .expect("initialize should succeed"); + + for expected_session_id in ["ses_first", "ses_second"] { + let response = client + .session_new_full("/tmp", servers.clone(), None, None) + .await + .expect("session/new should succeed"); + assert_eq!(response.session_id, expected_session_id); + let received = &response.raw["_receivedRequest"]["params"]["mcpServers"]; + assert_eq!(received[0]["name"], "analytics"); + assert_eq!(received[0]["command"], "/opt/MCP Servers/analytics,prod"); + assert_eq!( + received[0]["args"], + serde_json::json!(["--stdio", "literal value"]) + ); + assert_eq!(received[0]["env"][0]["name"], "ANALYTICS_ENDPOINT"); + assert_eq!(received[0]["env"][0]["value"], "https://example.test/a=b"); + assert_eq!(received[1]["name"], "search"); + assert_eq!(received[1]["command"], "/opt/search-mcp"); + } + + let writes = observer + .snapshot() + .into_iter() + .filter(|event| { + event.kind == "acp_write" && event.payload["method"] == "session/new" + }) + .collect::>(); + assert_eq!(writes.len(), 2); + for write in writes { + let sent = &write.payload["params"]["mcpServers"]; + assert_eq!(sent[0]["name"], "analytics"); + assert_eq!(sent[0]["command"], "/opt/MCP Servers/analytics,prod"); + assert_eq!( + sent[0]["args"], + serde_json::json!(["--stdio", "literal value"]) + ); + assert_eq!(sent[0]["env"][0]["name"], "ANALYTICS_ENDPOINT"); + assert_eq!(sent[0]["env"][0]["value"], REDACTED_ENV_VALUE); + assert_eq!(sent[1]["name"], "search"); + assert_eq!(sent[1]["command"], "/opt/search-mcp"); + } + client.shutdown().await; + } + } + #[tokio::test] async fn goose_system_prompt_request_uses_append_contract() { let script = r#" @@ -4356,7 +5218,7 @@ mod tests { // Errors without a string `message` field (e.g. only a `data` field) must // not be silently truncated to "unknown error" — the full JSON is preserved. let error = serde_json::json!({"code": -32000, "data": "quota exceeded"}); - match super::agent_error_from_json(&error) { + match super::agent_error_from_json(&error, None) { AcpError::AgentError { code, message } => { assert_eq!(code, -32000); assert!( @@ -4368,10 +5230,30 @@ mod tests { } } + #[test] + fn agent_error_from_json_redacts_mcp_env_before_display_or_turn_error() { + let secret = "agent-error-secret"; + let error = serde_json::json!({ + "code": -32002, + "data": { + "env": [{ + "name": "ANALYTICS_TOKEN", + "value": secret + }] + } + }); + + let rendered = super::agent_error_from_json(&error, None).to_string(); + + assert!(!rendered.contains(secret)); + assert!(rendered.contains(REDACTED_ENV_VALUE)); + assert_eq!(error["data"]["env"][0]["value"], secret); + } + #[test] fn agent_error_from_json_uses_message_field_when_present() { let error = serde_json::json!({"code": -32001, "message": "auth denied"}); - match super::agent_error_from_json(&error) { + match super::agent_error_from_json(&error, None) { AcpError::AgentError { code, message } => { assert_eq!(code, -32001); assert_eq!(message, "auth denied"); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188d..e3a33a0395 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -3,7 +3,8 @@ //! 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::collections::{BTreeMap, HashMap, HashSet}; +use std::io::Read; use std::path::PathBuf; use clap::Parser; @@ -47,6 +48,362 @@ pub enum ConfigError { ConfigFile(String), } +const MCP_CONFIG_VERSION: u32 = 1; +const MCP_CONFIG_MAX_BYTES: u64 = 64 * 1024; +const MCP_SERVER_MAX_COUNT: usize = 16; +const MCP_SERVER_MAX_ARGS: usize = 128; +const MCP_SERVER_MAX_ENV: usize = 128; +const MCP_SERVER_NAME_MAX_BYTES: usize = 128; +const PROTECTED_MCP_ENV_NAMES: [&str; 6] = [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", +]; + +/// One MCP server loaded from the structured MCP configuration. +/// +/// The transport tag is part of the version-1 document even though this PR +/// implements only stdio. Additional transports can extend the same ordered +/// server list without introducing a parallel configuration format. +#[derive(Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(tag = "transport", rename_all = "snake_case", deny_unknown_fields)] +pub enum ConfiguredMcpServer { + /// A local MCP child process connected over stdio. + Stdio { + /// Stable ACP identifier for this server. + name: String, + /// Executable to invoke, passed directly without shell parsing. + command: String, + /// Arguments passed to the executable in their configured order. + args: Vec, + /// Server-specific environment in deterministic key order. + #[serde(deserialize_with = "deserialize_mcp_env")] + env: BTreeMap, + }, +} + +struct RedactedMcpEnv<'a>(&'a BTreeMap); + +impl std::fmt::Debug for RedactedMcpEnv<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut map = formatter.debug_map(); + for key in self.0.keys() { + map.entry(key, &"[REDACTED]"); + } + map.finish() + } +} + +impl std::fmt::Debug for ConfiguredMcpServer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdio { + name, + command, + args, + env, + } => formatter + .debug_struct("Stdio") + .field("name", name) + .field("command", command) + .field("arg_count", &args.len()) + .field("env", &RedactedMcpEnv(env)) + .finish(), + } + } +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct McpConfigDocument { + version: u32, + servers: Vec, +} + +fn deserialize_mcp_env<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct EnvVisitor; + + impl<'de> serde::de::Visitor<'de> for EnvVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("an object containing unique environment variable names") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut env = BTreeMap::new(); + let mut normalized_names = HashSet::new(); + while let Some((key, value)) = map.next_entry::()? { + if !normalized_names.insert(key.to_ascii_uppercase()) { + return Err(serde::de::Error::custom(format!( + "duplicate environment key '{key}'" + ))); + } + env.insert(key, value); + } + Ok(env) + } + } + + deserializer.deserialize_map(EnvVisitor) +} + +/// Derive the ACP name used by the legacy single-command MCP configuration. +/// +/// This preserves the existing `build_mcp_servers` behavior so collision +/// validation and runtime construction use the same name. +pub fn legacy_mcp_server_name(command: &str) -> String { + std::path::Path::new(command) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("mcp") + .to_string() +} + +fn read_mcp_config(path: &std::path::Path) -> Result, ConfigError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} must not be a symbolic link", + path.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(ConfigError::ConfigFile(format!( + "failed to inspect MCP config {}: {error}", + path.display() + ))); + } + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(nix::libc::O_NONBLOCK); + } + let file = options.open(path).map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to open MCP config {}: {error}", + path.display() + )) + })?; + let metadata = file.metadata().map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to inspect MCP config {}: {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} must be a regular file", + path.display() + ))); + } + let mut content = Vec::new(); + file.take(MCP_CONFIG_MAX_BYTES + 1) + .read_to_end(&mut content) + .map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to read MCP config {}: {error}", + path.display() + )) + })?; + if content.len() as u64 > MCP_CONFIG_MAX_BYTES { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} exceeds the {} byte limit", + path.display(), + MCP_CONFIG_MAX_BYTES + ))); + } + Ok(content) +} + +fn load_mcp_config_with_cleanup( + path: &std::path::Path, + legacy_mcp_command: &str, + delete_after_read: bool, +) -> Result, ConfigError> { + let loaded = load_mcp_config(path, legacy_mcp_command); + if !delete_after_read { + return loaded; + } + let cleanup = std::fs::remove_file(path).map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to remove MCP config {} after reading: {error}", + path.display() + )) + }); + match (loaded, cleanup) { + (Ok(servers), Ok(())) => Ok(servers), + (Ok(_), Err(cleanup_error)) => Err(cleanup_error), + (Err(load_error), Ok(())) => Err(load_error), + (Err(load_error), Err(cleanup_error)) => Err(ConfigError::ConfigFile(format!( + "{load_error}; {cleanup_error}" + ))), + } +} + +fn valid_mcp_server_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= MCP_SERVER_NAME_MAX_BYTES + && !name.contains("__") + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn valid_mcp_env_name(name: &str) -> bool { + let mut bytes = name.bytes(); + matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn load_mcp_config( + path: &std::path::Path, + legacy_mcp_command: &str, +) -> Result, ConfigError> { + let content = read_mcp_config(path)?; + parse_mcp_config(&content, path, legacy_mcp_command) +} + +fn parse_mcp_config( + content: &[u8], + source: &std::path::Path, + legacy_mcp_command: &str, +) -> Result, ConfigError> { + if content.len() as u64 > MCP_CONFIG_MAX_BYTES { + return Err(ConfigError::ConfigFile(format!( + "MCP config {} exceeds the {} byte limit", + source.display(), + MCP_CONFIG_MAX_BYTES + ))); + } + let document: McpConfigDocument = serde_json::from_slice(content).map_err(|error| { + ConfigError::ConfigFile(format!("invalid MCP config {}: {error}", source.display())) + })?; + + if document.version != MCP_CONFIG_VERSION { + return Err(ConfigError::ConfigFile(format!( + "unsupported MCP config version {} (expected {})", + document.version, MCP_CONFIG_VERSION + ))); + } + + let legacy_count = usize::from(!legacy_mcp_command.is_empty()); + if document.servers.len() + legacy_count > MCP_SERVER_MAX_COUNT { + return Err(ConfigError::ConfigFile(format!( + "too many MCP servers ({} structured + {legacy_count} legacy, max {MCP_SERVER_MAX_COUNT})", + document.servers.len() + ))); + } + + let legacy_name = + (!legacy_mcp_command.is_empty()).then(|| legacy_mcp_server_name(legacy_mcp_command)); + let mut names = HashSet::with_capacity(document.servers.len()); + for (index, server) in document.servers.iter().enumerate() { + let ConfiguredMcpServer::Stdio { + name, + command, + args, + env, + } = server; + if !valid_mcp_server_name(name) { + return Err(ConfigError::ConfigFile(format!( + "MCP server {} has invalid name '{}': use 1 to {MCP_SERVER_NAME_MAX_BYTES} ASCII letters, digits, underscores, or hyphens, without '__'", + index + 1, + name + ))); + } + if !names.insert(name.as_str()) { + return Err(ConfigError::ConfigFile(format!( + "duplicate MCP server name '{}'", + name + ))); + } + if legacy_name.as_deref() == Some(name.as_str()) { + return Err(ConfigError::ConfigFile(format!( + "MCP server name '{}' collides with the legacy --mcp-command server", + name + ))); + } + if command.is_empty() || command.contains('\0') { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' command must be nonempty and contain no NUL bytes", + name + ))); + } + if args.len() > MCP_SERVER_MAX_ARGS { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has too many arguments ({}, max {MCP_SERVER_MAX_ARGS})", + name, + args.len() + ))); + } + if args.iter().any(|argument| argument.contains('\0')) { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' arguments must contain no NUL bytes", + name + ))); + } + if env.len() > MCP_SERVER_MAX_ENV { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has too many environment entries ({}, max {MCP_SERVER_MAX_ENV})", + name, + env.len() + ))); + } + for (key, value) in env { + if !valid_mcp_env_name(key) { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' has invalid environment key '{key}'", + name + ))); + } + if PROTECTED_MCP_ENV_NAMES + .iter() + .any(|protected| key.eq_ignore_ascii_case(protected)) + { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' may not configure protected environment key '{key}'", + name + ))); + } + if value.contains('\0') { + return Err(ConfigError::ConfigFile(format!( + "MCP server '{}' environment value for '{key}' contains a NUL byte", + name + ))); + } + } + } + + Ok(document.servers) +} + +pub(crate) fn validate_mcp_config_document( + content: &[u8], + legacy_mcp_command: Option<&str>, +) -> Result<(), ConfigError> { + parse_mcp_config( + content, + std::path::Path::new(""), + legacy_mcp_command.unwrap_or_default(), + ) + .map(|_| ()) +} + #[derive(Debug, Clone, PartialEq, clap::ValueEnum)] pub enum SubscribeMode { Mentions, @@ -261,6 +618,15 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")] pub mcp_command: String, + /// Path to a versioned JSON document defining additional local MCP servers. + #[arg(long, env = "BUZZ_ACP_MCP_CONFIG")] + pub mcp_config: Option, + + /// Remove the credential-bearing MCP configuration immediately after it + /// has been read. Intended for trusted launchers that create one-use files. + #[arg(long, env = "BUZZ_ACP_MCP_CONFIG_DELETE_AFTER_READ", hide = true)] + pub mcp_config_delete_after_read: bool, + /// Idle timeout: max seconds of silence before killing a turn. /// Resets on any agent stdout activity. #[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")] @@ -500,6 +866,8 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, pub mcp_command: String, + /// Additional local MCP servers loaded once from `--mcp-config`. + pub configured_mcp_servers: Vec, pub idle_timeout_secs: u64, pub max_turn_duration_secs: u64, pub agents: u32, @@ -827,10 +1195,23 @@ pub fn propagate_legacy_env_vars() { } } +/// Prepare environment fallbacks before Clap and Tokio read process state. +/// +/// Deployment templates commonly render optional values as empty strings. +/// Clap treats an empty value for `Option` as an invalid supplied +/// value, so normalize this one optional path to the same state as an unset +/// variable before argument parsing starts. +pub fn prepare_process_env() { + propagate_legacy_env_vars(); + if std::env::var_os("BUZZ_ACP_MCP_CONFIG").is_some_and(|value| value.is_empty()) { + std::env::remove_var("BUZZ_ACP_MCP_CONFIG"); + } +} + impl Config { pub fn from_cli() -> Result { // Legacy env-var propagation is intentionally NOT done here. - // Call `propagate_legacy_env_vars()` before the tokio runtime starts + // Call `prepare_process_env()` before the tokio runtime starts // (in the sync `fn main()` wrapper) — see Rust 2024 edition safety. let args = CliArgs::parse(); Self::from_args(args) @@ -913,6 +1294,14 @@ impl Config { } let agent_args = normalize_agent_args(&agent_command, args.agent_args); + let configured_mcp_servers = match args.mcp_config.as_deref() { + Some(path) => load_mcp_config_with_cleanup( + path, + &args.mcp_command, + args.mcp_config_delete_after_read, + )?, + None => Vec::new(), + }; if let Some(ref channels) = args.channels { for ch in channels { @@ -1066,6 +1455,7 @@ impl Config { agent_command, agent_args, mcp_command: args.mcp_command, + configured_mcp_servers, idle_timeout_secs, max_turn_duration_secs, agents: args.agents, @@ -1131,12 +1521,13 @@ impl Config { 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={} {}{}", + "relay={} pubkey={} agent_cmd={} {} legacy_mcp_server={} structured_mcp_servers={} 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.mcp_command.is_empty(), + self.configured_mcp_servers.len(), self.idle_timeout_secs, self.max_turn_duration_secs, self.agents, @@ -1445,6 +1836,7 @@ mod tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -2924,6 +3316,496 @@ channels = "ALL" assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + struct TempMcpConfig { + path: PathBuf, + } + + impl TempMcpConfig { + fn write(content: &[u8]) -> Self { + let path = + std::env::temp_dir().join(format!("buzz-acp-mcp-config-{}.json", Uuid::new_v4())); + std::fs::write(&path, content).expect("write temporary MCP config"); + Self { path } + } + } + + impl Drop for TempMcpConfig { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + fn config_from_mcp_file( + file: &TempMcpConfig, + legacy_command: Option<&str>, + ) -> Result { + config_from_mcp_file_with_cleanup(file, legacy_command, false) + } + + fn config_from_mcp_file_with_cleanup( + file: &TempMcpConfig, + legacy_command: Option<&str>, + delete_after_read: bool, + ) -> Result { + let mut argv = vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--mcp-config".to_string(), + file.path.display().to_string(), + ]; + if let Some(command) = legacy_command { + argv.push("--mcp-command".to_string()); + argv.push(command.to_string()); + } + if delete_after_read { + argv.push("--mcp-config-delete-after-read".to_string()); + } + let args = CliArgs::try_parse_from(argv).expect("clap should parse MCP config arguments"); + Config::from_args(args) + } + + fn server_json(name: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "transport": "stdio", + "command": "mcp", + "args": [], + "env": {} + }) + } + + fn document_json(servers: Vec) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "version": MCP_CONFIG_VERSION, + "servers": servers + })) + .expect("serialize MCP test document") + } + + #[test] + fn structured_mcp_config_preserves_order_and_exact_values() { + let posix_command = "/Applications/Tool Suite/工具 mcp"; + let windows_command = r"C:\Program Files\Agent Tools\server.exe"; + let literal_metacharacters = r#"$HOME;$(echo nope)|&<>*?`literal`"#; + let file = TempMcpConfig::write(&document_json(vec![ + serde_json::json!({ + "name": "analytics-primary", + "transport": "stdio", + "command": posix_command, + "args": [ + "", + "with spaces", + "comma,value", + "quote\"value", + r"C:\data\reports", + literal_metacharacters, + "雪" + ], + "env": { + "Z_LAST": "backslash\\quote\"雪", + "A_FIRST": "" + } + }), + serde_json::json!({ + "name": "windows_server", + "transport": "stdio", + "command": windows_command, + "args": ["--stdio"], + "env": {} + }), + ])); + + let config = config_from_mcp_file(&file, None).expect("structured config should load"); + assert_eq!(config.configured_mcp_servers.len(), 2); + let ConfiguredMcpServer::Stdio { + name, + command, + args, + env, + } = &config.configured_mcp_servers[0]; + assert_eq!(name, "analytics-primary"); + assert_eq!(command, posix_command); + assert_eq!( + args, + &vec![ + "", + "with spaces", + "comma,value", + "quote\"value", + r"C:\data\reports", + literal_metacharacters, + "雪" + ] + ); + assert_eq!( + env.keys().map(String::as_str).collect::>(), + vec!["A_FIRST", "Z_LAST"] + ); + assert_eq!(env["Z_LAST"], "backslash\\quote\"雪"); + let ConfiguredMcpServer::Stdio { command, .. } = &config.configured_mcp_servers[1]; + assert_eq!(command, windows_command); + } + + #[test] + fn summary_reports_only_structured_mcp_count() { + let secret_value = "value-that-must-not-be-logged"; + let command = "/private/path/tool"; + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "safe", + "transport": "stdio", + "command": command, + "args": [], + "env": {"DOMAIN_TOKEN": secret_value} + })])); + let config = + config_from_mcp_file(&file, Some("/legacy/private/tool")).expect("config should load"); + + let summary = config.summary(); + assert!(summary.contains("legacy_mcp_server=true")); + assert!(summary.contains("structured_mcp_servers=1")); + assert!(!summary.contains(secret_value)); + assert!(!summary.contains(command)); + assert!(!summary.contains("/legacy/private/tool")); + assert!(!summary.contains("DOMAIN_TOKEN")); + } + + #[test] + fn debug_output_redacts_structured_mcp_environment_values() { + let secret_value = "structured-debug-secret"; + let argument_secret = "structured-argument-secret"; + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "safe", + "transport": "stdio", + "command": "/opt/mcp", + "args": ["--token", argument_secret], + "env": {"DOMAIN_TOKEN": secret_value} + })])); + let config = config_from_mcp_file(&file, None).expect("config should load"); + + let rendered = format!("{config:?}"); + + assert!(rendered.contains("DOMAIN_TOKEN")); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains(secret_value)); + assert!(!rendered.contains(argument_secret)); + } + + #[test] + fn legacy_mcp_name_matches_existing_file_stem_behavior() { + assert_eq!( + legacy_mcp_server_name("/opt/bin/my-mcp-server"), + "my-mcp-server" + ); + assert_eq!(legacy_mcp_server_name("."), "mcp"); + assert_eq!(legacy_mcp_server_name(""), "mcp"); + } + + #[test] + fn mcp_config_rejects_unreadable_file() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-missing-mcp-config-{}.json", + Uuid::new_v4() + )); + let argv = vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--mcp-config".to_string(), + path.display().to_string(), + ]; + let args = CliArgs::try_parse_from(argv).expect("clap should parse arguments"); + let error = Config::from_args(args).expect_err("missing MCP config must fail"); + assert!(error.to_string().contains("failed to open MCP config")); + } + + #[test] + fn mcp_config_rejects_non_regular_files() { + let path = std::env::temp_dir().join(format!("buzz-acp-mcp-config-dir-{}", Uuid::new_v4())); + std::fs::create_dir(&path).expect("create temporary MCP config directory"); + + let error = read_mcp_config(&path).expect_err("directories must not be read as MCP config"); + let _ = std::fs::remove_dir(&path); + assert!(error.to_string().contains("must be a regular file")); + } + + #[cfg(unix)] + #[test] + fn mcp_config_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + + let target = TempMcpConfig::write(&document_json(vec![server_json("safe")])); + let link = + std::env::temp_dir().join(format!("buzz-acp-mcp-config-link-{}.json", Uuid::new_v4())); + symlink(&target.path, &link).expect("create MCP config symlink"); + let error = read_mcp_config(&link).expect_err("symlinked MCP config must fail"); + let _ = std::fs::remove_file(link); + assert!(error.to_string().contains("must not be a symbolic link")); + } + + #[test] + fn one_use_mcp_config_is_deleted_after_successful_read() { + let file = TempMcpConfig::write(&document_json(vec![server_json("safe")])); + config_from_mcp_file_with_cleanup(&file, None, true) + .expect("one-use MCP config should load"); + assert!(!file.path.exists()); + } + + #[test] + fn one_use_mcp_config_is_deleted_after_parse_failure() { + let file = TempMcpConfig::write(br#"{"version":1,"servers":["#); + config_from_mcp_file_with_cleanup(&file, None, true) + .expect_err("malformed one-use MCP config must fail"); + assert!(!file.path.exists()); + } + + #[test] + fn mcp_config_enforces_file_size_boundary() { + let base = document_json(Vec::new()); + let mut at_limit = base.clone(); + at_limit.resize(MCP_CONFIG_MAX_BYTES as usize, b' '); + let file = TempMcpConfig::write(&at_limit); + config_from_mcp_file(&file, None).expect("64 KiB MCP config should be accepted"); + + let mut over_limit = base; + over_limit.resize(MCP_CONFIG_MAX_BYTES as usize + 1, b' '); + let file = TempMcpConfig::write(&over_limit); + let error = + config_from_mcp_file(&file, None).expect_err("MCP config over 64 KiB must fail"); + assert!(error.to_string().contains("65536 byte limit")); + } + + #[test] + fn mcp_config_rejects_malformed_wrong_version_and_unknown_fields() { + let cases: Vec<(&str, Vec)> = vec![ + ("malformed", br#"{"version":1,"servers":["#.to_vec()), + ( + "wrong version", + br#"{"version":2,"servers":[]}"#.to_vec(), + ), + ( + "unknown document field", + br#"{"version":1,"servers":[],"extra":true}"#.to_vec(), + ), + ( + "unknown server field", + br#"{"version":1,"servers":[{"name":"one","transport":"stdio","command":"mcp","args":[],"env":{},"extra":true}]}"#.to_vec(), + ), + ( + "missing required field", + br#"{"version":1,"servers":[{"name":"one","transport":"stdio","command":"mcp","env":{}}]}"#.to_vec(), + ), + ( + "missing transport", + br#"{"version":1,"servers":[{"name":"one","command":"mcp","args":[],"env":{}}]}"#.to_vec(), + ), + ( + "unsupported transport", + br#"{"version":1,"servers":[{"name":"one","transport":"http","url":"https://example.test/mcp","headers":{}}]}"#.to_vec(), + ), + ]; + + for (label, content) in cases { + let file = TempMcpConfig::write(&content); + assert!( + config_from_mcp_file(&file, None).is_err(), + "{label} should be rejected" + ); + } + } + + #[test] + fn mcp_config_validates_server_names_and_collisions() { + let invalid_names = vec![ + String::new(), + "contains space".to_string(), + "contains.dot".to_string(), + "double__underscore".to_string(), + "unicodé".to_string(), + "a".repeat(MCP_SERVER_NAME_MAX_BYTES + 1), + ]; + for invalid_name in invalid_names { + let file = TempMcpConfig::write(&document_json(vec![server_json(&invalid_name)])); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid name {invalid_name:?} should fail" + ); + } + + let file = TempMcpConfig::write(&document_json(vec![ + server_json("same"), + server_json("same"), + ])); + let error = + config_from_mcp_file(&file, None).expect_err("duplicate server name should fail"); + assert!(error.to_string().contains("duplicate MCP server name")); + + let file = TempMcpConfig::write(&document_json(vec![server_json("my-mcp-server")])); + let error = config_from_mcp_file(&file, Some("/opt/bin/my-mcp-server")) + .expect_err("legacy name collision should fail"); + assert!(error.to_string().contains("collides")); + } + + #[test] + fn mcp_config_limits_total_servers_including_legacy() { + let sixteen = (0..MCP_SERVER_MAX_COUNT) + .map(|index| server_json(&format!("server-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(sixteen)); + config_from_mcp_file(&file, None).expect("16 structured servers should be accepted"); + assert!( + config_from_mcp_file(&file, Some("legacy-mcp")).is_err(), + "16 structured plus one legacy server should fail" + ); + + let seventeen = (0..=MCP_SERVER_MAX_COUNT) + .map(|index| server_json(&format!("server-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(seventeen)); + assert!( + config_from_mcp_file(&file, None).is_err(), + "17 structured servers should fail" + ); + } + + #[test] + fn mcp_config_enforces_argument_limit() { + let args = (0..MCP_SERVER_MAX_ARGS) + .map(|index| format!("arg-{index}")) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "limit", + "transport": "stdio", + "command": "mcp", + "args": args, + "env": {} + })])); + config_from_mcp_file(&file, None).expect("128 arguments should be accepted"); + + let args = (0..=MCP_SERVER_MAX_ARGS) + .map(|index| format!("arg-{index}")) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "over-limit", + "transport": "stdio", + "command": "mcp", + "args": args, + "env": {} + })])); + let error = + config_from_mcp_file(&file, None).expect_err("129 arguments should be rejected"); + assert!(error.to_string().contains("too many arguments")); + } + + #[test] + fn mcp_config_enforces_environment_limit() { + let env = (0..MCP_SERVER_MAX_ENV) + .map(|index| (format!("KEY_{index}"), format!("value-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "limit", + "transport": "stdio", + "command": "mcp", + "args": [], + "env": env + })])); + config_from_mcp_file(&file, None).expect("128 environment entries should be accepted"); + + let env = (0..=MCP_SERVER_MAX_ENV) + .map(|index| (format!("KEY_{index}"), format!("value-{index}"))) + .collect::>(); + let file = TempMcpConfig::write(&document_json(vec![serde_json::json!({ + "name": "over-limit", + "transport": "stdio", + "command": "mcp", + "args": [], + "env": env + })])); + let error = config_from_mcp_file(&file, None) + .expect_err("129 environment entries should be rejected"); + assert!(error.to_string().contains("too many environment entries")); + } + + #[test] + fn mcp_config_rejects_invalid_duplicate_and_protected_env_names() { + for invalid_key in ["", "1STARTS_WITH_DIGIT", "BAD-NAME", "UNICODÉ"] { + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","transport":"stdio","command":"mcp","args":[],"env":{{"{invalid_key}":"value"}}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid environment key {invalid_key:?} should fail" + ); + } + + for protected in PROTECTED_MCP_ENV_NAMES { + let lowercase = protected.to_ascii_lowercase(); + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","transport":"stdio","command":"mcp","args":[],"env":{{"{lowercase}":"value"}}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + let error = config_from_mcp_file(&file, None) + .expect_err("protected environment key should fail case-insensitively"); + assert!(error.to_string().contains("protected environment key")); + } + + for duplicate_env in [ + r#"{"KEY":"one","KEY":"two"}"#, + r#"{"KEY":"one","key":"two"}"#, + ] { + let content = format!( + r#"{{"version":1,"servers":[{{"name":"one","transport":"stdio","command":"mcp","args":[],"env":{duplicate_env}}}]}}"# + ); + let file = TempMcpConfig::write(content.as_bytes()); + let error = + config_from_mcp_file(&file, None).expect_err("duplicate env key should fail"); + assert!(error.to_string().contains("duplicate environment key")); + } + } + + #[test] + fn mcp_config_rejects_empty_or_nul_process_values() { + let cases = vec![ + serde_json::json!({ + "name": "empty-command", + "transport": "stdio", + "command": "", + "args": [], + "env": {} + }), + serde_json::json!({ + "name": "nul-command", + "transport": "stdio", + "command": "mc\u{0}p", + "args": [], + "env": {} + }), + serde_json::json!({ + "name": "nul-arg", + "transport": "stdio", + "command": "mcp", + "args": ["ok", "bad\u{0}arg"], + "env": {} + }), + serde_json::json!({ + "name": "nul-value", + "transport": "stdio", + "command": "mcp", + "args": [], + "env": {"DOMAIN_KEY": "bad\u{0}value"} + }), + ]; + + for server in cases { + let file = TempMcpConfig::write(&document_json(vec![server])); + assert!( + config_from_mcp_file(&file, None).is_err(), + "invalid process value should fail" + ); + } + } + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH /// must set `hide_env_values = true` to prevent credential leakage in --help. #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..a0acbb7785 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -14,6 +14,19 @@ mod usage; pub use usage::TurnUsage; +/// Validate an in-memory structured MCP configuration with the same parser and +/// limits used by the harness at process startup. +/// +/// `legacy_mcp_command` must be the effective legacy MCP executable, when one +/// will be launched alongside the structured servers. +pub fn validate_structured_mcp_config( + content: &[u8], + legacy_mcp_command: Option<&str>, +) -> Result<(), String> { + config::validate_mcp_config_document(content, legacy_mcp_command) + .map_err(|error| error.to_string()) +} + use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; @@ -1282,7 +1295,7 @@ mod inactivity_tests { } pub fn run() -> Result<()> { - config::propagate_legacy_env_vars(); + config::prepare_process_env(); tokio_main() } @@ -4278,60 +4291,83 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { - if config.mcp_command.is_empty() { - return vec![]; - } - vec![McpServer { - name: std::path::Path::new(&config.mcp_command) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("mcp") - .to_string(), - command: config.mcp_command.clone(), - args: vec![], - env: { - let mut env = vec![ - EnvVar { - name: "BUZZ_RELAY_URL".into(), - value: config.relay_url.clone(), - }, - EnvVar { - name: "BUZZ_PRIVATE_KEY".into(), - // bech32 encoding of a valid secret key is infallible. - // Panic here is correct: injecting a bogus secret would cause - // delayed, hard-to-diagnose agent failures downstream. - value: config - .keys - .secret_key() - .to_bech32() - .expect("secret key bech32 encoding should never fail"), - }, - ]; - // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) - // so the MCP server can attach it to every signed event. - if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { - if !auth_tag.is_empty() { - env.push(EnvVar { - name: "BUZZ_AUTH_TAG".into(), - value: auth_tag, - }); + let mut servers = Vec::with_capacity( + usize::from(!config.mcp_command.is_empty()) + config.configured_mcp_servers.len(), + ); + + if !config.mcp_command.is_empty() { + servers.push(McpServer { + name: config::legacy_mcp_server_name(&config.mcp_command), + command: config.mcp_command.clone(), + args: vec![], + env: { + let mut env = vec![ + EnvVar { + name: "BUZZ_RELAY_URL".into(), + value: config.relay_url.clone(), + }, + EnvVar { + name: "BUZZ_PRIVATE_KEY".into(), + // bech32 encoding of a valid secret key is infallible. + // Panic here is correct: injecting a bogus secret would cause + // delayed, hard-to-diagnose agent failures downstream. + value: config + .keys + .secret_key() + .to_bech32() + .expect("secret key bech32 encoding should never fail"), + }, + ]; + // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) + // so the MCP server can attach it to every signed event. + if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { + if !auth_tag.is_empty() { + env.push(EnvVar { + name: "BUZZ_AUTH_TAG".into(), + value: auth_tag, + }); + } } - } - // Forward the agent's display name so dev-mcp can use it as the git - // author name instead of the raw npub. Read from the process env - // rather than Config: this is a pass-through of a contract owned - // upstream, and absent simply means dev-mcp falls back to the npub. - if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { - if !display_name.is_empty() { - env.push(EnvVar { - name: "BUZZ_ACP_DISPLAY_NAME".into(), - value: display_name, - }); + // Forward the agent's display name so dev-mcp can use it as the git + // author name instead of the raw npub. Read from the process env + // rather than Config: this is a pass-through of a contract owned + // upstream, and absent simply means dev-mcp falls back to the npub. + if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { + if !display_name.is_empty() { + env.push(EnvVar { + name: "BUZZ_ACP_DISPLAY_NAME".into(), + value: display_name, + }); + } } - } - env - }, - }] + env + }, + }); + } + + servers.extend(config.configured_mcp_servers.iter().map(|configured| { + match configured { + config::ConfiguredMcpServer::Stdio { + name, + command, + args, + env, + } => McpServer { + name: name.clone(), + command: command.clone(), + args: args.clone(), + env: env + .iter() + .map(|(name, value)| EnvVar { + name: name.clone(), + value: value.clone(), + }) + .collect(), + }, + } + })); + + servers } #[cfg(test)] @@ -5089,6 +5125,8 @@ mod observer_chunk_coalescer_tests { #[cfg(test)] mod build_mcp_servers_tests { use super::*; + use clap::Parser; + use std::collections::BTreeMap; use std::sync::Mutex; /// Env-var-touching tests must run serially — env vars are process-global. @@ -5101,6 +5139,7 @@ mod build_mcp_servers_tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -5140,6 +5179,44 @@ mod build_mcp_servers_tests { } } + fn configured_server( + name: &str, + command: &str, + args: &[&str], + env: &[(&str, &str)], + ) -> config::ConfiguredMcpServer { + config::ConfiguredMcpServer::Stdio { + name: name.into(), + command: command.into(), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + env: env + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect::>(), + } + } + + struct TempMcpConfig { + path: std::path::PathBuf, + } + + impl TempMcpConfig { + fn write(content: &[u8]) -> Self { + let path = std::env::temp_dir().join(format!( + "buzz-acp-mcp-build-test-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, content).expect("write temporary MCP config"); + Self { path } + } + } + + impl Drop for TempMcpConfig { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + #[test] fn session_new_mcp_server_has_required_fields() { let config = test_config(); @@ -5254,6 +5331,169 @@ mod build_mcp_servers_tests { ); } + #[test] + fn structured_servers_preserve_order_and_literal_values_without_legacy_credentials() { + let mut config = test_config(); + config.mcp_command.clear(); + config.configured_mcp_servers = vec![ + configured_server( + "analytics", + "/opt/MCP Servers/analytics,prod", + &[ + "--stdio", + "two words", + "comma,value", + "\"quoted\"", + r"C:\Program Files\MCP\server.exe", + "雪", + "$(literal)", + "`literal`", + "a|b;c", + ], + &[ + ("ANALYTICS_ENDPOINT", "https://example.test/a=b"), + ("LITERAL_VALUE", "$HOME;`id`|雪"), + ], + ), + configured_server("search", "/opt/search-mcp", &[], &[]), + ]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "analytics"); + assert_eq!(servers[1].name, "search"); + assert_eq!(servers[0].command, "/opt/MCP Servers/analytics,prod"); + assert_eq!( + servers[0].args, + vec![ + "--stdio", + "two words", + "comma,value", + "\"quoted\"", + r"C:\Program Files\MCP\server.exe", + "雪", + "$(literal)", + "`literal`", + "a|b;c", + ] + ); + assert_eq!( + servers[0] + .env + .iter() + .map(|entry| (entry.name.as_str(), entry.value.as_str())) + .collect::>(), + vec![ + ("ANALYTICS_ENDPOINT", "https://example.test/a=b"), + ("LITERAL_VALUE", "$HOME;`id`|雪"), + ] + ); + assert!( + servers[0].env.iter().all(|entry| !matches!( + entry.name.as_str(), + "BUZZ_PRIVATE_KEY" | "BUZZ_AUTH_TAG" | "BUZZ_RELAY_URL" + )), + "structured servers must receive only their declared environment" + ); + } + + #[test] + fn legacy_server_remains_first_when_structured_servers_are_present() { + let mut config = test_config(); + config.configured_mcp_servers = vec![configured_server( + "analytics", + "/opt/analytics-mcp", + &["--stdio"], + &[("ANALYTICS_TOKEN", "opaque-test-token")], + )]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "test-mcp-server"); + assert_eq!(servers[1].name, "analytics"); + assert!(servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_PRIVATE_KEY")); + assert_eq!( + servers[1] + .env + .iter() + .map(|entry| (entry.name.as_str(), entry.value.as_str())) + .collect::>(), + vec![("ANALYTICS_TOKEN", "opaque-test-token")] + ); + } + + #[test] + fn structured_json_reaches_initial_repeated_and_respawn_session_lists() { + let document = serde_json::json!({ + "version": 1, + "servers": [ + { + "name": "analytics", + "transport": "stdio", + "command": "/opt/MCP Servers/analytics,prod", + "args": ["--stdio", "literal value"], + "env": { + "ANALYTICS_ENDPOINT": "https://example.test/a=b" + } + }, + { + "name": "search", + "transport": "stdio", + "command": "/opt/search-mcp", + "args": [], + "env": {} + } + ] + }); + let file = TempMcpConfig::write( + &serde_json::to_vec(&document).expect("serialize MCP config fixture"), + ); + let private_key = nostr::Keys::generate() + .secret_key() + .to_bech32() + .expect("encode temporary private key"); + let args = config::CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + private_key.as_str(), + "--mcp-command", + "/opt/buzz-dev-mcp", + "--mcp-config", + file.path.to_str().expect("temporary path is UTF-8"), + ]) + .expect("parse MCP arguments"); + let config = Config::from_args(args).expect("load structured MCP config"); + + let initial_session = build_mcp_servers(&config); + let repeated_session = initial_session.clone(); + let respawned_session = repeated_session.clone(); + let initial_json = + serde_json::to_value(&initial_session).expect("serialize initial MCP list"); + let repeated_json = + serde_json::to_value(&repeated_session).expect("serialize repeated MCP list"); + let respawned_json = + serde_json::to_value(&respawned_session).expect("serialize respawned MCP list"); + + assert_eq!(initial_json, repeated_json); + assert_eq!(initial_json, respawned_json); + assert_eq!(initial_json.as_array().map(Vec::len), Some(3)); + assert_eq!(initial_json[0]["name"], "buzz-dev-mcp"); + assert_eq!(initial_json[1]["name"], "analytics"); + assert_eq!(initial_json[2]["name"], "search"); + assert_eq!( + initial_json[1]["env"], + serde_json::json!([{ + "name": "ANALYTICS_ENDPOINT", + "value": "https://example.test/a=b" + }]) + ); + } + #[test] fn absolute_path_mcp_command_uses_file_stem_as_name() { let mut config = test_config(); @@ -5323,6 +5563,7 @@ mod error_outcome_emission_tests { agent_command: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), + configured_mcp_servers: Vec::new(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 64edf68ee2..ddc0330d9f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -31,8 +31,8 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, - SystemPromptTransport, + resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, + StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -867,13 +867,13 @@ const UNKNOWN_CHANNEL_NAME: &str = "unknown"; async fn resolve_new_session_channel_context( channel_info: &ChannelInfoResolver, channel_id: Uuid, -) -> (bool, Option) { +) -> (bool, Option, Option) { let Some(info) = channel_info.resolve(channel_id).await else { - return (true, None); + return (true, None, None); }; let is_dm = info.channel_type == "dm"; let title_channel = (!is_dm && info.name != UNKNOWN_CHANNEL_NAME).then_some(info.name); - (is_dm, title_channel) + (is_dm, title_channel, Some(info.channel_type)) } /// Create a new ACP session via `session_new_full()`, populate model capabilities @@ -888,6 +888,8 @@ async fn create_session_and_apply_model( agent_core: Option<&str>, agent_canvas: Option<&str>, channel_name: Option<&str>, + channel_id: Option, + channel_type: Option<&str>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -911,12 +913,18 @@ async fn create_session_and_apply_model( .session_title .as_deref() .map(|agent_name| compose_session_title(agent_name, channel_name)); + let mcp_servers = mcp_servers_with_git_origin( + &ctx.mcp_servers, + channel_id, + channel_type, + ctx.session_title.as_deref(), + ); let resp = agent .acp .session_new_full( &ctx.cwd, - ctx.mcp_servers.clone(), + mcp_servers, session_new_system_prompt( is_goose, agent.protocol_version, @@ -1019,6 +1027,34 @@ async fn create_session_and_apply_model( Ok(resp.session_id) } +fn mcp_servers_with_git_origin( + servers: &[McpServer], + channel_id: Option, + channel_type: Option<&str>, + agent_name: Option<&str>, +) -> Vec { + let mut servers = servers.to_vec(); + let origin = match (channel_id, channel_type) { + (Some(channel_id), Some("stream")) => Some(EnvVar { + name: "BUZZ_GIT_ORIGIN_CHANNEL_ID".into(), + value: channel_id.to_string(), + }), + (Some(_), _) => agent_name + .filter(|name| !name.trim().is_empty()) + .map(|name| EnvVar { + name: "BUZZ_GIT_ORIGIN_AGENT_NAME".into(), + value: name.trim().to_string(), + }), + (None, _) => None, + }; + if let Some(origin) = origin { + for server in &mut servers { + server.env.push(origin.clone()); + } + } + servers +} + /// Send the appropriate ACP model-switch request with a timeout. /// /// On timeout or error, logs a warning and returns — the caller proceeds @@ -1519,14 +1555,15 @@ pub async fn run_prompt_task( // Channel name for the session title, from the same single resolve the // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; + let mut origin_channel_type: Option = None; if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); - let needs_title = is_new_channel_session && ctx.session_title.is_some(); - if needs_canvas || needs_title { - let (is_dm, resolved_channel) = + if is_new_channel_session { + let (is_dm, resolved_channel, resolved_channel_type) = resolve_new_session_channel_context(&ctx.channel_info, *cid).await; title_channel = resolved_channel; + origin_channel_type = resolved_channel_type; // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { @@ -1571,6 +1608,8 @@ pub async fn run_prompt_task( agent_core.as_deref(), agent_canvas.as_deref(), title_channel.as_deref(), + Some(*cid), + origin_channel_type.as_deref(), ) .await { @@ -1618,7 +1657,9 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None) + .await + { Ok(sid) => { tracing::info!( target: "pool::session", @@ -3989,6 +4030,50 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn test_mcp_server() -> McpServer { + McpServer { + name: "dev".into(), + command: "buzz-dev-mcp".into(), + args: vec![], + env: vec![], + } + } + + #[test] + fn public_session_forwards_channel_origin_to_mcp() { + let channel_id = Uuid::new_v4(); + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(channel_id), + Some("stream"), + None, + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID" && entry.value == channel_id.to_string() + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME")); + } + + #[test] + fn private_session_forwards_agent_name_without_channel_id() { + let servers = mcp_servers_with_git_origin( + &[test_mcp_server()], + Some(Uuid::new_v4()), + Some("dm"), + Some("Builder"), + ); + assert!(servers[0].env.iter().any(|entry| { + entry.name == "BUZZ_GIT_ORIGIN_AGENT_NAME" && entry.value == "Builder" + })); + assert!(!servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_GIT_ORIGIN_CHANNEL_ID")); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -6833,12 +6918,14 @@ mod tests { let response = channel_metadata_response(id, &[["name", "buzz-dev"], ["t", "stream"]]); let (resolver, requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a stream channel is not a DM"); assert_eq!(title_channel.as_deref(), Some("buzz-dev")); + assert_eq!(channel_type.as_deref(), Some("stream")); assert_eq!(requests.load(Ordering::SeqCst), 1); - let (_, again) = resolve_new_session_channel_context(&resolver, id).await; + let (_, again, _) = resolve_new_session_channel_context(&resolver, id).await; assert_eq!(again.as_deref(), Some("buzz-dev")); assert_eq!( requests.load(Ordering::SeqCst), @@ -6856,8 +6943,10 @@ mod tests { let response = channel_metadata_response(id, &[["name", "DM"], ["t", "dm"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, channel_type) = + resolve_new_session_channel_context(&resolver, id).await; assert!(is_dm); + assert_eq!(channel_type.as_deref(), Some("dm")); assert_eq!( title_channel, None, "a DM name must never reach the session title" @@ -6874,7 +6963,7 @@ mod tests { let response = channel_metadata_response(id, &[["t", "stream"]]); let (resolver, _requests, server) = counting_resolver(response).await; - let (is_dm, title_channel) = resolve_new_session_channel_context(&resolver, id).await; + let (is_dm, title_channel, _) = resolve_new_session_channel_context(&resolver, id).await; assert!(!is_dm, "a nameless stream channel is still not a DM"); assert_eq!( title_channel, None, @@ -6894,10 +6983,11 @@ mod tests { let (resolver, requests, server) = counting_resolver(json!([])).await; - let (is_dm, title_channel) = + let (is_dm, title_channel, channel_type) = resolve_new_session_channel_context(&resolver, Uuid::new_v4()).await; assert!(is_dm, "an undeterminable channel type must fail closed"); assert_eq!(title_channel, None, "unresolved channels get a bare title"); + assert_eq!(channel_type, None); assert_eq!( requests.load(Ordering::SeqCst), 2, diff --git a/crates/buzz-acp/tests/config_env.rs b/crates/buzz-acp/tests/config_env.rs new file mode 100644 index 0000000000..177ac3fea5 --- /dev/null +++ b/crates/buzz-acp/tests/config_env.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +#[test] +fn empty_mcp_config_environment_value_is_treated_as_unset() { + let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .env("BUZZ_ACP_MCP_CONFIG", "") + .args(["--private-key", "not-a-valid-nostr-key"]) + .output() + .expect("run buzz-acp"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success()); + assert!( + stderr.contains("configuration error: failed to parse nostr keys"), + "empty optional MCP config should reach normal configuration validation: {stderr}" + ); + assert!( + !stderr.contains("a value is required for '--mcp-config"), + "empty optional MCP config must not fail Clap parsing: {stderr}" + ); +} diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 6cd7b6808f..3a3e976962 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -15,6 +15,17 @@ pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MO pub use config::Provider; pub use types::AgentError; +/// Maximum number of MCP tools the bundled agent accepts across one session. +pub const MAX_MCP_TOOLS_PER_SESSION: usize = mcp::MAX_TOOLS_PER_SESSION; + +/// Return whether the bundled agent can expose `tool_name` under `server_name`. +/// +/// The check includes the provider-facing qualified-name budget used by the +/// bundled agent, not only the MCP server's bare tool name. +pub fn supports_mcp_server_tool_name(server_name: &str, tool_name: &str) -> bool { + mcp::valid_server_tool_name(server_name, tool_name) +} + /// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()` /// forwards every key in this list into its otherwise-cleared MCP child; Doctor /// uses the same contract so a ready agent can always start its shell tool. diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 9ae125a0b7..f9f89a2f55 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -19,7 +19,7 @@ use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult, ToolR const SEP: &str = "__"; const MAX_NAME_LEN: usize = 128; const MAX_QNAME_LEN: usize = 64; -const MAX_TOOLS_PER_SESSION: usize = 128; +pub(crate) const MAX_TOOLS_PER_SESSION: usize = 128; const MAX_DESCRIPTION_BYTES: usize = 1024; const MAX_SCHEMA_BYTES: usize = 4096; const MARKER_FIELD_MAX: usize = 256; @@ -265,11 +265,11 @@ impl McpRegistry { ))); } let bare = t.name.to_string(); - if !valid_name(&bare) || bare.contains("__") { + if !valid_name(&bare) || bare.contains(SEP) { return Err(AgentError::Mcp(format!("invalid tool name: {bare}"))); } let qname = format!("{}{SEP}{}", s.name, bare); - if qname.len() > MAX_QNAME_LEN { + if !valid_server_tool_name(&s.name, &bare) { return Err(AgentError::Mcp(format!( "qualified tool name too long: {} ({} > {MAX_QNAME_LEN})", qname, @@ -886,6 +886,12 @@ fn killpg(_pgid: u32, name: &str, stage: &str) { tracing::info!("relying on Drop to kill MCP {name} ({stage})"); } +pub(crate) fn valid_server_tool_name(server_name: &str, tool_name: &str) -> bool { + valid_name(tool_name) + && !tool_name.contains(SEP) + && format!("{server_name}{SEP}{tool_name}").len() <= MAX_QNAME_LEN +} + fn valid_name(s: &str) -> bool { !s.is_empty() && s.len() <= MAX_NAME_LEN @@ -1036,6 +1042,15 @@ fn configure_no_window(cmd: &mut Command) { mod content_tests { use super::*; + #[test] + fn project_server_tool_contract_covers_character_and_qualified_name_limits() { + let server = format!("project_{}", "c".repeat(32)); + assert!(valid_server_tool_name(&server, "analytics_weekly")); + assert!(!valid_server_tool_name(&server, "analytics.weekly_summary")); + assert!(!valid_server_tool_name(&server, "double__separator")); + assert!(!valid_server_tool_name(&server, &"x".repeat(23))); + } + #[test] fn passthrough_includes_buzz_owner_attestation() { assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG")); diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json index 28fe6ce90e..beffc29440 100644 --- a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -22,6 +22,7 @@ "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "policy_env": { "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_DISPLAY_NAME": "worker", "BUZZ_ACP_LAZY_POOL": "true", "BUZZ_ACP_MODEL": "gpt-5", "BUZZ_ACP_RELAY_OBSERVER": "true", diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 3d7d92a1b4..d531e53eb6 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; @@ -26,7 +27,9 @@ pub async fn cmd_create_issue( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -137,7 +140,8 @@ pub async fn cmd_issue_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 1ccc37a702..ad2c36e200 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -21,6 +21,55 @@ pub mod users; pub mod workflows; use crate::{client::normalize_write_response, error::CliError}; +use nostr::{EventBuilder, Tag}; + +const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID"; +const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME"; + +/// Add trusted, session-scoped provenance supplied by the ACP harness. +/// +/// Public channels use the standard NIP-29 `h` tag. Private conversations +/// intentionally omit their channel coordinate and retain only the agent's +/// display name. +pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result { + apply_git_provenance( + builder, + std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(), + std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(), + ) +} + +fn apply_git_provenance( + builder: EventBuilder, + channel_id: Option<&str>, + agent_name: Option<&str>, +) -> Result { + if let Some(channel_id) = channel_id { + let channel_id = channel_id.trim(); + uuid::Uuid::parse_str(channel_id) + .map_err(|_| CliError::Other("invalid git origin channel ID".into()))?; + let origin_tag = Tag::parse(["h", channel_id]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + if let Some(agent_name) = agent_name { + let agent_name = agent_name.trim(); + if agent_name.is_empty() + || agent_name.len() > 256 + || agent_name.chars().any(char::is_control) + { + return Err(CliError::Other( + "invalid private-conversation agent name".into(), + )); + } + let origin_tag = Tag::parse(["buzz-origin-agent", agent_name]) + .map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?; + return Ok(builder.tag(origin_tag)); + } + + Ok(builder) +} /// Parse a relay write-response JSON blob, mapping a duplicate (dominated) /// write to [`CliError::Conflict`] with the caller-supplied message. @@ -46,3 +95,47 @@ pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result, agent_name: Option<&str>) -> nostr::Event { + apply_git_provenance( + EventBuilder::new(Kind::Custom(1621), "issue"), + channel_id, + agent_name, + ) + .expect("apply provenance") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + } + + #[test] + fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() { + let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + let event = event_with_origin(Some(channel_id), Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["h", channel_id])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent"))); + } + + #[test] + fn private_origin_exposes_only_agent_name() { + let event = event_with_origin(None, Some("Builder")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("h"))); + } +} diff --git a/crates/buzz-cli/src/commands/patches.rs b/crates/buzz-cli/src/commands/patches.rs index 13f1714d06..413934a3c1 100644 --- a/crates/buzz-cli/src/commands/patches.rs +++ b/crates/buzz-cli/src/commands/patches.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -47,7 +48,8 @@ pub async fn cmd_send_patch( id: repo_id.to_string(), }; - let builder = buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -180,7 +182,8 @@ pub async fn cmd_patch_status( applied_as_commits: applied_as_commit.to_vec(), }; - let builder = buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/buzz-cli/src/commands/pr.rs b/crates/buzz-cli/src/commands/pr.rs index 4272c2bfd8..2a689e75a5 100644 --- a/crates/buzz-cli/src/commands/pr.rs +++ b/crates/buzz-cli/src/commands/pr.rs @@ -1,4 +1,5 @@ use crate::client::BuzzClient; +use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{ read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id, @@ -55,7 +56,9 @@ pub async fn cmd_open_pr( revision_of: revision_of.map(str::to_string), }; - let builder = buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -97,7 +100,9 @@ pub async fn cmd_update_pr( merge_base: merge_base.map(str::to_string), }; - let builder = buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?; + let builder = with_git_provenance( + buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -206,7 +211,8 @@ pub async fn cmd_pr_status( applied_as_commits: vec![], }; - let builder = buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?; + let builder = + with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e561e502c7..41fc5d52ef 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", @@ -136,6 +137,8 @@ export default defineConfig({ "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", "**/where-to-run-config.spec.ts", + "**/project-connections-screenshots.spec.ts", + "**/agent-project-bindings-screenshots.spec.ts", "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", ], diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index da80c5b07a..e589d58c2b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1013,6 +1013,39 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "buzz-acp" +version = "0.1.0" +dependencies = [ + "aho-corasick", + "anyhow", + "base64 0.22.1", + "buzz-core", + "buzz-persona", + "buzz-sdk", + "chrono", + "clap", + "evalexpr", + "futures-util", + "hex", + "nix 0.31.3", + "nostr", + "reqwest 0.13.4", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "tokio-util", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "buzz-agent" version = "0.1.0" @@ -1069,6 +1102,7 @@ dependencies = [ "axum", "base64 0.22.1", "block2", + "buzz-acp", "buzz-agent", "buzz-core", "buzz-media", @@ -2751,6 +2785,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "evalexpr" +version = "11.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aff27af350e7b53e82aac3e5ab6389abd8f280640ac034508dff0608c4c7e5" + [[package]] name = "event-listener" version = "5.4.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3b97ff1fe9..ad72e38865 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -153,3 +153,4 @@ tokio = { version = "1", features = ["test-util"] } # The relay's media validation, so the snapshot-sharing tests can prove the # full export → sanitize → relay-accept → import contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } +buzz_acp_pkg = { package = "buzz-acp", path = "../../crates/buzz-acp" } diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5519153578..8cf7095ac5 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -119,6 +119,9 @@ fn agent_record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -144,6 +147,7 @@ fn persona_with_model(model: &str) -> AgentDefinition { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..7a213d59c2 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use tauri::{AppHandle, State}; use super::agent_model_process::run_agent_models_command; +use crate::managed_agents::project_connections; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. #[cfg(test)] @@ -890,6 +891,12 @@ pub async fn update_managed_agent( crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; } + project_connections::apply_agent_project_connection_update( + &app, + record, + input.project_scope, + input.connection_bindings, + )?; // Native provider/model fields are authoritative. Keep the typed marker // derived for new records while retaining legacy typed records for @@ -1007,15 +1014,12 @@ pub async fn update_managed_agent( )); } } - Ok(UpdateManagedAgentResponse { agent: summary, profile_sync_error: None, }) } - // ── Model normalization ─────────────────────────────────────────────────────── - /// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. /// /// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), @@ -1032,10 +1036,8 @@ pub(super) fn normalize_agent_models( .as_str() .unwrap_or("unknown") .to_string(); - let mut models: Vec = Vec::new(); let mut seen_ids: HashSet = HashSet::new(); - // 1. Stable configOptions (preferred). Only entries with category "model" // are model options — the CLI pre-filters, but we're defensive here. if let Some(config_options) = raw["stable"]["configOptions"].as_array() { @@ -1083,9 +1085,7 @@ pub(super) fn normalize_agent_models( } } } - let supports_switching = !models.is_empty(); - AgentModelsResponse { agent_name, agent_version, diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 14c981d730..5d2d54b14e 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -404,6 +404,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), }; // agent_model_discovery_config is the single helper get_agent_models diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..e015ab2f03 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,6 +1,7 @@ use nostr::{Keys, ToBech32}; use tauri::{AppHandle, State}; +use crate::managed_agents::project_connections; use crate::{ app_state::AppState, managed_agents::{ @@ -808,6 +809,12 @@ pub async fn create_managed_agent( let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone()); let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone()); let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); + let (pinned_tool_requirements, project_scope) = + project_connections::prepare_agent_project_assignment( + &app, + linked_persona.as_ref(), + input.project_scope.as_ref(), + )?; let effective_provider = snapshot_provider .or_else(|| input.provider.as_deref().and_then(trim_to_optional_string)); let mut effective_model = @@ -817,7 +824,6 @@ pub async fn create_managed_agent( { effective_model = Some(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID.to_string()); } - // Mint-time behavioral quad: explicit input wins, then the linked // definition's NIP-AP defaults, then client defaults. The ONLY parse // point for definition behavioral strings — fails loudly on a bad @@ -828,7 +834,6 @@ pub async fn create_managed_agent( input.parallelism, linked_persona.as_ref(), )?; - let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), @@ -868,6 +873,9 @@ pub async fn create_managed_agent( model: effective_model.clone(), provider: effective_provider.clone(), persona_source_version: snapshot_source_version, + project_scope, + pinned_tool_requirements, + connection_bindings: input.connection_bindings.clone(), // Provider agents are managed externally — force false. start_on_app_launch: if input.backend != BackendKind::Local { false @@ -914,11 +922,9 @@ pub async fn create_managed_agent( relay_mesh.clone() }, }; - + project_connections::validate_agent_project_connections(&app, &record)?; records.push(record); - save_managed_agents(&app, &records)?; - let record = records .iter() .find(|record| record.pubkey == pubkey) @@ -1269,7 +1275,6 @@ pub async fn stop_managed_agent( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } - // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. #[tauri::command] @@ -1291,7 +1296,6 @@ pub async fn delete_managed_agent( .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( &mut records, &mut runtimes, @@ -1303,7 +1307,6 @@ pub async fn delete_managed_agent( for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - // Guard: reject deletion of deployed remote agents unless explicitly forced. // This turns "don't orphan remote infra" from a UI convention into a backend // invariant — a buggy or compromised IPC caller cannot silently orphan a live @@ -1320,7 +1323,6 @@ pub async fn delete_managed_agent( ); } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(&app, record, &mut runtimes)?; } @@ -1349,13 +1351,11 @@ pub async fn delete_managed_agent( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } - // Remote agent shutdown is handled entirely by the frontend: // 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key) // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. - #[path = "agents_deploy.rs"] mod deploy; use deploy::build_deploy_payload; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..e5565176ab 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -40,7 +40,9 @@ pub(super) fn build_launch_block( effective_model: Option<&str>, owner_pubkey: &str, ) -> serde_json::Value { - use crate::managed_agents::{known_acp_runtime, resolve_session_title, SESSION_TITLE_ENV_VAR}; + use crate::managed_agents::{ + known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + }; let runtime = known_acp_runtime(&descriptor.command); let mut policy_env = BTreeMap::new(); @@ -73,7 +75,8 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); } if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { - policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value.clone()); + policy_env.insert(DISPLAY_NAME_ENV_VAR.into(), value); } if let Some(value) = crate::managed_agents::spawn_hash::effective_team_instructions(record, teams) @@ -250,6 +253,7 @@ mod tests { "Coordinate" ); assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_DISPLAY_NAME"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..e7956d942c 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -62,6 +62,9 @@ fn bare_agent_record( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { @@ -87,6 +90,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d..7f4f1aeb84 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -624,6 +624,7 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), @@ -674,6 +675,7 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), @@ -720,6 +722,7 @@ mod tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Test".to_string(), diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index bcaec6a592..c1a22f54b2 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -169,6 +169,7 @@ mod tests { idle_timeout_seconds: None, max_turn_duration_seconds: None, name_pool: vec![], + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Tree Trunks".to_string(), diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..ebf7c28be9 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -42,6 +42,7 @@ pub mod pairing; mod personas; mod prevent_sleep; mod profile; +mod project_connections; mod project_git; mod project_git_branches; mod project_git_diff; @@ -98,6 +99,7 @@ pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; +pub use project_connections::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index c00de1c6da..03f70dbaa6 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -7,8 +7,8 @@ use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition, - CatalogSource, CreatePersonaRequest, + apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, + validate_tool_requirements, AgentDefinition, CatalogSource, CreatePersonaRequest, }, util::now_iso, }; @@ -51,6 +51,7 @@ pub async fn create_persona( .filter(|s| !s.is_empty()) .collect(); crate::managed_agents::validate_user_env_keys(&input.env_vars)?; + validate_tool_requirements(&input.tool_requirements)?; let mut persona = AgentDefinition { id: Uuid::new_v4().to_string(), display_name, @@ -67,6 +68,7 @@ pub async fn create_persona( source_team_persona_slug: None, catalog_source, env_vars: input.env_vars, + tool_requirements: input.tool_requirements, respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..42427b29fe 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -70,6 +70,9 @@ fn make_agent( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..291bd4a6cb 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -353,6 +353,7 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi local.model = inbound.model; local.provider = inbound.provider; local.name_pool = inbound.name_pool; + local.tool_requirements = inbound.tool_requirements; local.respond_to = inbound.respond_to; local.respond_to_allowlist = inbound.respond_to_allowlist; local.parallelism = inbound.parallelism; diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..c3a61fd189 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -30,6 +30,7 @@ fn local_in_app() -> AgentDefinition { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -57,6 +58,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: "2025-06-01T00:00:00Z".to_string(), updated_at: "2025-06-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -215,6 +217,9 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababc..983ed585b4 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -274,6 +274,7 @@ mod tests { parallelism: None, created_at: "2026-07-27T00:00:00Z".to_string(), updated_at: "2026-07-27T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 914c56252d..9923356cc4 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -163,6 +163,7 @@ mod tests { parallelism: None, created_at: "2026-07-27T00:00:00Z".to_string(), updated_at: "2026-07-27T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e6..7ad919693a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -490,6 +490,7 @@ mod png_body_tests { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: crate::managed_agents::agent_snapshot::AgentSnapshotProfile { display_name: "Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..340efd7028 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,9 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -88,6 +91,7 @@ fn make_snapshot( name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..86b3181edb 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -47,7 +47,6 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri } // ── Import preview types ────────────────────────────────────────────────────── - /// Materialized preview returned to the UI before any write is committed. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -579,6 +578,7 @@ pub async fn confirm_agent_snapshot_import( source_team_persona_slug: None, catalog_source: None, env_vars: std::collections::BTreeMap::new(), + tool_requirements: snapshot.definition.tool_requirements.clone(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), parallelism: minted_parallelism, @@ -621,6 +621,9 @@ pub async fn confirm_agent_snapshot_import( provider: snapshot.definition.provider.clone(), persona_source_version: None, env_vars: std::collections::BTreeMap::new(), + project_scope: None, + pinned_tool_requirements: snapshot.definition.tool_requirements.clone(), + connection_bindings: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, @@ -857,7 +860,6 @@ pub(crate) async fn submit_engram_event( } // ── NIP-49 egress guard: boundary 7 (persona snapshot engram submit) ───────── - #[cfg(test)] mod egress_guard_tests { use super::submit_engram_event; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..fa3e4348c5 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,9 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -108,6 +111,7 @@ fn make_snapshot( name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Test Agent".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54e..5925a1e6bc 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -9,7 +9,7 @@ use crate::{ managed_agents::{ apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, - AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + validate_tool_requirements, AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, }, util::now_iso, }; @@ -128,6 +128,10 @@ pub(super) async fn update_persona_with( crate::managed_agents::validate_user_env_keys(&env_vars)?; persona.env_vars = env_vars; } + if let Some(tool_requirements) = input.tool_requirements { + validate_tool_requirements(&tool_requirements)?; + persona.tool_requirements = tool_requirements; + } apply_persona_behavior(persona, input.behavior)?; persona.updated_at = now_iso(); diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..f0f46d3b3a 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -58,6 +58,9 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/commands/project_connections.rs b/desktop/src-tauri/src/commands/project_connections.rs new file mode 100644 index 0000000000..e1675ccebb --- /dev/null +++ b/desktop/src-tauri/src/commands/project_connections.rs @@ -0,0 +1,52 @@ +use tauri::AppHandle; + +use crate::managed_agents::project_connections::{ + self, CreateProjectConnectionRequest, ProjectConnection, ProjectConnectionScope, + UpdateProjectConnectionRequest, +}; + +#[tauri::command] +pub fn list_project_connections( + app: AppHandle, + project_scope: ProjectConnectionScope, +) -> Result, String> { + project_connections::list_project_connections(&app, &project_scope) +} + +#[tauri::command] +pub fn create_project_connection( + app: AppHandle, + input: CreateProjectConnectionRequest, +) -> Result { + project_connections::create_project_connection(&app, input) +} + +#[tauri::command] +pub fn update_project_connection( + app: AppHandle, + input: UpdateProjectConnectionRequest, +) -> Result { + project_connections::update_project_connection(&app, input) +} + +#[tauri::command] +pub async fn test_project_connection( + app: AppHandle, + project_scope: ProjectConnectionScope, + connection_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + project_connections::test_project_connection(&app, &project_scope, &connection_id) + }) + .await + .map_err(|error| format!("Project connection test task failed: {error}"))? +} + +#[tauri::command] +pub fn delete_project_connection( + app: AppHandle, + project_scope: ProjectConnectionScope, + connection_id: String, +) -> Result<(), String> { + project_connections::delete_project_connection(&app, &project_scope, &connection_id) +} diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b41..c616d39db1 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result Result { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(GitAuthConfig { + git_path: resolve_command("git") + .ok_or_else(|| "git was not found on PATH".to_string())?, + credential_helper: None, + nsec: String::new(), + allow_file_transport: false, + }); + } + build_git_auth_config(state) +} + pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result { let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; let credential_helper = resolve_command("git-credential-nostr"); @@ -288,6 +304,56 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> { Ok(()) } +fn validate_github_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("github.com") + || parsed.port().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("GitHub clone URL must use public https://github.com/owner/repository".into()); + } + let segments = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let valid_segment = |segment: &&str| { + !segment.starts_with('-') + && !segment.contains("..") + && segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + }; + if segments.len() != 2 || !segments.iter().all(valid_segment) { + return Err("GitHub clone URL must name one owner and repository".into()); + } + Ok(()) +} + +pub(crate) fn validate_local_clone_url(clone_url: &str) -> Result<(), String> { + if validate_clone_url(clone_url).is_ok() || validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + Err("clone URL must point at a Buzz repository or public GitHub repository".into()) +} + +pub(crate) fn validate_local_clone_url_for_workspace( + clone_url: &str, + state: &AppState, +) -> Result<(), String> { + if validate_github_clone_url(clone_url).is_ok() { + return Ok(()); + } + validate_workspace_clone_url(clone_url, state) +} + pub(crate) fn clone_url_owner(clone_url: &str) -> Option { let parsed = Url::parse(clone_url).ok()?; let segments = parsed @@ -329,6 +395,7 @@ mod tests { use super::{ clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, git_subcommand, validate_clone_url, validate_clone_url_against_relay, + validate_local_clone_url, }; #[test] @@ -441,4 +508,15 @@ mod tests { ) .is_err()); } + + #[test] + fn local_clone_url_allows_only_public_github_https_urls() { + assert!(validate_local_clone_url("https://github.com/block/buzz").is_ok()); + assert!(validate_local_clone_url("https://github.com/block/buzz.git").is_ok()); + assert!(validate_local_clone_url("http://github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com/block/buzz/issues").is_err()); + assert!(validate_local_clone_url("https://user@github.com/block/buzz").is_err()); + assert!(validate_local_clone_url("https://github.com.evil.test/block/buzz").is_err()); + assert!(validate_local_clone_url("https://gitlab.com/block/buzz").is_err()); + } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 624bbf4dfc..9e06852762 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -3,8 +3,9 @@ use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; use super::project_git_exec::{ - build_git_auth_config, build_git_auth_config_for_keys, clone_url_owner, run_git, - validate_clone_url, validate_workspace_clone_url, GitAuthConfig, + build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git, + validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url, + GitAuthConfig, }; use super::project_repo_paths::{ canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, @@ -353,7 +354,7 @@ pub(crate) fn clone_project_repository_blocking( default_branch: Option<&str>, auth: &GitAuthConfig, ) -> Result { - validate_clone_url(clone_url)?; + validate_local_clone_url(clone_url)?; let branch = normalize_branch_option(default_branch); if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? { return Ok(ProjectRepoCloneResult { @@ -411,8 +412,8 @@ pub async fn clone_project_repository( default_branch: Option, state: State<'_, AppState>, ) -> Result { - validate_workspace_clone_url(&clone_url, &state)?; - let auth = build_git_auth_config(&state)?; + validate_local_clone_url_for_workspace(&clone_url, &state)?; + let auth = build_git_clone_auth_config(&clone_url, &state)?; tauri::async_runtime::spawn_blocking(move || { clone_project_repository_blocking( repos_dir.as_deref(), diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index 31dbc74c6d..c583dd0db5 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -9,7 +9,10 @@ use crate::app_state::AppState; use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; -use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url}; +use super::project_git_exec::{ + build_git_auth_config, build_git_clone_auth_config, run_git, + validate_local_clone_url_for_workspace, validate_workspace_clone_url, +}; use super::project_git_workflow::clone_project_repository_blocking; use super::project_repo_paths::find_local_repo_dir; @@ -99,9 +102,8 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> { } /// Opens the OS terminal at the project's local checkout. When there is no -/// local checkout yet, clones the repository from `clone_url` (authenticated -/// with the identity key, same as push/snapshot) into the repos dir first, -/// then opens the terminal at the fresh checkout. +/// local checkout yet, clones the repository from `clone_url` into the repos +/// dir first, then opens the terminal at the fresh checkout. #[tauri::command] pub async fn open_project_terminal( repos_dir: Option, @@ -111,11 +113,16 @@ pub async fn open_project_terminal( state: State<'_, AppState>, ) -> Result { if let Some(clone_url) = clone_url.as_deref() { - validate_workspace_clone_url(clone_url, &state)?; + validate_local_clone_url_for_workspace(clone_url, &state)?; } - // Auth is only needed for the clone path — keep the result outside the - // blocking task so it owns no borrowed Tauri state. - let auth = build_git_auth_config(&state); + // Public GitHub clones stay anonymous; Buzz remotes use the workspace + // identity. Keep the result outside the blocking task so it borrows no + // Tauri state. + let auth = if let Some(clone_url) = clone_url.as_deref() { + build_git_clone_auth_config(clone_url, &state) + } else { + build_git_auth_config(&state) + }; tauri::async_runtime::spawn_blocking(move || { // An inaccessible repos root (fresh machine, nothing cloned yet) is // not fatal here — the clone path below creates the default root. A diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..7038ba6b40 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -134,6 +134,7 @@ fn definition_from_snapshot( source_team_persona_slug: None, catalog_source: None, env_vars: Default::default(), + tool_requirements: member.definition.tool_requirements.clone(), respond_to, respond_to_allowlist: behavior.respond_to_allowlist, parallelism: behavior.parallelism, @@ -574,6 +575,9 @@ pub async fn confirm_team_snapshot_import( provider: member.definition.provider.clone(), persona_source_version: None, env_vars: std::collections::BTreeMap::new(), + project_scope: None, + pinned_tool_requirements: definition.tool_requirements.clone(), + connection_bindings: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..7a492d6179 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -24,6 +24,7 @@ fn member(name: &str) -> AgentSnapshot { name_pool: vec![], idle_timeout_seconds: None, max_turn_duration_seconds: None, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: name.to_string(), @@ -75,6 +76,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + tool_requirements: Vec::new(), }, AgentDefinition { id: "bob".to_string(), @@ -97,6 +99,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + tool_requirements: Vec::new(), }, ]; let team = TeamRecord { @@ -160,6 +163,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + tool_requirements: Vec::new(), }]; let team = TeamRecord { id: "t1".to_string(), @@ -231,6 +235,9 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { relay_mesh: None, runtime: None, name_pool: vec![], + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), }; let mut memory_map = std::collections::HashMap::new(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d59936946f..d1f26ef39c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -138,28 +138,23 @@ pub fn run() { if webview.label() != "main" { return; } - // Linux/WebKitGTK needs media-stream settings and a // permission-request handler for getUserMedia; no-op // on macOS/Windows. linux_media::enable_media_capture(&webview); - // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. let window = webview.window(); - #[cfg(target_os = "macos")] { set_initial_window_backing(&window); - let (initial_render_tx, initial_render_rx) = tokio::sync::oneshot::channel(); window .app_handle() .once(INITIAL_RENDER_READY_EVENT, move |_| { let _ = initial_render_tx.send(()); }); - tauri::async_runtime::spawn(async move { wait_for_stable_initial_window_geometry(&window).await; @@ -644,6 +639,11 @@ pub fn run() { get_project_local_repo_diff, get_project_local_repo_snapshot, get_project_repo_sync_status, + list_project_connections, + create_project_connection, + update_project_connection, + test_project_connection, + delete_project_connection, list_project_local_repositories, clone_project_repository, create_project_remote_branch, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..ff68799a49 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -216,6 +216,9 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 7c08e7095f..1fa9a9f806 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -45,7 +45,7 @@ use png::{BitDepth, ColorType, Decoder, Encoder}; use serde::{Deserialize, Serialize}; use std::io::Cursor; -use crate::managed_agents::types::ManagedAgentRecord; +use crate::managed_agents::types::{AgentToolRequirement, ManagedAgentRecord}; // ── Constants ──────────────────────────────────────────────────────────────── @@ -113,6 +113,10 @@ pub struct AgentSnapshotDefinition { pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub name_pool: Vec, + /// Logical capabilities required by the portable definition. Concrete + /// connection ids and credentials remain local to the importing Project. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_requirements: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub idle_timeout_seconds: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -208,6 +212,7 @@ pub fn build_snapshot( respond_to: record.definition_respond_to.clone(), respond_to_allowlist: record.definition_respond_to_allowlist.clone(), name_pool: record.name_pool.clone(), + tool_requirements: record.pinned_tool_requirements.clone(), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, }; @@ -403,6 +408,7 @@ pub(crate) fn validate_snapshot(snapshot: &AgentSnapshot) -> Result<(), String> if snapshot.profile.display_name.trim().is_empty() { return Err("Snapshot profile.displayName is empty".to_string()); } + crate::managed_agents::validate_tool_requirements(&snapshot.definition.tool_requirements)?; Ok(()) } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..3fa1f3c500 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -344,6 +344,7 @@ mod tests { idle_timeout_seconds: None, max_turn_duration_seconds: None, source_is_builtin: false, + tool_requirements: Vec::new(), }, profile: AgentSnapshotProfile { display_name: "Locked Test".to_string(), @@ -419,6 +420,9 @@ mod tests { agent_command_override: None, persona_source_version: None, provider: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..695fbc2d70 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -72,6 +72,9 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..a3adab9b1e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -118,6 +118,9 @@ fn test_record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..68b437c8af 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -209,6 +209,7 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -283,6 +284,9 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -1729,9 +1733,7 @@ fn builtin_catalog_entry_has_empty_definition_env() { builtin.definition_env ); } - // ── Discovery publish via the PRODUCTION call path (stale-snapshot regression) ─ -// // These drive `discover_acp_runtimes_from` itself and land a save/delete in // the window between its directory scan and its registry publish (via the // `pre_publish_test_hook` seam). They red if discovery's final line reverts @@ -1742,14 +1744,12 @@ fn builtin_catalog_entry_has_empty_definition_env() { /// RAII guard: installs the pre-publish hook, clears it on drop (even on /// panic) so a failing test cannot poison later ones. struct PrePublishHookGuard; - impl PrePublishHookGuard { fn install(hook: Box) -> Self { super::pre_publish_test_hook::set(Some(hook)); PrePublishHookGuard } } - impl Drop for PrePublishHookGuard { fn drop(&mut self) { super::pre_publish_test_hook::set(None); diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..084c267bed 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -28,6 +28,7 @@ fn definition( parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), } } @@ -92,6 +93,9 @@ fn record( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7f..b4fd82a7fc 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,12 +71,18 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_MCP_CONFIG", + "BUZZ_ACP_MCP_CONFIG_DELETE_AFTER_READ", + "BUZZ_ACP_CHANNELS", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. "BUZZ_ACP_EXIT_AFTER_INACTIVITY", diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 534c2e0835..117dc5187c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -175,6 +175,7 @@ fn reserved_keys_include_code_execution_surface() { "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_MCP_CONFIG", ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); } diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..12df8bfc47 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -352,6 +352,9 @@ fn bare_record() -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -377,6 +380,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), } } @@ -638,6 +642,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + tool_requirements: Vec::new(), }; let cmd = crate::managed_agents::record_agent_command(&record, &[persona]); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..e95a8210aa 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod persona_events; mod personas; #[cfg(windows)] mod process_lifecycle; +pub(crate) mod project_connections; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a49..8ddeca298b 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -444,6 +444,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: String::new(), updated_at: String::new(), + tool_requirements: Vec::new(), } } @@ -502,6 +503,9 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6afc18a501..de713db16d 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -9,7 +9,7 @@ use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; -use super::{AgentDefinition, ManagedAgentRecord}; +use super::{AgentDefinition, AgentToolRequirement, ManagedAgentRecord}; use crate::app_state::AppState; /// The JSON body stored in a persona event's content field. @@ -39,6 +39,10 @@ pub struct PersonaEventContent { pub provider: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub name_pool: Vec, + /// Portable logical requirements. Project connection ids and credentials + /// are intentionally instance-local and never enter persona events. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_requirements: Vec, /// Definition-level defaults copied onto instances at creation /// (NIP-AP behavioral fields). Absent = defer to client defaults; /// `skip_serializing_if` keeps pre-revision hashes stable. @@ -178,6 +182,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result Result PersonaEventContent { model: record.model.clone(), provider: record.provider.clone(), name_pool: record.name_pool.clone(), + tool_requirements: record.tool_requirements.clone(), // NIP-AP behavioral defaults: live since the create-path unification // (B5) — carried on AgentDefinition in wire shape and copied verbatim. // Quad-absent records serialize identically to the reserved era, so diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..c404bfd149 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -58,6 +58,9 @@ fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -161,6 +164,7 @@ fn sample_persona() -> AgentDefinition { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -325,6 +329,7 @@ fn content_matches_nip_ap_vector() { respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + tool_requirements: Vec::new(), }; assert_eq!( serde_json::to_string(&content).unwrap(), @@ -388,6 +393,7 @@ fn content_matches_nip_ap_vector() { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), }; let event = build_persona_event(&record) .unwrap() @@ -419,6 +425,7 @@ fn round_trip_minimal_persona() { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), }; let builder = build_persona_event(&record).unwrap(); @@ -516,6 +523,7 @@ fn quad_absent_definition_hash_stable_across_activation() { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), }; let live = persona_event_content(&record); // The reserved-era projection: identical fields, quad hardcoded off. @@ -560,6 +568,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef parallelism: content.parallelism, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -576,6 +585,7 @@ fn persona_content_hash_is_deterministic() { respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + tool_requirements: Vec::new(), }; let hash1 = persona_content_hash(&content); let hash2 = persona_content_hash(&content); @@ -596,6 +606,7 @@ fn persona_content_hash_changes_on_edit() { respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, + tool_requirements: Vec::new(), }; let mut content2 = content1.clone(); content2.system_prompt = Some("Goodbye".to_string()); diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..3795198b55 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -126,6 +126,7 @@ fn built_in_persona_records(now: &str) -> Vec { source_team_persona_slug: None, catalog_source: None, env_vars: std::collections::BTreeMap::new(), + tool_requirements: Vec::new(), respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c6..a2fccca634 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -28,6 +28,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: "2026-03-19T00:00:00Z".to_string(), updated_at: "2026-03-19T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 8dddf9f715..b4cb48022e 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -137,6 +137,7 @@ pub fn finish_spawn( setup_mode: bool, adapter_availability: Option, start_nonce: String, + project_mcp_config_path: Option, agent_name: &str, ) -> super::ManagedAgentProcess { let job = create_job_for_child(child.id()); @@ -149,6 +150,7 @@ pub fn finish_spawn( super::ManagedAgentProcess { child, log_path, + project_mcp_config_path, spawn_config_hash, setup_mode, adapter_availability, diff --git a/desktop/src-tauri/src/managed_agents/project_connections.rs b/desktop/src-tauri/src/managed_agents/project_connections.rs new file mode 100644 index 0000000000..f004be3ccf --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections.rs @@ -0,0 +1,950 @@ +//! Project-owned MCP connection metadata and credentials. +//! +//! Connection metadata is partitioned by the active Buzz community and +//! identity. Secret values are never written to metadata or returned to the +//! webview after a write. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::Read as _, + path::{Path, PathBuf}, + sync::{Mutex, MutexGuard}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager as _}; +use uuid::Uuid; + +use super::{atomic_write_json_restricted, managed_agents_base_dir}; +use crate::util::now_iso; +#[cfg(feature = "system-keyring")] +use crate::{app_state::keyring_service, secret_store::SecretStore}; + +const CONNECTION_STORE_VERSION: u32 = 1; +const MAX_CONNECTIONS: usize = 128; +const MAX_NAME_BYTES: usize = 128; +const MAX_PROVIDER_BYTES: usize = 64; +const MAX_COMMAND_BYTES: usize = 1024; +const MAX_ARGS: usize = 128; +const MAX_ARG_BYTES: usize = 4096; +const MAX_ENV_KEYS: usize = 128; +const MAX_SECRET_BYTES: usize = 64 * 1024; +const MAX_SECRET_FILE_BYTES: usize = 512 * 1024; +const MAX_CONNECTION_STORE_BYTES: usize = 16 * 1024 * 1024; +const MAX_HEALTH_DETAIL_BYTES: usize = 512; +const HEALTH_STALE_AFTER_SECONDS: i64 = 24 * 60 * 60; + +static PROJECT_CONNECTIONS_LOCK: Mutex<()> = Mutex::new(()); + +mod transactions; +use transactions::{commit_delete, commit_update, UpdateTransaction}; +mod store; +#[cfg(any(test, not(feature = "system-keyring")))] +use store::read_bounded_file; +#[cfg(test)] +use store::validate_stored_connection; +use store::{load_store_unlocked, save_store_unlocked}; + +pub(super) fn lock_project_connections() -> MutexGuard<'static, ()> { + PROJECT_CONNECTIONS_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConnectionScope { + pub relay_url: String, + pub operator_pubkey: String, + /// Canonical NIP-MP Project coordinate (`30621::`). + /// + /// Legacy one-repository Projects use their NIP-34 repository coordinate + /// (`30617::`). + #[serde(alias = "repoAddress")] + pub project_address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProjectConnectionHealthStatus { + Ready, + NotTested, + CheckNeeded, + SignInRequired, + MissingAccess, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConnectionHealth { + pub status: ProjectConnectionHealthStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_verified_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl Default for ProjectConnectionHealth { + fn default() -> Self { + Self { + status: ProjectConnectionHealthStatus::NotTested, + last_verified_at: None, + detail: None, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectConnection { + pub id: String, + pub project_scope: ProjectConnectionScope, + pub name: String, + pub provider: String, + pub capability_ids: Vec, + pub command: String, + pub args: Vec, + /// Names only. Values are never returned by a Tauri command. + pub env_keys: Vec, + pub discovered_tools: Vec, + pub health: ProjectConnectionHealth, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct StoredProjectConnection { + id: String, + project_scope: ProjectConnectionScope, + name: String, + provider: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capability_ids: Vec, + command: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + discovered_tools: Vec, + #[serde(default)] + health: ProjectConnectionHealth, + executable_sha256: String, + generation: String, + credential_generation: String, + created_at: String, + updated_at: String, +} + +impl From for ProjectConnection { + fn from(connection: StoredProjectConnection) -> Self { + Self { + id: connection.id, + project_scope: connection.project_scope, + name: connection.name, + provider: connection.provider, + capability_ids: connection.capability_ids, + command: connection.command, + args: connection.args, + env_keys: connection.env_keys, + discovered_tools: connection.discovered_tools, + health: connection.health, + created_at: connection.created_at, + updated_at: connection.updated_at, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectConnectionRequest { + pub project_scope: ProjectConnectionScope, + pub name: String, + pub provider: String, + pub command: String, + #[serde(default)] + pub args: Vec, + /// Secret environment values. They are write-only at this boundary. + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub execution_acknowledged: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectConnectionRequest { + pub id: String, + pub project_scope: ProjectConnectionScope, + pub name: String, + pub provider: String, + pub command: String, + #[serde(default)] + pub args: Vec, + /// Changed or added values. Omitted keys retain their saved value. + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub remove_env_keys: Vec, + #[serde(default)] + pub execution_acknowledged: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectConnectionStore { + version: u32, + connections: Vec, +} + +impl Default for ProjectConnectionStore { + fn default() -> Self { + Self { + version: CONNECTION_STORE_VERSION, + connections: Vec::new(), + } + } +} + +fn next_generation() -> String { + Uuid::new_v4().simple().to_string() +} + +pub(super) fn connection_mcp_server_name(connection_id: &str) -> String { + let stable_suffix = connection_id.get(..12).unwrap_or(connection_id); + format!("project_{stable_suffix}") +} + +fn valid_stable_id(value: &str, max: usize) -> bool { + !value.is_empty() + && value.len() <= max + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +pub(super) fn canonical_project_scope( + scope: &ProjectConnectionScope, +) -> Result { + let relay_url = buzz_core_pkg::relay::normalize_relay_url(&scope.relay_url) + .map_err(|_| "Choose a valid Buzz community before continuing.".to_string())?; + if scope.operator_pubkey.len() != 64 + || !scope + .operator_pubkey + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("Buzz could not verify who owns these connections.".to_string()); + } + let mut parts = scope.project_address.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let d_tag = parts.next(); + if !matches!(kind, Some("30617") | Some("30621")) + || !owner.is_some_and(|value| { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + || !d_tag.is_some_and(|value| { + !value.is_empty() + && value.len() <= 256 + && !value.chars().any(char::is_control) + && !value.contains(':') + }) + { + return Err("Choose a valid Buzz Project before continuing.".to_string()); + } + Ok(ProjectConnectionScope { + relay_url, + operator_pubkey: scope.operator_pubkey.to_ascii_lowercase(), + project_address: format!( + "{}:{}:{}", + kind.unwrap_or_default(), + owner.unwrap_or_default().to_ascii_lowercase(), + d_tag.unwrap_or_default() + ), + }) +} + +fn validate_project_scope_for_app( + app: &AppHandle, + scope: &ProjectConnectionScope, +) -> Result { + let canonical = canonical_project_scope(scope)?; + let state = app.state::(); + let active_relay = buzz_core_pkg::relay::normalize_relay_url( + &crate::relay::relay_ws_url_with_override(&state), + ) + .map_err(|_| "Buzz could not verify the active community.".to_string())?; + if canonical.relay_url != active_relay { + return Err("This Project belongs to another Buzz community.".to_string()); + } + let active_operator = state + .keys + .lock() + .map_err(|_| "Buzz could not verify the active identity.".to_string())? + .public_key() + .to_hex(); + if !canonical + .operator_pubkey + .eq_ignore_ascii_case(&active_operator) + { + return Err("These connections belong to another Buzz identity.".to_string()); + } + Ok(canonical) +} + +fn workspace_scope_id(scope: &ProjectConnectionScope) -> String { + let mut hasher = Sha256::new(); + hasher.update(scope.operator_pubkey.as_bytes()); + hasher.update(b"\0"); + hasher.update(scope.relay_url.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn ensure_owner_only_directory(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("Buzz refused an unsafe Project connection directory.".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(path).map_err(|error| { + format!( + "failed to create Project connection directory {}: {error}", + path.display() + ) + })?; + } + Err(error) => { + return Err(format!( + "failed to inspect Project connection directory {}: {error}", + path.display() + )); + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| { + format!( + "failed to protect Project connection directory {}: {error}", + path.display() + ) + })?; + } + Ok(()) +} + +fn workspace_connection_dir( + app: &AppHandle, + scope: &ProjectConnectionScope, +) -> Result { + let root = managed_agents_base_dir(app)?.join("project-connections"); + ensure_owner_only_directory(&root)?; + let scoped = root.join(workspace_scope_id(scope)); + ensure_owner_only_directory(&scoped)?; + Ok(scoped) +} + +fn reject_unsafe_owner_file(path: &Path) -> Result<(), String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to inspect Project connection file {}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("Buzz refused an unsafe Project connection file.".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o077 != 0 { + return Err( + "Project connection data is not owner-only. Fix its permissions before continuing." + .to_string(), + ); + } + } + Ok(()) +} + +#[cfg(feature = "system-keyring")] +fn connection_secret_key( + scope: &ProjectConnectionScope, + id: &str, + credential_generation: &str, +) -> String { + format!( + "project-connection:{}:{id}:{credential_generation}", + workspace_scope_id(scope) + ) +} + +#[cfg(not(feature = "system-keyring"))] +fn connection_secret_path( + app: &AppHandle, + scope: &ProjectConnectionScope, + id: &str, + credential_generation: &str, +) -> Result { + let dir = workspace_connection_dir(app, scope)?.join("secrets"); + ensure_owner_only_directory(&dir)?; + let digest = Sha256::digest(format!("{id}\0{credential_generation}").as_bytes()); + Ok(dir.join(format!("{}.json", hex::encode(digest)))) +} + +fn serialize_secrets(env: &BTreeMap) -> Result, String> { + let serialized = serde_json::to_vec(env) + .map_err(|error| format!("failed to prepare connection credentials: {error}"))?; + if serialized.len() > MAX_SECRET_FILE_BYTES { + return Err("The connection secret values exceed Buzz's size limit.".to_string()); + } + Ok(serialized) +} + +fn store_secrets( + app: &AppHandle, + scope: &ProjectConnectionScope, + id: &str, + credential_generation: &str, + env: &BTreeMap, +) -> Result<(), String> { + if env.is_empty() { + return delete_secrets(app, scope, id, credential_generation); + } + let serialized = serialize_secrets(env)?; + #[cfg(feature = "system-keyring")] + { + let raw = String::from_utf8(serialized) + .map_err(|_| "Buzz could not prepare these credentials.".to_string())?; + let key = connection_secret_key(scope, id, credential_generation); + let store = SecretStore::shared(keyring_service()); + store.store(&key, &raw).map_err(|_| { + "Buzz could not save these credentials in the system keyring.".to_string() + })?; + if !store + .verify_stored_raw(&key, &raw) + .map_err(|_| "Buzz could not verify the saved credentials.".to_string())? + { + return Err("Buzz could not verify the saved credentials.".to_string()); + } + } + #[cfg(not(feature = "system-keyring"))] + { + let path = connection_secret_path(app, scope, id, credential_generation)?; + reject_unsafe_owner_file(&path)?; + atomic_write_json_restricted(&path, &serialized)?; + } + Ok(()) +} + +fn load_secrets( + app: &AppHandle, + connection: &StoredProjectConnection, +) -> Result, String> { + if connection.env_keys.is_empty() { + return Ok(BTreeMap::new()); + } + #[cfg(feature = "system-keyring")] + let _ = app; + #[cfg(feature = "system-keyring")] + let raw = SecretStore::shared(keyring_service()) + .load(&connection_secret_key( + &connection.project_scope, + &connection.id, + &connection.credential_generation, + )) + .map_err(|_| { + format!( + "Sign in again to '{}'. Buzz could not read its saved credentials.", + connection.name + ) + })? + .ok_or_else(|| { + format!( + "Sign in again to '{}'. Its saved credentials are missing.", + connection.name + ) + })? + .into_bytes(); + #[cfg(not(feature = "system-keyring"))] + let raw = { + let path = connection_secret_path( + app, + &connection.project_scope, + &connection.id, + &connection.credential_generation, + )?; + reject_unsafe_owner_file(&path)?; + read_bounded_file(&path, MAX_SECRET_FILE_BYTES).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!( + "Sign in again to '{}'. Its saved credentials are missing.", + connection.name + ) + } else { + format!( + "Sign in again to '{}'. Its saved credentials are invalid.", + connection.name + ) + } + })? + }; + if raw.len() > MAX_SECRET_FILE_BYTES { + return Err(format!( + "Sign in again to '{}'. Its saved credentials are invalid.", + connection.name + )); + } + let env: BTreeMap = serde_json::from_slice(&raw).map_err(|_| { + format!( + "Sign in again to '{}'. Its credentials are invalid.", + connection.name + ) + })?; + let actual: BTreeSet<&str> = env.keys().map(String::as_str).collect(); + let expected: BTreeSet<&str> = connection.env_keys.iter().map(String::as_str).collect(); + if actual != expected { + return Err(format!( + "Sign in again to '{}'. Its saved credentials are incomplete.", + connection.name + )); + } + validate_connection_input( + &connection.name, + &connection.provider, + &connection.command, + &connection.args, + &env, + ) + .map_err(|_| { + format!( + "Sign in again to '{}'. Its saved credentials are invalid.", + connection.name + ) + })?; + Ok(env) +} + +fn delete_secrets( + app: &AppHandle, + scope: &ProjectConnectionScope, + id: &str, + credential_generation: &str, +) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + let _ = app; + SecretStore::shared(keyring_service()).delete(&connection_secret_key( + scope, + id, + credential_generation, + )) + } + #[cfg(not(feature = "system-keyring"))] + { + let path = connection_secret_path(app, scope, id, credential_generation)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove saved credentials: {error}")), + } + } +} + +fn validate_connection_input( + name: &str, + provider: &str, + command: &str, + args: &[String], + env: &BTreeMap, +) -> Result<(), String> { + if name.trim().is_empty() || name.len() > MAX_NAME_BYTES || name.chars().any(char::is_control) { + return Err("Give this connection a short name.".to_string()); + } + if provider.trim().is_empty() + || provider.len() > MAX_PROVIDER_BYTES + || provider.chars().any(char::is_control) + { + return Err("Name the service this connection uses.".to_string()); + } + if command.trim().is_empty() + || command.len() > MAX_COMMAND_BYTES + || command.contains('\0') + || command.contains('\n') + { + return Err("Enter a valid MCP server executable path.".to_string()); + } + if args.len() > MAX_ARGS + || args + .iter() + .any(|arg| arg.len() > MAX_ARG_BYTES || arg.contains('\0')) + { + return Err("The MCP server arguments exceed Buzz's safety limits.".to_string()); + } + if env.len() > MAX_ENV_KEYS { + return Err("This connection has too many secret values.".to_string()); + } + let mut total = 0usize; + let mut normalized_env_keys = BTreeSet::new(); + for (key, value) in env { + if !super::is_well_formed_env_key(key) { + let displayed = super::display_invalid_key(key); + return Err(format!( + "'{displayed}' cannot be used as a connection secret name." + )); + } + if super::is_reserved_env_key(key) || !normalized_env_keys.insert(key.to_ascii_uppercase()) + { + return Err(format!( + "'{key}' cannot be used as a connection secret name." + )); + } + if value.is_empty() { + return Err(format!("Enter a value for '{key}' or remove it.")); + } + if value.contains('\0') { + return Err(format!( + "The value for '{key}' contains an invalid character." + )); + } + total = total.saturating_add(key.len()).saturating_add(value.len()); + } + if total > MAX_SECRET_BYTES { + return Err("The connection secret values exceed Buzz's size limit.".to_string()); + } + Ok(()) +} + +fn executable_sha256_file(file: &mut fs::File) -> Result { + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(|_| "Buzz could not read this executable.".to_string())?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(hex::encode(digest.finalize())) +} + +fn executable_sha256(path: &Path) -> Result { + let mut file = + fs::File::open(path).map_err(|_| "Buzz could not read this executable.".to_string())?; + executable_sha256_file(&mut file) +} + +fn open_canonical_executable(command: &str) -> Result<(String, fs::File), String> { + let path = Path::new(command.trim()); + if !path.is_absolute() { + return Err("Enter the executable's absolute path.".to_string()); + } + let canonical = + fs::canonicalize(path).map_err(|_| "Buzz could not verify this executable.".to_string())?; + let file = fs::File::open(&canonical) + .map_err(|_| "Buzz could not read this executable.".to_string())?; + let metadata = file + .metadata() + .map_err(|_| "Buzz could not verify this executable.".to_string())?; + if !metadata.is_file() { + return Err("The MCP server path is not an executable file.".to_string()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o111 == 0 { + return Err("The MCP server file is not executable.".to_string()); + } + } + let canonical = canonical + .to_str() + .map(str::to_string) + .ok_or_else(|| "The MCP server path is not valid Unicode.".to_string())?; + Ok((canonical, file)) +} + +fn canonical_connection_command(command: &str) -> Result<(String, String), String> { + let (canonical, mut file) = open_canonical_executable(command)?; + let fingerprint = executable_sha256_file(&mut file)?; + Ok((canonical, fingerprint)) +} + +fn health_for_display(mut connection: StoredProjectConnection) -> StoredProjectConnection { + if connection.health.status == ProjectConnectionHealthStatus::Ready { + let stale = connection + .health + .last_verified_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .is_none_or(|verified| { + chrono::Utc::now().signed_duration_since(verified.with_timezone(&chrono::Utc)) + > chrono::Duration::seconds(HEALTH_STALE_AFTER_SECONDS) + }); + if stale { + connection.health.status = ProjectConnectionHealthStatus::CheckNeeded; + } + } + connection +} + +mod agent_runtime; +pub(crate) use agent_runtime::{ + apply_agent_project_connection_update, materialize_agent_project_connections, + prepare_agent_project_assignment, remove_agent_project_connection_config, + validate_agent_project_connections, write_agent_project_connection_config, +}; + +fn find_connection<'a>( + store: &'a ProjectConnectionStore, + project_scope: &ProjectConnectionScope, + connection_id: &str, +) -> Result<&'a StoredProjectConnection, String> { + store + .connections + .iter() + .find(|connection| { + connection.id == connection_id && connection.project_scope == *project_scope + }) + .ok_or_else(|| "This connection no longer exists in this Project.".to_string()) +} + +pub fn list_project_connections( + app: &AppHandle, + project_scope: &ProjectConnectionScope, +) -> Result, String> { + let project_scope = validate_project_scope_for_app(app, project_scope)?; + let _guard = lock_project_connections(); + let mut connections: Vec<_> = load_store_unlocked(app, &project_scope)? + .connections + .into_iter() + .filter(|connection| connection.project_scope == project_scope) + .map(health_for_display) + .map(ProjectConnection::from) + .collect(); + connections.sort_by(|left, right| { + left.name + .to_ascii_lowercase() + .cmp(&right.name.to_ascii_lowercase()) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(connections) +} + +pub fn create_project_connection( + app: &AppHandle, + mut input: CreateProjectConnectionRequest, +) -> Result { + input.project_scope = validate_project_scope_for_app(app, &input.project_scope)?; + validate_connection_input( + &input.name, + &input.provider, + &input.command, + &input.args, + &input.env, + )?; + if !input.execution_acknowledged { + return Err("Review and acknowledge this local program before saving.".to_string()); + } + let (command, executable_sha256) = canonical_connection_command(&input.command)?; + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &input.project_scope)?; + if store.connections.len() >= MAX_CONNECTIONS { + return Err("Buzz has reached the Project connection limit.".to_string()); + } + let id = Uuid::new_v4().simple().to_string(); + let now = now_iso(); + let credential_generation = next_generation(); + let connection = StoredProjectConnection { + id: id.clone(), + project_scope: input.project_scope.clone(), + name: input.name.trim().to_string(), + provider: input.provider.trim().to_string(), + capability_ids: Vec::new(), + command, + args: input.args, + env_keys: input.env.keys().cloned().collect(), + discovered_tools: Vec::new(), + health: ProjectConnectionHealth::default(), + executable_sha256, + generation: next_generation(), + credential_generation: credential_generation.clone(), + created_at: now.clone(), + updated_at: now, + }; + if !input.env.is_empty() { + store_secrets( + app, + &input.project_scope, + &id, + &credential_generation, + &input.env, + )?; + } + store.connections.push(connection.clone()); + if let Err(error) = save_store_unlocked(app, &input.project_scope, &store) { + if !input.env.is_empty() { + if let Err(cleanup_error) = + delete_secrets(app, &input.project_scope, &id, &credential_generation) + { + return Err(format!( + "{error} Buzz also could not remove the unreferenced credentials: {cleanup_error}" + )); + } + } + return Err(error); + } + Ok(connection.into()) +} + +pub fn update_project_connection( + app: &AppHandle, + mut input: UpdateProjectConnectionRequest, +) -> Result { + input.project_scope = validate_project_scope_for_app(app, &input.project_scope)?; + for key in &input.remove_env_keys { + if !super::is_well_formed_env_key(key) { + return Err("A secret name is invalid.".to_string()); + } + } + let (command, executable_sha256) = canonical_connection_command(&input.command)?; + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &input.project_scope)?; + let previous = find_connection(&store, &input.project_scope, &input.id)?.clone(); + let previous_secrets = load_secrets(app, &previous)?; + let mut next_secrets = previous_secrets.clone(); + for key in &input.remove_env_keys { + next_secrets.remove(key); + } + next_secrets.extend(input.env); + validate_connection_input( + &input.name, + &input.provider, + &command, + &input.args, + &next_secrets, + )?; + let execution_changed = previous.command != command + || previous.executable_sha256 != executable_sha256 + || previous.args != input.args + || previous_secrets != next_secrets; + if execution_changed && !input.execution_acknowledged { + return Err( + "Review and acknowledge the changed program, arguments, and credentials before saving." + .to_string(), + ); + } + let index = store + .connections + .iter() + .position(|connection| connection.id == input.id) + .ok_or_else(|| "This connection no longer exists.".to_string())?; + let mut updated = previous.clone(); + updated.name = input.name.trim().to_string(); + updated.provider = input.provider.trim().to_string(); + updated.command = command; + updated.executable_sha256 = executable_sha256; + updated.args = input.args; + updated.env_keys = next_secrets.keys().cloned().collect(); + updated.generation = next_generation(); + updated.updated_at = now_iso(); + if execution_changed { + updated.capability_ids.clear(); + updated.discovered_tools.clear(); + updated.health = ProjectConnectionHealth::default(); + } + + let secrets_changed = previous_secrets != next_secrets; + if secrets_changed { + updated.credential_generation = next_generation(); + } + commit_update( + &mut store, + UpdateTransaction { + index, + previous: &previous, + updated: &updated, + secrets_changed, + }, + || { + store_secrets( + app, + &input.project_scope, + &input.id, + &updated.credential_generation, + &next_secrets, + ) + }, + |candidate| save_store_unlocked(app, &input.project_scope, candidate), + |generation| delete_secrets(app, &input.project_scope, &input.id, generation), + )?; + Ok(updated.into()) +} + +pub fn delete_project_connection( + app: &AppHandle, + project_scope: &ProjectConnectionScope, + connection_id: &str, +) -> Result<(), String> { + let project_scope = validate_project_scope_for_app(app, project_scope)?; + let state = app.state::(); + let _managed_agents_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = super::load_managed_agents(app)?; + let mut users: Vec<_> = records + .iter() + .filter(|record| { + record + .project_scope + .as_ref() + .is_some_and(|scope| ProjectConnectionScope::from(scope) == project_scope) + && record + .connection_bindings + .values() + .any(|bound| bound == connection_id) + }) + .map(|record| record.name.clone()) + .collect(); + users.sort_by_key(|name| name.to_ascii_lowercase()); + users.dedup(); + if !users.is_empty() { + return Err(format!( + "This connection is used by {}. Remove the agent bindings before deleting it.", + users.join(", ") + )); + } + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &project_scope)?; + let index = store + .connections + .iter() + .position(|connection| { + connection.id == connection_id && connection.project_scope == project_scope + }) + .ok_or_else(|| "This connection no longer exists in this Project.".to_string())?; + commit_delete( + &mut store, + index, + |candidate| save_store_unlocked(app, &project_scope, candidate), + |generation| delete_secrets(app, &project_scope, connection_id, generation), + ) +} + +mod probe; +pub use probe::test_project_connection; + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/project_connections/agent_runtime.rs b/desktop/src-tauri/src/managed_agents/project_connections/agent_runtime.rs new file mode 100644 index 0000000000..8e6e199a84 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections/agent_runtime.rs @@ -0,0 +1,578 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use serde::Serialize; +use sha2::Digest as _; +use tauri::AppHandle; + +use super::{ + canonical_project_scope, connection_mcp_server_name, find_connection, health_for_display, + load_secrets, load_store_unlocked, lock_project_connections, probe::approved_execution_target, + validate_project_scope_for_app, workspace_connection_dir, ProjectConnection, + ProjectConnectionHealthStatus, ProjectConnectionScope, StoredProjectConnection, +}; +use crate::managed_agents::{ + validate_agent_project_scope, validate_tool_requirements, AgentDefinition, AgentProjectScope, + AgentToolRequirement, BackendKind, ManagedAgentRecord, +}; + +#[derive(Serialize)] +struct McpConfigDocument { + version: u32, + servers: Vec, +} + +#[derive(Serialize)] +struct MaterializedMcpServer { + name: String, + transport: &'static str, + command: String, + args: Vec, + env: BTreeMap, +} + +pub(crate) fn canonical_agent_project_scope_for_app( + app: &AppHandle, + scope: &AgentProjectScope, +) -> Result { + let channel_id = uuid::Uuid::parse_str(&scope.channel_id) + .map_err(|_| "Choose a valid Project discussion channel.".to_string())? + .to_string(); + let canonical = validate_project_scope_for_app(app, &ProjectConnectionScope::from(scope))?; + Ok(AgentProjectScope { + relay_url: canonical.relay_url, + operator_pubkey: canonical.operator_pubkey, + project_address: canonical.project_address, + channel_id, + }) +} + +pub(crate) fn prepare_agent_project_assignment( + app: &AppHandle, + definition: Option<&AgentDefinition>, + requested_scope: Option<&AgentProjectScope>, +) -> Result<(Vec, Option), String> { + let requirements = definition + .map(|definition| definition.tool_requirements.clone()) + .unwrap_or_default(); + validate_tool_requirements(&requirements)?; + let scope = requested_scope + .map(|scope| canonical_agent_project_scope_for_app(app, scope)) + .transpose()?; + Ok((requirements, scope)) +} + +pub(crate) fn apply_agent_project_connection_update( + app: &AppHandle, + record: &mut ManagedAgentRecord, + project_scope: Option>, + connection_bindings: Option>, +) -> Result<(), String> { + if let Some(scope) = project_scope { + record.project_scope = scope + .as_ref() + .map(|scope| canonical_agent_project_scope_for_app(app, scope)) + .transpose()?; + } + if let Some(bindings) = connection_bindings { + record.connection_bindings = bindings; + } + validate_agent_project_connections(app, record) +} + +fn validate_agent_bindings_against( + requirements: &[AgentToolRequirement], + project_scope: Option<&AgentProjectScope>, + bindings: &BTreeMap, + connections: &[ProjectConnection], +) -> Result<(), String> { + validate_tool_requirements(requirements)?; + if let Some(scope) = project_scope { + validate_agent_project_scope(scope)?; + if canonical_project_scope(&ProjectConnectionScope::from(scope))? + != ProjectConnectionScope::from(scope) + { + return Err("The agent Project assignment is not canonical.".to_string()); + } + } + + let requirement_by_id: BTreeMap<_, _> = requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement)) + .collect(); + for requirement_id in bindings.keys() { + if !requirement_by_id.contains_key(requirement_id.as_str()) { + return Err(format!( + "Connection binding {:?} does not match a tool requirement.", + requirement_id + )); + } + } + for requirement in requirements { + let Some(connection_id) = bindings.get(&requirement.id) else { + if requirement.required { + return Err(format!( + "Choose a Project connection for {}.", + requirement.label + )); + } + continue; + }; + let scope = project_scope.ok_or_else(|| { + "Choose the Project where this agent will use its connections.".to_string() + })?; + let expected_scope = ProjectConnectionScope::from(scope); + let connection = connections + .iter() + .find(|connection| { + connection.id == *connection_id && connection.project_scope == expected_scope + }) + .ok_or_else(|| { + format!( + "The connection selected for {} no longer exists in this Project.", + requirement.label + ) + })?; + if connection.health.status != ProjectConnectionHealthStatus::Ready { + return Err(format!( + "Test {} again before using it with this agent.", + connection.name + )); + } + if !connection + .capability_ids + .iter() + .any(|capability| capability == &requirement.capability) + { + return Err(format!( + "{} does not provide the capability required by {}.", + connection.name, requirement.label + )); + } + } + Ok(()) +} + +fn connections_for_scope( + app: &AppHandle, + scope: &AgentProjectScope, +) -> Result<(ProjectConnectionScope, Vec), String> { + let canonical = validate_project_scope_for_app(app, &ProjectConnectionScope::from(scope))?; + let store = load_store_unlocked(app, &canonical)?; + let connections = store + .connections + .into_iter() + .filter(|connection| connection.project_scope == canonical) + .collect(); + Ok((canonical, connections)) +} + +pub(crate) fn validate_agent_project_connections( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Result<(), String> { + if record.backend != BackendKind::Local + && (!record.pinned_tool_requirements.is_empty() + || !record.connection_bindings.is_empty() + || record.project_scope.is_some()) + { + return Err( + "Project Connections are currently available only to agents running on this device." + .to_string(), + ); + } + validate_tool_requirements(&record.pinned_tool_requirements)?; + if record.project_scope.is_none() { + return validate_agent_bindings_against( + &record.pinned_tool_requirements, + None, + &record.connection_bindings, + &[], + ); + } + + let Some(scope) = record.project_scope.as_ref() else { + return Err("Choose the Project where this agent will use its connections.".to_string()); + }; + let _guard = lock_project_connections(); + let (_, stored) = connections_for_scope(app, scope)?; + let public: Vec<_> = stored + .into_iter() + .map(health_for_display) + .map(ProjectConnection::from) + .collect(); + validate_agent_bindings_against( + &record.pinned_tool_requirements, + Some(scope), + &record.connection_bindings, + &public, + ) +} + +fn materialized_server( + app: &AppHandle, + connection: &StoredProjectConnection, +) -> Result { + let approved_command = approved_execution_target(app, connection)?; + Ok(MaterializedMcpServer { + name: connection_mcp_server_name(&connection.id), + transport: "stdio", + command: approved_command.to_string_lossy().to_string(), + args: connection.args.clone(), + env: load_secrets(app, connection)?, + }) +} + +fn serialize_mcp_config( + servers: Vec, + legacy_mcp_command: Option<&str>, +) -> Result, String> { + const MAX_CONFIG_BYTES: usize = 64 * 1024; + const MAX_SERVERS: usize = 16; + let legacy_count = usize::from(legacy_mcp_command.is_some_and(|command| !command.is_empty())); + if servers.len() + legacy_count > MAX_SERVERS { + return Err(format!( + "Project connections exceed the agent runtime limit of {MAX_SERVERS} MCP servers." + )); + } + let legacy_name = legacy_mcp_command + .filter(|command| !command.is_empty()) + .and_then(|command| Path::new(command).file_stem()) + .and_then(|name| name.to_str()) + .unwrap_or("mcp"); + let mut server_names = BTreeSet::new(); + for server in &servers { + if !server_names.insert(server.name.as_str()) + || (legacy_count != 0 && server.name == legacy_name) + { + return Err("Project connections have colliding MCP server names.".to_string()); + } + let mut normalized_env_keys = BTreeSet::new(); + if server + .env + .keys() + .any(|key| !normalized_env_keys.insert(key.to_ascii_uppercase())) + { + return Err(format!( + "Project connection {} has duplicate secret names.", + server.name + )); + } + } + let document = McpConfigDocument { + version: 1, + servers, + }; + let bytes = serde_json::to_vec(&document) + .map_err(|error| format!("failed to prepare Project connections: {error}"))?; + if bytes.len() > MAX_CONFIG_BYTES { + return Err(format!( + "Project connections exceed the agent runtime's {MAX_CONFIG_BYTES} byte limit." + )); + } + #[cfg(test)] + buzz_acp_pkg::validate_structured_mcp_config(&bytes, legacy_mcp_command) + .map_err(|error| format!("Project connections exceed the agent runtime limits: {error}"))?; + Ok(bytes) +} + +fn validate_session_tool_count(counts: impl IntoIterator) -> Result<(), String> { + let total = counts + .into_iter() + .try_fold(0usize, usize::checked_add) + .ok_or_else(|| "Project connections expose too many tools.".to_string())?; + if total > buzz_agent_pkg::MAX_MCP_TOOLS_PER_SESSION { + return Err(format!( + "Project connections exceed the bundled agent limit of {} tools.", + buzz_agent_pkg::MAX_MCP_TOOLS_PER_SESSION + )); + } + Ok(()) +} + +pub(crate) fn materialize_agent_project_connections( + app: &AppHandle, + record: &ManagedAgentRecord, + legacy_mcp_command: Option<&str>, +) -> Result>, String> { + if record.backend != BackendKind::Local + && (!record.pinned_tool_requirements.is_empty() + || !record.connection_bindings.is_empty() + || record.project_scope.is_some()) + { + return Err( + "Project Connections are currently available only to agents running on this device." + .to_string(), + ); + } + validate_tool_requirements(&record.pinned_tool_requirements)?; + let Some(scope) = record.project_scope.as_ref() else { + validate_agent_bindings_against( + &record.pinned_tool_requirements, + None, + &record.connection_bindings, + &[], + )?; + return Ok(None); + }; + if record.connection_bindings.is_empty() { + validate_agent_bindings_against( + &record.pinned_tool_requirements, + Some(scope), + &record.connection_bindings, + &[], + )?; + return Ok(None); + } + let _guard = lock_project_connections(); + let (canonical, store_connections) = connections_for_scope(app, scope)?; + let public: Vec<_> = store_connections + .iter() + .cloned() + .map(health_for_display) + .map(ProjectConnection::from) + .collect(); + validate_agent_bindings_against( + &record.pinned_tool_requirements, + Some(scope), + &record.connection_bindings, + &public, + )?; + let store = super::ProjectConnectionStore { + version: super::CONNECTION_STORE_VERSION, + connections: store_connections, + }; + let connection_ids: BTreeSet<_> = record.connection_bindings.values().cloned().collect(); + let selected_connections = connection_ids + .iter() + .map(|connection_id| find_connection(&store, &canonical, connection_id)) + .collect::, _>>()?; + validate_session_tool_count( + selected_connections + .iter() + .map(|connection| connection.discovered_tools.len()), + )?; + let mut servers = Vec::with_capacity(connection_ids.len()); + for connection in selected_connections { + servers.push(materialized_server(app, connection)?); + } + serialize_mcp_config(servers, legacy_mcp_command).map(Some) +} + +pub(crate) fn write_agent_project_connection_config( + app: &AppHandle, + record: &ManagedAgentRecord, + bytes: &[u8], +) -> Result { + let scope = record.project_scope.as_ref().ok_or_else(|| { + "Choose the Project where this agent will use its connections.".to_string() + })?; + let canonical = validate_project_scope_for_app(app, &ProjectConnectionScope::from(scope))?; + let runtime_dir = workspace_connection_dir(app, &canonical)?.join("runtime"); + super::ensure_owner_only_directory(&runtime_dir)?; + let agent_digest = sha2::Sha256::digest(record.pubkey.as_bytes()); + let path = runtime_dir.join(format!( + "agent-{}-{}.json", + hex::encode(&agent_digest[..8]), + uuid::Uuid::new_v4().simple() + )); + super::reject_unsafe_owner_file(&path)?; + super::atomic_write_json_restricted(&path, bytes)?; + Ok(path) +} + +pub(crate) fn remove_agent_project_connection_config(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove Project connection launch file {}: {error}", + path.display() + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::project_connections::ProjectConnectionHealth; + + fn scope(channel_id: &str) -> AgentProjectScope { + AgentProjectScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:analytics", "a".repeat(64)), + channel_id: channel_id.to_string(), + } + } + + fn requirement(id: &str, capability: &str, required: bool) -> AgentToolRequirement { + AgentToolRequirement { + id: id.to_string(), + label: "Analytics".to_string(), + capability: capability.to_string(), + required, + } + } + + fn connection(scope: &AgentProjectScope, id: &str) -> ProjectConnection { + ProjectConnection { + id: id.to_string(), + project_scope: ProjectConnectionScope::from(scope), + name: "Analytics connection".to_string(), + provider: "Local".to_string(), + capability_ids: vec!["mcp.tool.analytics_weekly_summary".to_string()], + command: "/usr/bin/true".to_string(), + args: Vec::new(), + env_keys: Vec::new(), + discovered_tools: vec!["analytics_weekly_summary".to_string()], + health: ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::Ready, + last_verified_at: Some(crate::util::now_iso()), + detail: None, + }, + created_at: crate::util::now_iso(), + updated_at: crate::util::now_iso(), + } + } + + fn materialized_server_for_test(index: usize) -> MaterializedMcpServer { + MaterializedMcpServer { + name: format!("project_{index:032x}"), + transport: "stdio", + command: "/usr/bin/true".to_string(), + args: Vec::new(), + env: BTreeMap::new(), + } + } + + #[test] + fn required_optional_orphan_scope_health_and_capability_are_enforced() { + let scope = scope(&uuid::Uuid::nil().to_string()); + let connection_id = "c".repeat(32); + let required = requirement("analytics", "mcp.tool.analytics_weekly_summary", true); + assert!(validate_agent_bindings_against( + std::slice::from_ref(&required), + Some(&scope), + &BTreeMap::new(), + &[] + ) + .is_err()); + + let optional = requirement("analytics", "mcp.tool.analytics_weekly_summary", false); + assert!(validate_agent_bindings_against(&[optional], None, &BTreeMap::new(), &[]).is_ok()); + + let bindings = BTreeMap::from([("unknown".to_string(), connection_id.clone())]); + assert!(validate_agent_bindings_against( + std::slice::from_ref(&required), + Some(&scope), + &bindings, + &[], + ) + .is_err()); + + let bindings = BTreeMap::from([("analytics".to_string(), connection_id.clone())]); + let mut wrong_scope = scope.clone(); + wrong_scope.project_address = format!("30621:{}:other", "a".repeat(64)); + assert!(validate_agent_bindings_against( + std::slice::from_ref(&required), + Some(&wrong_scope), + &bindings, + &[connection(&scope, &connection_id)], + ) + .is_err()); + + let mut unavailable = connection(&scope, &connection_id); + unavailable.health.status = ProjectConnectionHealthStatus::CheckNeeded; + assert!(validate_agent_bindings_against( + std::slice::from_ref(&required), + Some(&scope), + &bindings, + &[unavailable], + ) + .is_err()); + + let wrong_capability = requirement("analytics", "mcp.tool.analytics.delete_all", true); + assert!(validate_agent_bindings_against( + &[wrong_capability], + Some(&scope), + &bindings, + &[connection(&scope, &connection_id)], + ) + .is_err()); + + assert!(validate_agent_bindings_against( + &[required], + Some(&scope), + &bindings, + &[connection(&scope, &connection_id)], + ) + .is_ok()); + } + + #[test] + fn one_connection_can_satisfy_multiple_requirements_once() { + let scope = scope(&uuid::Uuid::nil().to_string()); + let connection_id = "c".repeat(32); + let connection = connection(&scope, &connection_id); + let requirements = vec![ + requirement("weekly", "mcp.tool.analytics_weekly_summary", true), + requirement("monthly", "mcp.tool.analytics_weekly_summary", true), + ]; + let bindings = BTreeMap::from([ + ("weekly".to_string(), connection_id.clone()), + ("monthly".to_string(), connection_id), + ]); + assert!(validate_agent_bindings_against( + &requirements, + Some(&scope), + &bindings, + &[connection], + ) + .is_ok()); + assert_eq!(bindings.values().collect::>().len(), 1,); + } + + #[test] + fn materialized_config_enforces_harness_server_and_size_limits() { + let sixteen = (0..16) + .map(materialized_server_for_test) + .collect::>(); + assert!(serialize_mcp_config(sixteen, None).is_ok()); + + let sixteen_with_legacy = (0..16) + .map(materialized_server_for_test) + .collect::>(); + assert!(serialize_mcp_config(sixteen_with_legacy, Some("/usr/bin/legacy")).is_err()); + + let mut oversized = materialized_server_for_test(0); + oversized.args.push("x".repeat(64 * 1024)); + assert!(serialize_mcp_config(vec![oversized], None).is_err()); + } + + #[test] + fn materialized_config_rejects_case_colliding_environment_names() { + let mut server = materialized_server_for_test(0); + server.env = BTreeMap::from([ + ("API_TOKEN".to_string(), "one".to_string()), + ("api_token".to_string(), "two".to_string()), + ]); + assert!(serialize_mcp_config(vec![server], None).is_err()); + } + + #[test] + fn materialized_config_rejects_structured_and_legacy_server_name_collisions() { + let server = materialized_server_for_test(0); + let legacy = format!("/usr/local/bin/{}", server.name); + assert!(serialize_mcp_config(vec![server], Some(&legacy)).is_err()); + } + + #[test] + fn selected_project_tools_fit_the_bundled_session_count_contract() { + assert!(validate_session_tool_count([64, 64]).is_ok()); + assert!(validate_session_tool_count([128, 1]).is_err()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/project_connections/probe.rs b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs new file mode 100644 index 0000000000..dd5b039e54 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs @@ -0,0 +1,655 @@ +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Read as _, Write as _}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::Mutex, + time::Duration, +}; + +use super::*; + +const TEST_TIMEOUT: Duration = Duration::from_secs(8); +const CLEANUP_TIMEOUT: Duration = Duration::from_millis(500); +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const PROBE_BUSY_ERROR: &str = + "Another Project connection is being tested. Try again when it finishes."; +const EXECUTABLE_CHANGED_ERROR: &str = + "This executable changed after it was approved. Edit the connection and review it again."; + +static PROJECT_CONNECTION_PROBE_LOCK: Mutex<()> = Mutex::new(()); + +enum ReaderMessage { + Line(Vec), + Oversized, + Closed, +} + +fn inherited_test_env() -> BTreeMap { + [ + "PATH", + "HOME", + "USER", + "TMPDIR", + "TEMP", + "TMP", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] + .into_iter() + .filter_map(|key| { + std::env::var(key) + .ok() + .map(|value| (key.to_string(), value)) + }) + .collect() +} + +fn recv_json_response( + rx: &std::sync::mpsc::Receiver, + expected_id: u64, +) -> Result { + let deadline = std::time::Instant::now() + TEST_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let message = rx + .recv_timeout(remaining) + .map_err(|_| "The MCP server did not respond in time.".to_string())?; + let line = match message { + ReaderMessage::Line(line) => line, + ReaderMessage::Oversized => { + return Err("The MCP server returned an oversized response.".to_string()); + } + ReaderMessage::Closed => { + return Err("The MCP server closed before responding.".to_string()); + } + }; + let value: serde_json::Value = serde_json::from_slice(&line) + .map_err(|_| "The MCP server returned an invalid response.".to_string())?; + if value.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id) { + if value.get("error").is_some() { + return Err("The MCP server rejected the request.".to_string()); + } + return value + .get("result") + .cloned() + .ok_or_else(|| "The MCP server returned no result.".to_string()); + } + } +} + +fn read_bounded_line(reader: &mut impl BufRead) -> Result>, ()> { + let mut line = Vec::new(); + let count = reader + .take((MAX_RESPONSE_BYTES + 1) as u64) + .read_until(b'\n', &mut line) + .map_err(|_| ())?; + if count == 0 { + return Ok(None); + } + if line.len() > MAX_RESPONSE_BYTES { + return Err(()); + } + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + Ok(Some(line)) +} + +fn stop_child(child: &mut Child, pid: u32) -> Result<(), String> { + let termination = super::super::runtime::terminate_process(pid); + let deadline = std::time::Instant::now() + CLEANUP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => { + return termination.map_err(|_| { + "Buzz stopped the MCP server, but could not verify process-group cleanup." + .to_string() + }) + } + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => break, + Err(_) => return Err("Buzz could not verify that the MCP server stopped.".to_string()), + } + } + let _ = child.kill(); + let kill_deadline = std::time::Instant::now() + CLEANUP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => { + return Err("Buzz had to force-stop this MCP server after the test.".to_string()); + } + Ok(None) if std::time::Instant::now() < kill_deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + _ => { + return Err("Buzz could not stop this MCP server after the test.".to_string()); + } + } + } +} + +#[cfg(test)] +fn verify_saved_executable(connection: &StoredProjectConnection) -> Result<(), String> { + let (canonical, fingerprint) = canonical_connection_command(&connection.command)?; + if canonical != connection.command || fingerprint != connection.executable_sha256 { + return Err(EXECUTABLE_CHANGED_ERROR.to_string()); + } + Ok(()) +} + +fn approved_target_path(directory: &Path, connection: &StoredProjectConnection) -> PathBuf { + let base = format!("{}-{}", connection.id, connection.executable_sha256); + match Path::new(&connection.command) + .extension() + .and_then(|extension| extension.to_str()) + { + Some(extension) if !extension.is_empty() => directory.join(format!("{base}.{extension}")), + _ => directory.join(base), + } +} + +fn validate_existing_approved_target( + path: &Path, + expected_sha256: &str, +) -> Result { + reject_unsafe_owner_file(path)?; + let actual = executable_sha256(path)?; + if actual != expected_sha256 { + return Err("Buzz refused a modified approved Project executable.".to_string()); + } + fs::canonicalize(path) + .map_err(|error| format!("failed to resolve approved Project executable: {error}")) +} + +fn prepare_approved_executable_in_dir( + directory: &Path, + connection: &StoredProjectConnection, +) -> Result { + let (canonical, mut source) = open_canonical_executable(&connection.command)?; + if canonical != connection.command { + return Err(EXECUTABLE_CHANGED_ERROR.to_string()); + } + let target = approved_target_path(directory, connection); + if target.exists() { + let source_sha256 = executable_sha256_file(&mut source)?; + if source_sha256 != connection.executable_sha256 { + return Err(EXECUTABLE_CHANGED_ERROR.to_string()); + } + return validate_existing_approved_target(&target, &connection.executable_sha256); + } + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o500); + } + let mut destination = options + .open(&target) + .map_err(|error| format!("failed to prepare approved Project executable: {error}"))?; + let copied = (|| { + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = source + .read(&mut buffer) + .map_err(|_| "Buzz could not read this executable.".to_string())?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + destination.write_all(&buffer[..count]).map_err(|error| { + format!("failed to prepare approved Project executable: {error}") + })?; + } + let actual = hex::encode(digest.finalize()); + if actual != connection.executable_sha256 { + return Err(EXECUTABLE_CHANGED_ERROR.to_string()); + } + destination + .sync_all() + .map_err(|error| format!("failed to prepare approved Project executable: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + destination + .set_permissions(fs::Permissions::from_mode(0o500)) + .map_err(|error| { + format!("failed to protect approved Project executable: {error}") + })?; + } + Ok(()) + })(); + drop(destination); + if let Err(error) = copied { + let _ = fs::remove_file(&target); + return Err(error); + } + validate_existing_approved_target(&target, &connection.executable_sha256) +} + +pub(super) fn approved_execution_target( + app: &AppHandle, + connection: &StoredProjectConnection, +) -> Result { + let directory = workspace_connection_dir(app, &connection.project_scope)?.join("approved"); + ensure_owner_only_directory(&directory)?; + prepare_approved_executable_in_dir(&directory, connection) +} + +fn probe_mcp_connection( + connection: &StoredProjectConnection, + secrets: &BTreeMap, +) -> Result, String> { + let _probe_guard = PROJECT_CONNECTION_PROBE_LOCK + .try_lock() + .map_err(|_| PROBE_BUSY_ERROR.to_string())?; + let mut command = Command::new(&connection.command); + command + .args(&connection.args) + .env_clear() + .envs(inherited_test_env()) + .envs(secrets) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + let mut child = command + .spawn() + .map_err(|_| "Buzz could not start this MCP server.".to_string())?; + let pid = child.id(); + let Some(stdout) = child.stdout.take() else { + let _ = stop_child(&mut child, pid); + return Err("Buzz could not read from this MCP server.".to_string()); + }; + let Some(mut stdin) = child.stdin.take() else { + let _ = stop_child(&mut child, pid); + return Err("Buzz could not write to this MCP server.".to_string()); + }; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let message = match read_bounded_line(&mut reader) { + Ok(Some(line)) => ReaderMessage::Line(line), + Ok(None) => ReaderMessage::Closed, + Err(()) => ReaderMessage::Oversized, + }; + let terminal = matches!(message, ReaderMessage::Closed | ReaderMessage::Oversized); + if tx.send(message).is_err() || terminal { + return; + } + } + }); + + let result = (|| { + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "buzz-desktop", + "version": env!("CARGO_PKG_VERSION") + } + } + }); + writeln!(stdin, "{initialize}") + .and_then(|_| stdin.flush()) + .map_err(|_| "Buzz could not initialize this MCP server.".to_string())?; + let initialized = recv_json_response(&rx, 1)?; + if initialized.get("protocolVersion").is_none() { + return Err("The MCP server did not complete initialization.".to_string()); + } + writeln!( + stdin, + "{}", + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + ) + .and_then(|_| { + writeln!( + stdin, + "{}", + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }) + ) + }) + .and_then(|_| stdin.flush()) + .map_err(|_| "Buzz could not inspect this MCP server.".to_string())?; + let tools_result = recv_json_response(&rx, 2)?; + let tools = tools_result + .get("tools") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "The MCP server did not return a tool list.".to_string())?; + if tools.len() > buzz_agent_pkg::MAX_MCP_TOOLS_PER_SESSION { + return Err("The MCP server returned too many tools.".to_string()); + } + let server_name = connection_mcp_server_name(&connection.id); + let mut names = Vec::with_capacity(tools.len()); + for tool in tools { + let name = tool + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|name| { + valid_stable_id(name, 128) + && buzz_agent_pkg::supports_mcp_server_tool_name(&server_name, name) + }) + .ok_or_else(|| "The MCP server returned an invalid tool name.".to_string())?; + names.push(name.to_string()); + } + names.sort(); + names.dedup(); + if names.is_empty() { + return Err("The MCP server did not expose any tools.".to_string()); + } + Ok(names) + })(); + + drop(stdin); + drop(rx); + match stop_child(&mut child, pid) { + Ok(()) => result, + Err(cleanup_error) => Err(cleanup_error), + } +} + +fn safe_health_detail(error: &str) -> String { + match error { + "The MCP server did not respond in time." => error.to_string(), + "Buzz could not start this MCP server." => error.to_string(), + PROBE_BUSY_ERROR | EXECUTABLE_CHANGED_ERROR => error.to_string(), + _ => "Buzz could not verify this MCP server.".to_string(), + } +} + +pub fn test_project_connection( + app: &AppHandle, + project_scope: &ProjectConnectionScope, + connection_id: &str, +) -> Result { + let project_scope = validate_project_scope_for_app(app, project_scope)?; + let connection = { + let _guard = lock_project_connections(); + let store = load_store_unlocked(app, &project_scope)?; + find_connection(&store, &project_scope, connection_id)?.clone() + }; + let approved_target = match approved_execution_target(app, &connection) { + Ok(path) => path, + Err(error) => { + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &project_scope)?; + if let Some(current) = store.connections.iter_mut().find(|candidate| { + candidate.id == connection.id + && candidate.project_scope == project_scope + && candidate.generation == connection.generation + }) { + current.updated_at = now_iso(); + current.health = ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::CheckNeeded, + last_verified_at: None, + detail: Some("Executable approval is out of date.".to_string()), + }; + save_store_unlocked(app, &project_scope, &store)?; + } + return Err(error); + } + }; + let secrets = match load_secrets(app, &connection) { + Ok(secrets) => secrets, + Err(error) => { + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &project_scope)?; + if let Some(current) = store.connections.iter_mut().find(|candidate| { + candidate.id == connection.id + && candidate.project_scope == project_scope + && candidate.generation == connection.generation + }) { + current.updated_at = now_iso(); + current.health = ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::SignInRequired, + last_verified_at: None, + detail: Some("Saved credentials are unavailable.".to_string()), + }; + save_store_unlocked(app, &project_scope, &store)?; + } + return Err(error); + } + }; + let mut approved_connection = connection.clone(); + approved_connection.command = approved_target.to_string_lossy().to_string(); + let result = probe_mcp_connection(&approved_connection, &secrets); + if matches!(&result, Err(error) if error == PROBE_BUSY_ERROR) { + return Err(PROBE_BUSY_ERROR.to_string()); + } + let _guard = lock_project_connections(); + let mut store = load_store_unlocked(app, &project_scope)?; + let index = store + .connections + .iter() + .position(|candidate| { + candidate.id == connection_id && candidate.project_scope == project_scope + }) + .ok_or_else(|| "This connection was removed while Buzz tested it.".to_string())?; + if store.connections[index].generation != connection.generation { + return Err("This connection changed while Buzz tested it. Test it again.".to_string()); + } + let connection = &mut store.connections[index]; + connection.updated_at = now_iso(); + match result { + Ok(tools) => { + connection.discovered_tools = tools.clone(); + connection.capability_ids = tools + .iter() + .map(|tool| format!("mcp.tool.{tool}")) + .collect(); + connection.health = ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::Ready, + last_verified_at: Some(now_iso()), + detail: None, + }; + let updated = connection.clone(); + save_store_unlocked(app, &project_scope, &store)?; + Ok(updated.into()) + } + Err(error) => { + connection.health = ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::Unavailable, + last_verified_at: None, + detail: Some(safe_health_detail(&error)), + }; + save_store_unlocked(app, &project_scope, &store)?; + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use std::path::Path; + + fn stored_connection_for_test( + command: String, + executable_sha256: String, + ) -> StoredProjectConnection { + StoredProjectConnection { + id: "c".repeat(32), + project_scope: ProjectConnectionScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:portable-agents", "a".repeat(64)), + }, + name: "Test".to_string(), + provider: "Fixture".to_string(), + capability_ids: Vec::new(), + command, + args: Vec::new(), + env_keys: Vec::new(), + discovered_tools: Vec::new(), + health: ProjectConnectionHealth::default(), + executable_sha256, + generation: next_generation(), + credential_generation: next_generation(), + created_at: now_iso(), + updated_at: now_iso(), + } + } + + #[test] + fn synthetic_server_proves_initialize_and_tool_discovery() { + let node = super::super::super::resolve_command("node") + .expect("Hermit must provide Node for desktop tests"); + let script = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tests/fixtures/synthetic-project-connection-mcp.mjs"); + assert!(script.is_file(), "missing fixture {}", script.display()); + let executable_sha256 = executable_sha256(&node).unwrap(); + let connection = StoredProjectConnection { + id: "synthetic-project-connection".to_string(), + project_scope: ProjectConnectionScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:portable-agents", "a".repeat(64)), + }, + name: "Synthetic analytics".to_string(), + provider: "Buzz test fixture".to_string(), + capability_ids: Vec::new(), + command: node.to_string_lossy().to_string(), + args: vec![script.to_string_lossy().to_string()], + env_keys: vec!["PROJECT_CONNECTION_CANARY".to_string()], + discovered_tools: Vec::new(), + health: ProjectConnectionHealth::default(), + executable_sha256, + generation: next_generation(), + credential_generation: next_generation(), + created_at: now_iso(), + updated_at: now_iso(), + }; + let secrets = BTreeMap::from([( + "PROJECT_CONNECTION_CANARY".to_string(), + "test-only".to_string(), + )]); + + assert_eq!( + probe_mcp_connection(&connection, &secrets).unwrap(), + ["analytics_weekly"] + ); + } + + #[test] + fn bounded_reader_rejects_a_response_without_a_newline() { + let mut input = Cursor::new(vec![b'x'; MAX_RESPONSE_BYTES + 1]); + assert!(read_bounded_line(&mut input).is_err()); + } + + #[test] + fn project_tool_names_fit_the_bundled_runtime_contract() { + let server_name = connection_mcp_server_name(&"c".repeat(32)); + assert!(buzz_agent_pkg::supports_mcp_server_tool_name( + &server_name, + "analytics_weekly" + )); + assert!(!buzz_agent_pkg::supports_mcp_server_tool_name( + &server_name, + "analytics.weekly_summary" + )); + assert!(!buzz_agent_pkg::supports_mcp_server_tool_name( + &server_name, + "double__separator" + )); + assert!(!buzz_agent_pkg::supports_mcp_server_tool_name( + &server_name, + &"x".repeat(43) + )); + assert_eq!(buzz_agent_pkg::MAX_MCP_TOOLS_PER_SESSION, 128); + } + + #[cfg(unix)] + #[test] + fn approved_execution_copy_is_immune_to_source_path_replacement() { + use std::os::unix::fs::PermissionsExt as _; + + let source_dir = tempfile::tempdir().unwrap(); + let approved_dir = tempfile::tempdir().unwrap(); + let executable = source_dir.path().join("server"); + fs::write(&executable, b"approved").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let (command, expected_sha256) = + canonical_connection_command(executable.to_str().unwrap()).unwrap(); + let mut connection = stored_connection_for_test(command, expected_sha256); + + let target = prepare_approved_executable_in_dir(approved_dir.path(), &connection).unwrap(); + fs::remove_file(&executable).unwrap(); + fs::write(&executable, b"replacement").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!(fs::read(&target).unwrap(), b"approved"); + assert_eq!( + prepare_approved_executable_in_dir(approved_dir.path(), &connection).unwrap_err(), + EXECUTABLE_CHANGED_ERROR + ); + + connection.command = target.to_string_lossy().to_string(); + connection.executable_sha256 = executable_sha256(&target).unwrap(); + verify_saved_executable(&connection).unwrap(); + } + + #[cfg(unix)] + #[test] + fn executable_replacement_invalidates_approval() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let executable = dir.path().join("server"); + fs::write(&executable, b"first").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let (command, executable_sha256) = + canonical_connection_command(executable.to_str().unwrap()).unwrap(); + let connection = StoredProjectConnection { + id: "c".repeat(32), + project_scope: ProjectConnectionScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:portable-agents", "a".repeat(64)), + }, + name: "Test".to_string(), + provider: "Fixture".to_string(), + capability_ids: Vec::new(), + command, + args: Vec::new(), + env_keys: Vec::new(), + discovered_tools: Vec::new(), + health: ProjectConnectionHealth::default(), + executable_sha256, + generation: next_generation(), + credential_generation: next_generation(), + created_at: now_iso(), + updated_at: now_iso(), + }; + + assert!(verify_saved_executable(&connection).is_ok()); + fs::write(&executable, b"second").unwrap(); + assert_eq!( + verify_saved_executable(&connection).unwrap_err(), + EXECUTABLE_CHANGED_ERROR + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/project_connections/store.rs b/desktop/src-tauri/src/managed_agents/project_connections/store.rs new file mode 100644 index 0000000000..d98e32614f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections/store.rs @@ -0,0 +1,195 @@ +//! Bounded persistence and full validation for Project connection metadata. + +use super::*; + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +pub(super) fn validate_stored_connection( + connection: &StoredProjectConnection, +) -> Result<(), String> { + if !is_lower_hex(&connection.id, 32) + || !is_lower_hex(&connection.generation, 32) + || !is_lower_hex(&connection.credential_generation, 32) + || !is_lower_hex(&connection.executable_sha256, 64) + || canonical_project_scope(&connection.project_scope)? != connection.project_scope + || !Path::new(&connection.command).is_absolute() + { + return Err("Project connection metadata is invalid.".to_string()); + } + let placeholder_env = connection + .env_keys + .iter() + .map(|key| (key.clone(), "stored".to_string())) + .collect(); + validate_connection_input( + &connection.name, + &connection.provider, + &connection.command, + &connection.args, + &placeholder_env, + ) + .map_err(|_| "Project connection metadata is invalid.".to_string())?; + if connection + .env_keys + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || connection + .discovered_tools + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || connection.discovered_tools.len() > buzz_agent_pkg::MAX_MCP_TOOLS_PER_SESSION + { + return Err("Project connection metadata is invalid.".to_string()); + } + let server_name = connection_mcp_server_name(&connection.id); + if connection + .discovered_tools + .iter() + .any(|tool| !buzz_agent_pkg::supports_mcp_server_tool_name(&server_name, tool)) + { + return Err("Project connection metadata is invalid.".to_string()); + } + let expected_capabilities = connection + .discovered_tools + .iter() + .map(|tool| format!("mcp.tool.{tool}")) + .collect::>(); + if connection.capability_ids != expected_capabilities { + return Err("Project connection metadata is invalid.".to_string()); + } + if chrono::DateTime::parse_from_rfc3339(&connection.created_at).is_err() + || chrono::DateTime::parse_from_rfc3339(&connection.updated_at).is_err() + || connection + .health + .last_verified_at + .as_deref() + .is_some_and(|value| chrono::DateTime::parse_from_rfc3339(value).is_err()) + || connection.health.detail.as_deref().is_some_and(|detail| { + detail.len() > MAX_HEALTH_DETAIL_BYTES || detail.chars().any(char::is_control) + }) + || (connection.health.status == ProjectConnectionHealthStatus::Ready + && (connection.health.last_verified_at.is_none() + || connection.discovered_tools.is_empty())) + { + return Err("Project connection metadata is invalid.".to_string()); + } + Ok(()) +} + +fn connection_store_path( + app: &AppHandle, + scope: &ProjectConnectionScope, +) -> Result { + Ok(workspace_connection_dir(app, scope)?.join("connections.json")) +} + +pub(super) fn read_bounded_file(path: &Path, max_bytes: usize) -> std::io::Result> { + let file = fs::File::open(path)?; + let mut bytes = Vec::new(); + file.take((max_bytes + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "file exceeds size limit", + )); + } + Ok(bytes) +} + +fn validate_store( + store: &ProjectConnectionStore, + scope: &ProjectConnectionScope, +) -> Result<(), String> { + if store.version != CONNECTION_STORE_VERSION { + return Err(format!( + "unsupported Project connection store version {}", + store.version + )); + } + if store.connections.len() > MAX_CONNECTIONS { + return Err("Project connection store exceeds its connection limit".to_string()); + } + let canonical_workspace = canonical_project_scope(scope)?; + let mut ids = BTreeSet::new(); + for connection in &store.connections { + validate_stored_connection(connection)?; + if connection.project_scope.relay_url != canonical_workspace.relay_url + || connection.project_scope.operator_pubkey != canonical_workspace.operator_pubkey + || !ids.insert(connection.id.as_str()) + { + return Err("Project connection metadata is invalid.".to_string()); + } + } + Ok(()) +} + +pub(super) fn load_store_unlocked( + app: &AppHandle, + scope: &ProjectConnectionScope, +) -> Result { + let path = connection_store_path(app, scope)?; + reject_unsafe_owner_file(&path)?; + let bytes = match read_bounded_file(&path, MAX_CONNECTION_STORE_BYTES) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ProjectConnectionStore::default()); + } + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + return Err("Project connection store exceeds its size limit".to_string()); + } + Err(error) => { + return Err(format!( + "failed to read Project connections from {}: {error}", + path.display() + )); + } + }; + let store: ProjectConnectionStore = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse Project connections: {error}"))?; + validate_store(&store, scope)?; + Ok(store) +} + +pub(super) fn save_store_unlocked( + app: &AppHandle, + scope: &ProjectConnectionScope, + store: &ProjectConnectionStore, +) -> Result<(), String> { + validate_store(store, scope)?; + let path = connection_store_path(app, scope)?; + reject_unsafe_owner_file(&path)?; + let bytes = serde_json::to_vec_pretty(store) + .map_err(|error| format!("failed to serialize Project connections: {error}"))?; + if bytes.len() > MAX_CONNECTION_STORE_BYTES { + return Err("Project connection store exceeds its size limit".to_string()); + } + atomic_write_json_restricted(&path, &bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_rejects_duplicate_ids_and_cross_workspace_records() { + let connection = super::super::tests::stored_connection(); + let duplicates = ProjectConnectionStore { + version: CONNECTION_STORE_VERSION, + connections: vec![connection.clone(), connection.clone()], + }; + assert!(validate_store(&duplicates, &connection.project_scope).is_err()); + + let mut foreign = connection.clone(); + foreign.project_scope.operator_pubkey = "f".repeat(64); + let store = ProjectConnectionStore { + version: CONNECTION_STORE_VERSION, + connections: vec![foreign], + }; + assert!(validate_store(&store, &connection.project_scope).is_err()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/project_connections/tests.rs b/desktop/src-tauri/src/managed_agents/project_connections/tests.rs new file mode 100644 index 0000000000..2b100d8213 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections/tests.rs @@ -0,0 +1,195 @@ +use super::*; + +fn scope() -> ProjectConnectionScope { + ProjectConnectionScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "b".repeat(64), + project_address: format!("30621:{}:portable-agents", "a".repeat(64)), + } +} + +pub(super) fn stored_connection() -> StoredProjectConnection { + StoredProjectConnection { + id: "c".repeat(32), + project_scope: scope(), + name: "Analytics".to_string(), + provider: "Local test".to_string(), + capability_ids: vec!["mcp.tool.run_report".to_string()], + command: "/usr/bin/true".to_string(), + args: Vec::new(), + env_keys: vec!["API_TOKEN".to_string()], + discovered_tools: vec!["run_report".to_string()], + health: ProjectConnectionHealth { + status: ProjectConnectionHealthStatus::Ready, + last_verified_at: Some(now_iso()), + detail: None, + }, + executable_sha256: "d".repeat(64), + generation: "e".repeat(32), + credential_generation: "f".repeat(32), + created_at: now_iso(), + updated_at: now_iso(), + } +} + +#[test] +fn project_scope_requires_canonical_relay_identity_and_coordinate() { + assert_eq!(canonical_project_scope(&scope()).unwrap(), scope()); + let mut localhost = scope(); + localhost.relay_url = "ws://localhost:3000".to_string(); + assert_eq!(canonical_project_scope(&localhost).unwrap(), scope()); + let mut invalid = scope(); + invalid.project_address = "local-project-id".to_string(); + assert!(canonical_project_scope(&invalid).is_err()); + let mut invalid = scope(); + invalid.operator_pubkey = "not-a-key".to_string(); + assert!(canonical_project_scope(&invalid).is_err()); + + let mut legacy = scope(); + legacy.project_address = format!("30617:{}:portable-agents", "a".repeat(64)); + assert_eq!(canonical_project_scope(&legacy).unwrap(), legacy); +} + +#[test] +fn public_projection_omits_secret_values_and_internal_generation() { + let public = ProjectConnection::from(stored_connection()); + let json = serde_json::to_value(public).unwrap(); + assert_eq!(json["envKeys"], serde_json::json!(["API_TOKEN"])); + assert!(json.get("generation").is_none()); + assert!(!json.to_string().contains("private-generation")); + assert!(!json.to_string().contains("secret-value")); +} + +#[test] +fn connection_input_rejects_reserved_empty_and_oversized_secrets() { + let valid = BTreeMap::from([("API_TOKEN".to_string(), "value".to_string())]); + assert!(validate_connection_input("Analytics", "Local", "/bin/true", &[], &valid).is_ok()); + let reserved = BTreeMap::from([("BUZZ_PRIVATE_KEY".to_string(), "value".to_string())]); + assert!(validate_connection_input("Analytics", "Local", "/bin/true", &[], &reserved).is_err()); + let empty = BTreeMap::from([("API_TOKEN".to_string(), String::new())]); + assert!(validate_connection_input("Analytics", "Local", "/bin/true", &[], &empty).is_err()); + let oversized = BTreeMap::from([("API_TOKEN".to_string(), "x".repeat(MAX_SECRET_BYTES + 1))]); + assert!(validate_connection_input("Analytics", "Local", "/bin/true", &[], &oversized).is_err()); +} + +#[test] +fn connection_input_rejects_case_collisions_without_echoing_pasted_secrets() { + let collision = BTreeMap::from([ + ("API_TOKEN".to_string(), "one".to_string()), + ("api_token".to_string(), "two".to_string()), + ]); + assert!(validate_connection_input("Analytics", "Local", "/bin/true", &[], &collision).is_err()); + + let pasted = BTreeMap::from([( + "ANTHROPIC_API_KEY=sk-must-not-echo".to_string(), + "ignored".to_string(), + )]); + let error = + validate_connection_input("Analytics", "Local", "/bin/true", &[], &pasted).unwrap_err(); + assert!(!error.contains("sk-must-not-echo")); + assert!(error.contains("ANTHROPIC_API_KEY")); +} + +#[test] +fn stale_ready_connection_is_presented_as_check_needed() { + let mut connection = stored_connection(); + connection.health.last_verified_at = Some("2020-01-01T00:00:00Z".to_string()); + assert_eq!( + health_for_display(connection).health.status, + ProjectConnectionHealthStatus::CheckNeeded + ); +} + +#[test] +fn stored_connection_rejects_invalid_ids_generations_and_fingerprints() { + let mut connection = stored_connection(); + assert!(validate_stored_connection(&connection).is_ok()); + connection.id = "../outside".to_string(); + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.credential_generation = "not-a-generation".to_string(); + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.executable_sha256 = "not-a-fingerprint".to_string(); + assert!(validate_stored_connection(&connection).is_err()); +} + +#[test] +fn stored_connection_revalidates_all_runtime_facing_fields() { + let mut connection = stored_connection(); + connection.name = "spoofed\nname".to_string(); + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.args = vec!["x".repeat(MAX_ARG_BYTES + 1)]; + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.env_keys = vec!["API_TOKEN".to_string(), "api_token".to_string()]; + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.discovered_tools = vec!["unsupported.dotted".to_string()]; + connection.capability_ids = vec!["mcp.tool.unsupported.dotted".to_string()]; + assert!(validate_stored_connection(&connection).is_err()); + + let mut connection = stored_connection(); + connection.capability_ids = vec!["mcp.tool.different".to_string()]; + assert!(validate_stored_connection(&connection).is_err()); +} + +#[test] +fn connection_store_size_is_bounded_before_deserialization() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("connections.json"); + fs::write(&path, vec![b' '; MAX_CONNECTION_STORE_BYTES + 1]).unwrap(); + let error = read_bounded_file(&path, MAX_CONNECTION_STORE_BYTES).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn connection_lookup_cannot_cross_project_boundaries() { + let connection = stored_connection(); + let store = ProjectConnectionStore { + version: CONNECTION_STORE_VERSION, + connections: vec![connection.clone()], + }; + assert!(find_connection(&store, &connection.project_scope, &connection.id).is_ok()); + + let mut other_project = connection.project_scope; + other_project.project_address = format!("30621:{}:other-project", "a".repeat(64)); + assert!(find_connection(&store, &other_project, &connection.id).is_err()); +} + +#[test] +fn generated_server_name_preserves_room_for_mcp_tool_names() { + let server_name = connection_mcp_server_name(&"c".repeat(32)); + assert_eq!(server_name, "project_cccccccccccc"); + assert!(buzz_agent_pkg::supports_mcp_server_tool_name( + &server_name, + "analytics_weekly_summary" + )); +} + +#[cfg(unix)] +#[test] +fn connection_store_rejects_symlinks_and_non_owner_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt as _}; + + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("connections.json"); + let target = dir.path().join("target.json"); + fs::write(&target, b"{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &store).unwrap(); + assert!(reject_unsafe_owner_file(&store).is_err()); + + fs::remove_file(&store).unwrap(); + fs::write(&store, b"{}").unwrap(); + fs::set_permissions(&store, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(reject_unsafe_owner_file(&store).is_err()); + fs::set_permissions(&store, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(reject_unsafe_owner_file(&store).is_ok()); +} diff --git a/desktop/src-tauri/src/managed_agents/project_connections/transactions.rs b/desktop/src-tauri/src/managed_agents/project_connections/transactions.rs new file mode 100644 index 0000000000..7dbfc4cfc9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/project_connections/transactions.rs @@ -0,0 +1,217 @@ +use super::{ProjectConnectionStore, StoredProjectConnection}; + +pub(super) struct UpdateTransaction<'a> { + pub(super) index: usize, + pub(super) previous: &'a StoredProjectConnection, + pub(super) updated: &'a StoredProjectConnection, + pub(super) secrets_changed: bool, +} + +pub(super) fn commit_update( + store: &mut ProjectConnectionStore, + transaction: UpdateTransaction<'_>, + mut write_new: WriteNew, + mut save_metadata: SaveMetadata, + mut delete_generation: DeleteGeneration, +) -> Result<(), String> +where + WriteNew: FnMut() -> Result<(), String>, + SaveMetadata: FnMut(&ProjectConnectionStore) -> Result<(), String>, + DeleteGeneration: FnMut(&str) -> Result<(), String>, +{ + if transaction.secrets_changed { + write_new()?; + } + store.connections[transaction.index] = transaction.updated.clone(); + if let Err(error) = save_metadata(store) { + store.connections[transaction.index] = transaction.previous.clone(); + if transaction.secrets_changed { + if let Err(cleanup_error) = + delete_generation(&transaction.updated.credential_generation) + { + return Err(format!( + "{error} Buzz also could not remove the unreferenced credentials: {cleanup_error}" + )); + } + } + return Err(error); + } + if transaction.secrets_changed && !transaction.previous.env_keys.is_empty() { + delete_generation(&transaction.previous.credential_generation).map_err(|error| { + format!( + "The connection was updated, but Buzz could not remove its superseded credentials: {error}" + ) + })?; + } + Ok(()) +} + +pub(super) fn commit_delete( + store: &mut ProjectConnectionStore, + index: usize, + mut save_metadata: SaveMetadata, + mut delete_generation: DeleteGeneration, +) -> Result<(), String> +where + SaveMetadata: FnMut(&ProjectConnectionStore) -> Result<(), String>, + DeleteGeneration: FnMut(&str) -> Result<(), String>, +{ + let removed = store.connections.remove(index); + save_metadata(store)?; + if removed.env_keys.is_empty() { + return Ok(()); + } + if let Err(error) = delete_generation(&removed.credential_generation) { + store.connections.insert(index, removed); + return match save_metadata(store) { + Ok(()) => Err(format!( + "Buzz could not remove the saved credentials, so the connection was restored: {error}" + )), + Err(restore_error) => Err(format!( + "Buzz could not remove the saved credentials, and could not restore the connection metadata: {error}; {restore_error}" + )), + }; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::project_connections::{ + next_generation, ProjectConnectionHealth, ProjectConnectionScope, + }; + + fn connection(env_keys: &[&str]) -> StoredProjectConnection { + StoredProjectConnection { + id: "c".repeat(32), + project_scope: ProjectConnectionScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:portable-agents", "a".repeat(64)), + }, + name: "Test".to_string(), + provider: "Fixture".to_string(), + capability_ids: Vec::new(), + command: "/usr/bin/true".to_string(), + args: Vec::new(), + env_keys: env_keys.iter().map(|key| (*key).to_string()).collect(), + discovered_tools: Vec::new(), + health: ProjectConnectionHealth::default(), + executable_sha256: "d".repeat(64), + generation: next_generation(), + credential_generation: next_generation(), + created_at: "2026-08-03T00:00:00Z".to_string(), + updated_at: "2026-08-03T00:00:00Z".to_string(), + } + } + + #[test] + fn update_metadata_failure_removes_only_the_new_secret_generation() { + let previous = connection(&["TOKEN"]); + let mut updated = previous.clone(); + updated.credential_generation = next_generation(); + let mut store = ProjectConnectionStore { + version: 1, + connections: vec![previous.clone()], + }; + let mut deleted = Vec::new(); + + let error = commit_update( + &mut store, + UpdateTransaction { + index: 0, + previous: &previous, + updated: &updated, + secrets_changed: true, + }, + || Ok(()), + |_| Err("metadata failed".to_string()), + |generation| { + deleted.push(generation.to_string()); + Ok(()) + }, + ) + .unwrap_err(); + + assert_eq!(error, "metadata failed"); + assert_eq!(store.connections[0], previous); + assert_eq!(deleted, [updated.credential_generation]); + } + + #[test] + fn update_reports_failed_cleanup_without_repointing_metadata() { + let previous = connection(&["TOKEN"]); + let mut updated = previous.clone(); + updated.credential_generation = next_generation(); + let mut store = ProjectConnectionStore { + version: 1, + connections: vec![previous.clone()], + }; + + let error = commit_update( + &mut store, + UpdateTransaction { + index: 0, + previous: &previous, + updated: &updated, + secrets_changed: true, + }, + || Ok(()), + |_| Err("metadata failed".to_string()), + |_| Err("cleanup failed".to_string()), + ) + .unwrap_err(); + + assert!(error.contains("cleanup failed")); + assert_eq!(store.connections[0], previous); + } + + #[test] + fn failed_secret_delete_restores_connection_metadata() { + let previous = connection(&["TOKEN"]); + let mut store = ProjectConnectionStore { + version: 1, + connections: vec![previous.clone()], + }; + let mut saves = 0; + + let error = commit_delete( + &mut store, + 0, + |_| { + saves += 1; + Ok(()) + }, + |_| Err("keyring failed".to_string()), + ) + .unwrap_err(); + + assert!(error.contains("connection was restored")); + assert_eq!(saves, 2); + assert_eq!(store.connections, [previous]); + } + + #[test] + fn secretless_delete_never_touches_the_credential_backend() { + let mut store = ProjectConnectionStore { + version: 1, + connections: vec![connection(&[])], + }; + let mut credential_calls = 0; + + commit_delete( + &mut store, + 0, + |_| Ok(()), + |_| { + credential_calls += 1; + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(credential_calls, 0); + assert!(store.connections.is_empty()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 26902ae8de..6255d6fb95 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1530,6 +1530,9 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), }; let runtime = known_acp_runtime_exact("buzz-agent"); @@ -1727,15 +1730,10 @@ mod tests { ]), ); let result = agent_readiness(&env); - assert!( - result.is_ready(), - "OPENROUTER_MODEL fallback should satisfy model requirement" - ); + assert!(result.is_ready()); } } -// Goose file-config-aware requirement tests live in a sibling file so this -// module stays under the desktop file-size ratchet. #[cfg(test)] #[path = "readiness_goose_file_config_tests.rs"] mod goose_file_config_tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3173126b90..095638e58d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,7 +22,8 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_session_title, runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars, + DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -34,6 +35,9 @@ pub(crate) use sweep::sweep_untracked_bundle_harnesses; type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); +mod configure; +pub(crate) use configure::{build_respond_to_env, configure_runtime_cli}; + mod process; #[cfg(test)] use process::{ @@ -300,6 +304,7 @@ pub fn build_managed_agent_summary( pubkey: record.pubkey.clone(), name: record.name.clone(), persona_id: record.persona_id.clone(), + project_scope: record.project_scope.clone(), runtime: record.runtime.clone(), team_id: record.team_id.clone(), relay_url: record.relay_url.clone(), @@ -321,6 +326,8 @@ pub fn build_managed_agent_summary( persona_orphaned, needs_restart, env_vars: record.env_vars.clone(), + tool_requirements: record.pinned_tool_requirements.clone(), + connection_bindings: record.connection_bindings.clone(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), status, @@ -363,87 +370,6 @@ pub fn find_managed_agent_mut<'a>( .ok_or_else(|| format!("agent {pubkey} not found")) } -/// Pure decision function for the inbound author gate env vars. -/// -/// Returns the env vars to **set** and the env vars to **remove**. Removal is -/// belt-and-suspenders: an inherited parent env var must not leak into a -/// child agent and silently change its security posture. -/// -/// The `owner_hex` argument is the current workspace owner pubkey. It's used -/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the -/// harness's owner cache stays empty and `owner-only` / `allowlist` modes -/// drop everything. -/// -/// Returns `Err(...)` if the record's allowlist fails validation. The harness -/// validates too, but doing it here means we never spawn a doomed process. -pub(crate) fn build_respond_to_env( - record: &ManagedAgentRecord, - owner_hex: Option<&str>, -) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) -} - -pub(crate) fn configure_runtime_cli( - command: &mut std::process::Command, - runtime: Option<&KnownAcpRuntime>, -) { - let Some(runtime) = runtime else { - return; - }; - if runtime.id != "claude" { - return; - } - if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { - // On Windows, `.cmd` and `.bat` files are batch shims — they cannot be - // passed directly to `CreateProcess` and cause EINVAL when the Claude - // adapter tries to spawn them (issue #2397). Skip setting - // `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to - // its own PATH lookup and finds the real binary instead. - // Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned. - if should_skip_claude_executable(&cli_path, cfg!(windows)) { - return; - } - command.env("CLAUDE_CODE_EXECUTABLE", cli_path); - } -} - /// Spawn an agent process without holding any locks on records or runtimes. /// Returns the child process and log path on success. The caller is responsible /// for updating `ManagedAgentRecord` fields and inserting into the runtimes map. @@ -505,7 +431,16 @@ pub fn spawn_agent_child( })?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; - + if let Some(scope) = record.project_scope.as_ref() { + let project_relay = buzz_core_pkg::relay::normalize_relay_url(&scope.relay_url) + .map_err(|_| "The agent's Project has an invalid Buzz community.".to_string())?; + if project_relay != runtime_key.relay_url { + return Err( + "This agent cannot use Project Connections while connected to another Buzz community." + .to_string(), + ); + } + } let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -539,6 +474,15 @@ pub fn spawn_agent_child( } } }; + let legacy_mcp_command = resolved_mcp_command + .as_deref() + .map(std::path::Path::to_string_lossy); + let project_mcp_config_bytes = + super::project_connections::materialize_agent_project_connections( + app, + record, + legacy_mcp_command.as_deref(), + )?; // Resolve agent command to a full path (DMG launches have minimal PATH). let resolved_agent_command = resolve_command(effective_command) .map(|p| p.display().to_string()) @@ -581,6 +525,13 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + command.env_remove("BUZZ_ACP_MCP_CONFIG"); + command.env_remove("BUZZ_ACP_MCP_CONFIG_DELETE_AFTER_READ"); + if let Some(scope) = record.project_scope.as_ref() { + command.env("BUZZ_ACP_CHANNELS", &scope.channel_id); + } else { + command.env_remove("BUZZ_ACP_CHANNELS"); + } match &resolved_mcp_command { Some(mcp_cmd) => { command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd); @@ -772,11 +723,10 @@ pub fn spawn_agent_child( // adapter names the session after it; it never reaches the prompt, so this // is display metadata only. `spawn_config_hash` hashes the same resolve, so // a rename raises the restart badge instead of leaving the process stale. - if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { - command.env(SESSION_TITLE_ENV_VAR, title); - } else { - command.env_remove(SESSION_TITLE_ENV_VAR); - } + apply_agent_display_env( + &mut command, + resolve_session_title(record.display_name.as_deref(), &record.name), + ); build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( @@ -881,6 +831,17 @@ pub fn spawn_agent_child( .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); + let project_mcp_config_path = project_mcp_config_bytes + .as_deref() + .map(|bytes| { + super::project_connections::write_agent_project_connection_config(app, record, bytes) + }) + .transpose()?; + if let Some(path) = project_mcp_config_path.as_ref() { + command.env("BUZZ_ACP_MCP_CONFIG", path); + command.env("BUZZ_ACP_MCP_CONFIG_DELETE_AFTER_READ", "true"); + } + // Spawn the harness in its own process group so we can kill the entire // tree (harness + MCP servers + agent subprocesses) on shutdown. #[cfg(unix)] @@ -899,6 +860,9 @@ pub fn spawn_agent_child( } let child = command.spawn().map_err(|error| { + if let Some(path) = project_mcp_config_path.as_deref() { + let _ = super::project_connections::remove_agent_project_connection_config(path); + } format!( "failed to spawn `{}` for agent {}: {error}", resolved_acp_command.display(), @@ -944,12 +908,14 @@ pub fn spawn_agent_child( spawned_setup_mode, spawned_adapter_availability, start_nonce, + project_mcp_config_path, &record.name, )); #[cfg(not(windows))] Ok(crate::managed_agents::ManagedAgentProcess { child, log_path, + project_mcp_config_path, spawn_config_hash, setup_mode: spawned_setup_mode, adapter_availability: spawned_adapter_availability, diff --git a/desktop/src-tauri/src/managed_agents/runtime/configure.rs b/desktop/src-tauri/src/managed_agents/runtime/configure.rs new file mode 100644 index 0000000000..adc81770c8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/configure.rs @@ -0,0 +1,53 @@ +//! Pure child-process environment configuration. + +use super::*; + +pub(crate) fn build_respond_to_env( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, +) -> Result { + let normalized = + super::super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if record.respond_to == super::super::types::RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + let mut set = vec![( + "BUZZ_ACP_RESPOND_TO", + record.respond_to.as_str().to_string(), + )]; + let mut remove = Vec::new(); + if record.respond_to == super::super::types::RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +pub(crate) fn configure_runtime_cli( + command: &mut std::process::Command, + runtime: Option<&KnownAcpRuntime>, +) { + let Some(runtime) = runtime else { + return; + }; + if runtime.id != "claude" { + return; + } + if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) { + if !should_skip_claude_executable(&cli_path, cfg!(windows)) { + command.env("CLAUDE_CODE_EXECUTABLE", cli_path); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 96ac73e347..2da9f0ff6c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -27,6 +27,23 @@ pub(crate) fn runtime_metadata_env_vars<'a>( /// Env var carrying the session title to the harness. Shared with /// `spawn_hash` so the restart badge hashes the same key the spawn writes. pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE"; +/// Stable agent display name forwarded to the ACP tool surface for git +/// attribution and private-conversation provenance. +pub(crate) const DISPLAY_NAME_ENV_VAR: &str = "BUZZ_ACP_DISPLAY_NAME"; + +/// Apply the shared stable agent name to both session display metadata and +/// git attribution, clearing both keys when no usable name is available. +pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title: Option) { + if let Some(title) = title { + command + .env(SESSION_TITLE_ENV_VAR, &title) + .env(DISPLAY_NAME_ENV_VAR, title); + } else { + command + .env_remove(SESSION_TITLE_ENV_VAR) + .env_remove(DISPLAY_NAME_ENV_VAR); + } +} /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..a96816d0b3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -181,6 +181,9 @@ fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -304,6 +307,7 @@ fn persona_with_provider( parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } @@ -1220,8 +1224,6 @@ fn receipt_invalid_when_process_not_running() { ); } -// ── Test helpers ──────────────────────────────────────────────────────────── - fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { serde_json::from_str(&format!( r#"{{ @@ -1251,9 +1253,7 @@ fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord { fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRuntime { use std::process::{Command, Stdio}; - // Spawn a real child so ManagedAgentProcess's Child field is satisfied. - // `true` exits immediately with 0 — just a handle we need for type purposes. - // + // Spawn a short-lived child to satisfy ManagedAgentProcess's Child field. // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a // bare `true` lookup during that window fails with NotFound (observed @@ -1271,6 +1271,7 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun let process = crate::managed_agents::ManagedAgentProcess { child, log_path: std::path::PathBuf::new(), + project_mcp_config_path: None, spawn_config_hash: 0, setup_mode: false, adapter_availability: None, @@ -1281,8 +1282,6 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun crate::managed_agents::ManagedAgentPairRuntime::starting(process) } -// ── restart_eligible tests ────────────────────────────────────────────── - #[test] fn restart_eligible_true_when_non_orphan_has_hash_drift() { assert!(super::restart_eligible(false, true, false)); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index f4ad404814..91329bb794 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -57,6 +57,9 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } @@ -82,6 +85,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { parallelism: None, created_at: "now".into(), updated_at: "now".into(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..009f52c2eb 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,9 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..88f4d869d6 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -216,6 +216,9 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + project_scope: None, + pinned_tool_requirements: Vec::new(), + connection_bindings: std::collections::BTreeMap::new(), } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 255c1aae32..aae7559f38 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, path::PathBuf, process::Child}; +use std::{collections::BTreeMap, path::PathBuf}; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] @@ -77,6 +77,10 @@ pub struct AgentDefinition { /// Stored as a BTreeMap for deterministic on-disk ordering. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, + /// Portable tool requirements. Concrete connections and credentials live + /// at the Project and instance-binding layers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_requirements: Vec, /// NIP-AP behavioral defaults, stored in WIRE shape (kebab-case string, /// not the `RespondTo` enum) so `persona_event_content` is a verbatim /// copy and quad-absent records serialize byte-identically to the @@ -120,6 +124,9 @@ impl AgentDefinition { provider: self.provider, persona_source_version: None, env_vars: self.env_vars, + project_scope: None, + pinned_tool_requirements: self.tool_requirements, + connection_bindings: BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, @@ -184,6 +191,7 @@ impl ManagedAgentRecord { source_team_persona_slug: self.source_team_persona_slug.clone(), catalog_source: self.catalog_source.clone(), env_vars: self.env_vars.clone(), + tool_requirements: self.pinned_tool_requirements.clone(), respond_to: self.definition_respond_to.clone(), respond_to_allowlist: self.definition_respond_to_allowlist.clone(), parallelism: self.definition_parallelism, @@ -307,6 +315,15 @@ pub struct ManagedAgentRecord { /// To "override" a persona env var: set the same key here. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, + /// Project assignment for resolving this instance's logical tool needs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_scope: Option, + /// Tool requirements pinned when the instance was minted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned_tool_requirements: Vec, + /// Requirement id to Project connection id. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub connection_bindings: BTreeMap, #[serde(default = "default_start_on_app_launch")] pub start_on_app_launch: bool, /// Auto-restart this agent when its effective spawn config drifts from @@ -458,44 +475,12 @@ pub struct RelayMeshConfig { pub model_ref: String, } -#[derive(Debug)] -pub struct ManagedAgentProcess { - pub child: Child, - pub log_path: PathBuf, - /// Digest of the effective spawn config at launch (see - /// `spawn_hash::spawn_config_hash`). Runtime-only — never persisted. The - /// summary builder recomputes the hash from current disk state and flags - /// `needs_restart` on mismatch. Agents adopted via a persisted - /// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn - /// config is unknown and the badge stays off. - pub spawn_config_hash: u64, - /// Whether this process was spawned in setup-listener mode (i.e. - /// `BUZZ_ACP_SETUP_PAYLOAD` was set at launch because the agent was - /// `NotReady`). Runtime-only — never persisted. Used by - /// `install_acp_runtime` to target only stuck agents for auto-restart, - /// excluding healthy in-pool agents. - pub setup_mode: bool, - /// Adapter availability status stamped at spawn time for runtimes with a - /// version gate (currently codex only; `None` for all others). Runtime-only - /// — never persisted. The summary builder compares this against the current - /// cached availability and sets `needs_restart` on drift, catching out-of- - /// band adapter changes that Phase-1 auto-restart doesn't cover. - pub adapter_availability: Option, - /// Unpredictable identity shared only with this harness generation. - pub start_nonce: String, - /// Win32 Job Object owning the harness + its entire process tree. Closing - /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole - /// tree — the Windows mirror of the Unix process-group teardown. `None` - /// if job creation/assignment failed (we fall back to `Child::kill()`). - #[cfg(windows)] - pub job: Option, -} - #[derive(Debug, Clone, Serialize)] pub struct ManagedAgentSummary { pub pubkey: String, pub name: String, pub persona_id: Option, + pub project_scope: Option, /// The record's harness/runtime id (mirror of `ManagedAgentRecord.runtime`). /// Lets the UI count agents referencing a harness definition (e.g. in the /// delete-confirmation flow). `None` = inherit from the linked persona. @@ -550,6 +535,8 @@ pub struct ManagedAgentSummary { pub needs_restart: bool, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, + pub tool_requirements: Vec, + pub connection_bindings: BTreeMap, pub backend: BackendKind, pub backend_agent_id: Option, pub status: String, @@ -992,6 +979,10 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod project_tools; +pub use project_tools::*; +mod process; +pub use process::ManagedAgentProcess; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/process.rs b/desktop/src-tauri/src/managed_agents/types/process.rs new file mode 100644 index 0000000000..dc700f5d5e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/process.rs @@ -0,0 +1,36 @@ +//! Runtime-only managed-agent process state. + +use std::{path::PathBuf, process::Child}; + +use super::AcpAvailabilityStatus; + +#[derive(Debug)] +pub struct ManagedAgentProcess { + pub child: Child, + pub log_path: PathBuf, + /// Credential-bearing structured MCP file for this launch. `buzz-acp` + /// deletes it immediately after reading; retained as a cleanup backstop. + pub project_mcp_config_path: Option, + /// Digest of the effective spawn config at launch. + pub spawn_config_hash: u64, + /// Whether this process was spawned in setup-listener mode. + pub setup_mode: bool, + /// Adapter availability status stamped at spawn time. + pub adapter_availability: Option, + /// Unpredictable identity shared only with this harness generation. + pub start_nonce: String, + /// Win32 Job Object owning the harness and its process tree. + #[cfg(windows)] + pub job: Option, +} + +impl Drop for ManagedAgentProcess { + fn drop(&mut self) { + if let Some(path) = self.project_mcp_config_path.as_deref() { + let _ = + crate::managed_agents::project_connections::remove_agent_project_connection_config( + path, + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/types/project_tools.rs b/desktop/src-tauri/src/managed_agents/types/project_tools.rs new file mode 100644 index 0000000000..189c515890 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/project_tools.rs @@ -0,0 +1,196 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::managed_agents::project_connections::ProjectConnectionScope; + +const MAX_TOOL_REQUIREMENTS: usize = 32; +const MAX_REQUIREMENT_ID_BYTES: usize = 64; +const MAX_REQUIREMENT_LABEL_BYTES: usize = 128; +const MAX_CAPABILITY_BYTES: usize = 128; + +/// A portable tool capability declared by an agent definition. +/// +/// Definitions describe what the agent needs. Project-owned connection +/// records provide the executable, endpoint details, and local credentials. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct AgentToolRequirement { + /// Stable definition-local identifier used by instance bindings. + pub id: String, + /// User-facing requirement name. + pub label: String, + /// MCP capability identifier, in the form `mcp.tool.`. + pub capability: String, + /// Whether an instance may start without a matching connection. + #[serde(default = "default_tool_requirement_required")] + pub required: bool, +} + +fn default_tool_requirement_required() -> bool { + true +} + +/// Project assignment for one managed-agent instance. +/// +/// Connection ownership stops at `project_address`. `channel_id` scopes the +/// agent's work, but does not create a second set of Project credentials when +/// the discussion channel changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct AgentProjectScope { + pub relay_url: String, + pub operator_pubkey: String, + /// Durable NIP-MP Project coordinate. Legacy one-repository Projects use + /// their NIP-34 repository coordinate. + #[serde(alias = "repoAddress")] + pub project_address: String, + pub channel_id: String, +} + +impl From<&AgentProjectScope> for ProjectConnectionScope { + fn from(scope: &AgentProjectScope) -> Self { + Self { + relay_url: scope.relay_url.clone(), + operator_pubkey: scope.operator_pubkey.clone(), + project_address: scope.project_address.clone(), + } + } +} + +fn valid_stable_id(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn is_unsafe_object_key(value: &str) -> bool { + matches!( + value.to_ascii_lowercase().as_str(), + "__proto__" | "constructor" | "prototype" + ) +} + +/// Validate a complete portable requirement set. +pub fn validate_tool_requirements(requirements: &[AgentToolRequirement]) -> Result<(), String> { + if requirements.len() > MAX_TOOL_REQUIREMENTS { + return Err(format!( + "An agent definition may declare at most {MAX_TOOL_REQUIREMENTS} tool requirements." + )); + } + + let mut ids = BTreeSet::new(); + for requirement in requirements { + if !valid_stable_id(&requirement.id, MAX_REQUIREMENT_ID_BYTES) + || requirement.id != requirement.id.to_ascii_lowercase() + || is_unsafe_object_key(&requirement.id) + { + return Err(format!( + "{:?} is not a valid tool requirement id.", + requirement.id + )); + } + if !ids.insert(requirement.id.to_ascii_lowercase()) { + return Err(format!( + "Tool requirement ids must be unique, ignoring case: {:?}.", + requirement.id + )); + } + if requirement.label.trim().is_empty() + || requirement.label.len() > MAX_REQUIREMENT_LABEL_BYTES + || requirement.label.chars().any(char::is_control) + { + return Err(format!( + "Tool requirement {:?} has an invalid label.", + requirement.id + )); + } + let Some(capability_id) = requirement.capability.strip_prefix("mcp.tool.") else { + return Err(format!( + "Tool requirement {:?} must use an MCP tool capability.", + requirement.id + )); + }; + if requirement.capability.len() > MAX_CAPABILITY_BYTES + || !valid_stable_id(capability_id, MAX_CAPABILITY_BYTES - "mcp.tool.".len()) + || is_unsafe_object_key(capability_id) + { + return Err(format!( + "Tool requirement {:?} has an invalid capability.", + requirement.id + )); + } + } + Ok(()) +} + +/// Validate fields specific to an agent's Project assignment. +pub fn validate_agent_project_scope(scope: &AgentProjectScope) -> Result<(), String> { + uuid::Uuid::parse_str(&scope.channel_id) + .map_err(|_| "Choose a valid Project discussion channel.".to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn requirement(id: &str, capability: &str) -> AgentToolRequirement { + AgentToolRequirement { + id: id.to_string(), + label: "Analytics reports".to_string(), + capability: capability.to_string(), + required: true, + } + } + + #[test] + fn accepts_distinct_portable_mcp_requirements() { + assert!(validate_tool_requirements(&[ + requirement("analytics", "mcp.tool.analytics.weekly_summary"), + requirement("crm", "mcp.tool.crm.accounts.read"), + ]) + .is_ok()); + } + + #[test] + fn rejects_count_case_collisions_and_unsafe_object_keys() { + let too_many = vec![requirement("analytics", "mcp.tool.analytics"); 33]; + assert!(validate_tool_requirements(&too_many).is_err()); + assert!(validate_tool_requirements(&[ + requirement("Analytics", "mcp.tool.analytics"), + requirement("analytics", "mcp.tool.analytics"), + ]) + .is_err()); + for id in ["__proto__", "Constructor", "prototype"] { + assert!(validate_tool_requirements(&[requirement(id, "mcp.tool.safe")]).is_err()); + } + } + + #[test] + fn rejects_invalid_labels_and_capabilities() { + let mut invalid_label = requirement("analytics", "mcp.tool.analytics"); + invalid_label.label = "\n".to_string(); + assert!(validate_tool_requirements(&[invalid_label]).is_err()); + assert!(validate_tool_requirements(&[requirement("analytics", "analytics")]).is_err()); + assert!( + validate_tool_requirements(&[requirement("analytics", "mcp.tool.__proto__")]).is_err() + ); + } + + #[test] + fn validates_project_channel_without_changing_connection_ownership() { + let scope = AgentProjectScope { + relay_url: "ws://127.0.0.1:3000".to_string(), + operator_pubkey: "a".repeat(64), + project_address: format!("30621:{}:analytics", "a".repeat(64)), + channel_id: uuid::Uuid::nil().to_string(), + }; + assert!(validate_agent_project_scope(&scope).is_ok()); + let mut other_channel = scope.clone(); + other_channel.channel_id = uuid::Uuid::new_v4().to_string(); + assert_eq!(scope.project_address, other_channel.project_address); + assert!(validate_agent_project_scope(&other_channel).is_ok()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..d224fea056 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use serde::Deserialize; use super::{ - default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind, - CatalogSource, RelayMeshConfig, RespondTo, + default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, AgentProjectScope, + AgentToolRequirement, BackendKind, CatalogSource, RelayMeshConfig, RespondTo, }; /// The NIP-AP behavioral group as one grouped request field. @@ -88,6 +88,10 @@ pub struct CreatePersonaRequest { /// Environment variables for agents created from this persona. #[serde(default)] pub env_vars: BTreeMap, + /// Portable tool requirements. Project connections are selected when an + /// instance is created. + #[serde(default)] + pub tool_requirements: Vec, /// NIP-AP behavioral group. Absent = behavior group stays unset. #[serde(default)] pub behavior: Option, @@ -120,6 +124,9 @@ pub struct UpdatePersonaRequest { /// stored credentials when an unrelated field is edited. #[serde(default)] pub env_vars: Option>, + /// Absent means Tools were not edited. Present replaces the complete set. + #[serde(default)] + pub tool_requirements: Option>, /// NIP-AP behavioral group. Same absent-vs-present contract as `env_vars`: /// absent = don't touch the stored behavior group (legacy callers don't send it), /// present = validate and replace the fields as a unit. @@ -170,6 +177,12 @@ pub struct CreateManagedAgentRequest { /// Environment variables for this agent. Layered on top of persona env. #[serde(default)] pub env_vars: BTreeMap, + /// Project and discussion-channel assignment for this instance. + #[serde(default)] + pub project_scope: Option, + /// Requirement id to Project connection id. + #[serde(default)] + pub connection_bindings: BTreeMap, #[serde(default)] pub spawn_after_create: bool, #[serde(default = "default_start_on_app_launch")] @@ -211,6 +224,12 @@ pub struct UpdateManagedAgentRequest { /// Absent = don't touch. Present = replace the env_vars map entirely. #[serde(default)] pub env_vars: Option>, + /// Absent means no change. Null removes the Project assignment. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub project_scope: Option>, + /// Absent means no change. Present replaces the complete binding map. + #[serde(default)] + pub connection_bindings: Option>, #[serde(default)] pub parallelism: Option, /// Accepted for wire compatibility; not applied to the stored record. @@ -284,6 +303,7 @@ mod tests { source_team_persona_slug: None, catalog_source: None, env_vars: BTreeMap::new(), + tool_requirements: Vec::new(), respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 96ed556068..3f2eb4b8f7 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -492,6 +492,7 @@ fn sample_persona() -> AgentDefinition { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-02T00:00:00Z".to_string(), + tool_requirements: Vec::new(), } } diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 39dfc988dd..9e9b36bd1e 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -45,6 +45,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati parallelism: None, created_at: "before".to_string(), updated_at: "before".to_string(), + tool_requirements: Vec::new(), }; let old_persona_version = crate::managed_agents::persona_events::persona_content_hash( &crate::managed_agents::persona_events::persona_event_content(&definition), diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 5afd0e7e4b..4c7382a306 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -109,6 +109,7 @@ export function useAppNavigation() { commitHash?: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }, ) => commitNavigation( @@ -125,6 +126,9 @@ export function useAppNavigation() { ? { pullRequestId: behavior.pullRequestId } : {}), ...(behavior?.issueId ? { issueId: behavior.issueId } : {}), + ...(behavior?.repositoryId + ? { repositoryId: behavior.repositoryId } + : {}), }, }, behavior, diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 3ce58efa8c..4954428748 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -19,13 +19,16 @@ export const Route = createFileRoute("/projects/$projectId")({ ? search.pullRequestId : undefined, issueId: typeof search.issueId === "string" ? search.issueId : undefined, + repositoryId: + typeof search.repositoryId === "string" ? search.repositoryId : undefined, }), }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId } = Route.useSearch(); + const { commitHash, pullRequestId, issueId, repositoryId } = + Route.useSearch(); return ( }> @@ -34,6 +37,7 @@ function ProjectDetailRouteComponent() { issueId={issueId} projectId={projectId} pullRequestId={pullRequestId} + repositoryId={repositoryId} /> ); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f2eb7f285c..d57739c741 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -172,7 +172,15 @@ with a TypeScript lookup table or an id comparison in a component. positions (after the people picker for `allowlist`). - `lib/agentAccessWarning.test.mjs` — every mode × run-location copy variant plus both resolvers, including unknown-reads-as-local and - blank-`runOn`-is-not-a-provider. + blank-`runOn`-is-not-a-provider. +12. **Projects own connections; agents own bindings.** An agent stores the + Project it works in and maps each portable tool requirement to a + Project-owned connection. Changing the Project clears the draft bindings + so credentials and connection IDs cannot cross that boundary. An + unrelated edit must preserve an assignment that is temporarily unavailable + to the client. Running agents restart immediately only when no active turn + is known; otherwise the edit is saved and the existing restart policy waits + for the current task to finish. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d79c7abcf8..656ab71549 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -230,6 +230,53 @@ test("provider intent forces startOnAppLaunch off and omits local commands", asy assert.equal(input.systemPrompt, "prompt"); }); +test("launch context persists Project scope and logical connection bindings", async () => { + const launchContext = { + projectScope: { + relayUrl: "wss://relay.example", + operatorPubkey: "a".repeat(64), + projectAddress: "30621:owner:growth", + channelId: "growth-channel", + }, + connectionBindings: { + analytics: "connection-ga", + }, + }; + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + undefined, + launchContext, + ); + + assert.deepEqual(input.projectScope, launchContext.projectScope); + assert.deepEqual(input.connectionBindings, launchContext.connectionBindings); +}); + +test("provider launch carries the same Project contract without local commands", async () => { + const launchContext = { + projectScope: { + relayUrl: "wss://relay.example", + operatorPubkey: "a".repeat(64), + projectAddress: "30621:owner:growth", + channelId: "growth-channel", + }, + connectionBindings: {}, + }; + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { type: "provider", id: "blox", config: { region: "us" } }, + launchContext, + ); + + assert.deepEqual(input.projectScope, launchContext.projectScope); + assert.deepEqual(input.connectionBindings, {}); + assert.equal("agentCommand" in input, false); +}); + test("row 1: refuses when the configured runtime is not available", () => { assert.throws( () => diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 5919309224..b4e8055ddc 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -13,6 +13,7 @@ import { resolveManagedAgentAvatarUrl, type UploadMediaBytes, } from "../ui/managedAgentAvatar"; +import type { AgentLaunchContext } from "../ui/agentCreateIntent"; type RuntimesQueryLike = { isFetched: boolean; @@ -112,6 +113,7 @@ export async function buildInstanceInputForDefinition( runtime: AcpRuntime, upload?: UploadMediaBytes, backendIntent?: BackendIntent, + launchContext?: AgentLaunchContext, ): Promise { const avatarUrl = await resolveManagedAgentAvatarUrl( persona.avatarUrl, @@ -136,6 +138,12 @@ export async function buildInstanceInputForDefinition( id: backendIntent.id, config: backendIntent.config, }, + ...(launchContext + ? { + projectScope: launchContext.projectScope, + connectionBindings: launchContext.connectionBindings, + } + : {}), }; } @@ -157,5 +165,11 @@ export async function buildInstanceInputForDefinition( spawnAfterCreate: true, startOnAppLaunch: true, backend: { type: "local" }, + ...(launchContext + ? { + projectScope: launchContext.projectScope, + connectionBindings: launchContext.connectionBindings, + } + : {}), }; } diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..8bf7fe63c8 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -22,6 +22,7 @@ function personaEvent({ avatarUrl = null, respondTo = null, sharedTag, + toolRequirements = [], }) { return { id, @@ -47,6 +48,7 @@ function personaEvent({ respond_to: respondTo, respond_to_allowlist: respondTo === "allowlist" ? [BOB] : undefined, parallelism: 4, + tool_requirements: toolRequirements, }), sig: "sig", }; @@ -66,6 +68,55 @@ test("a shared kind 30175 persona from Alice is discoverable by Bob", () => { assert.equal(personas[0].catalogSource.isOwn, false); }); +test("catalog personas preserve logical tool requirements without credentials", () => { + const [persona] = catalogPersonasFromPublications( + catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "alice-analytics", + toolRequirements: [ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ], + }), + ]), + [], + BOB, + ); + + assert.deepEqual(persona.toolRequirements, [ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ]); +}); + +test("an invalid tool requirement rejects the shared catalog head", () => { + const publications = catalogPublicationsFromEvents([ + personaEvent({ + createdAt: 1, + id: "unsafe-tools", + toolRequirements: [ + { + id: "__proto__", + label: "Unsafe", + capability: "mcp.tool.run_report", + required: true, + }, + ], + }), + ]); + + assert.deepEqual(publications, []); +}); + test("a newer unshared head hides the older shared head", () => { const publications = catalogPublicationsFromEvents([ personaEvent({ createdAt: 1, id: "shared" }), @@ -356,7 +407,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -377,7 +428,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 02c3f8e202..1e52834c46 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -6,6 +6,7 @@ import type { RespondToMode, } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; +import { agentToolRequirementsValid } from "../ui/agentToolRequirements"; export type CatalogPersonaShareLevel = "not-shared" | "none"; @@ -17,6 +18,7 @@ type CatalogAgentProjection = { model: string | null; provider: string | null; namePool: string[]; + toolRequirements: AgentPersona["toolRequirements"]; respondTo: RespondToMode | null; parallelism: number | null; }; @@ -152,6 +154,30 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { (candidate): candidate is string => typeof candidate === "string", ) : []; + const toolRequirements = Array.isArray(parsed.tool_requirements) + ? parsed.tool_requirements.flatMap((candidate) => { + if ( + !isObject(candidate) || + typeof candidate.id !== "string" || + typeof candidate.label !== "string" || + typeof candidate.capability !== "string" || + typeof candidate.required !== "boolean" + ) { + return []; + } + return [ + { + id: candidate.id, + label: candidate.label, + capability: candidate.capability, + required: candidate.required, + }, + ]; + }) + : []; + if (!agentToolRequirementsValid(toolRequirements)) { + return null; + } const respondTo = parsed.respond_to === "allowlist" ? "owner-only" @@ -175,6 +201,7 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { model: optionalString(parsed.model), provider: optionalString(parsed.provider), namePool, + toolRequirements, respondTo, parallelism, }; @@ -303,6 +330,7 @@ function publicationToPersona( shared: true, sourceTeam: null, envVars: {}, + toolRequirements: publication.agent.toolRequirements, respondTo: publication.agent.respondTo, respondToAllowlist: [], parallelism: publication.agent.parallelism, diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 12702f45ac..2f16ddecb0 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -2,11 +2,6 @@ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import type { - AcpRuntimeCatalogEntry, - CreatePersonaInput, - UpdatePersonaInput, -} from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; @@ -91,33 +86,8 @@ import { runtimeDropdownAction, usePendingHarnessSelection, } from "./addCustomHarness"; - -type AgentDefinitionDialogProps = { - open: boolean; - title: string; - description: string; - submitLabel: string; - initialValues: CreatePersonaInput | UpdatePersonaInput | null; - error: Error | null; - isPending: boolean; - runtimes: AcpRuntimeCatalogEntry[]; - runtimeCatalogStatus?: "loading" | "ready" | "error"; - onOpenChange: (open: boolean) => void; - onSubmit: ( - input: CreatePersonaInput | UpdatePersonaInput, - options: AgentDefinitionSubmitOptions, - ) => Promise; - /** Publishes saved changes when the edited agent is shared in the catalog. */ - publishCatalogUpdatesOnSave?: boolean; - /** Rendered below the form fields in create mode only ("Where to run"). */ - createRunSection?: React.ReactNode; - /** Extra create-mode submit gate (e.g. incomplete provider config). */ - createSubmitBlocked?: boolean; -}; - -export type AgentDefinitionSubmitOptions = { - publishCatalogUpdates: boolean; -}; +import { useAgentToolRequirementsDraft } from "./useAgentToolRequirementsDraft"; +import type { AgentDefinitionDialogProps } from "./agentDefinitionDialogTypes"; export function AgentDefinitionDialog({ open, @@ -134,6 +104,7 @@ export function AgentDefinitionDialog({ publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, + createSubmitBlockReason = null, }: AgentDefinitionDialogProps) { const runtimesLoading = runtimeCatalogStatus === "loading"; const [displayName, setDisplayName] = React.useState(""); @@ -171,6 +142,12 @@ export function AgentDefinitionDialog({ const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); const [hasUserChanges, setHasUserChanges] = React.useState(false); + const toolsDraft = useAgentToolRequirementsDraft({ + disabled: isPending, + initialValues, + onUserChange: () => setHasUserChanges(true), + open, + }); const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const { globalConfig, @@ -357,6 +334,7 @@ export function AgentDefinitionDialog({ provider: providerForSubmit, namePool: namePoolInput, envVars, + toolRequirements: toolsDraft.requirements, behavior: behaviorForSubmit( behaviorDraft, behaviorSeedRef.current, @@ -491,16 +469,30 @@ export function AgentDefinitionDialog({ const selectedRuntimeIsAvailable = runtime.trim().length === 0 || selectedRuntime?.availability === "available"; + const extraCreateBlocked = + typeof createSubmitBlocked === "function" + ? createSubmitBlocked(toolsDraft.requirements) + : createSubmitBlocked; + const extraCreateBlockReason = + typeof createSubmitBlockReason === "function" + ? createSubmitBlockReason(toolsDraft.requirements) + : createSubmitBlockReason; + const submitBlockReason = !toolsDraft.valid + ? "Complete each tool name and capability ID." + : isCreateMode + ? (extraCreateBlockReason ?? null) + : null; // Gate model/provider validity through missingNormalizedFields — single // source of truth with the readiness gate so display and Save can't drift. const canSubmit = canSubmitPersonaDialog({ displayName, isPending }) && (!isCreateMode || runtime.trim().length > 0) && (!isCreateMode || selectedRuntimeIsAvailable) && - (!isCreateMode || !createSubmitBlocked) && + (!isCreateMode || !extraCreateBlocked) && // Crash-loop guard, create AND edit: an empty allowlist would crash // every instance minted from this definition at startup. personaBehaviorDraftValid(behaviorDraft) && + toolsDraft.valid && // D1: localModeSatisfied covers both missingNormalizedFields AND // missingEnvKeys — credential env keys now block submit, not just display. localModeSatisfied && @@ -750,7 +742,7 @@ export function AgentDefinitionDialog({ publishesCatalogUpdates={ publishCatalogUpdatesOnSave && hasUserChanges } - submitBlockReason={null} + submitBlockReason={submitBlockReason} submitLabel={submitLabel} /> } @@ -963,7 +955,12 @@ export function AgentDefinitionDialog({ open={isAddHarnessOpen} /> - {isCreateMode ? createRunSection : null} + {toolsDraft.section} + {isCreateMode + ? typeof createRunSection === "function" + ? createRunSection(toolsDraft.requirements) + : createRunSection + : null}
} > -
- {/* Avatar is definition-level identity. hideEditControl suppresses - the internal pencil badge; the CTA below is the only edit path. */} -
- setAvatarUrl("")} - onUploadPendingChange={setIsAvatarUploadPending} - onSelectAvatar={setAvatarUrl} - /> - {onEditLinkedPersona ? ( - - ) : ( -

- Avatar is shared identity -

- )} -
-
- {/* Agent name */} -
- -
- setName(event.target.value)} - placeholder="Agent name" - value={name} - /> -
-
+
+ { + handleOpenChange(false); + onEditLinkedPersona(); + } + : undefined + } + onNameChange={setName} + onSelectAvatar={setAvatarUrl} + onUploadPendingChange={setIsAvatarUploadPending} + /> +
+ {connectionsDraft.section} {/* Who can send instructions */} setAgentCommand(event.target.value)} placeholder="Full path or shell command" @@ -1026,7 +999,7 @@ export function AgentInstanceEditDialog({ )} setProvider(event.target.value)} placeholder="Custom provider ID" @@ -1060,7 +1033,7 @@ export function AgentInstanceEditDialog({ {llmProviderFieldVisible && topLevelSecretEnvVar ? ( setModel(event.target.value)} placeholder="Custom model ID" @@ -1178,7 +1151,7 @@ export function AgentInstanceEditDialog({ acpCommand={acpCommand} agentArgs={agentArgs} autoRestartOnConfigChange={autoRestartOnConfigChange} - disabled={updateMutation.isPending} + disabled={connectionsDraft.isSaving} envVars={envVars} fileSatisfiedEnvKeys={fileSatisfiedEnvKeys} hiddenEnvKeys={ diff --git a/desktop/src/features/agents/ui/AgentInstanceIdentitySection.tsx b/desktop/src/features/agents/ui/AgentInstanceIdentitySection.tsx new file mode 100644 index 0000000000..9a1a5c2b76 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentInstanceIdentitySection.tsx @@ -0,0 +1,86 @@ +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { AgentCreationPreview } from "./AgentCreationPreview"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "./agentConfigOptions"; + +export function AgentInstanceIdentitySection({ + avatarUrl, + disabled, + name, + onEditTemplate, + onNameChange, + onSelectAvatar, + onUploadPendingChange, +}: { + avatarUrl: string | null; + disabled: boolean; + name: string; + onEditTemplate?: () => void; + onNameChange: (name: string) => void; + onSelectAvatar: (avatarUrl: string) => void; + onUploadPendingChange: (pending: boolean) => void; +}) { + const previewLabel = name.trim() || "Agent name"; + + return ( + <> +
+ + {onEditTemplate ? ( + + ) : ( +

+ Avatar is shared identity +

+ )} +
+ +
+ +
+ onNameChange(event.target.value)} + placeholder="Agent name" + value={name} + /> +
+
+ + ); +} diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index b72669e5f6..700ed2d985 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -53,7 +53,11 @@ export function AgentManagementDialogs() { runtimes={management.runtimes} runtimeCatalogStatus={management.runtimeCatalogStatus} submitLabel="Save changes" - title="Edit agent" + title={ + management.editInitialValues?.displayName + ? `Edit ${management.editInitialValues.displayName}` + : "Edit agent" + } /> ) : null} diff --git a/desktop/src/features/agents/ui/AgentProjectAccessSection.tsx b/desktop/src/features/agents/ui/AgentProjectAccessSection.tsx new file mode 100644 index 0000000000..811b37449d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentProjectAccessSection.tsx @@ -0,0 +1,267 @@ +import { + AlertCircle, + FolderGit2, + Link2, + LoaderCircle, + ShieldCheck, +} from "lucide-react"; +import * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import { useProjectConnectionsQuery } from "@/features/projects/projectConnectionHooks"; +import { + durableProjectAddress, + toProjectConnectionScope, +} from "@/shared/api/agentProjectTypes"; +import type { AgentToolRequirement } from "@/shared/api/types"; +import { PersonaDropdownField } from "./PersonaDropdownField"; +import { resolveAgentProjectAccessReadiness } from "./agentProjectAccessPolicy"; + +const NO_CONNECTION = "__no_connection__"; +const NO_PROJECT = "__no_project__"; + +export type AgentProjectAccessDraft = { + /** Local UI identity only. Never sent to Tauri or persisted. */ + projectId: string; + connectionBindings: Record; +}; + +export type AgentProjectAccessReadiness = { + ready: boolean; + reason: string | null; +}; + +export const emptyAgentProjectAccessDraft: AgentProjectAccessDraft = { + projectId: "", + connectionBindings: {}, +}; + +export function AgentProjectAccessSection({ + allowUnassigned = false, + description = "Its conversations and connected tools stay with that Project.", + disabled, + draft, + idPrefix = "agent", + onDraftChange, + onReadinessChange, + operatorPubkey, + projects, + projectsLoading, + relayUrl, + toolRequirements, +}: { + allowUnassigned?: boolean; + description?: React.ReactNode; + disabled: boolean; + draft: AgentProjectAccessDraft; + idPrefix?: string; + onDraftChange: (draft: AgentProjectAccessDraft) => void; + onReadinessChange: (readiness: AgentProjectAccessReadiness) => void; + operatorPubkey: string | null; + projects: readonly Project[]; + projectsLoading: boolean; + relayUrl: string | null; + toolRequirements: readonly AgentToolRequirement[]; +}) { + const selectedProject = + projects.find((project) => project.id === draft.projectId) ?? null; + const agentProjectScope = React.useMemo( + () => + selectedProject?.projectChannelId && relayUrl && operatorPubkey + ? { + relayUrl, + operatorPubkey, + projectAddress: durableProjectAddress(selectedProject), + channelId: selectedProject.projectChannelId, + } + : null, + [operatorPubkey, relayUrl, selectedProject], + ); + const projectConnectionScope = React.useMemo( + () => + agentProjectScope ? toProjectConnectionScope(agentProjectScope) : null, + [agentProjectScope], + ); + const connectionsQuery = useProjectConnectionsQuery(projectConnectionScope, { + enabled: toolRequirements.length > 0, + }); + const connections = React.useMemo( + () => connectionsQuery.data ?? [], + [connectionsQuery.data], + ); + + React.useEffect(() => { + onReadinessChange( + resolveAgentProjectAccessReadiness({ + projectRequired: !allowUnassigned, + connections, + connectionsError: connectionsQuery.isError, + connectionsPending: connectionsQuery.isPending, + draft, + scopeAvailable: Boolean(agentProjectScope), + selectedProject, + toolRequirements, + }), + ); + }, [ + agentProjectScope, + allowUnassigned, + connections, + connectionsQuery.isError, + connectionsQuery.isPending, + draft, + onReadinessChange, + selectedProject, + toolRequirements, + ]); + + const projectOptions = [ + ...(allowUnassigned ? [{ label: "No Project", value: NO_PROJECT }] : []), + ...projects.map((project) => ({ + disabled: !project.projectChannelId, + label: project.projectChannelId + ? project.name + : `${project.name} (add a discussion channel first)`, + value: project.id, + })), + ]; + + function setBinding(requirementId: string, connectionId: string) { + const nextBindings = { ...draft.connectionBindings }; + if (connectionId === NO_CONNECTION) { + delete nextBindings[requirementId]; + } else { + nextBindings[requirementId] = connectionId; + } + onDraftChange({ ...draft, connectionBindings: nextBindings }); + } + + return ( +
+
+ +

+ Project access +

+
+

{description}

+ +
+ + + onDraftChange({ + projectId: projectId === NO_PROJECT ? "" : projectId, + connectionBindings: {}, + }) + } + options={projectOptions} + placeholder={ + projectsLoading + ? "Loading Projects..." + : projectOptions.length === 0 + ? "No Projects available" + : "Choose a Project" + } + value={draft.projectId || (allowUnassigned ? NO_PROJECT : "")} + /> +
+ + {selectedProject && toolRequirements.length > 0 ? ( +
+
+ +

+ Tool connections +

+
+ +
+ +

+ A connection gives this agent access to every tool exposed by that + MCP server. Buzz also checks that it provides the capability + requested below. +

+
+ + {connectionsQuery.isPending ? ( +
+ + Loading connections... +
+ ) : connectionsQuery.isError ? ( +
+ + Couldn't load connections. Try again. +
+ ) : ( + toolRequirements.map((requirement) => { + const compatible = connections.filter((connection) => + connection.capabilityIds.includes(requirement.capability), + ); + const options = [ + ...(!requirement.required + ? [{ label: "No connection", value: NO_CONNECTION }] + : []), + ...compatible.map((connection) => ({ + disabled: connection.health.status !== "ready", + label: + connection.health.status === "ready" + ? connection.name + : `${connection.name} (${connection.health.status.replaceAll("_", " ")})`, + value: connection.id, + })), + ]; + return ( +
+ + + setBinding(requirement.id, connectionId) + } + options={options} + placeholder={ + compatible.length > 0 + ? "Choose a connection" + : "No compatible connection" + } + value={ + draft.connectionBindings[requirement.id] ?? + (requirement.required ? "" : NO_CONNECTION) + } + /> + {compatible.length === 0 ? ( +

+ Open {selectedProject.name} Connections to add and test + one. +

+ ) : null} +
+ ); + }) + )} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentProjectLaunchDialog.tsx b/desktop/src/features/agents/ui/AgentProjectLaunchDialog.tsx new file mode 100644 index 0000000000..9be42283ae --- /dev/null +++ b/desktop/src/features/agents/ui/AgentProjectLaunchDialog.tsx @@ -0,0 +1,149 @@ +import * as React from "react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { useProjectsQuery } from "@/features/projects/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { durableProjectAddress } from "@/shared/api/agentProjectTypes"; +import type { AgentPersona } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import type { AgentLaunchContext } from "./agentCreateIntent"; +import { + AgentProjectAccessSection, + emptyAgentProjectAccessDraft, + type AgentProjectAccessReadiness, +} from "./AgentProjectAccessSection"; + +export function AgentProjectLaunchDialog({ + error, + isPending, + onOpenChange, + onStart, + open, + persona, +}: { + error: string | null; + isPending: boolean; + onOpenChange: (open: boolean) => void; + onStart: (context: AgentLaunchContext) => Promise; + open: boolean; + persona: AgentPersona; +}) { + const projectsQuery = useProjectsQuery(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const [draft, setDraft] = React.useState(emptyAgentProjectAccessDraft); + const [readiness, setReadiness] = React.useState( + { + ready: false, + reason: "Choose a Project for this agent.", + }, + ); + + React.useEffect(() => { + if (!open) return; + setDraft(emptyAgentProjectAccessDraft); + setReadiness({ + ready: false, + reason: "Choose a Project for this agent.", + }); + }, [open]); + + const handleReadinessChange = React.useCallback( + (next: AgentProjectAccessReadiness) => { + setReadiness((current) => + current.ready === next.ready && current.reason === next.reason + ? current + : next, + ); + }, + [], + ); + + async function handleStart() { + const project = (projectsQuery.data ?? []).find( + (candidate) => candidate.id === draft.projectId, + ); + if ( + !readiness.ready || + !project?.projectChannelId || + !activeCommunity?.relayUrl || + !identityQuery.data?.pubkey + ) { + return; + } + + const started = await onStart({ + projectScope: { + relayUrl: activeCommunity.relayUrl, + operatorPubkey: identityQuery.data.pubkey, + projectAddress: durableProjectAddress(project), + channelId: project.projectChannelId, + }, + connectionBindings: draft.connectionBindings, + }); + if (started) onOpenChange(false); + } + + return ( + + + + Start {persona.displayName} + + Choose the Project this agent will work in and connect the tools it + needs. + + + + + + {error ? ( +

+ {error} +

+ ) : !readiness.ready && readiness.reason ? ( +

+ {readiness.reason} +

+ ) : null} + +
+ + + + +
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentToolsSection.tsx b/desktop/src/features/agents/ui/AgentToolsSection.tsx new file mode 100644 index 0000000000..b3a8cf7e20 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentToolsSection.tsx @@ -0,0 +1,211 @@ +import { Plus, Trash2, Wrench } from "lucide-react"; + +import type { AgentToolRequirement } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { Input } from "@/shared/ui/input"; +import type { AgentToolRequirementIssue } from "./agentToolRequirements"; + +function newRequirementId() { + return `tool_${crypto.randomUUID().replaceAll("-", "")}`; +} + +export function AgentToolsSection({ + disabled, + issues = [], + onChange, + value, +}: { + disabled: boolean; + issues?: AgentToolRequirementIssue[]; + onChange: (value: AgentToolRequirement[]) => void; + value: AgentToolRequirement[]; +}) { + function update( + id: string, + patch: Partial>, + ) { + onChange( + value.map((requirement) => + requirement.id === id ? { ...requirement, ...patch } : requirement, + ), + ); + } + + function issueFor(index: number, field: AgentToolRequirementIssue["field"]) { + return issues.find( + (issue) => issue.index === index && issue.field === field, + ); + } + + return ( +
+
+
+
+ +

Tools

+
+

+ List what this template needs. Each agent connects those tools from + its Project. +

+
+ +
+ + {value.length === 0 ? ( +
+ This template does not need any connected tools. +
+ ) : ( +
+ {value.map((requirement, index) => { + const rowIssue = issueFor(index, "row"); + const labelIssue = issueFor(index, "label"); + const capabilityIssue = issueFor(index, "capability"); + return ( +
+ {rowIssue ? ( +

+ {rowIssue.message} +

+ ) : null} +
+
+ + + update(requirement.id, { label: event.target.value }) + } + placeholder="Name this tool" + value={requirement.label} + /> + {labelIssue ? ( + + ) : null} +
+ +
+ + + update(requirement.id, { + capability: event.target.value.trim(), + }) + } + placeholder="mcp.tool." + spellCheck={false} + value={requirement.capability} + /> + {capabilityIssue ? ( + + ) : ( +

+ Copy this from a tested Project connection. +

+ )} +
+ + +
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 720d6e62ad..1d54785476 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -8,6 +8,7 @@ import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { AgentDialog } from "./AgentDialog"; +import { AgentProjectLaunchDialog } from "./AgentProjectLaunchDialog"; import { PersonaCatalogDialog } from "./PersonaCatalogDialog"; import { PersonaDeleteDialog } from "./PersonaDeleteDialog"; import { PersonaShareDialog } from "./PersonaShareDialog"; @@ -37,6 +38,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; +import type { AgentPersona } from "@/shared/api/types"; export function AgentsView() { const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel(); @@ -53,6 +55,8 @@ export function AgentsView() { // Exclusivity: create never sets `personaDialogState` (edit/dup/import do), // so the create-mode and definition-edit AgentDialog mounts never coexist. const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); + const [personaToStart, setPersonaToStart] = + React.useState(null); function openUnifiedCreate() { personas.prepareCreate(); @@ -236,7 +240,8 @@ export function AgentsView() { void agents.handleStart(pubkey); }} onStartPersona={(persona) => { - void agents.handleStartPersona(persona); + agents.setActionErrorMessage(null); + setPersonaToStart(persona); }} // Persona props personas={personas.libraryPersonas} @@ -328,6 +333,21 @@ export function AgentsView() { } /> ) : null} + {personaToStart ? ( + { + if (!open) setPersonaToStart(null); + }} + onStart={(launchContext) => + agents.handleStartPersona(personaToStart, launchContext) + } + open + persona={personaToStart} + /> + ) : null} {agents.agentToAddToChannel ? ( - personas.handleSubmit(input, intent, backendIntent, targetChannel) + onSubmitDefinition={(input, intent, backendIntent, launchContext) => + personas.handleSubmit( + input, + intent, + backendIntent, + launchContext, + targetChannel, + ) } runtimes={personas.acpRuntimesQuery.data ?? []} runtimeCatalogStatus={ diff --git a/desktop/src/features/agents/ui/agentCreateIntent.ts b/desktop/src/features/agents/ui/agentCreateIntent.ts index 502b261b75..4eccd3e9ea 100644 --- a/desktop/src/features/agents/ui/agentCreateIntent.ts +++ b/desktop/src/features/agents/ui/agentCreateIntent.ts @@ -7,6 +7,11 @@ */ export type AgentCreateIntent = "definition" | "definition_start"; +export type AgentLaunchContext = { + projectScope: AgentProjectScope; + connectionBindings: Record; +}; + /** * Default intent for callers that don't pass one. Un-migrated callers of * `usePersonaActions.handleSubmit` (AgentDefinitionDialog's duplicate path @@ -18,3 +23,4 @@ export function resolveCreateIntent( ): AgentCreateIntent { return intent ?? "definition_start"; } +import type { AgentProjectScope } from "@/shared/api/types"; diff --git a/desktop/src/features/agents/ui/agentDefinitionDialogTypes.ts b/desktop/src/features/agents/ui/agentDefinitionDialogTypes.ts new file mode 100644 index 0000000000..22ea81153d --- /dev/null +++ b/desktop/src/features/agents/ui/agentDefinitionDialogTypes.ts @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; + +import type { + AcpRuntimeCatalogEntry, + AgentToolRequirement, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; + +export type AgentDefinitionSubmitOptions = { + publishCatalogUpdates: boolean; +}; + +export type AgentDefinitionDialogProps = { + open: boolean; + title: string; + description: string; + submitLabel: string; + initialValues: CreatePersonaInput | UpdatePersonaInput | null; + error: Error | null; + isPending: boolean; + runtimes: AcpRuntimeCatalogEntry[]; + runtimeCatalogStatus?: "loading" | "ready" | "error"; + onOpenChange: (open: boolean) => void; + onSubmit: ( + input: CreatePersonaInput | UpdatePersonaInput, + options: AgentDefinitionSubmitOptions, + ) => Promise; + publishCatalogUpdatesOnSave?: boolean; + createRunSection?: + | ReactNode + | ((toolRequirements: AgentToolRequirement[]) => ReactNode); + createSubmitBlocked?: + | boolean + | ((toolRequirements: AgentToolRequirement[]) => boolean); + createSubmitBlockReason?: + | string + | null + | ((toolRequirements: AgentToolRequirement[]) => string | null); +}; diff --git a/desktop/src/features/agents/ui/agentInstanceEditDialogTypes.ts b/desktop/src/features/agents/ui/agentInstanceEditDialogTypes.ts new file mode 100644 index 0000000000..d89be1becf --- /dev/null +++ b/desktop/src/features/agents/ui/agentInstanceEditDialogTypes.ts @@ -0,0 +1,11 @@ +import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; +import type { ManagedAgent } from "@/shared/api/types"; + +export type AgentInstanceEditDialogProps = { + agent: ManagedAgent; + initialFocus?: EditAgentFocusTarget; + open: boolean; + onEditLinkedPersona?: () => void; + onOpenChange: (open: boolean) => void; + onUpdated?: (agent: ManagedAgent) => void; +}; diff --git a/desktop/src/features/agents/ui/agentProjectAccessPolicy.test.mjs b/desktop/src/features/agents/ui/agentProjectAccessPolicy.test.mjs new file mode 100644 index 0000000000..7b78a8d596 --- /dev/null +++ b/desktop/src/features/agents/ui/agentProjectAccessPolicy.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentProjectAccessReadiness } from "./agentProjectAccessPolicy.ts"; + +const project = { + id: "local-project", + name: "Growth", + projectChannelId: "growth-channel", +}; +const requirement = { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, +}; +const readyConnection = { + id: "ga", + capabilityIds: ["mcp.tool.run_report"], + health: { status: "ready" }, +}; + +function readiness(overrides = {}) { + return resolveAgentProjectAccessReadiness({ + connections: [readyConnection], + connectionsError: false, + connectionsPending: false, + draft: { + projectId: project.id, + connectionBindings: { analytics: readyConnection.id }, + }, + scopeAvailable: true, + selectedProject: project, + toolRequirements: [requirement], + ...overrides, + }); +} + +test("requires a Project with a discussion channel", () => { + assert.equal( + readiness({ + draft: { projectId: "", connectionBindings: {} }, + }).reason, + "Choose a Project for this agent.", + ); + assert.equal( + readiness({ + selectedProject: { ...project, projectChannelId: null }, + }).reason, + "Add a discussion channel to this Project.", + ); +}); + +test("blocks a required tool until a ready compatible connection is bound", () => { + assert.equal( + readiness({ + draft: { projectId: project.id, connectionBindings: {} }, + }).ready, + false, + ); + assert.equal( + readiness({ + connections: [ + { + ...readyConnection, + capabilityIds: ["mcp.tool.export_report"], + }, + ], + }).ready, + false, + ); + assert.equal(readiness().ready, true); +}); + +test("optional tools do not block launch", () => { + assert.equal( + readiness({ + connections: [], + draft: { projectId: project.id, connectionBindings: {} }, + toolRequirements: [{ ...requirement, required: false }], + }).ready, + true, + ); +}); + +test("a Project is required even when the template requests no tools", () => { + assert.equal( + readiness({ + draft: { projectId: "", connectionBindings: {} }, + selectedProject: null, + toolRequirements: [], + }).ready, + false, + ); + assert.equal( + readiness({ + connections: [], + draft: { projectId: project.id, connectionBindings: {} }, + toolRequirements: [], + }).ready, + true, + ); +}); + +test("an existing agent without required tools can remain outside a Project", () => { + assert.deepEqual( + readiness({ + draft: { projectId: "", connectionBindings: {} }, + projectRequired: false, + selectedProject: null, + toolRequirements: [], + }), + { ready: true, reason: null }, + ); +}); diff --git a/desktop/src/features/agents/ui/agentProjectAccessPolicy.ts b/desktop/src/features/agents/ui/agentProjectAccessPolicy.ts new file mode 100644 index 0000000000..de14dff571 --- /dev/null +++ b/desktop/src/features/agents/ui/agentProjectAccessPolicy.ts @@ -0,0 +1,81 @@ +import type { Project } from "@/features/projects/hooks"; +import type { ProjectConnection } from "@/shared/api/tauriProjectConnections"; +import type { AgentToolRequirement } from "@/shared/api/types"; +import type { + AgentProjectAccessDraft, + AgentProjectAccessReadiness, +} from "./AgentProjectAccessSection"; + +export function resolveAgentProjectAccessReadiness({ + connections, + connectionsError, + connectionsPending, + draft, + projectRequired = true, + scopeAvailable, + selectedProject, + toolRequirements, +}: { + connections: readonly ProjectConnection[]; + connectionsError: boolean; + connectionsPending: boolean; + draft: AgentProjectAccessDraft; + projectRequired?: boolean; + scopeAvailable: boolean; + selectedProject: Project | null; + toolRequirements: readonly AgentToolRequirement[]; +}): AgentProjectAccessReadiness { + if (!draft.projectId) { + return projectRequired + ? { ready: false, reason: "Choose a Project for this agent." } + : { ready: true, reason: null }; + } + if (!selectedProject) { + return { + ready: false, + reason: "The selected Project is no longer available.", + }; + } + if (!selectedProject.projectChannelId) { + return { + ready: false, + reason: "Add a discussion channel to this Project.", + }; + } + if (!scopeAvailable) { + return { + ready: false, + reason: "Reconnect to the community before launching this agent.", + }; + } + + const required = toolRequirements.filter( + (requirement) => requirement.required, + ); + if (connectionsPending && required.length > 0) { + return { ready: false, reason: "Loading this Project's connections..." }; + } + if (connectionsError && required.length > 0) { + return { + ready: false, + reason: "Couldn't load this Project's connections. Try again.", + }; + } + + const unresolved = required.find((requirement) => { + const connection = connections.find( + (candidate) => candidate.id === draft.connectionBindings[requirement.id], + ); + return ( + connection?.health.status !== "ready" || + !connection.capabilityIds.includes(requirement.capability) + ); + }); + + return unresolved + ? { + ready: false, + reason: `Choose a ready connection for ${unresolved.label || "each required tool"}.`, + } + : { ready: true, reason: null }; +} diff --git a/desktop/src/features/agents/ui/agentToolRequirements.test.mjs b/desktop/src/features/agents/ui/agentToolRequirements.test.mjs new file mode 100644 index 0000000000..cb3dfa56e1 --- /dev/null +++ b/desktop/src/features/agents/ui/agentToolRequirements.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { agentToolRequirementsValid } from "./agentToolRequirements.ts"; + +test("accepts a complete stable tool requirement", () => { + assert.equal( + agentToolRequirementsValid([ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ]), + true, + ); +}); + +test("rejects malformed capabilities and duplicate requirement identifiers", () => { + assert.equal( + agentToolRequirementsValid([ + { + id: "analytics", + label: "Analytics reports", + capability: "run_report", + required: true, + }, + ]), + false, + ); + assert.equal( + agentToolRequirementsValid([ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + { + id: "analytics", + label: "Analytics export", + capability: "mcp.tool.export_report", + required: false, + }, + ]), + false, + ); +}); + +test("rejects requirement identifiers that are unsafe as binding keys", () => { + for (const id of ["__proto__", "constructor", "prototype"]) { + assert.equal( + agentToolRequirementsValid([ + { + id, + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ]), + false, + ); + } +}); diff --git a/desktop/src/features/agents/ui/agentToolRequirements.ts b/desktop/src/features/agents/ui/agentToolRequirements.ts new file mode 100644 index 0000000000..60b400ea4b --- /dev/null +++ b/desktop/src/features/agents/ui/agentToolRequirements.ts @@ -0,0 +1,72 @@ +import type { AgentToolRequirement } from "@/shared/api/types"; + +const UNSAFE_RECORD_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_REQUIREMENTS = 32; + +export type AgentToolRequirementIssue = { + field: "row" | "label" | "capability"; + index: number; + message: string; +}; + +export function agentToolRequirementIssues( + requirements: readonly AgentToolRequirement[], +): AgentToolRequirementIssue[] { + const issues: AgentToolRequirementIssue[] = []; + const ids = new Set(); + + if (requirements.length > MAX_REQUIREMENTS) { + issues.push({ + field: "row", + index: MAX_REQUIREMENTS, + message: `A template can request up to ${MAX_REQUIREMENTS} tools.`, + }); + } + + for (const [index, requirement] of requirements.entries()) { + if ( + !/^[a-z0-9_.-]{1,64}$/.test(requirement.id) || + UNSAFE_RECORD_KEYS.has(requirement.id) || + ids.has(requirement.id) + ) { + issues.push({ + field: "row", + index, + message: "Remove this tool and add it again.", + }); + } + ids.add(requirement.id); + + const label = requirement.label.trim(); + if ( + label.length === 0 || + new TextEncoder().encode(label).length > 128 || + [...label].some((character) => /\p{Cc}/u.test(character)) + ) { + issues.push({ + field: "label", + index, + message: "Enter a tool name under 128 characters.", + }); + } + + if ( + new TextEncoder().encode(requirement.capability).length > 128 || + !/^mcp\.tool\.[A-Za-z0-9_.-]+$/.test(requirement.capability) + ) { + issues.push({ + field: "capability", + index, + message: "Enter a capability ID beginning with mcp.tool.", + }); + } + } + + return issues; +} + +export function agentToolRequirementsValid( + requirements: readonly AgentToolRequirement[], +) { + return agentToolRequirementIssues(requirements).length === 0; +} diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index e850c34775..3175eb789f 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -94,10 +94,11 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => provider: undefined, namePool: [], envVars: {}, + toolRequirements: [], }); }); -test("duplicatePersonaDialogState carries envVars and namePool into the duplicate", () => { +test("duplicatePersonaDialogState carries envVars, names, and tools into the duplicate", () => { // Regression: codex R10 P2. Without this, a duplicated persona that // relies on an API key in env_vars would silently fail at spawn until // the user re-entered every credential. @@ -112,6 +113,14 @@ test("duplicatePersonaDialogState carries envVars and namePool into the duplicat isActive: true, namePool: ["alice", "bob"], envVars: { ANTHROPIC_API_KEY: "sk-test", GOOSE_PROVIDER: "anthropic" }, + toolRequirements: [ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ], createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-01-02T00:00:00Z", }); @@ -121,6 +130,14 @@ test("duplicatePersonaDialogState carries envVars and namePool into the duplicat GOOSE_PROVIDER: "anthropic", }); assert.deepEqual(state.initialValues.namePool, ["alice", "bob"]); + assert.deepEqual(state.initialValues.toolRequirements, [ + { + id: "analytics", + label: "Analytics reports", + capability: "mcp.tool.run_report", + required: true, + }, + ]); }); test("editPersonaDialogState preserves the persona id for updates", () => { @@ -138,7 +155,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { updatedAt: "2025-01-02T00:00:00Z", }); - assert.equal(state.title, "Edit agent"); + assert.equal(state.title, "Edit Kit"); assert.equal(state.description, ""); assert.equal(state.submitLabel, "Save changes"); assert.deepEqual(state.initialValues, { @@ -151,6 +168,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { provider: undefined, namePool: [], envVars: {}, + toolRequirements: [], }); }); diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index a553182ce8..edb2de58d8 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -74,6 +74,7 @@ export function duplicatePersonaDialogState( // them if they want a blank template. namePool: persona.namePool ?? [], envVars: persona.envVars ?? {}, + toolRequirements: persona.toolRequirements ?? [], ...behaviorEntry(persona), }, }; @@ -106,7 +107,7 @@ export function editPersonaDialogState( persona: AgentPersona, ): PersonaDialogState { return { - title: "Edit agent", + title: `Edit ${persona.displayName}`, description: "", submitLabel: "Save changes", initialValues: { @@ -123,6 +124,7 @@ export function editPersonaDialogState( // the dialog must therefore round-trip the existing values.) namePool: persona.namePool ?? [], envVars: persona.envVars ?? {}, + toolRequirements: persona.toolRequirements ?? [], ...behaviorEntry(persona), }, }; diff --git a/desktop/src/features/agents/ui/useAgentConnectionBindingsDraft.tsx b/desktop/src/features/agents/ui/useAgentConnectionBindingsDraft.tsx new file mode 100644 index 0000000000..be3bd48ee8 --- /dev/null +++ b/desktop/src/features/agents/ui/useAgentConnectionBindingsDraft.tsx @@ -0,0 +1,229 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { useProjectsQuery } from "@/features/projects/hooks"; +import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { durableProjectAddress } from "@/shared/api/agentProjectTypes"; +import type { AgentProjectScope, ManagedAgent } from "@/shared/api/types"; +import { useManagedAgentRuntimeAction } from "../managedAgentRuntimeHooks"; +import { + AgentProjectAccessSection, + emptyAgentProjectAccessDraft, + type AgentProjectAccessReadiness, +} from "./AgentProjectAccessSection"; + +function recordsEqual( + left: Record, + right: Record, +) { + const entries = Object.entries(left); + return ( + entries.length === Object.keys(right).length && + entries.every(([key, value]) => right[key] === value) + ); +} + +function projectScopesEqual( + left: AgentProjectScope | null, + right: AgentProjectScope | null, +) { + return ( + left?.relayUrl === right?.relayUrl && + left?.operatorPubkey === right?.operatorPubkey && + left?.projectAddress === right?.projectAddress && + left?.channelId === right?.channelId + ); +} + +export function useAgentConnectionBindingsDraft({ + agent, + open, + updatePending, +}: { + agent: ManagedAgent; + open: boolean; + updatePending: boolean; +}) { + const runtimeActionMutation = useManagedAgentRuntimeAction(); + const activeTurns = useActiveAgentTurns(agent.pubkey); + const projectsQuery = useProjectsQuery(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const [draft, setDraft] = React.useState(emptyAgentProjectAccessDraft); + const [readiness, setReadiness] = React.useState( + { + ready: !agent.toolRequirements.some( + (requirement) => requirement.required, + ), + reason: null, + }, + ); + const [projectTouched, setProjectTouched] = React.useState(false); + const seededAgentRef = React.useRef(null); + const projects = React.useMemo( + () => projectsQuery.data ?? [], + [projectsQuery.data], + ); + + React.useEffect(() => { + if (!open) { + seededAgentRef.current = null; + return; + } + if (seededAgentRef.current === agent.pubkey) return; + if (agent.projectScope && projectsQuery.isPending) return; + + const projectId = + projects.find( + (project) => + durableProjectAddress(project) === agent.projectScope?.projectAddress, + )?.id ?? ""; + setDraft({ + projectId, + connectionBindings: agent.connectionBindings, + }); + setReadiness({ + ready: !agent.toolRequirements.some( + (requirement) => requirement.required, + ), + reason: null, + }); + setProjectTouched(false); + seededAgentRef.current = agent.pubkey; + }, [ + agent.connectionBindings, + agent.projectScope, + agent.pubkey, + agent.toolRequirements, + open, + projects, + projectsQuery.isPending, + ]); + + const handleReadinessChange = React.useCallback( + (nextReadiness: AgentProjectAccessReadiness) => + setReadiness((current) => + current.ready === nextReadiness.ready && + current.reason === nextReadiness.reason + ? current + : nextReadiness, + ), + [], + ); + const handleDraftChange = React.useCallback( + (nextDraft: typeof draft) => { + if (nextDraft.projectId !== draft.projectId) { + setProjectTouched(true); + } + setDraft(nextDraft); + }, + [draft.projectId], + ); + + const selectedProject = + projects.find((project) => project.id === draft.projectId) ?? null; + const selectedProjectScope: AgentProjectScope | null = + selectedProject?.projectChannelId && + activeCommunity?.relayUrl && + identityQuery.data?.pubkey + ? { + relayUrl: activeCommunity.relayUrl, + operatorPubkey: identityQuery.data.pubkey, + projectAddress: durableProjectAddress(selectedProject), + channelId: selectedProject.projectChannelId, + } + : null; + const projectScopeUpdate = + !projectTouched || + projectScopesEqual(selectedProjectScope, agent.projectScope) + ? undefined + : selectedProjectScope; + const update = recordsEqual( + draft.connectionBindings, + agent.connectionBindings, + ) + ? undefined + : draft.connectionBindings; + const hasUpdate = update !== undefined || projectScopeUpdate !== undefined; + const shouldRestart = hasUpdate && isManagedAgentActive(agent); + const restartAfterCurrentTask = shouldRestart && activeTurns.length > 0; + const isSaving = updatePending || runtimeActionMutation.isPending; + + async function restartAfterSave( + savedAgent: ManagedAgent, + autoRestartEnabled: boolean, + ) { + if (restartAfterCurrentTask) { + toast.success( + autoRestartEnabled + ? `${savedAgent.name}'s changes are saved. Buzz will restart it after its current task.` + : `${savedAgent.name}'s changes are saved. Restart it when the current task is finished.`, + ); + return; + } + const relayUrl = + savedAgent.projectScope?.relayUrl ?? + agent.projectScope?.relayUrl ?? + activeCommunity?.relayUrl; + if (!shouldRestart || !relayUrl) return; + try { + await runtimeActionMutation.mutateAsync({ + action: "restart", + pubkey: savedAgent.pubkey, + relayUrl, + }); + toast.success(`${savedAgent.name} restarted with its changes.`); + } catch (error) { + const message = + error instanceof Error ? error.message : "The restart failed."; + toast.error( + `${savedAgent.name} was saved, but could not restart: ${message}`, + ); + } + } + + return { + isSaving, + projectScopeUpdate, + restartAfterSave, + saveLabel: runtimeActionMutation.isPending + ? "Restarting..." + : updatePending + ? "Saving..." + : shouldRestart + ? restartAfterCurrentTask + ? "Save changes" + : "Save and restart" + : "Save changes", + valid: !hasUpdate || readiness.ready, + update, + section: ( + <> + requirement.required) + } + description="Choose where this agent works and which Project connections it can use. Existing messages stay where they are." + draft={draft} + disabled={isSaving} + idPrefix="edit-agent" + onDraftChange={handleDraftChange} + onReadinessChange={handleReadinessChange} + operatorPubkey={identityQuery.data?.pubkey ?? null} + projects={projects} + projectsLoading={projectsQuery.isPending} + relayUrl={activeCommunity?.relayUrl ?? null} + toolRequirements={agent.toolRequirements} + /> + {!readiness.ready && readiness.reason ? ( +

+ {readiness.reason} +

+ ) : null} + + ), + }; +} diff --git a/desktop/src/features/agents/ui/useAgentToolRequirementsDraft.tsx b/desktop/src/features/agents/ui/useAgentToolRequirementsDraft.tsx new file mode 100644 index 0000000000..77ef2b8aa3 --- /dev/null +++ b/desktop/src/features/agents/ui/useAgentToolRequirementsDraft.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +import type { + AgentToolRequirement, + CreatePersonaInput, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { AgentToolsSection } from "./AgentToolsSection"; +import { agentToolRequirementIssues } from "./agentToolRequirements"; + +export function useAgentToolRequirementsDraft({ + disabled, + initialValues, + onUserChange, + open, +}: { + disabled: boolean; + initialValues: CreatePersonaInput | UpdatePersonaInput | null; + onUserChange: () => void; + open: boolean; +}) { + const [requirements, setRequirements] = React.useState< + AgentToolRequirement[] + >(initialValues?.toolRequirements ?? []); + + React.useEffect(() => { + if (open && initialValues) { + setRequirements(initialValues.toolRequirements ?? []); + } + }, [initialValues, open]); + + const issues = agentToolRequirementIssues(requirements); + return { + requirements, + valid: issues.length === 0, + section: ( + { + onUserChange(); + setRequirements(nextRequirements); + }} + value={requirements} + /> + ), + }; +} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..bb6689d1be 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -35,6 +35,7 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "../lib/instanceInputForDefinition"; +import type { AgentLaunchContext } from "./agentCreateIntent"; export function useManagedAgentActions() { const { globalConfig } = useGlobalAgentConfig(); @@ -185,9 +186,12 @@ export function useManagedAgentActions() { setStartingPersonaIds(next); } - async function handleStartPersona(persona: AgentPersona) { + async function handleStartPersona( + persona: AgentPersona, + launchContext: AgentLaunchContext, + ): Promise { if (startingPersonaIdsRef.current.has(persona.id)) { - return; + return false; } setPersonaStartPending(persona.id, true); clearFeedback(); @@ -198,7 +202,13 @@ export function useManagedAgentActions() { runtimes, globalConfig.preferred_runtime, ); - const input = await buildInstanceInputForDefinition(persona, runtime); + const input = await buildInstanceInputForDefinition( + persona, + runtime, + undefined, + undefined, + launchContext, + ); const created = await createAgentMutation.mutateAsync(input); setCreatedAgent(created); @@ -219,10 +229,12 @@ export function useManagedAgentActions() { void managedAgentsQuery.refetch(); void relayAgentsQuery.refetch(); + return !created.spawnError; } catch (error) { setActionErrorMessage( error instanceof Error ? error.message : "Failed to start agent.", ); + return false; } finally { setPersonaStartPending(persona.id, false); } diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 2c7668969a..c9bfda2acd 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -57,6 +57,7 @@ import { import { resolveCreateIntent, type AgentCreateIntent, + type AgentLaunchContext, } from "./agentCreateIntent"; import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar"; import { @@ -176,6 +177,7 @@ export function usePersonaActions() { input: CreatePersonaInput | UpdatePersonaInput, intent?: AgentCreateIntent, backendIntent?: BackendIntent | null, + launchContext?: AgentLaunchContext, targetChannel?: Pick | null, options?: { publishCatalogUpdates?: boolean }, ): Promise { @@ -242,6 +244,7 @@ export function usePersonaActions() { runtime, undefined, startIntent ?? undefined, + launchContext, ); try { diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index 066f7949a9..a8a1184a21 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -26,7 +26,10 @@ import { useCreatedAgentChannelAttachment } from "./useCreatedAgentChannelAttach import { classifyAgentManagementOrigin } from "./agentManagementBuffer"; import { useChannelsQuery } from "@/features/channels/hooks"; import { resolveManagedAgentAvatarUrl } from "./ui/managedAgentAvatar"; -import type { AgentCreateIntent } from "./ui/agentCreateIntent"; +import type { + AgentCreateIntent, + AgentLaunchContext, +} from "./ui/agentCreateIntent"; import { editPersonaDialogState } from "./ui/personaDialogState"; import type { CreatePersonaInput, @@ -179,6 +182,7 @@ export function useAgentManagement() { input: CreatePersonaInput | UpdatePersonaInput, intent: AgentCreateIntent, backendIntent: BackendIntent | null, + launchContext?: AgentLaunchContext, ): Promise { if (request?.action !== "create" || "id" in input) { return false; @@ -211,6 +215,7 @@ export function useAgentManagement() { runtime, undefined, backendIntent ?? undefined, + launchContext, ), ); if (created.spawnError) throw new Error(created.spawnError); diff --git a/desktop/src/features/home/lib/projectInbox.test.mjs b/desktop/src/features/home/lib/projectInbox.test.mjs index 040f54e847..e82fccb79e 100644 --- a/desktop/src/features/home/lib/projectInbox.test.mjs +++ b/desktop/src/features/home/lib/projectInbox.test.mjs @@ -34,13 +34,21 @@ function feedItem(overrides = {}) { }; } -const project = { +const repository = { id: "buzz", + dtag: "buzz", name: "Buzz", owner: OWNER, repoAddress: REPO_ADDRESS, }; +const project = { + id: "buzz-project", + name: "Buzz", + owner: OWNER, + repositories: [repository], +}; + const pullRequest = { id: PR_ID, author: OWNER, @@ -109,11 +117,11 @@ test("resolves the canonical project root from status and comment events", () => test("matches a selected inbox event to its canonical pull request or issue", () => { const workItems = { pullRequests: { - items: [{ project, pullRequest }], + items: [{ project, repository, pullRequest }], failedSections: [], }, issues: { - items: [{ project, issue }], + items: [{ project, repository, issue }], failedSections: [], }, }; @@ -121,6 +129,7 @@ test("matches a selected inbox event to its canonical pull request or issue", () assert.deepEqual(resolveProjectInboxWorkItem(feedItem(), workItems), { type: "pull-request", project, + repository, pullRequest, }); assert.deepEqual( @@ -139,6 +148,7 @@ test("matches a selected inbox event to its canonical pull request or issue", () { type: "issue", project, + repository, issue, }, ); diff --git a/desktop/src/features/home/lib/projectInbox.ts b/desktop/src/features/home/lib/projectInbox.ts index a22b355214..80602241da 100644 --- a/desktop/src/features/home/lib/projectInbox.ts +++ b/desktop/src/features/home/lib/projectInbox.ts @@ -2,6 +2,7 @@ import type { Project, ProjectIssue, ProjectPullRequest, + Repository, } from "@/features/projects/hooks"; import type { ProjectsWorkItemsResult } from "@/features/projects/projectWorkItems"; import type { FeedItem } from "@/shared/api/types"; @@ -31,11 +32,13 @@ export type ProjectInboxWorkItem = | { type: "pull-request"; project: Project; + repository: Repository; pullRequest: ProjectPullRequest; } | { type: "issue"; project: Project; + repository: Repository; issue: ProjectIssue; }; @@ -82,8 +85,8 @@ export function resolveProjectInboxWorkItem( } const pullRequestEntry = workItems.pullRequests.items.find( - ({ project, pullRequest }) => - project.repoAddress === reference.repoAddress && + ({ repository, pullRequest }) => + repository.repoAddress === reference.repoAddress && pullRequest.id === reference.rootId, ); if (pullRequestEntry) { @@ -91,8 +94,8 @@ export function resolveProjectInboxWorkItem( } const issueEntry = workItems.issues.items.find( - ({ issue, project }) => - project.repoAddress === reference.repoAddress && + ({ issue, repository }) => + repository.repoAddress === reference.repoAddress && issue.id === reference.rootId, ); return issueEntry ? { type: "issue", ...issueEntry } : null; diff --git a/desktop/src/features/home/ui/ProjectInboxDetail.tsx b/desktop/src/features/home/ui/ProjectInboxDetail.tsx index c3d17e687e..19dda5889c 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetail.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetail.tsx @@ -120,7 +120,10 @@ export function ProjectInboxDetail({ workItem.type === "pull-request" ? { pullRequestId: workItem.pullRequest.id } : { issueId: workItem.issue.id }; - void goProject(workItem.project.id, workItemId); + void goProject(workItem.project.id, { + ...workItemId, + repositoryId: workItem.repository.id, + }); }} profiles={profiles} workItem={workItem} diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx index 5b80c13cb9..d31ac95274 100644 --- a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -61,13 +61,13 @@ export function ProjectInboxDetailPane({ if (workItem.type !== "pull-request") { throw new Error("Merge recovery is only available for pull requests."); } - const targetCloneUrl = workItem.project.cloneUrls[0]; + const targetCloneUrl = workItem.repository.cloneUrls[0]; if (!targetCloneUrl) { - throw new Error("This project has no clone URL."); + throw new Error("This repository has no clone URL."); } return openProjectMergeRecoveryTerminal({ ...input, - projectDtag: workItem.project.dtag, + projectDtag: workItem.repository.dtag, reposDir: activeCommunity?.reposDir, targetCloneUrl, }); @@ -151,13 +151,13 @@ export function ProjectInboxDetailPane({ mode="conversation" onOpenTerminal={handleOpenMergeRecoveryTerminal} profiles={profiles} - project={workItem.project} + project={workItem.repository} pullRequest={workItem.pullRequest} />
@@ -166,7 +166,7 @@ export function ProjectInboxDetailPane({ )} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d3e0e34dc6..06295cc615 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -60,6 +60,8 @@ type UserProfilePopoverProps = { triggerAriaLabel?: string; /** Set false when the trigger is inside another interactive control. */ enableProfilePanel?: boolean; + /** Set false when a smaller, context-specific hover treatment is provided. */ + enableHoverPopover?: boolean; /** When set to "bot", a BotIdenticon badge renders next to the display name. */ role?: string; /** Value used to generate the BotIdenticon glyph (typically the author name). */ @@ -174,6 +176,7 @@ export function UserProfilePopover({ triggerElement = "div", triggerAriaLabel, enableProfilePanel = true, + enableHoverPopover = true, role, botIdenticonValue, }: UserProfilePopoverProps) { @@ -298,11 +301,14 @@ export function UserProfilePopover({ }, []); const handleTriggerMouseEnter = React.useCallback(() => { + if (!enableHoverPopover) { + return; + } clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); }, HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer]); + }, [clearHoverTimer, enableHoverPopover]); const handleMouseLeave = React.useCallback(() => { clearHoverTimer(); diff --git a/desktop/src/features/projects/branchMutations.ts b/desktop/src/features/projects/branchMutations.ts index 55874dc776..647e6fac2b 100644 --- a/desktop/src/features/projects/branchMutations.ts +++ b/desktop/src/features/projects/branchMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; import { createProjectRemoteBranch, deleteProjectRemoteBranch, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d479..fb1c527355 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -23,6 +23,7 @@ import { KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_TEXT_NOTE, @@ -39,10 +40,11 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; -import { resolveProjectDefaultBranch } from "./lib/projectBranches"; -import { effectiveCloneUrls } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; -import { projectIssueEventsToIssues } from "./projectIssues.mjs"; +import { + nextProjectIssueCommentCreatedAt, + projectIssueEventsToIssues, +} from "./projectIssues.mjs"; import type { ProjectPullRequest, ProjectPullRequestCommentAnchor, @@ -55,33 +57,27 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { + buildProjectReadModels, + eventToRepository, + type Project, + type Repository, +} from "./projectModels"; +import { fetchProjectEventsExhaustively } from "./projectEnumeration"; +import { projectMatchesRouteId } from "./projectRoutes"; export type { + Project, ProjectIssue, ProjectPullRequest, ProjectPullRequestCommentAnchor, + Repository, }; export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; -export type Project = { - id: string; - dtag: string; - name: string; - description: string; - cloneUrls: string[]; - webUrl: string | null; - owner: string; - contributors: string[]; - createdAt: number; - projectChannelId: string | null; - status: string; - defaultBranch: string; - repoAddress: string; -}; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -120,34 +116,16 @@ export type { export type ProjectPullRequestListItem = { project: Project; + repository: Repository; pullRequest: ProjectPullRequest; }; export type ProjectIssueListItem = { project: Project; + repository: Repository; issue: ProjectIssue; }; -function getTag(event: RelayEvent, name: string): string | undefined { - const value = event.tags.find((t) => t[0] === name)?.[1]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function getAllTags(event: RelayEvent, name: string): string[] { - return event.tags - .filter((t) => t[0] === name && typeof t[1] === "string" && t[1].length > 0) - .map((t) => t[1]); -} - -function getCloneUrls(event: RelayEvent): string[] { - const tag = event.tags.find((t) => t[0] === "clone"); - return tag ? tag.slice(1) : []; -} - -function projectCoordinate(project: Pick): string { - return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; -} - function readHiddenProjectCards(): string[] { if (typeof window === "undefined") { return []; @@ -165,21 +143,6 @@ function readHiddenProjectCards(): string[] { } } -function isHiddenLocally(project: Project): boolean { - return readHiddenProjectCards().includes(projectCoordinate(project)); -} - -function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { - const coordinate = projectCoordinate(project); - // NIP-09: a deletion is only valid when signed by the author of the - // referenced event — otherwise anyone could hide someone else's project. - return deletionEvents.some( - (event) => - event.pubkey.toLowerCase() === project.owner.toLowerCase() && - event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), - ); -} - /** * Converts a kind:30617 repo announcement into a `Project`. * @@ -191,136 +154,26 @@ function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { export function eventToProject( event: RelayEvent, relayOrigin?: string | null, -): Project { - const d = getTag(event, "d") ?? event.id; - const name = getTag(event, "name") || d; - const description = getTag(event, "description") || event.content || ""; - const cloneUrls = effectiveCloneUrls( - getCloneUrls(event), - relayOrigin, - event.pubkey, - d, - ); - const webUrl = getTag(event, "web") ?? null; - const setupUsers = getAllTags(event, "auth"); - const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])]; - // `h`/`project-channel`, `status`, and `default-branch` are NOT part of - // NIP-34 — they are read-side tolerance for extension tags no code writes - // today (the write path that emitted them was removed). If a write path is - // reintroduced it must go through the buzz-sdk repo-announcement builder; - // the canonical NIP-34 source for the default branch is the kind:30618 - // state event's HEAD ref, not a 30617 tag. - const projectChannelId = - getTag(event, "h") ?? getTag(event, "project-channel") ?? null; - - return { - id: `${event.pubkey}:${d}`, - dtag: d, - name, - description, - cloneUrls, - webUrl, - owner: event.pubkey, - contributors, - createdAt: event.created_at, - projectChannelId, - status: getTag(event, "status") ?? "active", - defaultBranch: getTag(event, "default-branch") ?? "main", - repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }), - }; -} - -function dedup(events: RelayEvent[]): RelayEvent[] { - const best = new Map(); - - for (const e of events) { - const d = getTag(e, "d") ?? ""; - const key = `${e.pubkey}:${e.kind}:${d}`; - const prev = best.get(key); - - if (!prev || e.created_at > prev.created_at) { - best.set(key, e); - } +): Repository { + const repository = eventToRepository(event, relayOrigin); + if (!repository) { + throw new Error("Invalid repository announcement."); } - - return [...best.values()]; + return repository; } export async function fetchProjects(): Promise { - const [events, deletionEvents] = await Promise.all([ - relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - limit: 200, - }), - relayClient.fetchEvents({ - kinds: [KIND_DELETION], - limit: 500, - }), + const [projectEvents, repositoryEvents] = await Promise.all([ + fetchProjectEventsExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchProjectEventsExhaustively([KIND_REPO_ANNOUNCEMENT]), ]); - return dedup(events) - .map((event) => eventToProject(event, getCachedRelayOrigin())) - .filter( - (project) => - !isHiddenLocally(project) && !isDeletedByA(project, deletionEvents), - ) - .sort((a, b) => b.createdAt - a.createdAt); -} - -/** - * Splits a project route ID into its owner pubkey and dtag. The canonical - * form is `:` (matching `Project.id`) — NIP-34 repo - * identity is the full `30617::` coordinate, and two owners can - * both publish the same dtag (forks). Bare-dtag IDs from legacy links are - * still resolved, ambiguously, to whichever owner the relay returns first. - */ -function parseProjectRouteId(projectId: string): { - owner: string | null; - dtag: string; -} { - const owner = projectId.slice(0, 64); - if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { - return { owner: owner.toLowerCase(), dtag: projectId.slice(65) }; - } - return { owner: null, dtag: projectId }; -} - -async function fetchProject(projectId: string): Promise { - const { owner, dtag } = parseProjectRouteId(projectId); - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - ...(owner ? { authors: [owner] } : {}), - "#d": [dtag], - limit: 10, - }); - - const deduped = dedup(events).filter( - (event) => !owner || event.pubkey.toLowerCase() === owner, - ); - const project = - deduped.length > 0 - ? eventToProject(deduped[0], getCachedRelayOrigin()) - : null; - if (!project) { - return null; - } - - const deletionEvents = await relayClient.fetchEvents({ - kinds: [KIND_DELETION], - authors: [project.owner], - "#a": [project.repoAddress], - limit: 10, - }); - - if (isDeletedByA(project, deletionEvents)) return null; - const repoState = await fetchRepoState(project); - return { - ...project, - defaultBranch: resolveProjectDefaultBranch( - project.defaultBranch, - repoState, - ), - }; + return buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: getCachedRelayOrigin(), + hiddenAddresses: new Set(readHiddenProjectCards()), + }).sort((a, b) => b.createdAt - a.createdAt); } function eventToRepoState(event: RelayEvent): RepoState { @@ -349,7 +202,7 @@ function eventToRepoState(event: RelayEvent): RepoState { }; } -async function fetchRepoState(project: Project): Promise { +async function fetchRepoState(project: Repository): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( @@ -368,7 +221,9 @@ async function fetchRepoState(project: Project): Promise { return events.length > 0 ? eventToRepoState(events[0]) : null; } -async function fetchProjectIssues(project: Project): Promise { +async function fetchProjectIssues( + project: Repository, +): Promise { const [issueEvents, statusEvents, commentEvents] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_GIT_ISSUE], @@ -396,7 +251,7 @@ async function fetchProjectIssues(project: Project): Promise { } async function fetchProjectPullRequests( - project: Project, + project: Repository, ): Promise { const [pullRequestEvents, updateEvents, commentEvents, statusEvents] = await Promise.all([ @@ -454,7 +309,7 @@ async function createProjectPullRequestComment({ decision?: ProjectPullRequestCommentDecision; mediaTags?: string[][]; mentionPubkeys?: string[]; - project: Project; + project: Repository; pullRequest: ProjectPullRequest; }): Promise { const body = content.trim(); @@ -531,7 +386,7 @@ async function createProjectIssueComment({ mediaTags?: string[][]; mentionPubkeys?: string[]; issue: ProjectIssue; - project: Project; + project: Repository; }): Promise { const body = content.trim(); if (!body) { @@ -550,10 +405,16 @@ async function createProjectIssueComment({ ...[...recipients].map((recipient) => ["p", recipient]), ...(mediaTags ?? []), ]; + const identity = await getIdentity(); const event = await signRelayEvent({ kind: KIND_TEXT_NOTE, content: body, + createdAt: nextProjectIssueCommentCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + identity.pubkey, + ), tags, }); @@ -565,7 +426,7 @@ async function createProjectIssueComment({ } async function fetchProjectRepoSnapshot( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, @@ -587,7 +448,7 @@ async function fetchProjectRepoSnapshot( } async function fetchProjectRepoDiff( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, ): Promise { @@ -604,7 +465,7 @@ async function fetchProjectRepoDiff( } async function fetchProjectLocalRepoDiff( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -625,7 +486,7 @@ async function fetchProjectLocalRepoDiff( } async function fetchProjectLocalRepoSnapshot( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, ): Promise { @@ -638,11 +499,11 @@ async function fetchProjectLocalRepoSnapshot( }); } -async function fetchProjectActivitySummaries( - projects: Project[], +/** Loads commit, pull-request, and issue activity keyed by repository address. */ +export async function fetchRepositoryActivitySummaries( + repositories: Repository[], ): Promise> { - if (projects.length === 0) return {}; - + if (repositories.length === 0) return {}; const events = await relayClient.fetchEvents({ kinds: [ KIND_GIT_ISSUE, @@ -654,16 +515,90 @@ async function fetchProjectActivitySummaries( KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, ], - "#a": projects.map((project) => project.repoAddress), + "#a": repositories.map((repository) => repository.repoAddress), limit: 1_000, }); - return summarizeProjectActivityEvents(events, projects) as Record< + return summarizeProjectActivityEvents(events, repositories) as Record< string, ProjectActivitySummary >; } +async function fetchProjectActivitySummaries( + projects: Project[], +): Promise> { + if (projects.length === 0) return {}; + + const repositories = [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ]; + const summariesByRepository = + await fetchRepositoryActivitySummaries(repositories); + return Object.fromEntries( + projects.map((project) => { + const summaries = project.repositories.map( + (repository) => summariesByRepository[repository.repoAddress], + ); + const latestCommit = + summaries + .map((summary) => summary?.latestCommit) + .filter( + ( + commit, + ): commit is NonNullable => + Boolean(commit), + ) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? null; + const activityByDay: Record = {}; + for (const summary of summaries) { + for (const [day, count] of Object.entries( + summary?.activityByDay ?? {}, + )) { + activityByDay[day] = (activityByDay[day] ?? 0) + count; + } + } + return [ + project.id, + { + repoAddress: project.projectAddress, + issueCount: summaries.reduce( + (count, summary) => count + (summary?.issueCount ?? 0), + 0, + ), + prCount: summaries.reduce( + (count, summary) => count + (summary?.prCount ?? 0), + 0, + ), + commitCount: summaries.reduce( + (count, summary) => count + (summary?.commitCount ?? 0), + 0, + ), + activityCount: summaries.reduce( + (count, summary) => count + (summary?.activityCount ?? 0), + 0, + ), + updatedAt: Math.max( + 0, + ...summaries.map((summary) => summary?.updatedAt ?? 0), + ), + participantPubkeys: [ + ...new Set( + summaries.flatMap((summary) => summary?.participantPubkeys ?? []), + ), + ], + latestCommit, + activityByDay, + } satisfies ProjectActivitySummary, + ]; + }), + ); +} + async function deleteProject(project: Project): Promise { const identity = await getIdentity(); if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { @@ -673,7 +608,7 @@ async function deleteProject(project: Project): Promise { const event = await signRelayEvent({ kind: KIND_DELETION, content: `Delete project ${project.name}`, - tags: [["a", project.repoAddress]], + tags: [["a", project.projectAddress]], }); await relayClient.publishEvent( @@ -695,13 +630,16 @@ export function useProjectsQuery() { export function useProjectQuery(projectId: string) { return useQuery({ - queryKey: ["project", projectId], - queryFn: () => fetchProject(projectId), + queryKey: projectsQueryKey, + queryFn: fetchProjects, + select: (projects) => + projects.find((project) => projectMatchesRouteId(project, projectId)) ?? + null, staleTime: 60_000, }); } -export function useRepoStateQuery(project: Project | null | undefined) { +export function useRepoStateQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "repo-state"], @@ -714,15 +652,16 @@ export function useRepoStateQuery(project: Project | null | undefined) { } export function useProjectRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, + enabled = true, ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(enabled && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", @@ -748,7 +687,7 @@ export function useProjectRepoSnapshotQuery( } export function useProjectRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, enabled = true, @@ -775,7 +714,7 @@ export function useProjectRepoDiffQuery( } export function useProjectLocalRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -809,7 +748,7 @@ export function useProjectLocalRepoDiffQuery( } export function useProjectLocalRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, ) { @@ -842,7 +781,7 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { }); } -export function useProjectIssuesQuery(project: Project | null | undefined) { +export function useProjectIssuesQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "issues"], @@ -855,7 +794,7 @@ export function useProjectIssuesQuery(project: Project | null | undefined) { } export function useProjectPullRequestsQuery( - project: Project | null | undefined, + project: Repository | null | undefined, ) { return useQuery({ enabled: Boolean(project), @@ -879,7 +818,7 @@ export function useProjectsWorkItemsQuery(projects: Project[]) { } export function useCreateProjectIssueCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -919,7 +858,7 @@ export function useCreateProjectIssueCommentMutation( } export function useCreateProjectPullRequestCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -966,7 +905,12 @@ export function useCreateProjectPullRequestCommentMutation( export function useProjectActivitySummariesQuery(projects: Project[]) { const repoAddresses = React.useMemo( - () => projects.map((project) => project.repoAddress).sort(), + () => + projects + .flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ) + .sort(), [projects], ); diff --git a/desktop/src/features/projects/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts index 0d18e47226..57834f4401 100644 --- a/desktop/src/features/projects/issueMutations.ts +++ b/desktop/src/features/projects/issueMutations.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import { KIND_GIT_ISSUE } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { buildGitIssueTags } from "./projectIssues.mjs"; type CreateProjectIssueInput = { diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index 6179c46a04..cc00d5f631 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts"; +import { + projectRepoHost, + projectRepoHostForProject, +} from "./projectRepoHost.ts"; const OWNER = "a".repeat(64); const ORIGIN = "https://relay.example"; @@ -60,3 +64,43 @@ test("effectiveCloneUrls derives a default when none is advertised", () => { test("effectiveCloneUrls returns empty when no default can be derived", () => { assert.deepEqual(effectiveCloneUrls([], null, OWNER, "repo"), []); }); + +test("projectRepoHost recognizes a canonical repository on the relay", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, ORIGIN), { + kind: "buzz", + }); +}); + +test("projectRepoHost identifies an external repository by host", () => { + assert.deepEqual( + projectRepoHost("https://github.com/block/buzz.git", ORIGIN), + { kind: "external", host: "github.com" }, + ); +}); + +test("projectRepoHost treats a non-repository relay path as external", () => { + assert.deepEqual(projectRepoHost(`${ORIGIN}/other/path`, ORIGIN), { + kind: "external", + host: "relay.example", + }); +}); + +test("projectRepoHost fails closed while either URL is unresolved", () => { + assert.deepEqual(projectRepoHost(null, ORIGIN), { kind: "unresolved" }); + assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, null), { + kind: "unresolved", + }); + assert.deepEqual(projectRepoHost("not a URL", ORIGIN), { + kind: "unresolved", + }); +}); + +test("projectRepoHostForProject recognizes an implicit relay repository", () => { + assert.deepEqual( + projectRepoHostForProject( + { cloneUrls: [], dtag: "buzz", owner: OWNER }, + ORIGIN, + ), + { kind: "buzz" }, + ); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.test.mjs b/desktop/src/features/projects/lib/projectGitError.test.mjs new file mode 100644 index 0000000000..cc691bb0d7 --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectCloneErrorPresentation } from "./projectGitError.ts"; + +test("explains unsupported authenticated GitHub clones without exposing git output", () => { + assert.deepEqual( + projectCloneErrorPresentation( + new Error( + "Cloning into '/Users/person/repos/app'... remote: repository requires SSH certificate authentication. fatal: requested URL returned error: 403", + ), + "https://github.com/example/app.git", + ), + { + title: "Repository access required", + description: + "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials.", + }, + ); +}); + +test("presents missing and network failures clearly", () => { + assert.equal( + projectCloneErrorPresentation(new Error("Repository not found")).title, + "Repository not found", + ); + assert.equal( + projectCloneErrorPresentation(new Error("Could not resolve host")).title, + "Couldn’t reach the repository", + ); +}); + +test("uses a concise fallback", () => { + assert.deepEqual(projectCloneErrorPresentation(new Error("git failed")), { + title: "Couldn’t clone repository", + description: + "Try again. If the problem continues, contact the repository owner.", + }); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.ts b/desktop/src/features/projects/lib/projectGitError.ts new file mode 100644 index 0000000000..b99933f1e7 --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.ts @@ -0,0 +1,72 @@ +export type ProjectGitErrorPresentation = { + title: string; + description: string; +}; + +function errorText(error: unknown) { + if (error instanceof Error) return error.message.toLowerCase(); + return typeof error === "string" ? error.toLowerCase() : ""; +} + +function isGitHubUrl(cloneUrl: string | null | undefined) { + try { + return new URL(cloneUrl ?? "").hostname.toLowerCase() === "github.com"; + } catch { + return false; + } +} + +export function projectCloneErrorPresentation( + error: unknown, + cloneUrl?: string | null, +): ProjectGitErrorPresentation { + const message = errorText(error); + const github = isGitHubUrl(cloneUrl); + + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test( + message, + ) + ) { + return { + title: "Repository access required", + description: github + ? "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials." + : "Buzz could not authenticate with this repository. Check your access and try again.", + }; + } + if (/\b404\b|repository not found|repository does not exist/.test(message)) { + return { + title: "Repository not found", + description: + "Check that the repository link is correct and that the repository still exists.", + }; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return { + title: "Couldn’t reach the repository", + description: "Check your connection and try cloning again.", + }; + } + if ( + /already exists and is not an empty directory|destination path .* exists/.test( + message, + ) + ) { + return { + title: "Local folder already exists", + description: + "Choose a different repositories directory or remove the existing checkout.", + }; + } + return { + title: "Couldn’t clone repository", + description: github + ? "Try again, or open the repository on GitHub for more information." + : "Try again. If the problem continues, contact the repository owner.", + }; +} diff --git a/desktop/src/features/projects/lib/projectLocalRepos.ts b/desktop/src/features/projects/lib/projectLocalRepos.ts index e89e323129..8380a1e19f 100644 --- a/desktop/src/features/projects/lib/projectLocalRepos.ts +++ b/desktop/src/features/projects/lib/projectLocalRepos.ts @@ -1,4 +1,4 @@ -import type { Project } from "@/features/projects/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; function localRepoNameCandidate(value: string | null | undefined) { const trimmed = value?.trim().replace(/\.git$/i, "") ?? ""; @@ -26,7 +26,7 @@ function cloneUrlRepoName(cloneUrl: string | undefined) { } } -function localRepoCandidates(project: Project) { +function localRepoCandidates(project: Repository) { return [ localRepoNameCandidate(project.dtag), cloneUrlRepoName(project.cloneUrls[0]), @@ -39,7 +39,16 @@ export function hasLocalCheckout( project: Project, localRepoNames: Set, ) { - return localRepoCandidates(project).some((candidate) => + return project.repositories.some((repository) => + hasLocalRepositoryCheckout(repository, localRepoNames), + ); +} + +export function hasLocalRepositoryCheckout( + repository: Repository, + localRepoNames: Set, +) { + return localRepoCandidates(repository).some((candidate) => localRepoNames.has(candidate), ); } diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs new file mode 100644 index 0000000000..b1a40ca9ba --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts"; + +test("classifies a missing repository", () => { + assert.equal( + projectRepoUnavailableReason(new Error("remote: Repository not found")), + "missing", + ); + assert.equal(projectRepoUnavailableReason(null), "missing"); +}); + +test("classifies authentication failures before generic availability errors", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("The requested URL returned error: 403"), + ), + "authentication", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Authentication failed")), + "authentication", + ); +}); + +test("classifies branch and network failures", () => { + assert.equal( + projectRepoUnavailableReason( + new Error("Remote branch main not found in upstream origin"), + ), + "ref", + ); + assert.equal( + projectRepoUnavailableReason(new Error("Could not resolve host: relay")), + "network", + ); + assert.equal( + projectRepoUnavailableReason(new Error("git timed out after 300s")), + "network", + ); +}); + +test("keeps unmatched failures generic", () => { + assert.equal( + projectRepoUnavailableReason(new Error("git exited with status 128")), + "unknown", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts new file mode 100644 index 0000000000..803548d3dd --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -0,0 +1,48 @@ +export type ProjectRepoUnavailableReason = + | "missing" + | "authentication" + | "network" + | "ref" + | "unknown"; + +export function projectRepoUnavailableReason( + error: unknown, +): ProjectRepoUnavailableReason { + const message = + error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : ""; + + if (!message) return "missing"; + if ( + /\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied/.test( + message, + ) + ) { + return "authentication"; + } + if ( + /\b404\b|repository not found|repository does not exist|not found on the relay/.test( + message, + ) + ) { + return "missing"; + } + if ( + /remote branch .* not found|could not resolve the requested repository ref|couldn't find remote ref/.test( + message, + ) + ) { + return "ref"; + } + if ( + /timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test( + message, + ) + ) { + return "network"; + } + return "unknown"; +} diff --git a/desktop/src/features/projects/lib/projectRepoHost.ts b/desktop/src/features/projects/lib/projectRepoHost.ts new file mode 100644 index 0000000000..07e27f922c --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoHost.ts @@ -0,0 +1,78 @@ +import { effectiveCloneUrls } from "./projectCloneUrl"; + +export type ProjectRepoHost = + | { kind: "buzz" } + | { kind: "external"; host: string } + | { kind: "unresolved" }; + +/** + * Classifies the canonical git remote using the same origin and path boundary + * enforced by the Tauri git commands. This is presentation/query gating only; + * Rust remains the security boundary for clone operations. + */ +export function projectRepoHost( + cloneUrl: string | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!cloneUrl || !relayOrigin) return { kind: "unresolved" }; + + try { + const clone = new URL(cloneUrl); + const relay = new URL(relayOrigin); + const isBuzzPath = /^\/git\/[0-9a-f]{64}\/[^/]+\/?$/i.test(clone.pathname); + + if (clone.origin === relay.origin && isBuzzPath) { + return { kind: "buzz" }; + } + + return { kind: "external", host: clone.host }; + } catch { + return { kind: "unresolved" }; + } +} + +type RepositoryHostInput = { + cloneUrls: string[]; + dtag: string; + owner: string; + repoAddress?: string; +}; + +export function projectRepoHostForRepository( + repository: RepositoryHostInput | null | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!repository) return { kind: "unresolved" }; + const cloneUrl = effectiveCloneUrls( + repository.cloneUrls, + relayOrigin, + repository.owner, + repository.dtag, + )[0]; + return projectRepoHost(cloneUrl, relayOrigin); +} + +export function projectRepoHostForProject( + project: + | RepositoryHostInput + | { + primaryRepositoryAddress: string | null; + repositories: RepositoryHostInput[]; + } + | null + | undefined, + relayOrigin: string | null | undefined, +): ProjectRepoHost { + if (!project) return { kind: "unresolved" }; + if (!("repositories" in project)) { + return projectRepoHostForRepository(project, relayOrigin); + } + + const repository = + project.repositories.find( + (candidate) => candidate.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null; + return projectRepoHostForRepository(repository, relayOrigin); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 0328271441..6084c7275f 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -2,16 +2,23 @@ import type { Project, ProjectActivitySummary, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; export type ProjectsViewMode = "grid" | "list"; -export type ProjectsRepositoryScope = "all" | "mine" | "local"; +export type ProjectsRepositoryScope = + | "all" + | "mine" + | "local" + | "buzz" + | "linked"; export type ProjectsWorkItemScope = "all" | "mine"; export type ProjectsFilter = | "all" | "mine" | "local" + | "projects" | "repositories" | "prs" | "issues" @@ -51,6 +58,7 @@ export function readStoredFilter(): ProjectsFilter { const value = globalThis.localStorage?.getItem(PROJECTS_FILTER_STORAGE_KEY); return value === "mine" || value === "local" || + value === "projects" || value === "repositories" || value === "prs" || value === "issues" || @@ -76,7 +84,14 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope { const value = globalThis.localStorage?.getItem( PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, ); - if (value === "mine" || value === "local") return value; + if ( + value === "mine" || + value === "local" || + value === "buzz" || + value === "linked" + ) { + return value; + } const legacyFilter = globalThis.localStorage?.getItem( PROJECTS_FILTER_STORAGE_KEY, ); @@ -244,7 +259,10 @@ export function projectPeople( ...new Set( [ project.owner, - ...project.contributors, + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), ...(summary?.participantPubkeys ?? []), ].map(normalizePubkey), ), @@ -269,7 +287,7 @@ export function normalizeRepositoryUrl(url: string) { } export function getClonePathLabel(project: Project) { - const cloneUrl = project.cloneUrls[0]; + const cloneUrl = selectProjectRepository(project, null)?.cloneUrls[0]; if (!cloneUrl) return "Clone path pending"; try { @@ -281,9 +299,7 @@ export function getClonePathLabel(project: Project) { } function repositoryIdentityKey(project: Project) { - const cloneUrl = project.cloneUrls[0]; - if (cloneUrl) return normalizeRepositoryUrl(cloneUrl); - return (project.name || project.dtag).trim().toLowerCase(); + return project.id; } export function uniqueRepositories(projects: Project[]) { @@ -325,8 +341,12 @@ export function isProjectMine( const normalizedCurrentPubkey = normalizePubkey(currentPubkey); return ( normalizePubkey(project.owner) === normalizedCurrentPubkey || - project.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + project.repositories.some( + (repository) => + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ), ) ); } diff --git a/desktop/src/features/projects/projectActivity.d.mts b/desktop/src/features/projects/projectActivity.d.mts index 7277e7bf79..ec09ba9095 100644 --- a/desktop/src/features/projects/projectActivity.d.mts +++ b/desktop/src/features/projects/projectActivity.d.mts @@ -1,7 +1,7 @@ -import type { ProjectActivitySummary, Project } from "./hooks"; +import type { ProjectActivitySummary, Repository } from "./hooks"; import type { RelayEvent } from "@/shared/api/types"; export function summarizeProjectActivityEvents( events: RelayEvent[], - projects: Project[], + projects: Repository[], ): Record; diff --git a/desktop/src/features/projects/projectConnectionHooks.ts b/desktop/src/features/projects/projectConnectionHooks.ts new file mode 100644 index 0000000000..91502d4a62 --- /dev/null +++ b/desktop/src/features/projects/projectConnectionHooks.ts @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createProjectConnection, + deleteProjectConnection, + listProjectConnections, + testProjectConnection, + type ProjectConnectionDraft, + updateProjectConnection, +} from "@/shared/api/tauriProjectConnections"; +import type { ProjectConnectionScope } from "@/shared/api/projectConnectionTypes"; + +export const projectConnectionsQueryKey = ( + projectScope: ProjectConnectionScope | null, +) => + [ + "project-connections", + projectScope?.relayUrl ?? "", + projectScope?.operatorPubkey ?? "", + projectScope?.projectAddress ?? "", + ] as const; + +export function useProjectConnectionsQuery( + projectScope: ProjectConnectionScope | null, + options?: { enabled?: boolean }, +) { + return useQuery({ + enabled: Boolean(projectScope) && (options?.enabled ?? true), + queryKey: projectConnectionsQueryKey(projectScope), + queryFn: () => + listProjectConnections(projectScope as ProjectConnectionScope), + }); +} + +export function useCreateProjectConnectionMutation( + projectScope: ProjectConnectionScope, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: ProjectConnectionDraft) => + createProjectConnection(input), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: projectConnectionsQueryKey(projectScope), + }); + }, + }); +} + +export function useUpdateProjectConnectionMutation( + projectScope: ProjectConnectionScope, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: ProjectConnectionDraft & { id: string }) => + updateProjectConnection(input), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: projectConnectionsQueryKey(projectScope), + }); + }, + }); +} + +export function useTestProjectConnectionMutation( + projectScope: ProjectConnectionScope, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (connectionId: string) => + testProjectConnection(projectScope, connectionId), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: projectConnectionsQueryKey(projectScope), + }); + }, + }); +} + +export function useDeleteProjectConnectionMutation( + projectScope: ProjectConnectionScope, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (connectionId: string) => + deleteProjectConnection(projectScope, connectionId), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: projectConnectionsQueryKey(projectScope), + }); + }, + }); +} diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs new file mode 100644 index 0000000000..ed6e9328cd --- /dev/null +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildInitialProjectEventTemplates, + isUnsupportedProjectKindError, +} from "./projectCreation.ts"; + +const OWNER = "a".repeat(64); +const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { + const templates = buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + cloneUrl: "https://relay.example/git/owner/sprout.git", + description: "A multi-repository workspace", + name: "Sprout", + ownerPubkey: OWNER, + webUrl: "https://example.com/sprout", + }); + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["a", `30617:${OWNER}:sprout`], + ]); + assert.equal(templates.project.content, ""); + assert.deepEqual(templates.repository.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A multi-repository workspace"], + ["clone", "https://relay.example/git/owner/sprout.git"], + ["web", "https://example.com/sprout"], + ]); +}); + +test("buildInitialProjectEventTemplates rejects names without an identifier", () => { + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + name: "!!!", + ownerPubkey: OWNER, + }), + /letters or numbers/, + ); +}); + +test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { + assert.doesNotThrow(() => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(512), + name: "Sprout", + ownerPubkey: OWNER, + }), + ); + assert.throws( + () => + buildInitialProjectEventTemplates({ + accessChannelId: CHANNEL, + description: "🙂".repeat(513), + name: "Sprout", + ownerPubkey: OWNER, + }), + /2,048 bytes/, + ); +}); + +test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { + assert.equal( + isUnsupportedProjectKindError( + new Error("restricted: unknown event kind 30621"), + ), + true, + ); + assert.equal( + isUnsupportedProjectKindError(new Error("mock project event rejection")), + false, + ); +}); diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts new file mode 100644 index 0000000000..a42cf7bfd7 --- /dev/null +++ b/desktop/src/features/projects/projectCreation.ts @@ -0,0 +1,113 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { isValidProjectChannelId } from "./projectModels"; + +export type ProjectEventTemplate = { + kind: number; + content: string; + tags: string[][]; +}; + +export type InitialProjectEventTemplates = { + dtag: string; + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; +}; + +export function isUnsupportedProjectKindError(error: unknown): boolean { + return ( + error instanceof Error && + /(?:unknown|unsupported) event kind/i.test(error.message) + ); +} + +function projectDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function buildInitialProjectEventTemplates({ + accessChannelId, + cloneUrl, + description, + name, + ownerPubkey, + webUrl, +}: { + accessChannelId: string; + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + webUrl?: string; +}): InitialProjectEventTemplates { + const normalizedName = name.trim(); + if (!normalizedName) { + throw new Error("Project name is required."); + } + if (new TextEncoder().encode(normalizedName).byteLength > 256) { + throw new Error("Project name must not exceed 256 bytes."); + } + const dtag = projectDtagFromName(normalizedName); + if (!dtag) { + throw new Error("Project name must include letters or numbers."); + } + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalizedOwner)) { + throw new Error("Project owner public key is invalid."); + } + + const normalizedDescription = description?.trim() ?? ""; + if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { + throw new Error("Project description must not exceed 2,048 bytes."); + } + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const projectTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId.trim(); + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + projectTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + projectTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) { + repositoryTags.push(["clone", normalizedCloneUrl]); + } + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) { + repositoryTags.push(["web", normalizedWebUrl]); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + projectTags.push(["a", repositoryAddress]); + + return { + dtag, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: "", + tags: projectTags, + }, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + }; +} diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs new file mode 100644 index 0000000000..8ffc0f90da --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { enumerateProjectEvents } from "./projectEnumeration.ts"; + +function relayEvent(id, createdAt) { + return { + id: id.repeat(64), + kind: 30617, + pubkey: "a".repeat(64), + created_at: createdAt, + content: "", + tags: [["d", id]], + }; +} + +function fetcherFor(events) { + return async ({ limit, since, until }) => + events + .filter( + (event) => + (since === undefined || event.created_at >= since) && + (until === undefined || event.created_at <= until), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ) + .slice(0, limit); +} + +test("enumerateProjectEvents drains a tied boundary second before advancing", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 900), + relayEvent("c", 900), + relayEvent("d", 800), + ]; + + const result = await enumerateProjectEvents(fetcherFor(events), [30617], 3); + + assert.deepEqual( + result.map((event) => event.id).sort(), + events.map((event) => event.id).sort(), + ); +}); + +test("enumerateProjectEvents refuses to present a truncated boundary as complete", async () => { + const events = [ + relayEvent("a", 1_000), + relayEvent("b", 1_000), + relayEvent("c", 1_000), + ]; + + await assert.rejects( + enumerateProjectEvents(fetcherFor(events), [30617], 2), + /cannot exhaustively enumerate/, + ); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts new file mode 100644 index 0000000000..0a2b9931f4 --- /dev/null +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -0,0 +1,72 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +const PROJECT_ENUMERATION_PAGE_SIZE = 500; + +type ProjectEventFilter = { + kinds: number[]; + limit: number; + since?: number; + until?: number; +}; + +type FetchProjectEventPage = ( + filter: ProjectEventFilter, +) => Promise; + +/** + * Enumerates a NIP-01 websocket filter with the boundary-bucket drain required + * by NIP-MP. A bare `until` cursor cannot safely advance until every event in + * the oldest returned second has been retrieved. + */ +export async function enumerateProjectEvents( + fetchPage: FetchProjectEventPage, + kinds: number[], + pageSize: number, +): Promise { + if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { + throw new Error( + "Project enumeration page size must be a positive integer.", + ); + } + + const eventsById = new Map(); + let until: number | undefined; + + for (;;) { + const page = await fetchPage({ + kinds, + limit: pageSize, + ...(until === undefined ? {} : { until }), + }); + for (const event of page) eventsById.set(event.id, event); + if (page.length < pageSize) return [...eventsById.values()]; + + const oldest = Math.min(...page.map((event) => event.created_at)); + const boundary = await fetchPage({ + kinds, + limit: pageSize, + since: oldest, + until: oldest, + }); + for (const event of boundary) eventsById.set(event.id, event); + if (boundary.length >= pageSize) { + throw new Error( + "The relay cannot exhaustively enumerate projects because too many events share one timestamp.", + ); + } + if (oldest <= 0) return [...eventsById.values()]; + until = oldest - 1; + } +} + +export function fetchProjectEventsExhaustively( + kinds: number[], + pageSize = PROJECT_ENUMERATION_PAGE_SIZE, +): Promise { + return enumerateProjectEvents( + (filter) => relayClient.fetchEvents(filter), + kinds, + pageSize, + ); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b31dc3c1ff..4b0420602c 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -24,6 +24,8 @@ export type ProjectIssue = { author: string; createdAt: number; repoAddress: string | null; + channelId: string | null; + originAgentName: string | null; labels: string[]; recipients: string[]; status: ProjectIssueStatus; @@ -54,6 +56,11 @@ export function projectIssueEventsToIssues( statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], ): ProjectIssue[]; +export function nextProjectIssueCommentCreatedAt( + issue: ProjectIssue, + now: number, + author: string, +): number; export function buildGitIssueTags(input: { repoAddress: string; repoOwner: string; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 0655245866..331837ac5b 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -110,6 +110,8 @@ export function eventToProjectIssue( author: issue.pubkey, createdAt: issue.created_at, repoAddress: getTag(issue, "a") ?? null, + channelId: getTag(issue, "h") ?? null, + originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), status: statusFromEvent(issue, latestStatus), @@ -134,6 +136,17 @@ export function projectIssueEventsToIssues( .sort((left, right) => right.updatedAt - left.updatedAt); } +/** Keep consecutive comments ordered across whole-second Nostr timestamps. */ +export function nextProjectIssueCommentCreatedAt(issue, now, author) { + const normalizedAuthor = author.toLowerCase(); + return Math.max( + now, + ...issue.comments + .filter((comment) => comment.author.toLowerCase() === normalizedAuthor) + .map((comment) => comment.createdAt + 1), + ); +} + export function buildGitIssueTags({ repoAddress, repoOwner, diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 2d0fb5fb45..3275412149 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,7 @@ import { eventToProjectIssue, getAllTags, getTag, + nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -125,6 +126,31 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); +test("parses public and private-safe issue provenance", () => { + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const publicIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["h", channelId], + ], + }), + ); + const privateIssue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["buzz-origin-agent", "Builder"], + ], + }), + ); + + assert.equal(publicIssue.channelId, channelId); + assert.equal(publicIssue.originAgentName, null); + assert.equal(privateIssue.channelId, null); + assert.equal(privateIssue.originAgentName, "Builder"); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ @@ -139,3 +165,39 @@ test("builds repository-scoped issue creation tags", () => { ], ); }); + +test("orders consecutive issue comments across whole-second timestamps", () => { + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + { + id: "comment-1", + kind: 1, + pubkey: AUTHOR, + created_at: 200, + content: "First", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "comment-2", + kind: 1, + pubkey: AUTHOR, + created_at: 201, + content: "Second", + tags: [["e", "e".repeat(64), "", "root"]], + }, + { + id: "attacker-comment", + kind: 1, + pubkey: ATTACKER, + created_at: 10_000, + content: "Future", + tags: [["e", "e".repeat(64), "", "root"]], + }, + ], + ); + + assert.equal(nextProjectIssueCommentCreatedAt(issue, 200, AUTHOR), 202); + assert.equal(nextProjectIssueCommentCreatedAt(issue, 300, AUTHOR), 300); +}); diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs new file mode 100644 index 0000000000..4a4705d161 --- /dev/null +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + addRepositoryToProject, + buildProjectReadModels, + eventToRepository, + selectProjectRepository, +} from "./projectModels.ts"; +import { projectMatchesRouteId } from "./projectRoutes.ts"; + +const PROJECT_OWNER = "a".repeat(64); +const FRONTEND_OWNER = "b".repeat(64); +const BACKEND_OWNER = "c".repeat(64); +const RELAY_ORIGIN = "https://relay.example"; + +function repositoryEvent(owner, id, createdAt = 100) { + return { + id: `${id}-${createdAt}`, + kind: 30617, + pubkey: owner, + created_at: createdAt, + content: "", + tags: [ + ["d", id], + ["name", id], + ], + }; +} + +function projectEvent(repositoryTags, overrides = {}) { + return { + id: "project-event", + kind: 30621, + pubkey: PROJECT_OWNER, + created_at: 200, + content: "ignored by NIP-MP readers", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["description", "A multi-repository project"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ...repositoryTags, + ], + ...overrides, + }; +} + +test("eventToRepository preserves repository-scoped identity and clone data", () => { + const repository = eventToRepository( + repositoryEvent(FRONTEND_OWNER, "frontend"), + RELAY_ORIGIN, + ); + + assert.equal(repository.id, `${FRONTEND_OWNER}:frontend`); + assert.equal(repository.repoAddress, `30617:${FRONTEND_OWNER}:frontend`); + assert.deepEqual(repository.cloneUrls, [ + `${RELAY_ORIGIN}/git/${FRONTEND_OWNER}/frontend`, + ]); +}); + +test("buildProjectReadModels resolves repositories with a deterministic selection fallback", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const backendAddress = `30617:${PROJECT_OWNER}:backend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", backendAddress, "wss://relay.example"], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0].id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].projectAddress, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].primaryRepositoryAddress, backendAddress); + assert.deepEqual( + projects[0].repositories.map((repository) => repository.repoAddress), + [backendAddress, frontendAddress], + ); + assert.equal( + projects[0].repositoryRelayHints[backendAddress], + "wss://relay.example", + ); +}); + +test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects[0].legacy, false); + assert.equal(projects[1].legacy, true); + assert.equal( + projects[1].primaryRepositoryAddress, + projects[1].projectAddress, + ); + assert.equal(projects[1].repositories[0].dtag, "backend"); +}); + +test("buildProjectReadModels does not let an unauthorized project hide a repository", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress]])], + repositoryEvents: [repositoryEvent(FRONTEND_OWNER, "frontend")], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects.filter((project) => project.legacy).length, 1); + assert.equal(projects.filter((project) => !project.legacy).length, 1); +}); + +test("project and repository routes stay distinct when coordinates share a d tag", () => { + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([])], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const explicitProject = projects.find((project) => !project.legacy); + const implicitProject = projects.find((project) => project.legacy); + + assert.notEqual(explicitProject.id, implicitProject.id); + assert.equal( + projectMatchesRouteId(explicitProject, explicitProject.projectAddress), + true, + ); + assert.equal( + projectMatchesRouteId(explicitProject, implicitProject.projectAddress), + false, + ); +}); + +test("addRepositoryToProject promotes a legacy repository route to a project coordinate", () => { + const [legacyProject] = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")], + relayOrigin: RELAY_ORIGIN, + }); + const attachedRepository = eventToRepository( + repositoryEvent(PROJECT_OWNER, "mobile"), + RELAY_ORIGIN, + ); + const updated = addRepositoryToProject( + legacyProject, + attachedRepository, + 300, + ); + + assert.equal(updated.id, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(updated.legacy, false); + assert.equal(updated.repositories.length, 2); +}); + +test("selectProjectRepository honors a request and falls back to primary", () => { + const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + ["a", `30617:${PROJECT_OWNER}:backend`], + ]), + ], + repositoryEvents: [ + repositoryEvent(PROJECT_OWNER, "frontend"), + repositoryEvent(PROJECT_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal( + selectProjectRepository(projects[0], `${PROJECT_OWNER}:backend`)?.dtag, + "backend", + ); + assert.equal( + selectProjectRepository(projects[0], "missing:repository")?.dtag, + "backend", + ); + assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); +}); + +function coordinateParts(coordinate) { + const first = coordinate.indexOf(":"); + const second = coordinate.indexOf(":", first + 1); + return { + kind: Number(coordinate.slice(0, first)), + owner: coordinate.slice(first + 1, second), + dtag: coordinate.slice(second + 1), + }; +} + +function sortedJson(values) { + return values + .map((value) => JSON.stringify(value)) + .sort() + .map((value) => JSON.parse(value)); +} + +test("buildProjectReadModels conforms to the shared NIP-MP fold fixtures", () => { + const fixture = JSON.parse( + readFileSync( + new URL( + "../../../../docs/nips/NIP-MP.fold-fixtures.json", + import.meta.url, + ), + "utf8", + ), + ); + + for (const [caseIndex, fixtureCase] of fixture.cases.entries()) { + let eventIndex = caseIndex * 100; + const hiddenAddresses = new Set(); + const repositoryEvents = fixtureCase.repositories.flatMap((repository) => { + if (repository.viewer_hidden) hiddenAddresses.add(repository.coordinate); + if (repository.state !== "live") return []; + const { dtag, owner } = coordinateParts(repository.coordinate); + return [ + { + ...repositoryEvent( + owner, + dtag, + repository.created_at ?? 1_000 - caseIndex, + ), + id: (++eventIndex).toString(16).padStart(64, "0"), + tags: [ + ["d", dtag], + ["name", dtag], + ...(repository.maintainers?.length + ? [["maintainers", ...repository.maintainers]] + : []), + ], + }, + ]; + }); + const projectEvents = fixtureCase.projects.flatMap((project) => { + if (project.viewer_hidden) hiddenAddresses.add(project.coordinate); + if (project.state !== "live") return []; + const { dtag, owner } = coordinateParts(project.coordinate); + return [ + { + id: (++eventIndex).toString(16).padStart(64, "0"), + kind: 30621, + pubkey: owner, + created_at: project.created_at ?? 900 - caseIndex, + content: "", + tags: [ + ["d", dtag], + ["name", dtag], + ...(project.visibility === "unlisted" + ? [["buzz-visibility", "unlisted"]] + : []), + ...project.members.map((member) => ["a", member]), + ], + }, + ]; + }); + + const projects = buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: RELAY_ORIGIN, + hiddenAddresses, + }); + const actualContainers = projects + .filter((project) => !project.legacy) + .map((project) => ({ + project: project.projectAddress, + members: project.repositoryAddresses.flatMap((coordinate) => { + if ( + project.repositories.some( + (repository) => repository.repoAddress === coordinate, + ) + ) { + return [{ coordinate, render: "resolved" }]; + } + return project.unavailableRepositoryAddresses?.includes(coordinate) + ? [{ coordinate, render: "unavailable" }] + : []; + }), + })); + const expectedContainers = fixtureCase.expect.containers.map( + (container) => ({ + project: container.project, + members: sortedJson(container.members), + }), + ); + + assert.deepEqual( + sortedJson( + actualContainers.map((container) => ({ + ...container, + members: sortedJson(container.members), + })), + ), + sortedJson(expectedContainers), + fixtureCase.name, + ); + assert.deepEqual( + projects + .filter((project) => project.legacy) + .map((project) => project.projectAddress) + .sort(), + [...fixtureCase.expect.implicit_cards].sort(), + fixtureCase.name, + ); + } +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts new file mode 100644 index 0000000000..a74dd6128c --- /dev/null +++ b/desktop/src/features/projects/projectModels.ts @@ -0,0 +1,395 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { effectiveCloneUrls } from "./lib/projectCloneUrl"; + +export type Repository = { + id: string; + dtag: string; + name: string; + description: string; + cloneUrls: string[]; + webUrl: string | null; + owner: string; + contributors: string[]; + createdAt: number; + status: string; + defaultBranch: string; + repoAddress: string; + maintainers?: string[]; + channelId?: string | null; + eventContent?: string; + eventTags?: string[][]; +}; + +export type Project = { + id: string; + dtag: string; + name: string; + description: string; + owner: string; + createdAt: number; + projectChannelId: string | null; + status: string; + projectAddress: string; + primaryRepositoryAddress: string | null; + repositoryAddresses: string[]; + repositoryRelayHints?: Record; + repositories: Repository[]; + unavailableRepositoryAddresses?: string[]; + visibility?: "listed" | "unlisted"; + legacy: boolean; +}; + +type BuildProjectReadModelsInput = { + projectEvents: RelayEvent[]; + repositoryEvents: RelayEvent[]; + relayOrigin?: string | null; + hiddenAddresses?: ReadonlySet; +}; + +const MAX_D_TAG_BYTES = 1_024; + +function getTag(event: RelayEvent, name: string): string | undefined { + const value = event.tags.find((tag) => tag[0] === name)?.[1]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function getAllTags(event: RelayEvent, name: string): string[] { + return event.tags + .filter( + (tag) => + tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + ) + .map((tag) => tag[1]); +} + +function getAllTagValues(event: RelayEvent, name: string): string[] { + return event.tags + .filter((tag) => tag[0] === name) + .flatMap((tag) => tag.slice(1)) + .filter((value) => value.length > 0); +} + +function getCloneUrls(event: RelayEvent): string[] { + const tag = event.tags.find((candidate) => candidate[0] === "clone"); + return tag?.slice(1).filter((value) => value.length > 0) ?? []; +} + +function isValidDTag(value: string): boolean { + return ( + value.length > 0 && + new TextEncoder().encode(value).byteLength <= MAX_D_TAG_BYTES + ); +} + +function isValidPubkey(value: string): boolean { + return /^[a-fA-F0-9]{64}$/.test(value); +} + +export function isValidProjectChannelId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ); +} + +function deduplicateAddressableEvents(events: RelayEvent[]): RelayEvent[] { + const latest = new Map(); + for (const event of events) { + const dtag = getTag(event, "d"); + if (!dtag) continue; + const key = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const current = latest.get(key); + if ( + !current || + event.created_at > current.created_at || + (event.created_at === current.created_at && event.id < current.id) + ) { + latest.set(key, event); + } + } + return [...latest.values()]; +} + +function parseRepositoryAddress( + value: string, +): { owner: string; dtag: string } | null { + const firstSeparator = value.indexOf(":"); + const secondSeparator = value.indexOf(":", firstSeparator + 1); + if ( + value.slice(0, firstSeparator) !== String(KIND_REPO_ANNOUNCEMENT) || + secondSeparator < 0 + ) { + return null; + } + + const owner = value.slice(firstSeparator + 1, secondSeparator); + const dtag = value.slice(secondSeparator + 1); + return isValidPubkey(owner) && isValidDTag(dtag) + ? { owner: owner.toLowerCase(), dtag } + : null; +} + +export function eventToRepository( + event: RelayEvent, + relayOrigin?: string | null, +): Repository | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_REPO_ANNOUNCEMENT || + !dtag || + !isValidDTag(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const owner = event.pubkey.toLowerCase(); + const setupUsers = getAllTags(event, "auth"); + const channel = getTag(event, "buzz-channel"); + return { + id: `${owner}:${dtag}`, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? event.content ?? "", + cloneUrls: effectiveCloneUrls( + getCloneUrls(event), + relayOrigin, + owner, + dtag, + ), + webUrl: getTag(event, "web") ?? null, + owner, + contributors: [...new Set([...getAllTags(event, "p"), ...setupUsers])], + createdAt: event.created_at, + status: getTag(event, "status") ?? "active", + defaultBranch: getTag(event, "default-branch") ?? "main", + repoAddress: `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`, + channelId: channel && isValidProjectChannelId(channel) ? channel : null, + eventContent: event.content, + eventTags: event.tags.map((tag) => [...tag]), + maintainers: getAllTagValues(event, "maintainers") + .map((maintainer) => maintainer.toLowerCase()) + .filter(isValidPubkey), + }; +} + +function eventToExplicitProject( + event: RelayEvent, + repositoriesByAddress: ReadonlyMap, + visibleRepositoriesByAddress: ReadonlyMap, +): Project | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_PROJECT_ANNOUNCEMENT || + !dtag || + !isValidDTag(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const membershipTags = event.tags.filter((tag) => tag[0] === "a"); + const repositoryAddresses: string[] = []; + const repositoryRelayHints: Record = {}; + const seen = new Set(); + for (const membershipTag of membershipTags) { + const repositoryAddress = membershipTag[1]; + if ( + !repositoryAddress || + !parseRepositoryAddress(repositoryAddress) || + (membershipTag.length !== 2 && membershipTag.length !== 3) || + seen.has(repositoryAddress) + ) { + return null; + } + seen.add(repositoryAddress); + repositoryAddresses.push(repositoryAddress); + if (membershipTag[2]) { + repositoryRelayHints[repositoryAddress] = membershipTag[2]; + } + } + repositoryAddresses.sort(); + const primaryRepositoryAddress = + repositoryAddresses.find( + (address) => visibleRepositoriesByAddress.get(address)?.dtag === dtag, + ) ?? + repositoryAddresses.find((address) => + visibleRepositoriesByAddress.has(address), + ) ?? + null; + + const owner = event.pubkey.toLowerCase(); + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`; + const rawVisibility = getTag(event, "buzz-visibility"); + const visibility = + rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); + const channel = getTag(event, "buzz-channel"); + return { + id: projectAddress, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? "", + owner, + createdAt: event.created_at, + projectChannelId: + channel && isValidProjectChannelId(channel) ? channel : null, + status: visibility === "listed" ? "active" : "unlisted", + projectAddress, + primaryRepositoryAddress, + repositoryAddresses, + repositoryRelayHints, + repositories: repositoryAddresses.flatMap((address) => { + const repository = visibleRepositoriesByAddress.get(address); + return repository ? [repository] : []; + }), + unavailableRepositoryAddresses: repositoryAddresses.filter( + (address) => !repositoriesByAddress.has(address), + ), + visibility, + legacy: false, + }; +} + +function repositoryToLegacyProject(repository: Repository): Project { + return { + id: repository.repoAddress, + dtag: repository.dtag, + name: repository.name, + description: repository.description, + owner: repository.owner, + createdAt: repository.createdAt, + projectChannelId: null, + status: repository.status, + projectAddress: repository.repoAddress, + primaryRepositoryAddress: repository.repoAddress, + repositoryAddresses: [repository.repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + }; +} + +export function buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin, + hiddenAddresses = new Set(), +}: BuildProjectReadModelsInput): Project[] { + const repositories = deduplicateAddressableEvents(repositoryEvents).flatMap( + (event) => { + const repository = eventToRepository(event, relayOrigin); + return repository ? [repository] : []; + }, + ); + const repositoriesByAddress = new Map( + repositories.map((repository) => [repository.repoAddress, repository]), + ); + const visibleRepositories = repositories.filter( + (repository) => !hiddenAddresses.has(repository.repoAddress), + ); + const visibleRepositoriesByAddress = new Map( + visibleRepositories.map((repository) => [ + repository.repoAddress, + repository, + ]), + ); + + const explicitProjects = deduplicateAddressableEvents(projectEvents).flatMap( + (event) => { + const project = eventToExplicitProject( + event, + repositoriesByAddress, + visibleRepositoriesByAddress, + ); + return project && + project.visibility === "listed" && + !hiddenAddresses.has(project.projectAddress) + ? [project] + : []; + }, + ); + const claimedRepositories = new Set( + explicitProjects.flatMap((project) => + project.repositoryAddresses.filter((address) => { + const repository = repositoriesByAddress.get(address); + return ( + repository && + (repository.owner === project.owner || + repository.maintainers?.includes(project.owner)) + ); + }), + ), + ); + const legacyProjects = visibleRepositories + .filter((repository) => !claimedRepositories.has(repository.repoAddress)) + .map(repositoryToLegacyProject); + + return [...explicitProjects, ...legacyProjects].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} + +export function selectProjectRepository( + project: Project | null | undefined, + requestedRepositoryId: string | null | undefined, +): Repository | null { + if (!project) return null; + + const requested = requestedRepositoryId + ? project.repositories.find( + (repository) => repository.id === requestedRepositoryId, + ) + : null; + if (requested) return requested; + + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} + +/** Returns the optimistic read model after adding a resolved repository. */ +export function addRepositoryToProject( + project: Project, + repository: Repository, + createdAt: number, +): Project { + const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; + const repositoryAddresses = [ + ...new Set([...project.repositoryAddresses, repository.repoAddress]), + ].sort(); + const repositories = [ + ...project.repositories.filter( + (candidate) => candidate.repoAddress !== repository.repoAddress, + ), + repository, + ].sort((left, right) => left.repoAddress.localeCompare(right.repoAddress)); + + return { + ...project, + id: projectAddress, + createdAt, + legacy: false, + projectAddress, + primaryRepositoryAddress: + repositories.find((candidate) => candidate.dtag === project.dtag) + ?.repoAddress ?? + repositories[0]?.repoAddress ?? + null, + repositoryAddresses, + repositories, + unavailableRepositoryAddresses: + project.unavailableRepositoryAddresses?.filter( + (address) => address !== repository.repoAddress, + ) ?? [], + }; +} diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index af865d2433..87e03f8d38 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -78,6 +78,8 @@ export type ProjectPullRequest = { repoAddress: string | null; /** Channel where the pull request originated (`h` tag), when provided. */ channelId: string | null; + /** Agent display name retained instead of a private conversation ID. */ + originAgentName: string | null; labels: string[]; recipients: string[]; /** Requested reviewers (root `p` tags + trusted review-request comments). */ diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index 3eebaa74f0..044f421815 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -364,6 +364,7 @@ export function eventToProjectPullRequest( createdAt: pullRequest.created_at, repoAddress: getTag(pullRequest, "a") ?? null, channelId: getTag(pullRequest, "h") ?? null, + originAgentName: getTag(pullRequest, "buzz-origin-agent") ?? null, labels: getAllTags(pullRequest, "t"), recipients: getAllTags(pullRequest, "p"), reviewers, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index 9374604818..75a9877460 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -49,6 +49,15 @@ test("preserves an optional source channel from the pull request", () => { assert.equal(eventToProjectPullRequest(pullRequestEvent()).channelId, null); }); +test("preserves a private-safe agent origin without a channel ID", () => { + const event = pullRequestEvent(); + event.tags.push(["buzz-origin-agent", "Builder"]); + + const pullRequest = eventToProjectPullRequest(event); + assert.equal(pullRequest.channelId, null); + assert.equal(pullRequest.originAgentName, "Builder"); +}); + function updateEvent({ pubkey, createdAt, commit, cloneUrl }) { return { id: `update-${pubkey.slice(0, 8)}-${createdAt}`, diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs new file mode 100644 index 0000000000..cb6662d2d5 --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildAddedRepositoryEventTemplates, + buildAttachedRepositoryProjectEventTemplate, + buildRepositoryChannelBindingTemplate, +} from "./projectRepositoryCreation.ts"; + +const OWNER = "a".repeat(64); +const OTHER_OWNER = "b".repeat(64); + +const project = { + id: `${OWNER}:buzz`, + dtag: "buzz", + name: "Buzz", + description: "A multi-repository workspace", + owner: OWNER, + createdAt: 1, + projectChannelId: "11111111-1111-4111-8111-111111111111", + status: "active", + projectAddress: `30621:${OWNER}:buzz`, + primaryRepositoryAddress: `30617:${OWNER}:desktop`, + repositoryAddresses: [`30617:${OWNER}:desktop`, `30617:${OTHER_OWNER}:relay`], + repositoryRelayHints: { + [`30617:${OTHER_OWNER}:relay`]: "wss://relay.example", + }, + repositories: [], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, +}; + +test("buildAddedRepositoryEventTemplates preserves NIP-MP project metadata and membership", () => { + const templates = buildAddedRepositoryEventTemplates({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + project, + ownerPubkey: OWNER, + name: "Mobile App", + description: "Flutter client", + cloneUrl: "https://relay.example/git/mobile-app.git", + }); + + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.repository.tags, [ + ["d", "mobile-app"], + ["name", "Mobile App"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["description", "Flutter client"], + ["clone", "https://relay.example/git/mobile-app.git"], + ]); + assert.equal(templates.project.kind, 30621); + assert.deepEqual(templates.project.tags, [ + ["d", "buzz"], + ["name", "Buzz"], + ["description", "A multi-repository workspace"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["a", `30617:${OWNER}:desktop`], + ["a", `30617:${OWNER}:mobile-app`], + ["a", `30617:${OTHER_OWNER}:relay`, "wss://relay.example"], + ]); + assert.equal(templates.project.content, ""); +}); + +test("buildRepositoryChannelBindingTemplate preserves repository metadata", () => { + const repository = { + id: `${OWNER}:desktop`, + dtag: "desktop", + name: "Desktop", + description: "Desktop app", + owner: OWNER, + createdAt: 1, + repoAddress: `30617:${OWNER}:desktop`, + eventContent: "Desktop app", + eventTags: [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ], + }; + const template = buildRepositoryChannelBindingTemplate({ + channelId: "11111111-1111-4111-8111-111111111111", + ownerPubkey: OWNER, + repository, + }); + + assert.equal(template.content, "Desktop app"); + assert.deepEqual(template.tags, [ + ["d", "desktop"], + ["name", "Desktop"], + ["x-custom", "preserve-me"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ]); +}); + +test("buildAttachedRepositoryProjectEventTemplate links an existing repository", () => { + const repositoryAddress = `30617:${"c".repeat(64)}:design-system`; + const template = buildAttachedRepositoryProjectEventTemplate({ + project, + ownerPubkey: OWNER, + repositoryAddress, + }); + + assert.equal(template.kind, 30621); + assert.equal(template.content, ""); + assert.deepEqual(template.tags.at(-1), ["a", repositoryAddress]); +}); + +test("buildAddedRepositoryEventTemplates rejects updates by another owner", () => { + assert.throws( + () => + buildAddedRepositoryEventTemplates({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + project, + ownerPubkey: OTHER_OWNER, + name: "Mobile", + }), + /Only the project owner/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts new file mode 100644 index 0000000000..ccaacb207d --- /dev/null +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -0,0 +1,198 @@ +import type { Project, Repository } from "@/features/projects/hooks"; +import { isValidProjectChannelId } from "@/features/projects/projectModels"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { ProjectEventTemplate } from "./projectCreation"; + +export type AddedRepositoryEventTemplates = { + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; + repositoryDtag: string; +}; + +function repositoryDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses, +}: { + ownerPubkey: string; + project: Project; + repositoryAddresses: string[]; +}): ProjectEventTemplate { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== project.owner.toLowerCase()) { + throw new Error("Only the project owner can add repositories."); + } + if (repositoryAddresses.length > 64) { + throw new Error("A project cannot contain more than 64 repositories."); + } + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/i.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + + const tags: string[][] = [ + ["d", project.dtag], + ["name", project.name], + ]; + if (project.description) tags.push(["description", project.description]); + if (project.projectChannelId) { + tags.push(["buzz-channel", project.projectChannelId]); + } + if (project.visibility === "unlisted") { + tags.push(["buzz-visibility", "unlisted"]); + } + for (const address of repositoryAddresses.sort()) { + const relayHint = project.repositoryRelayHints?.[address]; + tags.push(relayHint ? ["a", address, relayHint] : ["a", address]); + } + return { kind: KIND_PROJECT_ANNOUNCEMENT, content: "", tags }; +} + +export function buildAttachedRepositoryProjectEventTemplate({ + ownerPubkey, + project, + repositoryAddress, +}: { + ownerPubkey: string; + project: Project; + repositoryAddress: string; +}): ProjectEventTemplate { + if (project.repositoryAddresses.includes(repositoryAddress)) { + throw new Error("This repository is already part of the project."); + } + return buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses: [...project.repositoryAddresses, repositoryAddress], + }); +} + +export function buildRepositoryChannelBindingTemplate({ + channelId, + ownerPubkey, + repository, +}: { + channelId: string; + ownerPubkey: string; + repository: Repository; +}): ProjectEventTemplate { + const normalizedChannelId = channelId.trim(); + if (ownerPubkey.trim().toLowerCase() !== repository.owner.toLowerCase()) { + throw new Error("Only the repository owner can repair its access."); + } + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Repository access channel is invalid."); + } + if (!repository.eventTags) { + throw new Error( + "Repository metadata is unavailable. Refresh and try again.", + ); + } + + return { + kind: KIND_REPO_ANNOUNCEMENT, + content: repository.eventContent ?? repository.description, + tags: [ + ...repository.eventTags + .filter((tag) => tag[0] !== "buzz-channel") + .map((tag) => [...tag]), + ["buzz-channel", normalizedChannelId], + ], + }; +} + +export function buildAddedRepositoryEventTemplates({ + accessChannelId, + cloneUrl, + description, + name, + ownerPubkey, + project, + webUrl, +}: { + accessChannelId?: string; + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + project: Project; + webUrl?: string; +}): AddedRepositoryEventTemplates { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + + const normalizedName = name.trim(); + if (!normalizedName) throw new Error("Repository name is required."); + const repositoryDtag = repositoryDtagFromName(normalizedName); + if (!repositoryDtag) { + throw new Error("Repository name must include letters or numbers."); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${repositoryDtag}`; + const isUnavailableMember = + project.unavailableRepositoryAddresses?.includes(repositoryAddress) ?? + false; + if ( + project.repositoryAddresses.includes(repositoryAddress) && + !isUnavailableMember + ) { + throw new Error(`This project already contains "${repositoryDtag}".`); + } + const normalizedDescription = description?.trim() ?? ""; + const repositoryTags: string[][] = [ + ["d", repositoryDtag], + ["name", normalizedName], + ]; + const normalizedAccessChannelId = accessChannelId?.trim(); + if (!normalizedAccessChannelId) { + throw new Error( + "This project has no repository access channel to inherit.", + ); + } + if (!isValidProjectChannelId(normalizedAccessChannelId)) { + throw new Error("Repository access channel is invalid."); + } + repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) repositoryTags.push(["clone", normalizedCloneUrl]); + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]); + + const projectTemplate = buildProjectReplacementTemplate({ + ownerPubkey, + project, + repositoryAddresses: isUnavailableMember + ? [...project.repositoryAddresses] + : [...project.repositoryAddresses, repositoryAddress], + }); + + return { + project: projectTemplate, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + repositoryDtag, + }; +} diff --git a/desktop/src/features/projects/projectRoutes.ts b/desktop/src/features/projects/projectRoutes.ts new file mode 100644 index 0000000000..df55758b42 --- /dev/null +++ b/desktop/src/features/projects/projectRoutes.ts @@ -0,0 +1,53 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import type { Project } from "./projectModels"; + +function parseProjectRouteId(projectId: string): { + address: string | null; + owner: string | null; + dtag: string; +} { + const firstSeparator = projectId.indexOf(":"); + const secondSeparator = projectId.indexOf(":", firstSeparator + 1); + const kind = Number(projectId.slice(0, firstSeparator)); + if ( + secondSeparator > 0 && + (kind === KIND_PROJECT_ANNOUNCEMENT || kind === KIND_REPO_ANNOUNCEMENT) + ) { + const owner = projectId.slice(firstSeparator + 1, secondSeparator); + if (/^[0-9a-fA-F]{64}$/.test(owner)) { + const normalizedOwner = owner.toLowerCase(); + const dtag = projectId.slice(secondSeparator + 1); + return { + address: `${kind}:${normalizedOwner}:${dtag}`, + owner: normalizedOwner, + dtag, + }; + } + } + + const owner = projectId.slice(0, 64); + if (projectId[64] === ":" && /^[0-9a-fA-F]{64}$/.test(owner)) { + return { + address: null, + owner: owner.toLowerCase(), + dtag: projectId.slice(65), + }; + } + return { address: null, owner: null, dtag: projectId }; +} + +/** Matches canonical coordinate routes and legacy owner/d-tag project links. */ +export function projectMatchesRouteId( + project: Project, + projectId: string, +): boolean { + const { address, owner, dtag } = parseProjectRouteId(projectId); + return ( + (!address || project.projectAddress === address) && + project.dtag === dtag && + (!owner || project.owner.toLowerCase() === owner) + ); +} diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index a2f0047703..ed4b2ac61c 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -20,10 +20,17 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; -type ProjectReference = { +type RepositoryReference = { repoAddress: string; }; +type ProjectReference = { + repositories: RepositoryReference[]; +}; + +type ProjectRepository = + TProject["repositories"][number]; + /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = | "comments" @@ -33,11 +40,19 @@ export type ProjectWorkItemSection = /** Aggregate work items plus any optional event groups that failed to load. */ export type ProjectsWorkItemsResult = { issues: { - items: Array<{ project: TProject; issue: ProjectIssue }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + issue: ProjectIssue; + }>; failedSections: ProjectWorkItemSection[]; }; pullRequests: { - items: Array<{ project: TProject; pullRequest: ProjectPullRequest }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + pullRequest: ProjectPullRequest; + }>; failedSections: ProjectWorkItemSection[]; }; }; @@ -59,7 +74,11 @@ export async function fetchProjectsWorkItems( projects: TProject[], ): Promise> { const repoAddresses = [ - ...new Set(projects.map((project) => project.repoAddress)), + ...new Set( + projects.flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ), + ), ]; const [rootResult, updateResult, commentResult, statusResult] = await Promise.allSettled([ @@ -109,27 +128,31 @@ export async function fetchProjectsWorkItems( const pullRequests = projects .flatMap((project) => - projectPullRequestEventsToPullRequests( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_PULL_REQUEST, - ), - updatesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - statusesByRepo.get(project.repoAddress) ?? [], - ).map((pullRequest) => ({ project, pullRequest })), + project.repositories.flatMap((repository) => + projectPullRequestEventsToPullRequests( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_PULL_REQUEST, + ), + updatesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + statusesByRepo.get(repository.repoAddress) ?? [], + ).map((pullRequest) => ({ project, pullRequest, repository })), + ), ) .sort( (left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt, ); const issues = projects .flatMap((project) => - projectIssueEventsToIssues( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_ISSUE, - ), - statusesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - ).map((issue) => ({ project, issue })), + project.repositories.flatMap((repository) => + projectIssueEventsToIssues( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_ISSUE, + ), + statusesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + ).map((issue) => ({ issue, project, repository })), + ), ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; diff --git a/desktop/src/features/projects/pullRequestMutations.ts b/desktop/src/features/projects/pullRequestMutations.ts index 4eae6464bf..160c0a3403 100644 --- a/desktop/src/features/projects/pullRequestMutations.ts +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -12,7 +12,7 @@ import { KIND_GIT_PULL_REQUEST, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { Project, ProjectPullRequest } from "./hooks"; +import type { ProjectPullRequest, Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs"; import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews"; diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index aa72c31643..ed6484385a 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -14,7 +14,7 @@ import { KIND_GIT_STATUS_OPEN, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt, type ProjectPullRequest, diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 457ccca175..2a25f5d78f 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -6,7 +6,11 @@ import { pullProjectLocalRepository, pushProjectLocalRepository, } from "@/shared/api/projectGit"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; +import { useProjectRepoHost } from "@/features/projects/useProjectRepoHost"; import { publishProjectPullRequestUpdate } from "./pullRequestMutations"; /** Local-vs-remote git sync status for a project checkout (ahead/behind @@ -21,9 +25,10 @@ export function useProjectRepoSyncStatusQuery( ) { const selectedBranch = branchName ?? project?.defaultBranch ?? null; const selectedBaseBranch = baseBranch ?? project?.defaultBranch ?? null; + const host = useProjectRepoHost(project); return useQuery({ - enabled: Boolean(project?.cloneUrls[0]), + enabled: Boolean(host.kind === "buzz" && project?.cloneUrls[0]), queryKey: [ "project", project?.id ?? "none", diff --git a/desktop/src/features/projects/repositoryActivityHooks.ts b/desktop/src/features/projects/repositoryActivityHooks.ts new file mode 100644 index 0000000000..10733116a8 --- /dev/null +++ b/desktop/src/features/projects/repositoryActivityHooks.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { + fetchRepositoryActivitySummaries, + type Project, +} from "@/features/projects/hooks"; + +/** Fetches repository-specific activity for the repositories in these projects. */ +export function useRepositoryActivitySummariesQuery(projects: Project[]) { + const repositories = React.useMemo( + () => [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ], + [projects], + ); + const repoAddresses = React.useMemo( + () => repositories.map((repository) => repository.repoAddress).sort(), + [repositories], + ); + + return useQuery({ + enabled: repoAddresses.length > 0, + queryKey: ["projects", "activity-summaries", "repositories", repoAddresses], + queryFn: () => fetchRepositoryActivitySummaries(repositories), + staleTime: 30_000, + }); +} diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx new file mode 100644 index 0000000000..609c38a2cc --- /dev/null +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -0,0 +1,199 @@ +import * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import type { AddProjectRepositoryInput } from "@/features/projects/useAddProjectRepository"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +const FIELD_SHELL_CLASS = + "flex min-h-11 items-center rounded-xl border border-input bg-muted/40 px-3 transition-colors hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const FIELD_CONTROL_CLASS = + "h-8 border-0 bg-transparent px-0 py-0 text-muted-foreground/55 shadow-none outline-none ring-0 placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus-visible:ring-0"; + +export function AddProjectRepositoryDialog({ + accessChannelId, + channels, + isCreating, + onAdd, + onOpenChange, + open, + project, +}: { + accessChannelId?: string; + channels: Channel[]; + isCreating: boolean; + onAdd: (input: AddProjectRepositoryInput) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; +}) { + const [name, setName] = React.useState(""); + const [cloneUrl, setCloneUrl] = React.useState(""); + const [selectedChannelId, setSelectedChannelId] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(null); + const nameInputRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + setName(""); + setCloneUrl(""); + setSelectedChannelId(accessChannelId ?? ""); + setErrorMessage(null); + const timerId = globalThis.setTimeout( + () => nameInputRef.current?.focus(), + 50, + ); + return () => globalThis.clearTimeout(timerId); + }, [accessChannelId, open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!name.trim() || !selectedChannelId) return; + setErrorMessage(null); + try { + await onAdd({ + accessChannelId: selectedChannelId, + cloneUrl: cloneUrl.trim() || undefined, + name: name.trim(), + project, + }); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to add repository.", + ); + } + } + + return ( + { + if (!nextOpen && isCreating) return; + onOpenChange(nextOpen); + }} + open={open} + > + + {isCreating ? "Adding..." : "Add repository"} + + } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Add repository" + > +
void handleSubmit(event)} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder="mobile-app" + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+
+ +
+ +
+

+ Members of this channel can access the repository. +

+
+
+ +
+ { + setCloneUrl(event.target.value); + setErrorMessage(null); + }} + placeholder="https://relay.example.com/git/mobile-app.git" + spellCheck={false} + value={cloneUrl} + /> +
+
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx new file mode 100644 index 0000000000..8c4c080c61 --- /dev/null +++ b/desktop/src/features/projects/ui/AttachProjectRepositoryDialog.tsx @@ -0,0 +1,92 @@ +import { FolderGit2 } from "lucide-react"; +import * as React from "react"; + +import type { Project, Repository } from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; + +export function AttachProjectRepositoryDialog({ + isAttaching, + onAttach, + onOpenChange, + open, + project, + repositories, +}: { + isAttaching: boolean; + onAttach: (repository: Repository) => Promise; + onOpenChange: (open: boolean) => void; + open: boolean; + project: Project; + repositories: Repository[]; +}) { + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + if (open) setErrorMessage(null); + }, [open]); + + async function handleAttach(repository: Repository) { + setErrorMessage(null); + try { + await onAttach(repository); + onOpenChange(false); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to attach repository.", + ); + } + } + + return ( + { + if (!nextOpen && isAttaching) return; + onOpenChange(nextOpen); + }} + open={open} + > + +
+ {repositories.length === 0 ? ( +

+ Every available repository is already in this project. +

+ ) : ( + repositories.map((repository) => ( + + )) + )} + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index c5e6c2670c..ff214d8084 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { useChannelsQuery } from "@/features/channels/hooks"; import type { CreateProjectInput } from "@/features/projects/useCreateProject"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -22,7 +23,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a new project (NIP-34 repo announcement). */ +/** Modal for publishing a project with its initial NIP-34 repository. */ export function CreateProjectDialog({ isCreating, onCreate, @@ -33,8 +34,20 @@ export function CreateProjectDialog({ const [description, setDescription] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [webUrl, setWebUrl] = React.useState(""); + const [accessChannelId, setAccessChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const channelsQuery = useChannelsQuery({ enabled: open }); + const accessChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.isMember && + !channel.archivedAt && + channel.channelType !== "dm", + ), + [channelsQuery.data], + ); React.useEffect(() => { if (!open) return; @@ -43,6 +56,7 @@ export function CreateProjectDialog({ setDescription(""); setCloneUrl(""); setWebUrl(""); + setAccessChannelId(accessChannels[0]?.id ?? ""); setErrorMessage(null); // Small delay to let the dialog animation start before focusing. @@ -50,18 +64,19 @@ export function CreateProjectDialog({ nameInputRef.current?.focus(); }, 50); return () => globalThis.clearTimeout(timerId); - }, [open]); + }, [accessChannels, open]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const trimmedName = name.trim(); - if (!trimmedName) return; + if (!trimmedName || !accessChannelId) return; setErrorMessage(null); try { await onCreate({ + accessChannelId, name: trimmedName, description: description.trim() || undefined, cloneUrl: cloneUrl.trim() || undefined, @@ -88,12 +103,14 @@ export function CreateProjectDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects are repositories published to this workspace's relay." + description="Projects group one or more repositories published to this workspace's relay." footer={
+
+ +
+ +
+

+ Members of this channel can access project repositories. +

+
+
- Web URL + Initial repository web URL Optional
void | Promise; + onCreated: ( + project: Project, + repository: Repository, + issueId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const createMutation = useCreateProjectIssueMutation(project); + const [repositoryId, setRepositoryId] = React.useState( + selectProjectRepository(initialProject, null)?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const createMutation = useCreateProjectIssueMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); async function handleCreate(input: CreateProjectWorkItemDialogInput) { - if (!project) throw new Error("Choose a repository."); + if (!project || !repository) throw new Error("Choose a repository."); const issueId = await createMutation.mutateAsync(input); toast.success("Issue created."); - await onCreated(project, issueId); + await onCreated(project, repository, issueId); } return ( @@ -66,12 +84,17 @@ export function CreateProjectIssueDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-issue-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx index d64d132aed..11ba74d8d8 100644 --- a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx +++ b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx @@ -3,9 +3,11 @@ import { toast } from "sonner"; import { type Project, + type Repository, useProjectPullRequestsQuery, useRepoStateQuery, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useCreateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations"; import { useProjectRepoSyncStatusQuery } from "@/features/projects/repoSyncHooks"; @@ -25,61 +27,79 @@ export function CreatePullRequestDialog({ reposDir, }: { initialProjectId?: string; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; reposDir?: string | null; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); + const initialRepository = selectProjectRepository(initialProject, null); + const [repositoryId, setRepositoryId] = React.useState( + initialRepository?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); const initialSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, - project?.defaultBranch, + repository?.defaultBranch, ); const branchOptions = React.useMemo(() => { const names = [ - project?.defaultBranch, + repository?.defaultBranch, ...(repoStateQuery.data?.branches.map((branch) => branch.name) ?? []), initialSyncQuery.data?.localBranch, ].filter((name): name is string => Boolean(name)); return [...new Set(names)]; }, [ initialSyncQuery.data?.localBranch, - project?.defaultBranch, + repository?.defaultBranch, repoStateQuery.data?.branches, ]); const [targetBranch, setTargetBranch] = React.useState( - project?.defaultBranch ?? "", + repository?.defaultBranch ?? "", ); const [sourceBranch, setSourceBranch] = React.useState(""); const sourceSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, sourceBranch || null, targetBranch || null, ); - const createMutation = useCreateProjectPullRequestMutation(project); + const createMutation = useCreateProjectPullRequestMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); React.useEffect(() => { - if (!project) return; - setTargetBranch(project.defaultBranch); + if (!repository) return; + setTargetBranch(repository.defaultBranch); setSourceBranch(""); - }, [project]); + }, [repository]); React.useEffect(() => { if ( @@ -104,9 +124,9 @@ export function CreatePullRequestDialog({ (pullRequest) => (pullRequest.status === "Open" || pullRequest.status === "Draft") && pullRequest.branchName === sourceBranch && - (pullRequest.targetBranch ?? project?.defaultBranch) === targetBranch, + (pullRequest.targetBranch ?? repository?.defaultBranch) === targetBranch, ); - const selectionError = !project + const selectionError = !repository ? "Choose a repository." : !targetBranch ? "Choose a base branch." @@ -120,12 +140,12 @@ export function CreatePullRequestDialog({ ? "The compare branch must be pushed before opening a pull request." : null; const description = - project && sourceBranch && targetBranch - ? `${project.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` + repository && sourceBranch && targetBranch + ? `${repository.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` : "Choose a repository and branches to compare."; async function handleCreate(input: CreatePullRequestDialogInput) { - if (!project || !sourceCommit || selectionError) { + if (!project || !repository || !sourceCommit || selectionError) { throw new Error( selectionError ?? "Pull request branches are incomplete.", ); @@ -139,7 +159,7 @@ export function CreatePullRequestDialog({ reviewers: [], }); toast.success("Pull request created."); - await onCreated(project, pullRequestId); + await onCreated(project, repository, pullRequestId); } return ( @@ -165,12 +185,17 @@ export function CreatePullRequestDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-pull-request-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/GitHubMark.tsx b/desktop/src/features/projects/ui/GitHubMark.tsx new file mode 100644 index 0000000000..29c9602102 --- /dev/null +++ b/desktop/src/features/projects/ui/GitHubMark.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from "react"; + +export function GitHubMark(props: SVGProps) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx index 40757f1529..c023f4dbb8 100644 --- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx +++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx @@ -2,7 +2,10 @@ import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery"; import { useMergeProjectPullRequestMutation, diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx new file mode 100644 index 0000000000..8126292894 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -0,0 +1,72 @@ +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** Compact work-item author identity with a minimal hover summary. */ +export function ProjectAuthorIdentity({ + label, + profiles, + pubkey, + testId, +}: { + label: string; + profiles?: UserProfileLookup; + pubkey: string; + testId?: string; +}) { + const profile = profiles?.[normalizePubkey(pubkey)]; + const roleLabel = profile?.isAgent === true ? "Agent" : "Person"; + + return ( + + + + + + + + + + {label} + + {roleLabel} + + + + + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 4f4fef7678..ed28ddd583 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -1,6 +1,7 @@ import { + CircleAlert, CircleDot, - FolderGit2, + Folders, GitCommit, GitPullRequest, TerminalSquare, @@ -22,6 +23,7 @@ import { getProjectUpdatedAt, relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; +import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -83,7 +85,7 @@ function ProjectUpdatedLabel({ ); } -function ProjectPeopleStack({ +export function ProjectPeopleStack({ pubkeys, profiles, workOwnerPubkey, @@ -100,7 +102,7 @@ function ProjectPeopleStack({ } return ( -
+
{visible.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); @@ -167,7 +169,7 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; -function ProjectStatsRow({ +export function ProjectStatsRow({ summary, fixedColumns = false, }: { @@ -207,7 +209,7 @@ function ProjectStatsRow({ // Segmented commits/PRs/issues distribution — the card's "progress bar". // Hovering thickens the bar and reveals a tooltip with the exact breakdown. -function ProjectActivityBar({ +export function ProjectActivityBar({ summary, }: { summary: ProjectActivitySummary | undefined; @@ -263,10 +265,58 @@ function StatusPill({ status }: { status: string }) { ); } +function RepositoryUnavailableIndicator({ + reason, +}: { + reason: ProjectRepoUnavailableReason | undefined; +}) { + if (!reason) return null; + const status = { + authentication: { + description: "Buzz could not authenticate with this repository.", + label: "Access failed", + }, + missing: { + description: "No git repository was found on the Buzz relay.", + label: "Uninitialized", + }, + network: { + description: "The Buzz git service could not be reached.", + label: "Unreachable", + }, + ref: { + description: "The advertised branch is missing from the git remote.", + label: "Branch missing", + }, + unknown: { + description: "Buzz could not load this repository.", + label: "Unavailable", + }, + }[reason]; + + return ( + + + + + + + +

{status.label}

+

{status.description}

+
+
+ ); +} + export function EmptyState() { return (
- +

No projects yet

@@ -280,7 +330,7 @@ export function EmptyState() { export function EmptyFilteredState() { return (

- +

No matching projects @@ -302,7 +352,7 @@ function ProjectCardButton({ }) { return ( + {showTechnicalDetails ? ( +

+
+ +