From 139c451ef306a635d3bd3bb250f9d402d1563049 Mon Sep 17 00:00:00 2001
From: KC <79471844+wolfyy970@users.noreply.github.com>
Date: Mon, 3 Aug 2026 14:52:02 -0400
Subject: [PATCH 1/7] feat(desktop): add Project connection setup
Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com>
(cherry picked from commit 531a5bddabb961a2f972f1c262d3ea188261a423)
---
desktop/playwright.config.ts | 1 +
desktop/src-tauri/src/commands/mod.rs | 2 +
.../src/commands/project_connections.rs | 52 +
desktop/src-tauri/src/lib.rs | 10 +-
desktop/src-tauri/src/managed_agents/mod.rs | 1 +
.../src/managed_agents/project_connections.rs | 923 ++++++++++++++++++
.../project_connections/probe.rs | 465 +++++++++
.../project_connections/tests.rs | 125 +++
.../project_connections/transactions.rs | 217 ++++
.../projects/projectConnectionHooks.ts | 93 ++
.../projects/ui/ProjectConnectionsPanel.tsx | 708 ++++++++++++++
.../projects/ui/ProjectWorkspaceTabList.tsx | 3 +
.../projects/ui/ProjectWorkspaceTabs.tsx | 23 +
.../ui/projectConnectionSecrets.test.mjs | 80 ++
.../projects/ui/projectConnectionSecrets.ts | 55 ++
.../projects/ui/projectDetailHelpers.ts | 1 +
.../src/shared/api/projectConnectionTypes.ts | 6 +
.../src/shared/api/tauriProjectConnections.ts | 121 +++
desktop/src/testing/e2eBridge.ts | 188 ++++
.../project-connections-screenshots.spec.ts | 124 +++
.../synthetic-project-connection-mcp.mjs | 44 +
desktop/tests/helpers/bridge.ts | 2 +
22 files changed, 3239 insertions(+), 5 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/project_connections.rs
create mode 100644 desktop/src-tauri/src/managed_agents/project_connections.rs
create mode 100644 desktop/src-tauri/src/managed_agents/project_connections/probe.rs
create mode 100644 desktop/src-tauri/src/managed_agents/project_connections/tests.rs
create mode 100644 desktop/src-tauri/src/managed_agents/project_connections/transactions.rs
create mode 100644 desktop/src/features/projects/projectConnectionHooks.ts
create mode 100644 desktop/src/features/projects/ui/ProjectConnectionsPanel.tsx
create mode 100644 desktop/src/features/projects/ui/projectConnectionSecrets.test.mjs
create mode 100644 desktop/src/features/projects/ui/projectConnectionSecrets.ts
create mode 100644 desktop/src/shared/api/projectConnectionTypes.ts
create mode 100644 desktop/src/shared/api/tauriProjectConnections.ts
create mode 100644 desktop/tests/e2e/project-connections-screenshots.spec.ts
create mode 100644 desktop/tests/fixtures/synthetic-project-connection-mcp.mjs
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index c1ea0e061b..f78eeb8b31 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -138,6 +138,7 @@ export default defineConfig({
"**/harness-catalog-screenshots.spec.ts",
"**/inline-custom-harness.spec.ts",
"**/where-to-run-config.spec.ts",
+ "**/project-connections-screenshots.spec.ts",
"**/huddle-transcription.spec.ts",
"**/agent-numeric-tuning.spec.ts",
"**/needs-restart-screenshots.spec.ts",
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 322834630a..dd0b6c8fa7 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -43,6 +43,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;
@@ -100,6 +101,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/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/lib.rs b/desktop/src-tauri/src/lib.rs
index 2847b87877..17109455b8 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -141,28 +141,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;
@@ -650,6 +645,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/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index fe90ce430f..07c724ccae 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -24,6 +24,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/project_connections.rs b/desktop/src-tauri/src/managed_agents/project_connections.rs
new file mode 100644
index 0000000000..ffff59ebb5
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/project_connections.rs
@@ -0,0 +1,923 @@
+//! 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 HEALTH_STALE_AFTER_SECONDS: i64 = 24 * 60 * 60;
+
+static PROJECT_CONNECTIONS_LOCK: Mutex<()> = Mutex::new(());
+
+mod transactions;
+use transactions::{commit_delete, commit_update, UpdateTransaction};
+
+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-34 repository coordinate (`30617::`).
+ pub repo_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()
+}
+
+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'))
+}
+
+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
+ {
+ return Err("Project connection metadata is invalid.".to_string());
+ }
+ Ok(())
+}
+
+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.repo_address.splitn(3, ':');
+ let kind = parts.next();
+ let owner = parts.next();
+ let d_tag = parts.next();
+ if kind != Some("30617")
+ || !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(),
+ repo_address: format!(
+ "30617:{}:{}",
+ 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)?;
+ if canonical != *scope {
+ return Err("Buzz could not verify this Project's canonical identity.".to_string());
+ }
+ 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 connection_store_path(
+ app: &AppHandle,
+ scope: &ProjectConnectionScope,
+) -> Result {
+ Ok(workspace_connection_dir(app, scope)?.join("connections.json"))
+}
+
+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(())
+}
+
+fn load_store_unlocked(
+ app: &AppHandle,
+ scope: &ProjectConnectionScope,
+) -> Result {
+ let path = connection_store_path(app, scope)?;
+ reject_unsafe_owner_file(&path)?;
+ let bytes = match fs::read(&path) {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ return Ok(ProjectConnectionStore::default());
+ }
+ 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}"))?;
+ 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());
+ }
+ for connection in &store.connections {
+ validate_stored_connection(connection)?;
+ }
+ Ok(store)
+}
+
+fn save_store_unlocked(
+ app: &AppHandle,
+ scope: &ProjectConnectionScope,
+ store: &ProjectConnectionStore,
+) -> Result<(), String> {
+ 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}"))?;
+ atomic_write_json_restricted(&path, &bytes)
+}
+
+#[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> {
+ serde_json::to_vec(env)
+ .map_err(|error| format!("failed to prepare connection credentials: {error}"))
+}
+
+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)?;
+ fs::read(path).map_err(|_| {
+ format!(
+ "Sign in again to '{}'. Its saved credentials are missing.",
+ 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
+ ));
+ }
+ 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 {
+ return Err("Give this connection a short name.".to_string());
+ }
+ if provider.trim().is_empty() || provider.len() > MAX_PROVIDER_BYTES {
+ 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;
+ for (key, value) in env {
+ if !super::is_well_formed_env_key(key) || super::is_reserved_env_key(key) {
+ 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(path: &Path) -> Result {
+ let mut file =
+ fs::File::open(path).map_err(|_| "Buzz could not read this executable.".to_string())?;
+ 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 canonical_connection_command(command: &str) -> Result<(String, String), 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 metadata = canonical
+ .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())?;
+ let fingerprint = executable_sha256(Path::new(&canonical))?;
+ 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
+}
+
+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 _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())?;
+ if !store.connections[index].env_keys.is_empty() {
+ load_secrets(app, &store.connections[index])?;
+ }
+ 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/probe.rs b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs
new file mode 100644
index 0000000000..309bf1c313
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs
@@ -0,0 +1,465 @@
+use std::{
+ collections::BTreeMap,
+ io::{BufRead, BufReader, Write as _},
+ 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 MAX_DISCOVERED_TOOLS: usize = 256;
+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
+ {argsError ? (
+
+ {argsError}
+
+ ) : null}
@@ -339,6 +480,7 @@ function ConnectionDialog({
disabled={pending}
onClick={() => {
setSecrets((rows) => [...rows, emptySecretRow()]);
+ setExecutionDirty(true);
setTrusted(false);
}}
size="xs"
@@ -349,6 +491,16 @@ function ConnectionDialog({
Add secret
+ {secrets.length > 0 ? (
+
+ Name
+ Value
+
+
+ ) : null}
{secrets.map((row, index) => (
rows.filter((_, rowIndex) => rowIndex !== index),
);
+ setExecutionDirty(true);
setTrusted(false);
}}
size="icon-xs"
@@ -416,25 +571,32 @@ function ConnectionDialog({
))}
+ {!secretChanges.ok ? (
+
+ {secretChanges.error}
+
+ ) : null}
) : null}
-
+ {requiresApproval ? (
+
+ ) : null}
{error ? (
{error}
@@ -451,7 +613,11 @@ function ConnectionDialog({
Cancel
@@ -460,6 +626,149 @@ function ConnectionDialog({
);
}
+function ConnectionRow({
+ connection,
+ onEdit,
+ onRemove,
+ onTest,
+ testPending,
+ testing,
+}: {
+ connection: ProjectConnection;
+ onEdit: () => void;
+ onRemove: () => void;
+ onTest: () => void;
+ testPending: boolean;
+ testing: boolean;
+}) {
+ const recoveryNeedsEdit = connectionNeedsEditing(connection);
+ const [showAllTools, setShowAllTools] = React.useState(false);
+ const visibleTools = showAllTools
+ ? connection.discoveredTools
+ : connection.discoveredTools.slice(0, TOOL_PREVIEW_LIMIT);
+ const hiddenToolCount = Math.max(
+ 0,
+ connection.discoveredTools.length - TOOL_PREVIEW_LIMIT,
+ );
+ const actionLabel = connectionActionLabel(connection);
+
+ return (
+
+
+
+
+
+ {connection.name}
+
+
+
+
+
+ {connection.provider}
+
+
+ {testing
+ ? `Testing ${connection.name}…`
+ : formatVerificationTime(connection.health.lastVerifiedAt)}
+
+ {connection.health.detail ? (
+
+ {connection.health.detail}
+
+ ) : null}
+
+ {visibleTools.length > 0 ? (
+
+ {visibleTools.map((tool) => (
+
+ {toolLabel(tool)}
+
+ ))}
+ {hiddenToolCount > 0 ? (
+
+ ) : null}
+
+ ) : (
+
+ {connection.health.status === "not_tested"
+ ? "Test this connection to discover its tools."
+ : "No tools are currently available."}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
export function ProjectConnectionsPanel({
projectScope,
}: {
@@ -475,20 +784,41 @@ export function ProjectConnectionsPanel({
const [removing, setRemoving] = React.useState(
null,
);
+ const [removeError, setRemoveError] = React.useState(null);
function openAdd() {
setEditing(null);
setDialogOpen(true);
}
+ async function handleTest(connection: ProjectConnection) {
+ try {
+ const tested = await testMutation.mutateAsync(connection.id);
+ if (tested.health.status === "ready") {
+ toast.success(`Tools found for ${connection.name}.`);
+ } else {
+ toast.error(
+ tested.health.detail ?? `${connection.name} needs attention.`,
+ );
+ }
+ } catch (cause) {
+ toast.error(
+ cause instanceof Error
+ ? `Couldn't test ${connection.name}: ${cause.message}`
+ : `Couldn't test ${connection.name}. Check its details and try again.`,
+ );
+ }
+ }
+
async function handleDelete() {
if (!removing) return;
+ setRemoveError(null);
try {
await deleteMutation.mutateAsync(removing.id);
toast.success(`${removing.name} removed.`);
setRemoving(null);
} catch (cause) {
- toast.error(
+ setRemoveError(
cause instanceof Error
? `Couldn't remove ${removing.name}: ${cause.message}`
: `Couldn't remove ${removing.name}. Nothing was changed.`,
@@ -505,24 +835,34 @@ export function ProjectConnectionsPanel({
data-project-detail-panel
data-testid="project-connections-panel"
>
-
-
-
-
Connections
-
- Save and verify MCP connections for this Project.
-
+
+
+
+
+
+ Connections
+
+
+ Connect MCP servers across this Project. Credentials stay on
+ this device.
+
+
-
{query.isPending ? (
-
+
- Loading connections...
+ Loading connections…
) : query.isError ? (
@@ -555,103 +895,24 @@ export function ProjectConnectionsPanel({
) : (
{connections.map((connection) => (
-
-
-
-
-
-
-
- {connection.name}
-
-
-
-
- {connection.provider} ·{" "}
- {formatVerificationTime(connection.health.lastVerifiedAt)}
-
- {connection.health.detail ? (
-
- {connection.health.detail}
-
- ) : null}
- {connection.capabilityIds.length > 0 ? (
-
- {connection.capabilityIds.map((capability) => (
-
-
- {capabilityLabel(capability)}
-
- ))}
-
- ) : (
-
- Test this connection to discover its tools.
-
- )}
-
-
-
{
- try {
- const tested = await testMutation.mutateAsync(
- connection.id,
- );
- if (tested.health.status === "ready") {
- toast.success(`Tools found for ${connection.name}.`);
- } else {
- toast.error(
- tested.health.detail ??
- `${connection.name} needs attention.`,
- );
- }
- } catch (cause) {
- toast.error(
- cause instanceof Error
- ? `Couldn't test ${connection.name}: ${cause.message}`
- : `Couldn't test ${connection.name}. Check its details and try again.`,
- );
- }
- }}
- size="sm"
- variant="outline"
- >
- {testMutation.isPending ? (
-
- ) : (
-
- )}
- Test
-
-
{
- setEditing(connection);
- setDialogOpen(true);
- }}
- size="icon-xs"
- variant="ghost"
- >
-
-
-
setRemoving(connection)}
- size="icon-xs"
- variant="ghost"
- >
-
-
-
-
+ onEdit={() => {
+ setEditing(connection);
+ setDialogOpen(true);
+ }}
+ onRemove={() => {
+ setRemoveError(null);
+ setRemoving(connection);
+ }}
+ onTest={() => void handleTest(connection)}
+ testPending={testMutation.isPending}
+ testing={
+ testMutation.isPending &&
+ testMutation.variables === connection.id
+ }
+ />
))}
)}
@@ -668,6 +929,7 @@ export function ProjectConnectionsPanel({
})
: createMutation.mutateAsync(input)
}
+ onTest={handleTest}
open={dialogOpen}
pending={createMutation.isPending || updateMutation.isPending}
projectScope={projectScope}
@@ -675,7 +937,10 @@ export function ProjectConnectionsPanel({
{
- if (!open) setRemoving(null);
+ if (!open && !deleteMutation.isPending) {
+ setRemoving(null);
+ setRemoveError(null);
+ }
}}
open={Boolean(removing)}
>
@@ -689,17 +954,22 @@ export function ProjectConnectionsPanel({
this device. This does not delete data from the connected service.
+ {removeError ? (
+
+ {removeError}
+
+ ) : null}
- Cancel
-
- void handleDelete()}
- variant="destructive"
- >
- {deleteMutation.isPending ? "Removing..." : "Remove connection"}
-
-
+
+ Cancel
+
+ void handleDelete()}
+ variant="destructive"
+ >
+ {deleteMutation.isPending ? "Removing…" : "Remove connection"}
+
diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
index 1b2adf316a..da73f7159b 100644
--- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
+++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx
@@ -73,8 +73,8 @@ import {
import type { CreateIssueDialogInput } from "./CreateIssueDialog";
import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs";
import { ProjectDetailChrome } from "./ProjectDetailChrome";
-import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
-import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories";
+import { ProjectRepositoryHeaderControl } from "./ProjectRepositoryHeaderControl";
+import { ProjectWithoutRepositories } from "./ProjectWithoutRepositories";
import {
PROJECT_TAB_CRUMB_LABELS,
projectPeople,
@@ -755,19 +755,26 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
);
}
+ const projectConnectionScope =
+ activeCommunity?.relayUrl && identityQuery.data?.pubkey
+ ? {
+ relayUrl: activeCommunity.relayUrl,
+ operatorPubkey: identityQuery.data.pubkey,
+ projectAddress: project.projectAddress,
+ }
+ : null;
if (!repository) {
return (
-
-
-
{project.name}
-
- This project does not have any available repositories yet.
-
-
-
+
void goChannel(channelId)}
+ onGoProjects={() => void goProjects()}
+ project={project}
+ />
);
}
-
const repoContributors = repoSnapshotQuery.data?.contributors ?? [];
const selectedPullRequest =
pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ??
@@ -814,6 +821,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
+ setActiveTab("overview");
// Remount the workspace tabs so the project page opens on Overview
// instead of whatever tab the work item left behind.
setTabsResetKey((key) => key + 1);
@@ -886,18 +894,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
) : null}
-
+
@@ -931,6 +935,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
localSnapshot={localRepoSnapshotQuery.data}
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
+ initialSelectedTab={
+ activeTab === "connections" ? "connections" : "overview"
+ }
onBranchChange={handleBranchChange}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onOpenTerminal={() => {
@@ -945,6 +952,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
onSelectedTabChange={setActiveTab}
profiles={profiles}
project={repository}
+ projectScope={projectConnectionScope}
+ projectScopeLoading={identityQuery.isPending}
projectId={project.id}
repoDiff={displayedRepoDiff}
repoDiffError={displayedRepoDiffError}
diff --git a/desktop/src/features/projects/ui/ProjectRepositoryHeaderControl.tsx b/desktop/src/features/projects/ui/ProjectRepositoryHeaderControl.tsx
new file mode 100644
index 0000000000..2e0849b6e8
--- /dev/null
+++ b/desktop/src/features/projects/ui/ProjectRepositoryHeaderControl.tsx
@@ -0,0 +1,34 @@
+import type { Project, Repository } from "@/features/projects/hooks";
+import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
+
+export function ProjectRepositoryHeaderControl({
+ active,
+ identityPubkey,
+ onChange,
+ project,
+ projects,
+ repository,
+}: {
+ active: boolean;
+ identityPubkey?: string;
+ onChange: (repositoryId: string) => void;
+ project: Project;
+ projects: Project[];
+ repository: Repository;
+}) {
+ if (!active) return null;
+ return (
+
+ );
+}
diff --git a/desktop/src/features/projects/ui/ProjectWithoutRepositories.tsx b/desktop/src/features/projects/ui/ProjectWithoutRepositories.tsx
new file mode 100644
index 0000000000..fef599661e
--- /dev/null
+++ b/desktop/src/features/projects/ui/ProjectWithoutRepositories.tsx
@@ -0,0 +1,59 @@
+import type * as React from "react";
+
+import type { Project } from "@/features/projects/hooks";
+import type { ProjectConnectionScope } from "@/shared/api/projectConnectionTypes";
+import { ProjectConnectionScopeUnavailable } from "./ProjectConnectionScopeUnavailable";
+import { ProjectConnectionsPanel } from "./ProjectConnectionsPanel";
+import { ProjectDetailChrome } from "./ProjectDetailChrome";
+import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories";
+
+export function ProjectWithoutRepositories({
+ chromeRef,
+ connectionScope,
+ connectionScopeLoading,
+ onGoChannel,
+ onGoProjects,
+ project,
+}: {
+ chromeRef: React.Ref;
+ connectionScope: ProjectConnectionScope | null;
+ connectionScopeLoading: boolean;
+ onGoChannel: (channelId: string) => void;
+ onGoProjects: () => void;
+ project: Project;
+}) {
+ return (
+
+
+
+
+
+
+ {project.name}
+
+
+ No repositories are available yet. Project connections still work
+ and will apply when repositories are added.
+
+
+ {connectionScope ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx
index 90548a08ef..a05a625e9f 100644
--- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx
+++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx
@@ -26,42 +26,55 @@ function ProjectTabLabel({ children }: { children: string }) {
);
}
-export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
+export function ProjectTabsList({
+ prsActive,
+ repositoryLoaded = true,
+}: {
+ prsActive?: boolean;
+ repositoryLoaded?: boolean;
+}) {
return (
-
-
-
-
- Files
-
-
- Commits
-
-
- Issues
-
-
- Pull Request
-
-
- Contributors
-
Connections
+ {repositoryLoaded ? (
+ <>
+
+
+
+
+ Files
+
+
+ Commits
+
+
+ Issues
+
+
+ Pull Request
+
+
+ Contributors
+
+ >
+ ) : null}
);
}
diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
index 4e4d536a1c..c6a1efc2f1 100644
--- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
+++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
@@ -8,7 +8,6 @@ import {
import * as React from "react";
import type { ComponentType } from "react";
-import { useCommunities } from "@/features/communities/useCommunities";
import type {
Project,
ProjectLocalRepoSnapshot,
@@ -19,7 +18,7 @@ import type {
ProjectRepoSnapshot,
Repository,
} from "@/features/projects/hooks";
-import { useIdentityQuery } from "@/shared/api/hooks";
+import type { ProjectConnectionScope } from "@/shared/api/projectConnectionTypes";
import {
commitAuthorPubkeysFromPullRequests,
type ViewerGitIdentity,
@@ -41,6 +40,7 @@ import {
ProjectOverviewPanel,
} from "./ProjectOverviewPanel";
import { ProjectConnectionsPanel } from "./ProjectConnectionsPanel";
+import { ProjectConnectionScopeUnavailable } from "./ProjectConnectionScopeUnavailable";
import {
PullRequestDetailHeader,
PullRequestMetaRail,
@@ -128,7 +128,10 @@ export function WorkspaceTabs({
localSnapshot,
localSnapshotError,
localSnapshotLoading,
+ initialSelectedTab = "overview",
project,
+ projectScope,
+ projectScopeLoading,
projectId,
repoDiff,
repoDiffError,
@@ -166,7 +169,10 @@ export function WorkspaceTabs({
localSnapshot: ProjectLocalRepoSnapshot | null | undefined;
localSnapshotError: unknown;
localSnapshotLoading: boolean;
+ initialSelectedTab?: string;
project: Repository;
+ projectScope: ProjectConnectionScope | null;
+ projectScopeLoading?: boolean;
projectId: string;
repoDiff: ProjectRepoDiff | null | undefined;
repoDiffError: unknown;
@@ -197,18 +203,6 @@ export function WorkspaceTabs({
terminalTitle?: string;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
- const { activeCommunity } = useCommunities();
- const identityQuery = useIdentityQuery();
- const projectScope =
- activeCommunity?.relayUrl && identityQuery.data?.pubkey
- ? {
- relayUrl: activeCommunity.relayUrl,
- operatorPubkey: identityQuery.data.pubkey,
- // Legacy Projects are one repository. NIP-MP Projects pass their
- // own address here once the multi-repository model lands.
- projectAddress: project.repoAddress,
- }
- : null;
const localCheckoutSnapshot = localSnapshot?.snapshot ?? null;
const displayedSnapshot =
repoSource === "local" ? localCheckoutSnapshot : snapshot;
@@ -255,7 +249,7 @@ export function WorkspaceTabs({
[pullRequests, selectedCommitHash],
);
const isPullRequestSelected = Boolean(selectedPullRequest);
- const [selectedTab, setSelectedTab] = React.useState("overview");
+ const [selectedTab, setSelectedTab] = React.useState(initialSelectedTab);
const [pullRequestCommentTarget, setPullRequestCommentTarget] =
React.useState<{
anchor: ProjectPullRequestCommentAnchor;
@@ -333,36 +327,41 @@ export function WorkspaceTabs({
onValueChange={handleTabChange}
value={selectedTab}
>
- {repositoryLoaded ? (
-
-
- {onOpenTerminal ? (
-
onOpenTerminal()}
- size="icon"
- title={terminalTitle ?? "Open terminal"}
- variant="ghost"
- >
-
-
- ) : null}
- {updatePullRequestAction ? (
-
-
- {updatePullRequestAction.pending ? "Updating…" : "Update PR"}
-
- ) : null}
-
- ) : null}
+
+
+ {repositoryLoaded ? (
+ <>
+ {onOpenTerminal ? (
+
onOpenTerminal()}
+ size="icon"
+ title={terminalTitle ?? "Open terminal"}
+ variant="ghost"
+ >
+
+
+ ) : null}
+ {updatePullRequestAction ? (
+
+
+ {updatePullRequestAction.pending ? "Updating…" : "Update PR"}
+
+ ) : null}
+ >
+ ) : null}
+
{selectedPullRequest ? (
{/* Two full-height columns: the meta rail runs all the way to the
@@ -561,10 +560,9 @@ export function WorkspaceTabs({
{projectScope ? (
) : (
-
- Couldn't identify this Project's connection scope. Reconnect to the
- community and try again.
-
+
)}
{createPullRequestAction && createPullRequestOpen ? (
diff --git a/desktop/src/features/projects/ui/projectConnectionSecrets.test.mjs b/desktop/src/features/projects/ui/projectConnectionSecrets.test.mjs
index bb50403ad2..0a4531df77 100644
--- a/desktop/src/features/projects/ui/projectConnectionSecrets.test.mjs
+++ b/desktop/src/features/projects/ui/projectConnectionSecrets.test.mjs
@@ -78,3 +78,50 @@ test("explicit removals stay separate from replacement values", () => {
},
);
});
+
+test("rejects Buzz-managed and NUL-containing secret values", () => {
+ assert.deepEqual(
+ buildProjectConnectionSecretChanges(
+ [{ key: "BUZZ_PRIVATE_KEY", value: "value" }],
+ [],
+ [],
+ ),
+ {
+ ok: false,
+ error: "BUZZ_PRIVATE_KEY is managed by Buzz and cannot be used here.",
+ },
+ );
+ assert.deepEqual(
+ buildProjectConnectionSecretChanges(
+ [{ key: "API_TOKEN", value: "before\0after" }],
+ [],
+ [],
+ ),
+ {
+ ok: false,
+ error: "Remove the invalid character from API_TOKEN.",
+ },
+ );
+});
+
+test("rejects secret count and aggregate byte limits before saving", () => {
+ const tooMany = Array.from({ length: 129 }, (_, index) => ({
+ key: `TOKEN_${index}`,
+ value: "value",
+ }));
+ assert.deepEqual(buildProjectConnectionSecretChanges(tooMany, [], []), {
+ ok: false,
+ error: "Use no more than 128 secrets for one connection.",
+ });
+ assert.deepEqual(
+ buildProjectConnectionSecretChanges(
+ [{ key: "TOKEN", value: "x".repeat(64 * 1024) }],
+ [],
+ [],
+ ),
+ {
+ ok: false,
+ error: "Keep the connection's secret values within 64 KiB.",
+ },
+ );
+});
diff --git a/desktop/src/features/projects/ui/projectConnectionSecrets.ts b/desktop/src/features/projects/ui/projectConnectionSecrets.ts
index c6bb66f06c..b3339969a3 100644
--- a/desktop/src/features/projects/ui/projectConnectionSecrets.ts
+++ b/desktop/src/features/projects/ui/projectConnectionSecrets.ts
@@ -11,6 +11,34 @@ export type ProjectConnectionSecretChanges =
}
| { ok: false; error: string };
+const MAX_SECRET_KEYS = 128;
+const MAX_SECRET_BYTES = 64 * 1024;
+const RESERVED_SECRET_KEYS = new Set([
+ "BUZZ_PRIVATE_KEY",
+ "NOSTR_PRIVATE_KEY",
+ "BUZZ_AUTH_TAG",
+ "BUZZ_API_TOKEN",
+ "BUZZ_ACP_PRIVATE_KEY",
+ "BUZZ_ACP_API_TOKEN",
+ "BUZZ_RELAY_URL",
+ "BUZZ_ACP_AGENT_COMMAND",
+ "BUZZ_ACP_AGENT_ARGS",
+ "BUZZ_ACP_MCP_COMMAND",
+ "BUZZ_ACP_RESPOND_TO",
+ "BUZZ_ACP_RESPOND_TO_ALLOWLIST",
+ "BUZZ_ACP_AGENT_OWNER",
+ "BUZZ_ACP_DISPLAY_NAME",
+ "BUZZ_ACP_EXIT_AFTER_INACTIVITY",
+ "BUZZ_ACP_NO_PRESENCE",
+ "BUZZ_ACP_SETUP_PAYLOAD",
+ "BUZZ_MANAGED_AGENT",
+ "BUZZ_MANAGED_AGENT_START_NONCE",
+]);
+
+function byteLength(value: string) {
+ return new TextEncoder().encode(value).byteLength;
+}
+
export function buildProjectConnectionSecretChanges(
rows: readonly ProjectConnectionSecretRow[],
existingKeys: readonly string[],
@@ -29,6 +57,12 @@ export function buildProjectConnectionSecretChanges(
"Secret names can use uppercase letters, numbers, and underscores.",
};
}
+ if (RESERVED_SECRET_KEYS.has(key)) {
+ return {
+ ok: false,
+ error: `${key} is managed by Buzz and cannot be used here.`,
+ };
+ }
if (seen.has(key)) {
return {
ok: false,
@@ -42,14 +76,39 @@ export function buildProjectConnectionSecretChanges(
error: `Enter a value for ${key}.`,
};
}
+ if (value.includes("\0")) {
+ return {
+ ok: false,
+ error: `Remove the invalid character from ${key}.`,
+ };
+ }
if (!existingKeys.includes(key) || value.length > 0) {
envEntries.set(key, value);
}
}
const env = Object.fromEntries(envEntries);
+ const finalKeys = new Set(
+ existingKeys.filter((key) => !removedKeys.includes(key)),
+ );
+ for (const key of seen) finalKeys.add(key);
+ if (finalKeys.size > MAX_SECRET_KEYS) {
+ return {
+ ok: false,
+ error: `Use no more than ${MAX_SECRET_KEYS} secrets for one connection.`,
+ };
+ }
+ let knownBytes = 0;
+ for (const key of finalKeys) knownBytes += byteLength(key);
+ for (const value of envEntries.values()) knownBytes += byteLength(value);
+ if (knownBytes > MAX_SECRET_BYTES) {
+ return {
+ ok: false,
+ error: "Keep the connection's secret values within 64 KiB.",
+ };
+ }
return {
ok: true,
env,
- removeEnvKeys: removedKeys.filter((key) => !(key in env)),
+ removeEnvKeys: removedKeys.filter((key) => !Object.hasOwn(env, key)),
};
}
diff --git a/desktop/src/shared/api/tauriProjectConnections.ts b/desktop/src/shared/api/tauriProjectConnections.ts
index a8efcd56c2..84bd82e312 100644
--- a/desktop/src/shared/api/tauriProjectConnections.ts
+++ b/desktop/src/shared/api/tauriProjectConnections.ts
@@ -5,6 +5,7 @@ export type ProjectConnectionHealthStatus =
| "ready"
| "not_tested"
| "check_needed"
+ | "approval_required"
| "sign_in_required"
| "missing_access"
| "unavailable";
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 0fab1b1406..dc37d3f53e 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -263,6 +263,10 @@ type E2eConfig = {
};
managedAgents?: MockManagedAgentSeed[];
projectConnections?: ProjectConnection[];
+ projectConnectionSaveDelayMs?: number;
+ projectConnectionTestDelayMs?: number;
+ projectConnectionTestError?: string;
+ projectConnectionDeleteError?: string;
/** Result returned by the mocked `add_agent_to_huddle` command. */
addAgentToHuddleResult?: {
ephemeral_added: boolean;
@@ -3019,17 +3023,24 @@ function handleListProjectConnections(args: {
.map(cloneProjectConnection);
}
-function handleCreateProjectConnection(args: {
- input: {
- projectScope: ProjectConnection["projectScope"];
- name: string;
- provider: string;
- command: string;
- args: string[];
- env: Record
;
- executionAcknowledged: boolean;
- };
-}) {
+async function handleCreateProjectConnection(
+ args: {
+ input: {
+ projectScope: ProjectConnection["projectScope"];
+ name: string;
+ provider: string;
+ command: string;
+ args: string[];
+ env: Record;
+ executionAcknowledged: boolean;
+ };
+ },
+ config?: E2eConfig,
+) {
+ const delayMs = config?.mock?.projectConnectionSaveDelayMs ?? 0;
+ if (delayMs > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+ }
if (!args.input.executionAcknowledged) {
throw new Error("Review and acknowledge this local program before saving.");
}
@@ -3075,7 +3086,9 @@ function handleUpdateProjectConnection(args: {
);
if (!connection) throw new Error("Connection not found.");
const nextArgs = [...args.input.args];
+ const approvalIsStale = connection.health.status === "approval_required";
const executionChanged =
+ approvalIsStale ||
connection.command !== args.input.command ||
JSON.stringify(connection.args) !== JSON.stringify(nextArgs) ||
Object.keys(args.input.env).length > 0 ||
@@ -3109,18 +3122,39 @@ function handleUpdateProjectConnection(args: {
return cloneProjectConnection(connection);
}
-function handleTestProjectConnection(args: {
- projectScope: ProjectConnection["projectScope"];
- connectionId: string;
-}) {
+async function handleTestProjectConnection(
+ args: {
+ projectScope: ProjectConnection["projectScope"];
+ connectionId: string;
+ },
+ config?: E2eConfig,
+) {
+ const delayMs = config?.mock?.projectConnectionTestDelayMs ?? 0;
+ if (delayMs > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+ }
const connection = mockProjectConnections.find(
(candidate) =>
candidate.id === args.connectionId &&
projectConnectionScopesEqual(candidate.projectScope, args.projectScope),
);
if (!connection) throw new Error("Connection not found.");
- connection.capabilityIds = ["mcp.tool.analytics.weekly_summary"];
- connection.discoveredTools = ["analytics.weekly_summary"];
+ if (config?.mock?.projectConnectionTestError) {
+ connection.health = {
+ status: "unavailable",
+ lastVerifiedAt: null,
+ detail: "The MCP server did not respond in time.",
+ };
+ connection.updatedAt = new Date().toISOString();
+ throw new Error(config.mock.projectConnectionTestError);
+ }
+ const discoveredTool = connection.provider
+ .toLocaleLowerCase()
+ .includes("linear")
+ ? "linear.search_issues"
+ : "analytics.weekly_summary";
+ connection.capabilityIds = [`mcp.tool.${connection.id}.${discoveredTool}`];
+ connection.discoveredTools = [discoveredTool];
connection.health = {
status: "ready",
lastVerifiedAt: new Date().toISOString(),
@@ -3130,10 +3164,16 @@ function handleTestProjectConnection(args: {
return cloneProjectConnection(connection);
}
-function handleDeleteProjectConnection(args: {
- projectScope: ProjectConnection["projectScope"];
- connectionId: string;
-}) {
+function handleDeleteProjectConnection(
+ args: {
+ projectScope: ProjectConnection["projectScope"];
+ connectionId: string;
+ },
+ config?: E2eConfig,
+) {
+ if (config?.mock?.projectConnectionDeleteError) {
+ throw new Error(config.mock.projectConnectionDeleteError);
+ }
mockProjectConnections = mockProjectConnections.filter(
(connection) =>
connection.id !== args.connectionId ||
@@ -11897,6 +11937,7 @@ export function maybeInstallE2eTauriMocks() {
case "create_project_connection":
return handleCreateProjectConnection(
payload as Parameters[0],
+ activeConfig,
);
case "update_project_connection":
return handleUpdateProjectConnection(
@@ -11905,10 +11946,12 @@ export function maybeInstallE2eTauriMocks() {
case "test_project_connection":
return handleTestProjectConnection(
payload as Parameters[0],
+ activeConfig,
);
case "delete_project_connection":
return handleDeleteProjectConnection(
payload as Parameters[0],
+ activeConfig,
);
case "get_channels": {
// Claim the one-shot before starting the read, then hold only that
diff --git a/desktop/tests/e2e/project-connections-screenshots.spec.ts b/desktop/tests/e2e/project-connections-screenshots.spec.ts
index 0c6719aec1..949647fe4f 100644
--- a/desktop/tests/e2e/project-connections-screenshots.spec.ts
+++ b/desktop/tests/e2e/project-connections-screenshots.spec.ts
@@ -2,6 +2,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
+import type { ProjectConnection } from "../../src/shared/api/tauriProjectConnections";
const SHOTS = "test-results/project-connections-screenshots";
const CONNECTION_ID = "connection-google-analytics";
@@ -9,25 +10,78 @@ const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const PROJECT_SCOPE = {
relayUrl: "ws://localhost:3000",
operatorPubkey: DEFAULT_MOCK_PUBKEY,
- projectAddress: `30617:${DEFAULT_MOCK_PUBKEY}:buzz`,
+ projectAddress: `30621:${DEFAULT_MOCK_PUBKEY}:buzz`,
};
+function connectionFixture(
+ overrides: Partial = {},
+): ProjectConnection {
+ return {
+ id: CONNECTION_ID,
+ projectScope: PROJECT_SCOPE,
+ name: "Google Analytics",
+ provider: "Google Analytics",
+ capabilityIds: [
+ `mcp.tool.${CONNECTION_ID}.run_report`,
+ `mcp.tool.${CONNECTION_ID}.export_report`,
+ ],
+ discoveredTools: ["run_report", "export_report"],
+ command: "/opt/homebrew/bin/analytics-connector",
+ args: ["--account", "acme"],
+ envKeys: ["GOOGLE_ANALYTICS_TOKEN"],
+ health: {
+ status: "ready",
+ lastVerifiedAt: "2026-08-02T14:30:00.000Z",
+ detail: null,
+ },
+ createdAt: "2026-08-02T14:00:00.000Z",
+ updatedAt: "2026-08-02T14:30:00.000Z",
+ ...overrides,
+ };
+}
+
async function capture(page: Page, subject: Locator, filename: string) {
await waitForAnimations(page);
await subject.screenshot({ path: `${SHOTS}/${filename}` });
}
-async function openConnections(page: Page) {
+async function captureVisible(page: Page, subject: Locator, filename: string) {
+ await waitForAnimations(page);
+ const box = await subject.boundingBox();
+ const viewport = page.viewportSize();
+ if (!box || !viewport) {
+ throw new Error("Cannot capture a subject outside the current viewport");
+ }
+
+ const x = Math.max(0, box.x);
+ const y = Math.max(0, box.y);
+ const right = Math.min(viewport.width, box.x + box.width);
+ const bottom = Math.min(viewport.height, box.y + box.height);
+ if (right <= x || bottom <= y) {
+ throw new Error("Cannot capture a subject outside the current viewport");
+ }
+
+ await page.screenshot({
+ path: `${SHOTS}/${filename}`,
+ clip: { x, y, width: right - x, height: bottom - y },
+ });
+}
+
+async function openProject(page: Page, projectSlug = "buzz") {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
- await page.getByRole("button", { name: "Repositories", exact: true }).click();
+ await page.getByTestId("projects-section-projects").click();
const project = page
.locator(
- '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
+ `[data-testid="project-card-${projectSlug}"], [data-testid="project-row-${projectSlug}"]`,
)
.first();
await expect(project).toBeVisible({ timeout: 10_000 });
await project.click();
+}
+
+async function openConnections(page: Page) {
+ await openProject(page);
await page.getByRole("tab", { name: "Connections", exact: true }).click();
await expect(page.getByTestId("project-connections-panel")).toBeVisible();
}
@@ -35,33 +89,87 @@ async function openConnections(page: Page) {
test.describe("Project Connections screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
- test.beforeEach(async ({ page }) => {
- await page.addInitScript(() => {
- window.localStorage.setItem(
- "buzz-feature-overrides-v1",
- JSON.stringify({ projects: true }),
- );
- });
+ test.beforeEach(async ({ page }, testInfo) => {
+ const zeroRepositoryProject = testInfo.title.includes("zero repositories");
+ await page.addInitScript(
+ ({ identityPubkey, seedZeroRepositoryProject }) => {
+ window.localStorage.setItem(
+ "buzz-feature-overrides-v1",
+ JSON.stringify({ projects: true }),
+ );
+ if (seedZeroRepositoryProject) {
+ window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [
+ {
+ id: "project-empty".padEnd(64, "0"),
+ kind: 30621,
+ pubkey: identityPubkey,
+ created_at: 1_800_000_000,
+ content: "",
+ tags: [
+ ["d", "empty"],
+ ["name", "Empty Project"],
+ ["description", "A Project before its first repository."],
+ ],
+ },
+ ];
+ }
+ },
+ {
+ identityPubkey: DEFAULT_MOCK_PUBKEY,
+ seedZeroRepositoryProject: zeroRepositoryProject,
+ },
+ );
+ const staleApproval = testInfo.title.includes("stale approval");
+ const multipleRows = testInfo.title.includes("only the tested row");
+ const manyTools = testInfo.title.includes("all discovered tools");
+ const primaryConnection = connectionFixture(
+ staleApproval
+ ? {
+ health: {
+ status: "approval_required",
+ lastVerifiedAt: null,
+ detail: null,
+ },
+ }
+ : manyTools
+ ? {
+ capabilityIds: Array.from(
+ { length: 6 },
+ (_, index) => `mcp.tool.${CONNECTION_ID}.tool_${index + 1}`,
+ ),
+ discoveredTools: Array.from(
+ { length: 6 },
+ (_, index) => `tool_${index + 1}`,
+ ),
+ }
+ : undefined,
+ );
await installMockBridge(page, {
+ projectConnectionDeleteError: testInfo.title.includes("delete failure")
+ ? "Keyring unavailable."
+ : undefined,
+ projectConnectionSaveDelayMs: testInfo.title.includes("pending save")
+ ? 500
+ : undefined,
+ projectConnectionTestDelayMs: multipleRows ? 500 : undefined,
+ projectConnectionTestError: testInfo.title.includes("test failure")
+ ? "Server exited."
+ : undefined,
projectConnections: [
- {
- id: CONNECTION_ID,
- projectScope: PROJECT_SCOPE,
- name: "Google Analytics",
- provider: "Google Analytics",
- capabilityIds: ["mcp.tool.run_report", "mcp.tool.export_report"],
- discoveredTools: ["run_report", "export_report"],
- command: "/opt/homebrew/bin/analytics-connector",
- args: ["--account", "acme"],
- envKeys: ["GOOGLE_ANALYTICS_TOKEN"],
- health: {
- status: "ready",
- lastVerifiedAt: "2026-08-02T14:30:00.000Z",
- detail: null,
- },
- createdAt: "2026-08-02T14:00:00.000Z",
- updatedAt: "2026-08-02T14:30:00.000Z",
- },
+ ...(zeroRepositoryProject ? [] : [primaryConnection]),
+ ...(multipleRows
+ ? [
+ connectionFixture({
+ id: "connection-linear",
+ name: "Linear",
+ provider: "Linear",
+ capabilityIds: ["mcp.tool.connection-linear.search"],
+ discoveredTools: ["search"],
+ command: "/opt/homebrew/bin/linear-connector",
+ envKeys: ["LINEAR_API_TOKEN"],
+ }),
+ ]
+ : []),
],
});
});
@@ -75,8 +183,28 @@ test.describe("Project Connections screenshots", () => {
await expect(panel).toContainText("Run Report");
await capture(page, panel, "01-ready-connection.png");
+ const repositoryPicker = page.getByTestId("project-repository-picker");
+ await expect(repositoryPicker).not.toBeVisible();
+ await page
+ .getByRole("navigation", { name: "Project breadcrumb" })
+ .getByRole("button", { name: "Buzz" })
+ .click();
+ await expect(
+ page.getByRole("tab", { name: "Overview", exact: true }),
+ ).toHaveAttribute("data-state", "active");
+ await expect(repositoryPicker).toBeVisible();
+ await repositoryPicker.click();
+ await page.getByTestId("project-repository-relay-tools").click();
+ await expect(repositoryPicker).toContainText("relay-tools");
+ await page.getByRole("tab", { name: "Connections", exact: true }).click();
+ await expect(page.getByTestId("project-connections-panel")).toContainText(
+ "Google Analytics",
+ );
+
await panel.getByRole("button", { name: "Add connection" }).click();
- const setup = page.getByRole("dialog", { name: "Add connection" });
+ const setup = page.getByRole("dialog", {
+ name: "Add Project connection",
+ });
await setup.getByLabel("Connection name").fill("Issue tracker");
await setup.getByLabel("Service").fill("Linear");
await setup
@@ -92,33 +220,347 @@ test.describe("Project Connections screenshots", () => {
.getByLabel(/I trust this executable and the arguments above/)
.check();
await capture(page, setup, "02-add-connection.png");
- await setup.getByRole("button", { name: "Cancel" }).click();
+ await setup.getByRole("button", { name: "Save and test" }).click();
+ const issueTrackerRow = panel
+ .locator('[data-testid^="project-connection-"]')
+ .filter({ hasText: "Issue tracker" });
+ await expect(issueTrackerRow).toContainText("Tools found");
+ await expect(issueTrackerRow).toContainText("Linear.Search Issues");
- await panel.getByRole("button", { name: "Edit Google Analytics" }).click();
- const edit = page.getByRole("dialog", { name: "Edit connection" });
+ const analyticsRow = panel.getByTestId(
+ `project-connection-${CONNECTION_ID}`,
+ );
+ await analyticsRow
+ .getByRole("button", { name: "Edit Google Analytics" })
+ .click();
+ const edit = page.getByRole("dialog", { name: "Google Analytics" });
await edit
.getByLabel("Connection command")
.fill("/opt/homebrew/bin/analytics-connector-v2");
await edit
.getByLabel(/I trust this executable and the arguments above/)
.check();
- await edit.getByRole("button", { name: "Save connection" }).click();
- await expect(panel).toContainText("Not tested");
+ await edit.getByRole("button", { name: "Save and test" }).click();
+ await expect(analyticsRow).toContainText("Tools found");
+ await expect(analyticsRow).toContainText("Analytics.Weekly Summary");
+
+ await panel
+ .getByRole("button", { name: "Remove Google Analytics" })
+ .click();
+ const confirmation = page.getByRole("alertdialog", {
+ name: "Remove Google Analytics?",
+ });
+ await capture(page, confirmation, "03-remove-connection-confirmation.png");
+ await confirmation
+ .getByRole("button", { name: "Remove connection" })
+ .click();
+ await expect(panel).not.toContainText("Google Analytics");
+ await expect(panel).toContainText("Issue tracker");
+ });
+
+ test("stale approval can be reviewed without changing the command", async ({
+ page,
+ }) => {
+ await openConnections(page);
+
+ const panel = page.getByTestId("project-connections-panel");
+ await expect(
+ panel.getByRole("button", { name: "Review command Google Analytics" }),
+ ).toBeVisible();
+ await panel
+ .getByRole("button", { name: "Review command Google Analytics" })
+ .click();
+ const dialog = page.getByRole("dialog", { name: "Google Analytics" });
+ await expect(
+ dialog.getByLabel(/I trust this executable and the arguments above/),
+ ).toBeVisible();
+ await dialog
+ .getByLabel(/I trust this executable and the arguments above/)
+ .check();
+ await dialog.getByRole("button", { name: "Save and test" }).click();
- await panel.getByRole("button", { name: "Test", exact: true }).click();
await expect(panel).toContainText("Tools found");
await expect(panel).toContainText("Analytics.Weekly Summary");
+ });
+
+ test("only the tested row reports progress", async ({ page }) => {
+ await openConnections(page);
+
+ const panel = page.getByTestId("project-connections-panel");
+ await panel
+ .getByRole("button", { name: "Test again Google Analytics" })
+ .click();
+
+ await expect(panel.getByText("Testing Google Analytics…")).toBeVisible();
+ await expect(
+ panel.getByRole("button", { name: "Testing Google Analytics" }),
+ ).toBeDisabled();
+ await expect(
+ panel.getByRole("button", { name: "Test again Linear" }),
+ ).toBeDisabled();
+ await expect(panel.getByText("Testing Linear…")).toHaveCount(0);
+ await expect(panel.getByText("Testing Google Analytics…")).toHaveCount(0, {
+ timeout: 2_000,
+ });
+ });
+
+ test("all discovered tools can be inspected", async ({ page }) => {
+ await openConnections(page);
+
+ const row = page.getByTestId(`project-connection-${CONNECTION_ID}`);
+ await expect(row).toContainText("Tool 1");
+ await expect(row).not.toContainText("Tool 6");
+ await row.getByRole("button", { name: "Show 2 more" }).click();
+ await expect(row).toContainText("Tool 6");
+ await expect(
+ row.getByRole("button", { name: "Show fewer" }),
+ ).toHaveAttribute("aria-expanded", "true");
+ await row.getByRole("button", { name: "Show fewer" }).click();
+ await expect(row).not.toContainText("Tool 6");
+ });
+
+ test("delete failure stays in the confirmation flow", async ({ page }) => {
+ await openConnections(page);
+ const panel = page.getByTestId("project-connections-panel");
await panel
.getByRole("button", { name: "Remove Google Analytics" })
.click();
const confirmation = page.getByRole("alertdialog", {
name: "Remove Google Analytics?",
});
- await capture(page, confirmation, "03-remove-connection-confirmation.png");
await confirmation
.getByRole("button", { name: "Remove connection" })
.click();
+
+ await expect(confirmation).toBeVisible();
+ await expect(confirmation).toContainText(
+ "Couldn't remove Google Analytics: Keyring unavailable.",
+ );
+ await expect(panel).toContainText("Google Analytics");
+ });
+
+ test("remains usable at maximum text zoom", async ({ page }) => {
+ await page.setViewportSize({ width: 800, height: 700 });
+ await openProject(page);
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "24px";
+ window.localStorage.setItem("buzz:text-scale", "1.5");
+ });
+ const connectionsTab = page.getByRole("tab", {
+ name: "Connections",
+ exact: true,
+ });
+ await expect(connectionsTab).toBeInViewport();
+ await connectionsTab.click();
+
+ const panel = page.getByTestId("project-connections-panel");
+ await expect(panel).toContainText("Google Analytics");
+ await expect
+ .poll(() =>
+ panel.evaluate((element) => element.scrollWidth <= element.clientWidth),
+ )
+ .toBe(true);
+ await capture(page, panel, "04-maximum-text-zoom.png");
+ });
+
+ test("create flow remains operable at maximum text zoom", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 800, height: 700 });
+ await openConnections(page);
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "24px";
+ window.localStorage.setItem("buzz:text-scale", "1.5");
+ });
+ await page
+ .getByTestId("project-connections-panel")
+ .getByRole("button", { name: "Add connection" })
+ .click();
+
+ const dialog = page.getByRole("dialog", {
+ name: "Add Project connection",
+ });
+ await expect
+ .poll(() =>
+ dialog.evaluate(
+ (element) => element.scrollWidth <= element.clientWidth,
+ ),
+ )
+ .toBe(true);
+ await dialog.getByLabel("Connection name").fill("Linear");
+ await dialog.getByLabel("Service").fill("Linear");
+ await dialog.getByLabel("Connection command").fill("/usr/bin/true");
+ await dialog.getByRole("button", { name: "Technical details" }).click();
+ await dialog.getByLabel("Secret 1 name").fill("LINEAR_API_TOKEN");
+ await dialog.getByLabel("Secret 1 value").fill("test-only");
+ await dialog
+ .getByLabel(/I trust this executable and the arguments above/)
+ .check();
+ await dialog
+ .getByRole("button", { name: "Save and test" })
+ .scrollIntoViewIfNeeded();
+ await expect(
+ dialog.getByRole("button", { name: "Save and test" }),
+ ).toBeInViewport();
+ await captureVisible(page, dialog, "05-maximum-text-zoom-setup.png");
+
+ await page.keyboard.press("Tab");
+ await expect
+ .poll(() =>
+ dialog.evaluate((element) => element.contains(document.activeElement)),
+ )
+ .toBe(true);
+ await dialog.getByRole("button", { name: "Save and test" }).click();
+ const created = page
+ .locator('[data-testid^="project-connection-"]')
+ .filter({ hasText: "Linear" });
+ await expect(created).toContainText("Tools found");
+ });
+
+ test("pending save cannot be dismissed", async ({ page }) => {
+ await openConnections(page);
+
+ await page
+ .getByTestId("project-connections-panel")
+ .getByRole("button", { name: "Add connection" })
+ .click();
+ const dialog = page.getByRole("dialog", {
+ name: "Add Project connection",
+ });
+ await dialog.getByLabel("Connection name").fill("Linear");
+ await dialog.getByLabel("Service").fill("Linear");
+ await dialog
+ .getByLabel("Connection command")
+ .fill("/opt/homebrew/bin/linear-connector");
+ await dialog
+ .getByLabel(/I trust this executable and the arguments above/)
+ .check();
+ await dialog.getByRole("button", { name: "Save and test" }).click();
+
+ await expect(dialog.getByRole("button", { name: "Saving…" })).toBeVisible();
+ await expect(dialog.getByRole("button", { name: "Close" })).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toHaveCount(0, { timeout: 2_000 });
+ });
+
+ test("matches backend validation boundaries before saving", async ({
+ page,
+ }) => {
+ await openConnections(page);
+
+ await page
+ .getByTestId("project-connections-panel")
+ .getByRole("button", { name: "Add connection" })
+ .click();
+ const dialog = page.getByRole("dialog", {
+ name: "Add Project connection",
+ });
+ const submit = dialog.getByRole("button", { name: "Save and test" });
+ await expect(submit).toBeDisabled();
+
+ await dialog.getByLabel("Connection name").fill("x".repeat(128));
+ await expect(dialog).not.toContainText(
+ "Keep the connection name to 128 bytes or fewer.",
+ );
+ await dialog.getByLabel("Connection name").fill("😀".repeat(33));
+ await expect(dialog).toContainText(
+ "Keep the connection name to 128 bytes or fewer.",
+ );
+ await dialog.getByLabel("Connection name").fill("Analytics");
+
+ await dialog.getByLabel("Service").fill("x".repeat(64));
+ await expect(dialog).not.toContainText(
+ "Keep the service name to 64 bytes or fewer.",
+ );
+ await dialog.getByLabel("Service").fill("é".repeat(33));
+ await expect(dialog).toContainText(
+ "Keep the service name to 64 bytes or fewer.",
+ );
+ await dialog.getByLabel("Service").fill("Google Analytics");
+
+ await dialog.getByLabel("Connection command").fill(`/${"x".repeat(1_023)}`);
+ await expect(dialog).not.toContainText(
+ "Keep the command to 1024 bytes or fewer.",
+ );
+ await dialog.getByLabel("Connection command").fill(`/${"😀".repeat(256)}`);
+ await expect(dialog).toContainText(
+ "Keep the command to 1024 bytes or fewer.",
+ );
+ await dialog.getByLabel("Connection command").fill("/usr/bin/true");
+
+ await dialog.getByRole("button", { name: "Technical details" }).click();
+ await dialog.getByLabel("Secret 1 name").fill("BUZZ_PRIVATE_KEY");
+ await dialog.getByLabel("Secret 1 value").fill("not-allowed");
+ await expect(dialog).toContainText(
+ "BUZZ_PRIVATE_KEY is managed by Buzz and cannot be used here.",
+ );
+ await expect(submit).toBeDisabled();
+ await dialog.getByLabel("Secret 1 name").fill("");
+ await dialog.getByLabel("Secret 1 value").fill("");
+
+ const argumentsInput = dialog.getByRole("textbox", {
+ name: "Arguments",
+ exact: true,
+ });
+ await argumentsInput.fill("x".repeat(4_096));
+ await expect(dialog).not.toContainText(
+ "Use no more than 128 arguments, with each 4096 bytes or fewer.",
+ );
+ await argumentsInput.fill("😀".repeat(1_025));
+ await expect(dialog).toContainText(
+ "Use no more than 128 arguments, with each 4096 bytes or fewer.",
+ );
+ await argumentsInput.fill("--version");
+
+ await expect(submit).toBeDisabled();
+ await dialog
+ .getByLabel(/I trust this executable and the arguments above/)
+ .check();
+ await expect(submit).toBeEnabled();
+ });
+
+ test("test failure leaves a recoverable row state", async ({ page }) => {
+ await openConnections(page);
+
+ const panel = page.getByTestId("project-connections-panel");
+ await panel
+ .getByRole("button", { name: "Test again Google Analytics" })
+ .click();
+
+ await expect(panel).toContainText("Unavailable");
+ await expect(panel).toContainText(
+ "The MCP server did not respond in time.",
+ );
+ await expect(
+ panel.getByRole("button", { name: "Test again Google Analytics" }),
+ ).toBeEnabled();
+ });
+
+ test("a Project with zero repositories can create and test a connection", async ({
+ page,
+ }) => {
+ await openProject(page, "empty");
+
+ const panel = page.getByTestId("project-connections-panel");
+ await expect(panel).toBeVisible();
await expect(panel).toContainText("No connections yet");
+ await panel.getByRole("button", { name: "Add connection" }).first().click();
+ const dialog = page.getByRole("dialog", {
+ name: "Add Project connection",
+ });
+ await dialog.getByLabel("Connection name").fill("Linear");
+ await dialog.getByLabel("Service").fill("Linear");
+ await dialog
+ .getByLabel("Connection command")
+ .fill("/opt/homebrew/bin/linear-connector");
+ await dialog
+ .getByLabel(/I trust this executable and the arguments above/)
+ .check();
+ await dialog.getByRole("button", { name: "Save and test" }).click();
+
+ await expect(panel).toContainText("Linear");
+ await expect(panel).toContainText("Tools found");
});
});
From 43fb7825af63c8d20c0e6a42f8a6f4ed4fc548dd Mon Sep 17 00:00:00 2001
From: KC <79471844+wolfyy970@users.noreply.github.com>
Date: Thu, 6 Aug 2026 06:54:37 -0400
Subject: [PATCH 6/7] chore(desktop): format project connection module
Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com>
---
desktop/src-tauri/src/managed_agents/project_connections.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/desktop/src-tauri/src/managed_agents/project_connections.rs b/desktop/src-tauri/src/managed_agents/project_connections.rs
index 15e23ca9b3..8e86341286 100644
--- a/desktop/src-tauri/src/managed_agents/project_connections.rs
+++ b/desktop/src-tauri/src/managed_agents/project_connections.rs
@@ -37,9 +37,9 @@ static PROJECT_CONNECTIONS_LOCK: Mutex<()> = Mutex::new(());
mod approval;
mod credential_journal;
mod transactions;
-use approval::{approved_execution_sha256, canonical_connection_command};
#[cfg(test)]
use approval::executable_sha256;
+use approval::{approved_execution_sha256, canonical_connection_command};
use transactions::{commit_delete, commit_update, UpdateTransaction};
pub(super) fn lock_project_connections() -> MutexGuard<'static, ()> {
From 6687805dd83574c589b7cc120ac589a2298d567e Mon Sep 17 00:00:00 2001
From: KC <79471844+wolfyy970@users.noreply.github.com>
Date: Thu, 6 Aug 2026 10:41:39 -0400
Subject: [PATCH 7/7] test(desktop): canonicalize Project connection fixture
Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com>
Signed-off-by: wolfyy970 <79471844+wolfyy970@users.noreply.github.com>
---
.../src-tauri/src/managed_agents/project_connections.rs | 2 --
.../src/managed_agents/project_connections/probe.rs | 9 +++++----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/desktop/src-tauri/src/managed_agents/project_connections.rs b/desktop/src-tauri/src/managed_agents/project_connections.rs
index 8e86341286..08dda6e1c4 100644
--- a/desktop/src-tauri/src/managed_agents/project_connections.rs
+++ b/desktop/src-tauri/src/managed_agents/project_connections.rs
@@ -37,8 +37,6 @@ static PROJECT_CONNECTIONS_LOCK: Mutex<()> = Mutex::new(());
mod approval;
mod credential_journal;
mod transactions;
-#[cfg(test)]
-use approval::executable_sha256;
use approval::{approved_execution_sha256, canonical_connection_command};
use transactions::{commit_delete, commit_update, UpdateTransaction};
diff --git a/desktop/src-tauri/src/managed_agents/project_connections/probe.rs b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs
index bdbe7e2904..c6c8d8dd6a 100644
--- a/desktop/src-tauri/src/managed_agents/project_connections/probe.rs
+++ b/desktop/src-tauri/src/managed_agents/project_connections/probe.rs
@@ -367,14 +367,15 @@ mod tests {
#[test]
fn synthetic_server_proves_initialize_and_tool_discovery() {
- let node = super::super::super::resolve_command("node")
+ let resolved_node = super::super::super::resolve_command("node")
.expect("Hermit must provide Node for desktop tests");
+ let (node, node_fingerprint) =
+ canonical_connection_command(&resolved_node.to_string_lossy()).unwrap();
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 args = vec![script.to_string_lossy().to_string()];
- let executable_sha256 =
- approved_execution_sha256(&executable_sha256(&node).unwrap(), &args).unwrap();
+ let executable_sha256 = approved_execution_sha256(&node_fingerprint, &args).unwrap();
let connection = StoredProjectConnection {
id: "synthetic-project-connection".to_string(),
project_scope: ProjectConnectionScope {
@@ -385,7 +386,7 @@ mod tests {
name: "Synthetic analytics".to_string(),
provider: "Buzz test fixture".to_string(),
capability_ids: Vec::new(),
- command: node.to_string_lossy().to_string(),
+ command: node,
args,
env_keys: vec!["PROJECT_CONNECTION_CANARY".to_string()],
discovered_tools: Vec::new(),