diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0ad7fd321c5..414a516bf48 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,5 +1,5 @@ use nostr::{Keys, ToBech32}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager as _, State}; use super::managed_agent_definition::validate_create_definition; @@ -8,13 +8,14 @@ use crate::{ managed_agents::{ bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, - find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agents_base_dir, normalize_agent_args, resolve_provider_binary, - save_managed_agents, start_managed_agent_process, stop_managed_agent_process, - stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + find_managed_agent_mut, load_managed_agents, load_managed_agents_for_active_community, + load_personas, load_teams, managed_agents_base_dir, normalize_agent_args, + resolve_provider_binary, save_managed_agents, start_managed_agent_process, + stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -298,7 +299,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result()); + let resolved_relay_url = { + let supplied = input + .relay_url + .as_deref() + .map(str::trim) + .unwrap_or("") + .to_string(); + if supplied.is_empty() && !active_workspace_relay.is_empty() { + active_workspace_relay + } else { + supplied + } + }; (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a66f9c75ba2..09b6c40473c 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -110,6 +110,7 @@ pub(crate) use session_policy::{ AcpSessionPolicy, ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR, }; pub use storage::*; +pub(crate) use storage::load_managed_agents_for_active_community; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750d..58f5db4577d 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -75,18 +75,32 @@ fn reconcile_agents_in_dir_at( keys: &nostr::Keys, db_path: &Path, ) -> Result { - let store_path = base_dir.join("managed-agents.json"); - if !store_path.exists() { - return Ok(0); - } - - let content = std::fs::read_to_string(&store_path) - .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + // Enumerate every store file: the legacy global store plus each community + // shard (#7184). Every record's kind:30177 head must stay reconciled + // regardless of which community it is scoped to — the retention store is + // relay-scoped, so a head published by a save in community A must not be + // orphaned just because A's shard was written while B is active. + let mut store_paths = vec![base_dir.join("managed-agents.json")]; + store_paths.extend(super::storage::community_shard_paths(base_dir)); + + let mut records: Vec = Vec::new(); + for store_path in &store_paths { + if !store_path.exists() { + continue; + } - let records: Vec = serde_json::from_str(&content).map_err(|e| { - super::storage::backup_invalid_store(&store_path); - format!("failed to parse managed-agents.json (preserved as .invalid): {e}") - })?; + let content = std::fs::read_to_string(store_path) + .map_err(|e| format!("failed to read {}: {e}", store_path.display()))?; + + let shard_records: Vec = serde_json::from_str(&content).map_err(|e| { + super::storage::backup_invalid_store(store_path); + format!( + "failed to parse {} (preserved as .invalid): {e}", + store_path.display() + ) + })?; + records.extend(shard_records); + } if records.is_empty() { return Ok(0); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a8..9cb2d71dd6a 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, fs::{self, File, OpenOptions}, io::{Read as _, Seek, SeekFrom, Write}, path::{Path, PathBuf}, @@ -7,12 +7,112 @@ use std::{ use tauri::{AppHandle, Manager}; -use crate::app_state::keyring_service; +use crate::app_state::{keyring_service, AppState}; use crate::managed_agents::{ ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, }; use crate::secret_store::{KeyringProbe, SecretStore}; +/// Filename prefix of the per-community agent store shards. Each community +/// (relay host) gets `managed-agents-community..json` next to the legacy +/// global store. The prefix is deliberately distinct from +/// `managed-agents.json` so hand-made backup copies (e.g. +/// `managed-agents.my-community.json`) are never mistaken for a shard. +const COMMUNITY_SHARD_PREFIX: &str = "managed-agents-community."; +const COMMUNITY_SHARD_SUFFIX: &str = ".json"; + +/// Extract the host component of a relay URL for use in a shard filename. +/// +/// Returns `None` when the URL is empty, has no host, or the host contains +/// characters that would be unsafe as a filename component (anything outside +/// `[a-z0-9.-]`). A `None` means the record cannot be scoped to a community +/// and must stay in the legacy global store (fail-open to the pre-#7184 +/// behavior) — never silently dropped. +fn relay_host_of(relay_url: &str) -> Option { + let trimmed = relay_url.trim().trim_end_matches('/'); + // An authority is only present after "scheme://". A bare string without a + // scheme separator ("localhost:3000", "wss") is not a relay URL — treat it + // as hostless so it can never become a misleading shard filename. + let (_, after_scheme) = trimmed.split_once("://")?; + let host_port = after_scheme.split('/').next()?; + if host_port.is_empty() { + return None; + } + let host = host_port + .rsplit_once(':') + .map(|(host, _)| host) + .unwrap_or(host_port); + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return None; + } + let safe = host + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.') + && !host.starts_with('.') + && !host.contains("..") + && host.split('.').all(|label| !label.is_empty()); + safe.then_some(host) +} + +/// Shard filename for a relay host (host is already sanitized). +fn community_shard_file_name(host: &str) -> String { + format!("{COMMUNITY_SHARD_PREFIX}{host}{COMMUNITY_SHARD_SUFFIX}") +} + +/// Enumerate existing community shard files under `base_dir`, sorted by name +/// for deterministic load order. Only files whose middle segment parses as a +/// sanitized relay host are returned; anything else (backups, hand copies, +/// `.invalid` preserves) is ignored. +pub(crate) fn community_shard_paths(base_dir: &Path) -> Vec { + let mut shards = Vec::new(); + let Ok(entries) = fs::read_dir(base_dir) else { + return shards; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(host) = name + .strip_prefix(COMMUNITY_SHARD_PREFIX) + .and_then(|rest| rest.strip_suffix(COMMUNITY_SHARD_SUFFIX)) + else { + continue; + }; + let safe = !host.is_empty() + && host + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'); + if safe { + shards.push(entry.path()); + } + } + shards.sort(); + shards +} + +/// Partition keyed instances by destination store: records carrying a +/// sanitized relay host go to that community's shard; everything else +/// (unpinned legacy records, and by construction all key-less definitions, +/// which callers keep separate anyway) stays in the legacy global store. +fn partition_by_community( + instances: Vec, +) -> ( + Vec, + BTreeMap>, +) { + let mut legacy = Vec::new(); + let mut shards: BTreeMap> = BTreeMap::new(); + for record in instances { + match relay_host_of(&record.relay_url) { + Some(host) => shards.entry(host).or_default().push(record), + None => legacy.push(record), + } + } + (legacy, shards) +} + /// Keyring key name for an agent's nsec, namespaced from the human identity /// key (`"identity"`) which shares the service. fn agent_keyring_name(pubkey: &str) -> String { @@ -238,28 +338,45 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { /// Read the raw unified store — keyed instances AND key-less definitions — /// with fail-loud parse handling. Internal seam; public readers filter. -fn load_agent_store( - app: &AppHandle, -) -> Result, String> { - let path = managed_agents_store_path(app)?; +/// +/// Reads the legacy global store PLUS every community shard found on disk and +/// concatenates their records (legacy first, then shards in filename order). +/// Fail-loud contract per file: a malformed shard is preserved as +/// `.invalid` and its parse error propagates exactly like the global +/// store's (a later save would rewrite it wholesale, so silent swallowing +/// would destroy a malformed hand edit). +fn read_store_file(path: &Path) -> Result, String> { if !path.exists() { return Ok(Vec::new()); } - - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read agent store: {error}"))?; + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read agent store {}: {error}", path.display()))?; serde_json::from_str(&content).map_err(|error| { - // Fail loudly and preserve the evidence: a later in-app save rewrites - // this file wholesale, which would silently destroy a malformed hand - // edit. Best-effort file-authoring contract (see managed_agents:: - // reconcile): the broken content survives as `.invalid` for the user - // to recover, and the parse error propagates instead of being - // swallowed into an empty store. - backup_invalid_store(&path); - format!("failed to parse agent store (preserved as .invalid): {error}") + backup_invalid_store(path); + format!( + "failed to parse agent store {} (preserved as .invalid): {error}", + path.display() + ) }) } +fn load_agent_store( + app: &AppHandle, +) -> Result, String> { + let base_dir = managed_agents_base_dir(app)?; + let mut records = read_store_file(&legacy_store_path(&base_dir))?; + for shard in community_shard_paths(&base_dir) { + records.extend(read_store_file(&shard)?); + } + Ok(records) +} + +/// Legacy (pre-sharding) store filename, still used for key-less definitions +/// and unpinned records. Path = `/managed-agents.json`. +fn legacy_store_path(base_dir: &Path) -> PathBuf { + base_dir.join("managed-agents.json") +} + /// Load the keyed agent *instances*. Key-less definitions (former personas, /// folded into the same store) are filtered out so every pre-fold call site /// keeps seeing exactly the records it always did. @@ -272,6 +389,44 @@ pub fn load_managed_agents( Ok(records) } +/// Active workspace relay host, or `None` when it cannot be resolved. +/// +/// Precedence mirrors `relay::relay_ws_url_with_override`: workspace override +/// first (community switch), then env/build vars, then the default. Resolving +/// through `relay_ws_url_with_override` (not the override alone) keeps the +/// pre-apply boot path working: before the frontend applies the first +/// workspace, no override is set and the default/env relay is the host used. +fn active_community_host(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let url = crate::relay::relay_ws_url_with_override(&state); + relay_host_of(&url) +} + +/// Load the keyed instances VISIBLE to the active community (#7184). +/// +/// A record is visible when its `relay_url` is empty (unpinned legacy record — +/// fail-open, matches the pre-sharding shared-roster behavior and keeps boot +/// migrations working before a workspace is applied) or when its relay host +/// equals the active workspace host. Records pinned to another community are +/// invisible here: their definitions never leak across the tenant boundary. +/// +/// Fail-open rule: when the active host cannot be resolved at all (no state, +/// unparseable URL) every instance is returned — isolation degrades to the +/// pre-fix behavior, never to an empty roster that would look like data loss. +pub fn load_managed_agents_for_active_community( + app: &AppHandle, +) -> Result, String> { + let mut records = load_managed_agents(app)?; + if let Some(active) = active_community_host(app) { + records.retain(|record| { + relay_host_of(&record.relay_url) + .map(|host| host == active) + .unwrap_or(true) // unpinned → visible everywhere (fail-open) + }); + } + Ok(records) +} + /// Load the key-less agent *definitions* (former personas) from the unified /// store. The persona compatibility shim (`load_personas`) presents these in /// the legacy shape via `to_definition_view`. @@ -405,27 +560,83 @@ pub(crate) fn save_agent_definitions( write_agent_store(app, definitions, instances) } -/// Serialize definitions + instances into the single unified store file. -/// Definitions sort first (by slug) for stable diffs; instances keep the -/// name/pubkey order their save path established. +/// Serialize definitions + instances into the store files. +/// +/// Definitions (key-less) and unpinned instances always land in the legacy +/// global store (`managed-agents.json`). Keyed instances whose `relay_url` +/// carries a sanitized host are written to that community's shard +/// (`managed-agents-community..json`) — the #7184 tenant-isolation +/// boundary. A shard holds ONLY its community's records: saving one +/// community's roster never rewrites another's file, and the fail-open rule +/// (unparseable relay → legacy store) means a record is never dropped by a +/// save. fn write_agent_store( app: &AppHandle, mut definitions: Vec, instances: Vec, ) -> Result<(), String> { definitions.sort_by(|left, right| left.slug.cmp(&right.slug)); - let mut all = definitions; - all.extend(instances); - let path = managed_agents_store_path(app)?; - let payload = serde_json::to_vec_pretty(&all) - .map_err(|error| format!("failed to serialize agent store: {error}"))?; + let (legacy_instances, shards) = partition_by_community(instances); + + let base_dir = managed_agents_base_dir(app)?; + let legacy_path = legacy_store_path(&base_dir); + + // Persist each key to the keyring before serializing so the inline copy + // can be stripped on success (keyring-unreachable keys stay inline). + let mut all: Vec = definitions; + all.extend(legacy_instances); + for shard_records in shards.values() { + all.extend(shard_records.iter().cloned()); + } + persist_agent_keys(&mut all); + + // Re-partition after key handling: `persist_agent_keys` only blanks + // in-memory inline copies, it does not touch `relay_url`, so membership + // is stable — but serialize from the same records we just stripped. + let mut definitions_out: Vec = Vec::new(); + let mut legacy_out: Vec = Vec::new(); + for record in all { + if record.pubkey.is_empty() { + definitions_out.push(record); + } else { + legacy_out.push(record); + } + } + definitions_out.sort_by(|left, right| left.slug.cmp(&right.slug)); + + // Legacy store: definitions + unpinned instances (the pre-#7184 shape). + let legacy_payload = serde_json::to_vec_pretty(&{ + let mut combined = definitions_out; + combined.extend(legacy_out); + combined + }) + .map_err(|error| format!("failed to serialize agent store: {error}"))?; // `managed-agents.json` carries plaintext agent nsecs in the keyringless // fallback. Write it owner-only (`0o600`) unconditionally — harmless for the // keyring-backed case (it is the user's own agent store) and closes the // umask window a post-write `chmod` would leave open. - atomic_write_json_restricted(&path, &payload) + atomic_write_json_restricted(&legacy_path, &legacy_payload)?; + + // Per-community shards: each holds only its own records. A shard for a + // community that no longer has any records is rewritten as `[]` rather + // than deleted — deletion would race a concurrent reader and `[]` keeps + // the fail-loud parse contract uniform (a missing file is also valid). + for (host, mut shard_records) in shards { + shard_records.sort_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.pubkey.cmp(&right.pubkey)) + }); + let shard_path = base_dir.join(community_shard_file_name(&host)); + let payload = serde_json::to_vec_pretty(&shard_records) + .map_err(|error| format!("failed to serialize agent shard {host}: {error}"))?; + atomic_write_json_restricted(&shard_path, &payload)?; + } + + Ok(()) } /// Write each record's in-memory key to the keyring and blank the inline copy diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index d39fcf41009..b9048222f5f 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -830,3 +830,111 @@ fn install_log_filename_accepts_ordinary_runtime_ids() { ); } } + +// ── #7184 community sharding ──────────────────────────────────────────────── + +#[test] +fn relay_host_parses_wss_urls() { + assert_eq!( + super::relay_host_of("wss://bookd.communities.buzz.xyz"), + Some("bookd.communities.buzz.xyz".to_string()) + ); + assert_eq!( + super::relay_host_of("ws://localhost:3000"), + Some("localhost".to_string()) + ); + assert_eq!( + super::relay_host_of("wss://brightops.communities.buzz.xyz/"), + Some("brightops.communities.buzz.xyz".to_string()) + ); + assert_eq!( + super::relay_host_of("https://relay.example.com/path"), + Some("relay.example.com".to_string()) + ); +} + +#[test] +fn relay_host_rejects_unsafe_or_empty() { + assert_eq!(super::relay_host_of(""), None); + assert_eq!(super::relay_host_of(" "), None); + assert_eq!(super::relay_host_of("wss://"), None); + // Path traversal or odd characters never become filename components. + assert_eq!(super::relay_host_of("wss://../evil"), None); + assert_eq!(super::relay_host_of("wss://host with space"), None); + // A path is not part of the host — "host/slash" is host "host" with a + // path; the host itself is still a safe filename component. + assert_eq!( + super::relay_host_of("wss://host/slash"), + Some("host".to_string()) + ); +} + +#[test] +fn partition_routes_by_relay_and_fails_open() { + fn record(pubkey: &str, relay: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "agent-{pubkey}", + "relay_url": "{relay}", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .unwrap() + } + + let instances = vec![ + record("aa", "wss://bookd.communities.buzz.xyz"), + record("bb", "wss://av0.communities.buzz.xyz"), + record("cc", ""), // legacy unpinned + ]; + let (legacy, shards) = super::partition_by_community(instances); + assert_eq!(legacy.len(), 1, "unpinned stays in the legacy store"); + assert_eq!(legacy[0].pubkey, "cc"); + assert_eq!(shards.len(), 2, "one shard per community host"); + assert_eq!(shards["bookd.communities.buzz.xyz"][0].pubkey, "aa"); + assert_eq!(shards["av0.communities.buzz.xyz"][0].pubkey, "bb"); +} + +#[test] +fn community_shard_paths_ignores_non_shard_files() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "managed-agents.json", + "managed-agents.json.backup-20260901", + "managed-agents.brightops.json", // hand-made Copilot-style copy + "managed-agents.json.invalid", + ] { + std::fs::write(dir.path().join(name), "[]").unwrap(); + } + std::fs::write( + dir.path().join(super::community_shard_file_name( + "bookd.communities.buzz.xyz", + )), + "[]", + ) + .unwrap(); + + let shards = super::community_shard_paths(dir.path()); + assert_eq!(shards.len(), 1, "only real shards are enumerated"); + assert!(shards[0] + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with(super::COMMUNITY_SHARD_PREFIX)); +} + +#[test] +fn shard_filename_is_host_scoped() { + assert_eq!( + super::community_shard_file_name("bookd.communities.buzz.xyz"), + "managed-agents-community.bookd.communities.buzz.xyz.json" + ); +}