From 0309f9e071ab990c32754d3908ccf95cdd078f4f Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 11:27:37 -0700 Subject: [PATCH 1/7] Lock internal local agents to approved relays Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 6 +- desktop/src-tauri/build.rs | 10 ++ .../src-tauri/src/commands/agent_models.rs | 22 ++- desktop/src-tauri/src/commands/agents.rs | 3 + desktop/src-tauri/src/commands/channels.rs | 13 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src/managed_agents/relay_policy.rs | 157 ++++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 1 + .../src/managed_agents/runtime_commands.rs | 2 + .../src-tauri/src/managed_agents/storage.rs | 4 + desktop/src/features/agents/hooks.ts | 17 ++ .../channels/ui/AddChannelBotDialog.tsx | 16 ++ desktop/src/shared/api/tauri.ts | 5 + desktop/src/testing/e2eBridge.ts | 9 + desktop/tests/e2e/channels.spec.ts | 30 ++++ desktop/tests/helpers/bridge.ts | 1 + 17 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/relay_policy.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ebcec33c15..67f96e5e1b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -121,7 +121,8 @@ const overrides = new Map([ // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped // runtime log path. Load-bearing crash-recovery surface; queued to split. // internal-owner-only: persistence choke point normalizes local agent access. - ["src-tauri/src/managed_agents/storage.rs", 1386], + // internal-local-relay-lockdown: load/save rejects legacy disallowed pins. + ["src-tauri/src/managed_agents/storage.rs", 1390], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider @@ -136,7 +137,8 @@ const overrides = new Map([ // lowest env layer (+8 lines). Queued to split. // internal-owner-only: runtime authorization normalization protects stale // or hand-edited records before spawning an internal managed agent. - ["src-tauri/src/managed_agents/runtime.rs", 2228], + // internal-local-relay-lockdown: final spawn validates the effective relay. + ["src-tauri/src/managed_agents/runtime.rs", 2229], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 32f80e1463..5d357702cf 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -16,6 +16,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_INTERNAL"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); // Explicit distribution identity. Internal packaging sets this presence-only @@ -24,6 +25,15 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_INTERNAL=1"); } + // Newline-delimited exact relay URLs. Runtime parsing canonicalizes every + // entry with buzz-core before comparing an effective local-agent relay. + // Preserve malformed or empty input so internal builds fail loudly at the + // policy boundary instead of silently compiling to an empty allowlist. + if let Ok(raw) = std::env::var("BUZZ_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST") { + let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST={encoded}"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 3f5af47fe6..a90170e936 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -773,6 +773,22 @@ pub fn agent_access_owner_only() -> bool { crate::managed_agents::internal_build() } +/// Return whether local agents may attach to the active workspace relay. +#[tauri::command] +pub fn local_agent_relay_allowed( + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + if !crate::managed_agents::internal_build() { + return Ok(true); + } + let relay_url = crate::relay::relay_ws_url_with_override(&state); + crate::managed_agents::validate_local_agent_relay( + &crate::managed_agents::BackendKind::Local, + &relay_url, + )?; + Ok(true) +} + /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -831,7 +847,11 @@ pub async fn update_managed_agent( // value pins the agent; empty falls back to the workspace relay at // read-time. A name-only edit (relay_url == None) leaves the pin intact. if let Some(relay_url) = input.relay_url { - record.relay_url = relay_url.trim().to_string(); + let relay_url = relay_url.trim().to_string(); + if !relay_url.is_empty() { + crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?; + } + record.relay_url = relay_url; } if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 435f679bff..328cbc3355 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -632,6 +632,9 @@ pub async fn create_managed_agent( .map(str::trim) .unwrap_or("") .to_string(); + if !resolved_relay_url.is_empty() { + crate::managed_agents::validate_local_agent_relay(&input.backend, &resolved_relay_url)?; + } (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..f4fdf58536 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -1,4 +1,4 @@ -use tauri::State; +use tauri::{AppHandle, State}; use crate::{ app_state::AppState, @@ -785,9 +785,20 @@ pub async fn add_channel_members( channel_id: String, pubkeys: Vec, role: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { let uuid = parse_channel_uuid(&channel_id)?; + let relay_url = crate::relay::relay_ws_url_with_override(&state); + let local_agents = crate::managed_agents::load_managed_agents(&app)?; + for pubkey in &pubkeys { + if let Some(record) = local_agents.iter().find(|record| { + record.pubkey.eq_ignore_ascii_case(pubkey) + && record.backend == crate::managed_agents::BackendKind::Local + }) { + crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?; + } + } let role_str = match role.as_deref() { Some("admin") => Some("admin"), Some("bot") => Some("bot"), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5dfbadd1ff..746f495930 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -788,6 +788,7 @@ pub fn run() { get_agent_models, discover_agent_models, agent_access_owner_only, + local_agent_relay_allowed, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 8b830cda75..7e34fd7138 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; +pub(crate) mod relay_policy; pub(crate) mod team_snapshot; pub(crate) use access_policy::{ apply_update_access, internal_build, normalize_definition_access, @@ -10,6 +11,7 @@ pub(crate) use access_policy::{ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +pub(crate) use relay_policy::{validate_local_agent_relay, validate_managed_agent_relay_pin}; mod backend; pub(crate) mod config_bridge; mod discovery; diff --git a/desktop/src-tauri/src/managed_agents/relay_policy.rs b/desktop/src-tauri/src/managed_agents/relay_policy.rs new file mode 100644 index 0000000000..ee7e787979 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/relay_policy.rs @@ -0,0 +1,157 @@ +//! Distribution policy for relays used by local managed agents. + +use base64::Engine as _; + +use super::{BackendKind, ManagedAgentRecord}; + +const MISSING_ALLOWLIST_ERROR: &str = + "internal build has no valid local-agent relay allowlist; contact your Buzz administrator"; + +fn normalized_origin(raw: &str) -> Result { + let normalized = + buzz_core_pkg::relay::normalize_relay_url(raw).map_err(|error| error.to_string())?; + let url = + url::Url::parse(&normalized).map_err(|error| format!("invalid relay URL: {error}"))?; + let host = url + .host_str() + .ok_or_else(|| "relay URL must contain a host".to_string())?; + let mut origin = format!("{}://{host}", url.scheme()); + if let Some(port) = url.port() { + origin.push(':'); + origin.push_str(&port.to_string()); + } + Ok(origin) +} + +fn baked_allowlist() -> Result, String> { + let encoded = option_env!("BUZZ_DESKTOP_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST") + .ok_or_else(|| MISSING_ALLOWLIST_ERROR.to_string())?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| MISSING_ALLOWLIST_ERROR.to_string())?; + let raw = String::from_utf8(decoded).map_err(|_| MISSING_ALLOWLIST_ERROR.to_string())?; + parse_allowlist(&raw) +} + +fn parse_allowlist(raw: &str) -> Result, String> { + let entries: Vec<_> = raw + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(normalized_origin) + .collect::>()?; + if entries.is_empty() { + return Err(MISSING_ALLOWLIST_ERROR.to_string()); + } + Ok(entries) +} + +pub(crate) fn validate_local_agent_relay( + backend: &BackendKind, + relay_url: &str, +) -> Result<(), String> { + validate_local_agent_relay_with_policy( + backend, + relay_url, + super::internal_build(), + baked_allowlist, + ) +} + +fn validate_local_agent_relay_with_policy( + backend: &BackendKind, + relay_url: &str, + internal: bool, + allowlist: F, +) -> Result<(), String> +where + F: FnOnce() -> Result, String>, +{ + if !internal || *backend != BackendKind::Local { + return Ok(()); + } + let requested = normalized_origin(relay_url) + .map_err(|error| format!("local agent relay is invalid: {error}"))?; + if allowlist()?.iter().any(|allowed| allowed == &requested) { + return Ok(()); + } + Err(format!( + "local agents in this internal build cannot use relay {requested}" + )) +} + +/// Validate a legacy explicit record pin at persistence boundaries. Empty pins +/// are workspace-relative and are checked when their effective relay is known. +pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> Result<(), String> { + if record.relay_url.trim().is_empty() { + return Ok(()); + } + validate_local_agent_relay(&record.backend, &record.relay_url) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowlist() -> Result, String> { + parse_allowlist(" WSS://Buzz.Block.Builderlab.XYZ:443/\n") + } + + #[test] + fn internal_local_policy_matches_exact_normalized_origin() { + assert!(validate_local_agent_relay_with_policy( + &BackendKind::Local, + "wss://buzz.block.builderlab.xyz/channels", + true, + allowlist, + ) + .is_ok()); + assert!(validate_local_agent_relay_with_policy( + &BackendKind::Local, + "wss://public.example", + true, + allowlist, + ) + .is_err()); + } + + #[test] + fn provider_and_oss_relays_remain_configurable() { + let provider = BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + let unavailable = || Err("allowlist must not be read".into()); + assert!(validate_local_agent_relay_with_policy( + &BackendKind::Local, + "wss://public.example", + false, + unavailable, + ) + .is_ok()); + assert!(validate_local_agent_relay_with_policy( + &provider, + "wss://public.example", + true, + unavailable, + ) + .is_ok()); + } + + #[test] + fn internal_policy_fails_closed_on_missing_empty_or_malformed_allowlist() { + for allowlist in [ + || Err(MISSING_ALLOWLIST_ERROR.into()), + || parse_allowlist(" \n"), + || parse_allowlist("https://not-a-relay.example"), + ] { + assert!(validate_local_agent_relay_with_policy( + &BackendKind::Local, + "wss://buzz.block.builderlab.xyz", + true, + allowlist, + ) + .is_err()); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec27be1775..87f0971c5b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1649,6 +1649,7 @@ pub fn spawn_agent_child( if let Some(error) = spawn_key_refusal(record) { return Err(error); } + super::validate_local_agent_relay(&record.backend, relay_url)?; let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..f77d97f313 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -260,6 +260,7 @@ fn start_pair( if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); } + super::validate_local_agent_relay(&record.backend, &relay_url)?; if expected_updated_at.is_some_and(|expected| record.updated_at != expected) { return Err("managed agent changed while runtime reconciliation was in flight".into()); } @@ -400,6 +401,7 @@ async fn probe_agent_relay_access( record: super::ManagedAgentRecord, requested_relay_url: String, ) -> Result<(super::ManagedAgentRecord, ManagedAgentRuntimeKey, String), String> { + super::validate_local_agent_relay(&record.backend, &requested_relay_url)?; let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 1c92bcac8d..59b0e8ffa6 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -199,6 +199,9 @@ fn load_agent_store(app: &AppHandle) -> Result, String> pub fn load_managed_agents(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); + records + .iter() + .try_for_each(super::validate_managed_agent_relay_pin)?; hydrate_keys(&mut records); Ok(records) } @@ -302,6 +305,7 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R let mut sorted = records.to_vec(); for record in &mut sorted { super::normalize_managed_agent_access(record); + super::validate_managed_agent_relay_pin(record)?; } // A caller-supplied key-less record would collide with the definition // half re-read below; instances always carry a pubkey. diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ffa323b8e2..2b3c4ef433 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -27,6 +27,7 @@ import { discoverManagedAgentPrereqs, getAgentConfigSurface, getAgentAccessOwnerOnly, + getLocalAgentRelayAllowed, getBakedBuildEnv, getBakedBuildEnvKeys, getChannelMembers, @@ -910,10 +911,26 @@ export function useRuntimeFileConfigQuery( export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const; export const bakedBuildEnvQueryKey = ["baked-build-env"] as const; +export const localAgentRelayAllowedQueryKey = [ + "local-agent-relay-allowed", +] as const; export const agentAccessOwnerOnlyQueryKey = [ "agent-access-owner-only", ] as const; +export function useLocalAgentRelayAllowedQuery(options?: { + enabled?: boolean; +}) { + return useQuery({ + queryKey: localAgentRelayAllowedQueryKey, + queryFn: () => getLocalAgentRelayAllowed(), + enabled: options?.enabled ?? true, + staleTime: 30_000, + refetchInterval: false, + retry: false, + }); +} + export function useAgentAccessOwnerOnlyQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: agentAccessOwnerOnlyQueryKey, diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index fb27c659d5..2032eda2d6 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useCreateChannelManagedAgentsMutation, + useLocalAgentRelayAllowedQuery, usePersonasQuery, useTeamsQuery, type CreateChannelManagedAgentResult, @@ -63,6 +64,7 @@ export function AddChannelBotDialog({ onOpenChange, }: AddChannelBotDialogProps) { const personasQuery = usePersonasQuery(); + const localRelayPolicy = useLocalAgentRelayAllowedQuery({ enabled: open }); const teamsQuery = useTeamsQuery(); const inChannelPersonaIds = useInChannelPersonaIds( channelId, @@ -191,6 +193,7 @@ export function AddChannelBotDialog({ const canSubmit = providers.length > 0 && + localRelayPolicy.data === true && selectedPersonas.length > 0 && !providersLoading && !createBotsMutation.isPending; @@ -261,6 +264,19 @@ export function AddChannelBotDialog({ /> ) : null} + {localRelayPolicy.isError ? ( +
+ +

+ Local agents cannot be added to this community on this managed + build. +

+
+ ) : null} + {providers.length === 0 && !providersLoading ? (
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c999254f3e..0e64918473 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1014,6 +1014,11 @@ export async function getBakedBuildEnvKeys(): Promise { return invokeTauri("get_baked_build_env_keys"); } +/** Return whether local agents may attach to the active workspace relay. */ +export async function getLocalAgentRelayAllowed(): Promise { + return invokeTauri("local_agent_relay_allowed"); +} + /** Return whether this build forces managed-agent access to owner-only. */ export async function getAgentAccessOwnerOnly(): Promise { return invokeTauri("agent_access_owner_only"); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 2e351eb76e..f3117e65e6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -353,6 +353,8 @@ type E2eConfig = { }; /** Explicit internal-distribution marker; independent of baked defaults. */ internalBuild?: boolean; + /** Whether local agents may attach to the active mocked community. */ + localAgentRelayAllowed?: boolean; /** Baked build env returned by the display and key-name Tauri commands. */ bakedBuildEnv?: Array<{ key: string; @@ -10289,6 +10291,13 @@ export function maybeInstallE2eTauriMocks() { return (config?.mock?.bakedBuildEnv ?? []).map((entry) => entry.key); case "agent_access_owner_only": return config?.mock?.internalBuild ?? false; + case "local_agent_relay_allowed": + if (config?.mock?.localAgentRelayAllowed === false) { + throw new Error( + "local agents in this internal build cannot use the active relay", + ); + } + return true; case "update_managed_agent": return handleUpdateManagedAgent( payload as Parameters[0], diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 7da20334f3..4e24e65988 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1528,6 +1528,36 @@ test("empty channel shows intro actions", async ({ page }) => { ); }); +test("internal build blocks local agents on a non-allowlisted community", async ({ + page, +}) => { + await installMockBridge(page, { + internalBuild: true, + localAgentRelayAllowed: false, + activePersonaIds: ["builtin:fizz"], + }); + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await page.getByTestId("channel-intro-action-create-agent").click(); + + await expect(page.getByTestId("local-agent-relay-blocked")).toBeVisible(); + await expect(page.getByRole("button", { name: "Add agent" })).toBeDisabled(); +}); + +test("OSS build keeps local agent attachment available", async ({ page }) => { + await installMockBridge(page, { + internalBuild: false, + activePersonaIds: ["builtin:fizz"], + }); + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await page.getByTestId("channel-intro-action-create-agent").click(); + + await expect(page.getByTestId("local-agent-relay-blocked")).toHaveCount(0); + await page.getByRole("button", { name: "Fizz" }).click(); + await expect(page.getByRole("button", { name: "Add agent" })).toBeEnabled(); +}); + test("short channel with messages shows intro actions on open", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 389b4533df..9372b4717f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -394,6 +394,7 @@ type MockBridgeOptions = { preferred_runtime?: string | null; }; internalBuild?: boolean; + localAgentRelayAllowed?: boolean; bakedBuildEnv?: Array<{ key: string; masked: boolean; From 1a328cfa8bb3a4c30d6092e13834342099d65d28 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 12:39:09 -0700 Subject: [PATCH 2/7] Close local-agent relay enrollment gaps Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 5 +- desktop/src-tauri/src/commands/channels.rs | 9 +- desktop/src-tauri/src/huddle/mod.rs | 16 +++- desktop/src-tauri/src/managed_agents/mod.rs | 4 +- .../src/managed_agents/relay_policy.rs | 80 +++++++++++++++++ .../src-tauri/src/managed_agents/storage.rs | 90 +++++++++++++++++-- 6 files changed, 185 insertions(+), 19 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 67f96e5e1b..d17852e17d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -122,7 +122,10 @@ const overrides = new Map([ // runtime log path. Load-bearing crash-recovery surface; queued to split. // internal-owner-only: persistence choke point normalizes local agent access. // internal-local-relay-lockdown: load/save rejects legacy disallowed pins. - ["src-tauri/src/managed_agents/storage.rs", 1390], + // internal-local-relay-lockdown round 2: remediation-safe changed-pin checks and tests. + ["src-tauri/src/managed_agents/storage.rs", 1464], + // internal-local-relay-lockdown round 2: guard initial and incremental huddle enrollment. + ["src-tauri/src/huddle/mod.rs", 1006], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index f4fdf58536..3e9135e43f 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -791,14 +791,7 @@ pub async fn add_channel_members( let uuid = parse_channel_uuid(&channel_id)?; let relay_url = crate::relay::relay_ws_url_with_override(&state); let local_agents = crate::managed_agents::load_managed_agents(&app)?; - for pubkey in &pubkeys { - if let Some(record) = local_agents.iter().find(|record| { - record.pubkey.eq_ignore_ascii_case(pubkey) - && record.backend == crate::managed_agents::BackendKind::Local - }) { - crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?; - } - } + crate::managed_agents::validate_local_agent_members(&local_agents, &pubkeys, &relay_url)?; let role_str = match role.as_deref() { Some("admin") => Some("admin"), Some("bot") => Some("bot"), diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 8a8f1ec9ff..9f5113b852 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -67,7 +67,7 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; // ── Imports ─────────────────────────────────────────────────────────────────── use std::sync::{atomic::Ordering, Arc}; -use tauri::State; +use tauri::{AppHandle, State}; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; @@ -94,6 +94,16 @@ fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> S name.chars().take(80).collect() } +fn validate_huddle_agent_enrollment( + app: &AppHandle, + state: &AppState, + pubkeys: &[String], +) -> Result<(), String> { + let records = crate::managed_agents::load_managed_agents(app)?; + let relay_url = crate::relay::relay_ws_url_with_override(state); + crate::managed_agents::validate_local_agent_members(&records, pubkeys, &relay_url) +} + // ── Tauri commands ──────────────────────────────────────────────────────────── /// Set the voice input mode (push-to-talk or voice-activity detection). @@ -162,6 +172,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -184,6 +195,7 @@ pub async fn start_huddle( } deduped }; + validate_huddle_agent_enrollment(&app, &state, &member_pubkeys)?; // Transition to Creating. { @@ -925,9 +937,11 @@ pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Re #[tauri::command] pub async fn add_agent_to_huddle( agent_pubkey: String, + app: AppHandle, state: State<'_, AppState>, ) -> Result { validate_pubkey_hex(&agent_pubkey)?; + validate_huddle_agent_enrollment(&app, &state, std::slice::from_ref(&agent_pubkey))?; let (eph_id, parent_id) = { let hs = state.huddle()?; diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 7e34fd7138..1ae6739e07 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,7 +11,9 @@ pub(crate) use access_policy::{ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; -pub(crate) use relay_policy::{validate_local_agent_relay, validate_managed_agent_relay_pin}; +pub(crate) use relay_policy::{ + validate_local_agent_members, validate_local_agent_relay, validate_managed_agent_relay_pin, +}; mod backend; pub(crate) mod config_bridge; mod discovery; diff --git a/desktop/src-tauri/src/managed_agents/relay_policy.rs b/desktop/src-tauri/src/managed_agents/relay_policy.rs index ee7e787979..bbc8a2c45f 100644 --- a/desktop/src-tauri/src/managed_agents/relay_policy.rs +++ b/desktop/src-tauri/src/managed_agents/relay_policy.rs @@ -89,6 +89,36 @@ pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> R validate_local_agent_relay(&record.backend, &record.relay_url) } +/// Reject attachment of locally managed agents to a disallowed effective relay. +/// Unknown pubkeys and provider-backed records are outside this policy. +pub(crate) fn validate_local_agent_members( + records: &[ManagedAgentRecord], + pubkeys: &[String], + relay_url: &str, +) -> Result<(), String> { + validate_local_agent_members_with(records, pubkeys, |backend| { + validate_local_agent_relay(backend, relay_url) + }) +} + +fn validate_local_agent_members_with( + records: &[ManagedAgentRecord], + pubkeys: &[String], + validate: F, +) -> Result<(), String> +where + F: Fn(&BackendKind) -> Result<(), String>, +{ + for pubkey in pubkeys { + if let Some(record) = records.iter().find(|record| { + record.pubkey.eq_ignore_ascii_case(pubkey) && record.backend == BackendKind::Local + }) { + validate(&record.backend)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -97,6 +127,24 @@ mod tests { parse_allowlist(" WSS://Buzz.Block.Builderlab.XYZ:443/\n") } + fn record(pubkey: &str, backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, + "name": "test-agent", + "relay_url": "", + "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" + })) + .expect("sample record"); + record.backend = backend; + record + } + #[test] fn internal_local_policy_matches_exact_normalized_origin() { assert!(validate_local_agent_relay_with_policy( @@ -138,6 +186,38 @@ mod tests { .is_ok()); } + #[test] + fn member_enrollment_validates_only_matching_local_agents() { + let provider = BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + let records = vec![ + record("local", BackendKind::Local), + record("provider", provider), + ]; + let calls = std::cell::Cell::new(0); + + assert!( + validate_local_agent_members_with(&records, &["LOCAL".into()], |_| { + calls.set(calls.get() + 1); + Err("blocked".to_string()) + }) + .is_err() + ); + assert_eq!(calls.get(), 1); + assert!(validate_local_agent_members_with( + &records, + &["provider".into(), "unknown".into()], + |_| { + calls.set(calls.get() + 1); + Err("blocked".to_string()) + }, + ) + .is_ok()); + assert_eq!(calls.get(), 1); + } + #[test] fn internal_policy_fails_closed_on_missing_empty_or_malformed_allowlist() { for allowlist in [ diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 59b0e8ffa6..6e0462ed72 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -193,15 +193,18 @@ fn load_agent_store(app: &AppHandle) -> Result, String> }) } +fn retain_managed_agent_instances(records: &mut Vec) { + records.retain(|record| !record.pubkey.is_empty()); +} + /// 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. +/// keeps seeing exactly the records it always did. Relay-policy violations are +/// enforced at save/attach/start boundaries, not here, so a bad legacy pin can +/// still be listed, fixed, or deleted. pub fn load_managed_agents(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; - records.retain(|record| !record.pubkey.is_empty()); - records - .iter() - .try_for_each(super::validate_managed_agent_relay_pin)?; + retain_managed_agent_instances(&mut records); hydrate_keys(&mut records); Ok(records) } @@ -295,17 +298,49 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) } } +fn validate_changed_relay_pins_with( + records: &[ManagedAgentRecord], + previous: &[ManagedAgentRecord], + validate: F, +) -> Result<(), String> +where + F: Fn(&ManagedAgentRecord) -> Result<(), String>, +{ + for record in records { + let unchanged = previous.iter().any(|old| { + old.pubkey == record.pubkey + && old.backend == record.backend + && old.relay_url == record.relay_url + }); + if !unchanged { + validate(record)?; + } + } + Ok(()) +} + /// Save the keyed agent *instances*, preserving the key-less definitions that /// share the unified store: callers pass exactly the records they loaded via /// [`load_managed_agents`], and this re-reads the definition half from disk /// before the wholesale rewrite so a definition is never dropped by an /// instance-side save (and vice versa via [`save_agent_definitions`]). pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); + let stored = load_agent_store(app).unwrap_or_default(); + let definitions = stored + .iter() + .filter(|record| record.pubkey.is_empty()) + .cloned() + .collect(); + let previous_instances: Vec<_> = stored + .into_iter() + .filter(|record| !record.pubkey.is_empty()) + .collect(); + validate_changed_relay_pins_with(records, &previous_instances, |record| { + super::validate_managed_agent_relay_pin(record) + })?; let mut sorted = records.to_vec(); for record in &mut sorted { super::normalize_managed_agent_access(record); - super::validate_managed_agent_relay_pin(record)?; } // A caller-supplied key-less record would collide with the definition // half re-read below; instances always carry a pubkey. @@ -802,7 +837,8 @@ mod tests { use super::{ agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, - KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, + retain_managed_agent_instances, validate_changed_relay_pins_with, KeyMigration, KeyStore, + KeyringProbe, ManagedAgentRecord, }; /// In-memory [`KeyStore`] for testing the migrate decision without the OS @@ -932,6 +968,44 @@ mod tests { .expect("sample record") } + #[test] + fn instance_filter_keeps_legacy_pins_available_for_remediation() { + let mut pinned = record_with_key("nsec1realkey"); + pinned.relay_url = "wss://public.example".into(); + let mut definition = pinned.clone(); + definition.pubkey.clear(); + let mut records = vec![pinned.clone(), definition]; + + retain_managed_agent_instances(&mut records); + + assert_eq!(records, vec![pinned]); + } + + #[test] + fn legacy_bad_pin_allows_unrelated_save_but_changed_pin_is_validated() { + let mut pinned = record_with_key("nsec1realkey"); + pinned.relay_url = "wss://public.example".into(); + let previous = vec![pinned.clone()]; + let calls = std::cell::Cell::new(0); + + assert!( + validate_changed_relay_pins_with(&[pinned.clone()], &previous, |_| { + calls.set(calls.get() + 1); + Err("blocked".into()) + }) + .is_ok() + ); + assert_eq!(calls.get(), 0); + + pinned.relay_url = "wss://other.example".into(); + assert!(validate_changed_relay_pins_with(&[pinned], &previous, |_| { + calls.set(calls.get() + 1); + Err("blocked".into()) + }) + .is_err()); + assert_eq!(calls.get(), 1); + } + #[test] fn migrate_persists_and_signals_stripping_when_keyring_reachable() { // Item 2: an inline key (residue from a prior keyring-unreachable save) From 62c9f36578f67ed5d8dab00a944c829385565524 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 13:05:03 -0700 Subject: [PATCH 3/7] Harden relay policy remediation paths Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 8 +-- desktop/src-tauri/src/commands/channels.rs | 5 +- desktop/src-tauri/src/huddle/mod.rs | 5 +- desktop/src-tauri/src/managed_agents/mod.rs | 3 +- .../src/managed_agents/relay_policy.rs | 60 +++++++++++++++++++ .../src-tauri/src/managed_agents/storage.rs | 54 +++++++++++++---- desktop/src/features/agents/hooks.ts | 13 ++-- .../localAgentRelayPolicyQuery.test.mjs | 15 +++++ .../agents/localAgentRelayPolicyQuery.ts | 2 + .../channels/ui/AddChannelBotDialog.tsx | 7 ++- desktop/tests/e2e/channels.spec.ts | 1 + 11 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 desktop/src/features/agents/localAgentRelayPolicyQuery.test.mjs create mode 100644 desktop/src/features/agents/localAgentRelayPolicyQuery.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d17852e17d..e7cd6a2a19 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -122,10 +122,10 @@ const overrides = new Map([ // runtime log path. Load-bearing crash-recovery surface; queued to split. // internal-owner-only: persistence choke point normalizes local agent access. // internal-local-relay-lockdown: load/save rejects legacy disallowed pins. - // internal-local-relay-lockdown round 2: remediation-safe changed-pin checks and tests. - ["src-tauri/src/managed_agents/storage.rs", 1464], - // internal-local-relay-lockdown round 2: guard initial and incremental huddle enrollment. - ["src-tauri/src/huddle/mod.rs", 1006], + // internal-local-relay-lockdown round 3: fail-loud save baselines and regressions. + ["src-tauri/src/managed_agents/storage.rs", 1496], + // internal-local-relay-lockdown rounds 2-3: guarded enrollment; OSS fast-path. + ["src-tauri/src/huddle/mod.rs", 1007], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 3e9135e43f..1a5c1008f8 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -790,8 +790,9 @@ pub async fn add_channel_members( ) -> Result { let uuid = parse_channel_uuid(&channel_id)?; let relay_url = crate::relay::relay_ws_url_with_override(&state); - let local_agents = crate::managed_agents::load_managed_agents(&app)?; - crate::managed_agents::validate_local_agent_members(&local_agents, &pubkeys, &relay_url)?; + crate::managed_agents::validate_local_agent_members_from_store(&pubkeys, &relay_url, || { + crate::managed_agents::load_managed_agents(&app) + })?; let role_str = match role.as_deref() { Some("admin") => Some("admin"), Some("bot") => Some("bot"), diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 9f5113b852..9865d6f172 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -99,9 +99,10 @@ fn validate_huddle_agent_enrollment( state: &AppState, pubkeys: &[String], ) -> Result<(), String> { - let records = crate::managed_agents::load_managed_agents(app)?; let relay_url = crate::relay::relay_ws_url_with_override(state); - crate::managed_agents::validate_local_agent_members(&records, pubkeys, &relay_url) + crate::managed_agents::validate_local_agent_members_from_store(pubkeys, &relay_url, || { + crate::managed_agents::load_managed_agents(app) + }) } // ── Tauri commands ──────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 1ae6739e07..ace5934813 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,7 +12,8 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; pub(crate) use relay_policy::{ - validate_local_agent_members, validate_local_agent_relay, validate_managed_agent_relay_pin, + validate_local_agent_members_from_store, validate_local_agent_relay, + validate_managed_agent_relay_pin, }; mod backend; pub(crate) mod config_bridge; diff --git a/desktop/src-tauri/src/managed_agents/relay_policy.rs b/desktop/src-tauri/src/managed_agents/relay_policy.rs index bbc8a2c45f..a823084738 100644 --- a/desktop/src-tauri/src/managed_agents/relay_policy.rs +++ b/desktop/src-tauri/src/managed_agents/relay_policy.rs @@ -89,6 +89,39 @@ pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> R validate_local_agent_relay(&record.backend, &record.relay_url) } +/// Load and validate local members only when the internal-build policy applies. +/// OSS builds must not make ordinary membership depend on managed-agent store health. +pub(crate) fn validate_local_agent_members_from_store( + pubkeys: &[String], + relay_url: &str, + load: F, +) -> Result<(), String> +where + F: FnOnce() -> Result, String>, +{ + validate_local_agent_members_from_store_with_policy( + pubkeys, + relay_url, + super::internal_build(), + load, + ) +} + +fn validate_local_agent_members_from_store_with_policy( + pubkeys: &[String], + relay_url: &str, + internal: bool, + load: F, +) -> Result<(), String> +where + F: FnOnce() -> Result, String>, +{ + if !internal { + return Ok(()); + } + validate_local_agent_members(&load()?, pubkeys, relay_url) +} + /// Reject attachment of locally managed agents to a disallowed effective relay. /// Unknown pubkeys and provider-backed records are outside this policy. pub(crate) fn validate_local_agent_members( @@ -218,6 +251,33 @@ mod tests { assert_eq!(calls.get(), 1); } + #[test] + fn oss_member_enrollment_does_not_load_the_agent_store() { + let loads = std::cell::Cell::new(0); + assert!(validate_local_agent_members_from_store_with_policy( + &["human".into()], + "not-even-a-relay", + false, + || { + loads.set(loads.get() + 1); + Err("broken store".into()) + }, + ) + .is_ok()); + assert_eq!(loads.get(), 0); + } + + #[test] + fn internal_member_enrollment_fails_loudly_on_broken_store() { + assert!(validate_local_agent_members_from_store_with_policy( + &["human".into()], + "wss://buzz.block.builderlab.xyz", + true, + || Err("broken store".into()), + ) + .is_err()); + } + #[test] fn internal_policy_fails_closed_on_missing_empty_or_malformed_allowlist() { for allowlist in [ diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 6e0462ed72..4600ccdad7 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -319,22 +319,33 @@ where Ok(()) } -/// Save the keyed agent *instances*, preserving the key-less definitions that -/// share the unified store: callers pass exactly the records they loaded via -/// [`load_managed_agents`], and this re-reads the definition half from disk -/// before the wholesale rewrite so a definition is never dropped by an -/// instance-side save (and vice versa via [`save_agent_definitions`]). -pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { - let stored = load_agent_store(app).unwrap_or_default(); +fn load_managed_agent_save_baseline_with( + load: F, +) -> Result<(Vec, Vec), String> +where + F: FnOnce() -> Result, String>, +{ + let stored = load()?; let definitions = stored .iter() .filter(|record| record.pubkey.is_empty()) .cloned() .collect(); - let previous_instances: Vec<_> = stored + let instances = stored .into_iter() .filter(|record| !record.pubkey.is_empty()) .collect(); + Ok((definitions, instances)) +} + +/// Save the keyed agent *instances*, preserving the key-less definitions that +/// share the unified store: callers pass exactly the records they loaded via +/// [`load_managed_agents`], and this re-reads the definition half from disk +/// before the wholesale rewrite so a definition is never dropped by an +/// instance-side save (and vice versa via [`save_agent_definitions`]). +pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + let (definitions, previous_instances) = + load_managed_agent_save_baseline_with(|| load_agent_store(app))?; validate_changed_relay_pins_with(records, &previous_instances, |record| { super::validate_managed_agent_relay_pin(record) })?; @@ -836,9 +847,9 @@ mod tests { use tempfile::NamedTempFile; use super::{ - agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, - retain_managed_agent_instances, validate_changed_relay_pins_with, KeyMigration, KeyStore, - KeyringProbe, ManagedAgentRecord, + agent_keyring_name, hydrate_keys_with, load_managed_agent_save_baseline_with, + migrate_inline_key, persist_agent_keys_with, retain_managed_agent_instances, + validate_changed_relay_pins_with, KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, }; /// In-memory [`KeyStore`] for testing the migrate decision without the OS @@ -968,6 +979,27 @@ mod tests { .expect("sample record") } + #[test] + fn save_baseline_propagates_store_errors_without_rewriting_from_empty() { + let result = load_managed_agent_save_baseline_with(|| Err("broken store".into())); + assert_eq!(result.unwrap_err(), "broken store"); + } + + #[test] + fn save_baseline_preserves_definitions_and_instances() { + let instance = record_with_key("nsec1realkey"); + let mut definition = instance.clone(); + definition.pubkey.clear(); + + let (definitions, instances) = load_managed_agent_save_baseline_with(|| { + Ok(vec![definition.clone(), instance.clone()]) + }) + .expect("baseline"); + + assert_eq!(definitions, vec![definition]); + assert_eq!(instances, vec![instance]); + } + #[test] fn instance_filter_keeps_legacy_pins_available_for_remediation() { let mut pinned = record_with_key("nsec1realkey"); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 2b3c4ef433..ae73d59814 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -11,6 +11,7 @@ import { ensureChannelAgentPresetInChannel, provisionChannelManagedAgent, } from "@/features/agents/channelAgents"; +import { localAgentRelayAllowedQueryKey } from "@/features/agents/localAgentRelayPolicyQuery"; import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; import { channelsQueryKey, @@ -911,18 +912,16 @@ export function useRuntimeFileConfigQuery( export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const; export const bakedBuildEnvQueryKey = ["baked-build-env"] as const; -export const localAgentRelayAllowedQueryKey = [ - "local-agent-relay-allowed", -] as const; export const agentAccessOwnerOnlyQueryKey = [ "agent-access-owner-only", ] as const; -export function useLocalAgentRelayAllowedQuery(options?: { - enabled?: boolean; -}) { +export function useLocalAgentRelayAllowedQuery( + communityId: string | null, + options?: { enabled?: boolean }, +) { return useQuery({ - queryKey: localAgentRelayAllowedQueryKey, + queryKey: localAgentRelayAllowedQueryKey(communityId), queryFn: () => getLocalAgentRelayAllowed(), enabled: options?.enabled ?? true, staleTime: 30_000, diff --git a/desktop/src/features/agents/localAgentRelayPolicyQuery.test.mjs b/desktop/src/features/agents/localAgentRelayPolicyQuery.test.mjs new file mode 100644 index 0000000000..aea1a4a818 --- /dev/null +++ b/desktop/src/features/agents/localAgentRelayPolicyQuery.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { localAgentRelayAllowedQueryKey } from "./localAgentRelayPolicyQuery.ts"; + +test("local agent relay policy cache is scoped by community", () => { + assert.notDeepEqual( + localAgentRelayAllowedQueryKey("community-a"), + localAgentRelayAllowedQueryKey("community-b"), + ); + assert.deepEqual(localAgentRelayAllowedQueryKey(null), [ + "local-agent-relay-allowed", + "none", + ]); +}); diff --git a/desktop/src/features/agents/localAgentRelayPolicyQuery.ts b/desktop/src/features/agents/localAgentRelayPolicyQuery.ts new file mode 100644 index 0000000000..9ee26adbab --- /dev/null +++ b/desktop/src/features/agents/localAgentRelayPolicyQuery.ts @@ -0,0 +1,2 @@ +export const localAgentRelayAllowedQueryKey = (communityId: string | null) => + ["local-agent-relay-allowed", communityId ?? "none"] as const; diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index 2032eda2d6..8fb549deb3 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -11,6 +11,7 @@ import { import { getActivePersonas } from "@/features/agents/lib/catalog"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; +import { useCommunities } from "@/features/communities/useCommunities"; import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection"; import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection"; import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds"; @@ -64,7 +65,11 @@ export function AddChannelBotDialog({ onOpenChange, }: AddChannelBotDialogProps) { const personasQuery = usePersonasQuery(); - const localRelayPolicy = useLocalAgentRelayAllowedQuery({ enabled: open }); + const { activeCommunity } = useCommunities(); + const localRelayPolicy = useLocalAgentRelayAllowedQuery( + activeCommunity?.id ?? null, + { enabled: open }, + ); const teamsQuery = useTeamsQuery(); const inChannelPersonaIds = useInChannelPersonaIds( channelId, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 4e24e65988..0b6c1f63ad 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1541,6 +1541,7 @@ test("internal build blocks local agents on a non-allowlisted community", async await page.getByTestId("channel-intro-action-create-agent").click(); await expect(page.getByTestId("local-agent-relay-blocked")).toBeVisible(); + await page.getByRole("button", { name: "Fizz" }).click(); await expect(page.getByRole("button", { name: "Add agent" })).toBeDisabled(); }); From 93ea37c2364f5f648b0b312ed7375ec36d28b402 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 13:14:46 -0700 Subject: [PATCH 4/7] Close remaining local-agent relay gaps Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 3 +- .../src-tauri/src/commands/agent_models.rs | 16 +++- desktop/src-tauri/src/commands/agents.rs | 9 ++- desktop/src-tauri/src/commands/channels.rs | 7 ++ desktop/src-tauri/src/managed_agents/mod.rs | 4 +- .../src/managed_agents/relay_policy.rs | 75 +++++++++++++++++++ 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e7cd6a2a19..64c80991b2 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -468,7 +468,8 @@ const overrides = new Map([ // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). // +8: harness_override thread-through in update_managed_agent so a deliberate // Custom pin routes to update_time_agent_command_override (comment + call). - ["src-tauri/src/commands/agent_models.rs", 1079], + // +4: internal relay lockdown validates effective relay before update/profile sync. + ["src-tauri/src/commands/agent_models.rs", 1083], // global-agent-config: get_agent_config_surface / write_agent_config_field / // put_agent_session_config commands + GlobalAgentConfig serde types. New file // in this PR; queued to split with the command module refactor. diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index a90170e936..355e2b6366 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -801,6 +801,7 @@ pub async fn update_managed_agent( state: State<'_, AppState>, ) -> Result { // Phase 1: local save (synchronous, under lock) + let workspace_relay_url = relay_ws_url_with_override(&state); let (summary, sync_params, rollback) = { let _store_guard = state .managed_agents_store_lock @@ -848,9 +849,11 @@ pub async fn update_managed_agent( // read-time. A name-only edit (relay_url == None) leaves the pin intact. if let Some(relay_url) = input.relay_url { let relay_url = relay_url.trim().to_string(); - if !relay_url.is_empty() { - crate::managed_agents::validate_local_agent_relay(&record.backend, &relay_url)?; - } + crate::managed_agents::validate_effective_local_agent_relay( + &record.backend, + &relay_url, + &workspace_relay_url, + )?; record.relay_url = relay_url; } if let Some(acp_command) = input.acp_command { @@ -908,6 +911,13 @@ pub async fn update_managed_agent( record.updated_at = now_iso(); + if name_changed { + crate::managed_agents::validate_effective_local_agent_relay( + &record.backend, + &record.relay_url, + &workspace_relay_url, + )?; + } save_managed_agents(&app, &records)?; let record = records diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 328cbc3355..2cbf6a4b39 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -588,6 +588,7 @@ pub async fn create_managed_agent( // Snapshot the workspace owner pubkey for the legacy-record auth_tag // fallback. Computed outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; + let workspace_relay_url = relay_ws_url_with_override(&state); // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { @@ -632,9 +633,11 @@ pub async fn create_managed_agent( .map(str::trim) .unwrap_or("") .to_string(); - if !resolved_relay_url.is_empty() { - crate::managed_agents::validate_local_agent_relay(&input.backend, &resolved_relay_url)?; - } + crate::managed_agents::validate_effective_local_agent_relay( + &input.backend, + &resolved_relay_url, + &workspace_relay_url, + )?; (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 1a5c1008f8..bf58660fc0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -838,6 +838,7 @@ pub async fn change_channel_member_role( channel_id: String, pubkey: String, role: String, + app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; @@ -848,6 +849,12 @@ pub async fn change_channel_member_role( "owner" => return Err("cannot assign owner role — use transfer ownership".into()), other => return Err(format!("invalid role: {other}")), }; + let relay_url = crate::relay::relay_ws_url_with_override(&state); + crate::managed_agents::validate_local_agent_members_from_store( + std::slice::from_ref(&pubkey), + &relay_url, + || crate::managed_agents::load_managed_agents(&app), + )?; let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index ace5934813..0bf5980360 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,8 +12,8 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; pub(crate) use relay_policy::{ - validate_local_agent_members_from_store, validate_local_agent_relay, - validate_managed_agent_relay_pin, + validate_effective_local_agent_relay, validate_local_agent_members_from_store, + validate_local_agent_relay, validate_managed_agent_relay_pin, }; mod backend; pub(crate) mod config_bridge; diff --git a/desktop/src-tauri/src/managed_agents/relay_policy.rs b/desktop/src-tauri/src/managed_agents/relay_policy.rs index a823084738..3abad09af2 100644 --- a/desktop/src-tauri/src/managed_agents/relay_policy.rs +++ b/desktop/src-tauri/src/managed_agents/relay_policy.rs @@ -80,6 +80,34 @@ where )) } +pub(crate) fn validate_effective_local_agent_relay( + backend: &BackendKind, + relay_pin: &str, + workspace_relay_url: &str, +) -> Result<(), String> { + validate_effective_local_agent_relay_with_policy( + backend, + relay_pin, + workspace_relay_url, + super::internal_build(), + baked_allowlist, + ) +} + +fn validate_effective_local_agent_relay_with_policy( + backend: &BackendKind, + relay_pin: &str, + workspace_relay_url: &str, + internal: bool, + allowlist: F, +) -> Result<(), String> +where + F: FnOnce() -> Result, String>, +{ + let effective = crate::relay::effective_agent_relay_url(relay_pin, workspace_relay_url); + validate_local_agent_relay_with_policy(backend, &effective, internal, allowlist) +} + /// Validate a legacy explicit record pin at persistence boundaries. Empty pins /// are workspace-relative and are checked when their effective relay is known. pub(crate) fn validate_managed_agent_relay_pin(record: &ManagedAgentRecord) -> Result<(), String> { @@ -219,6 +247,44 @@ mod tests { .is_ok()); } + #[test] + fn empty_pin_validates_the_effective_workspace_relay() { + assert!(validate_effective_local_agent_relay_with_policy( + &BackendKind::Local, + "", + "wss://public.example", + true, + allowlist, + ) + .is_err()); + assert!(validate_effective_local_agent_relay_with_policy( + &BackendKind::Local, + "", + "wss://buzz.block.builderlab.xyz", + true, + allowlist, + ) + .is_ok()); + } + + #[test] + fn effective_relay_validation_skips_oss_and_provider_backends() { + let provider = BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + for (backend, internal) in [(&BackendKind::Local, false), (&provider, true)] { + assert!(validate_effective_local_agent_relay_with_policy( + backend, + "", + "not-even-a-relay", + internal, + || Err("allowlist must not load".into()), + ) + .is_ok()); + } + } + #[test] fn member_enrollment_validates_only_matching_local_agents() { let provider = BackendKind::Provider { @@ -251,6 +317,15 @@ mod tests { assert_eq!(calls.get(), 1); } + #[test] + fn role_change_rejects_matching_local_agent_before_membership_emit() { + let records = vec![record("local", BackendKind::Local)]; + let result = validate_local_agent_members_with(&records, &["LOCAL".into()], |backend| { + validate_local_agent_relay_with_policy(backend, "wss://public.example", true, allowlist) + }); + assert!(result.is_err()); + } + #[test] fn oss_member_enrollment_does_not_load_the_agent_store() { let loads = std::cell::Cell::new(0); From adbc1112dae464f3ba687ea524b242cbebf4ce48 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 13:34:01 -0700 Subject: [PATCH 5/7] Guard remaining agent-keyed relay I/O Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 3 +- desktop/src-tauri/src/commands/agents.rs | 3 +- .../src-tauri/src/commands/agents_profile.rs | 32 ++++++++++--- .../src-tauri/src/commands/agents_tests.rs | 29 ++++++++++++ .../src-tauri/src/commands/personas/mod.rs | 46 +++++++++++++++++-- .../personas/name_propagation_tests.rs | 41 +++++++++++++++++ .../src/commands/personas/snapshot/import.rs | 21 +++++++++ .../src/commands/personas/snapshot/tests.rs | 18 +++++++- .../src-tauri/src/commands/team_snapshot.rs | 21 +++++++++ .../src/commands/team_snapshot/tests.rs | 15 ++++++ .../src-tauri/src/managed_agents/restore.rs | 1 + 11 files changed, 216 insertions(+), 14 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 64c80991b2..b49c9c1d35 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -87,7 +87,8 @@ const overrides = new Map([ // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. // Consolidation removed the legacy persona-card import/export codecs. - ["src-tauri/src/commands/personas/mod.rs", 984], + // internal-local-relay-lockdown round 5: preflight linked-agent profile updates. + ["src-tauri/src/commands/personas/mod.rs", 1018], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so // the two p-gate filters can't drift) plus two guard unit tests. The file was diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 2cbf6a4b39..40a0eddcea 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1083,6 +1083,7 @@ pub async fn start_managed_agent( let reconcile = ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), + backend: record.backend.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), @@ -1325,7 +1326,7 @@ pub(crate) use deploy::resolve_deploy_model_provider; #[path = "agents_profile.rs"] mod profile; #[cfg(test)] -use profile::{profile_needs_sync, resolve_legacy_avatar}; +use profile::{profile_needs_sync, resolve_legacy_avatar, validate_profile_reconcile_relay_with}; pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f..cab45ec3fd 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -11,6 +11,7 @@ use super::*; pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, + pub(crate) backend: BackendKind, pub(crate) name: String, pub(crate) relay_url: String, /// Expected avatar URL for the published profile. `None` for legacy records @@ -49,6 +50,17 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } +pub(super) fn validate_profile_reconcile_relay_with( + data: &ProfileReconcileData, + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&BackendKind, &str, &str) -> Result<(), String>, +{ + validate(&data.backend, &data.relay_url, workspace_relay_url) +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing @@ -74,13 +86,6 @@ pub(crate) async fn reconcile_agent_profile( ) -> Result<(), String> { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); - if !state .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) @@ -88,6 +93,19 @@ pub(crate) async fn reconcile_agent_profile( return Ok(()); } + let workspace_relay_url = relay_ws_url_with_override(state); + validate_profile_reconcile_relay_with( + data, + &workspace_relay_url, + |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + }, + )?; + + // An explicit per-agent relay wins; an empty one falls back to the active + // workspace relay. Resolved once and used for both the read and write-back. + let relay_url = crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay_url); + // Query the relay for the agent's existing kind:0 profile. let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..ca443615ee 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -414,3 +414,32 @@ fn deploy_payload_carries_the_full_behavioral_quad() { assert_eq!(payload["provider"], "openai"); assert_eq!(payload["relay_url"], "wss://relay.example"); } + +#[test] +fn profile_reconcile_preflights_legacy_empty_pin_before_relay_io() { + let data = ProfileReconcileData { + private_key_nsec: String::new(), + backend: BackendKind::Local, + name: "legacy".into(), + relay_url: String::new(), + avatar_url: None, + auth_tag: None, + pubkey: "pubkey".into(), + agent_command: "goose".into(), + persona_id: None, + }; + let calls = std::cell::Cell::new(0); + let result = validate_profile_reconcile_relay_with( + &data, + "wss://public.example", + |backend, pin, workspace| { + calls.set(calls.get() + 1); + assert_eq!(backend, &BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(workspace, "wss://public.example"); + Err("blocked before query".into()) + }, + ); + assert_eq!(result.unwrap_err(), "blocked before query"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 7752d5567c..9e8c5f9786 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -144,6 +144,24 @@ fn propagate_persona_name_rename( renamed } +fn validate_persona_profile_updates_with( + records: &[ManagedAgentRecord], + persona_id: &str, + old_display_name: &str, + name_changed: bool, + avatar_changed: bool, + mut validate: F, +) -> Result<(), String> +where + F: FnMut(&ManagedAgentRecord) -> Result<(), String>, +{ + records + .iter() + .filter(|record| record.persona_id.as_deref() == Some(persona_id)) + .filter(|record| avatar_changed || (name_changed && record.name == old_display_name)) + .try_for_each(&mut validate) +} + #[tauri::command] pub async fn update_persona( input: UpdatePersonaRequest, @@ -176,10 +194,32 @@ pub async fn update_persona( .find(|record| record.id == input.id) .ok_or_else(|| format!("agent {} not found", input.id))?; - // Track what changed so we can propagate to linked agent records. + // Track what changed so we can preflight every linked agent before + // mutating either store or enqueueing agent-keyed profile I/O. let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + let mut linked_records = if avatar_changed || name_changed { + let records = load_managed_agents(&app)?; + validate_persona_profile_updates_with( + &records, + &persona.id, + &old_display_name, + name_changed, + avatar_changed, + |record| { + crate::managed_agents::validate_effective_local_agent_relay( + &record.backend, + &record.relay_url, + &workspace_relay, + ) + }, + )?; + Some(records) + } else { + None + }; persona.display_name = display_name; persona.avatar_url = avatar_url; @@ -209,11 +249,9 @@ pub async fn update_persona( // If the avatar or display_name changed, propagate to linked agent // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { - let mut records = load_managed_agents(&app)?; + let sync_params: ProfileSyncParams = if let Some(mut records) = linked_records.take() { let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; - let workspace_relay = crate::relay::relay_ws_url_with_override(&state); // Propagate the display_name rename to instances that still // carry the old definition display_name (pool-named instances diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs index ba855ccbd6..732b8291f4 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -156,3 +156,44 @@ fn test_rename_renames_all_matching_instances_in_one_pass() { assert_eq!(records[1].name, "Duncan Idaho"); assert_eq!(records[2].name, "Birch", "pool-named instance untouched"); } + +#[test] +fn persona_profile_updates_preflight_only_records_that_would_mutate_or_publish() { + let mut records = vec![ + agent("persona-1", "Paul", Some("Paul")), + agent("persona-1", "Birch", Some("Birch")), + agent("persona-2", "Paul", Some("Paul")), + ]; + records[0].backend = crate::managed_agents::BackendKind::Local; + records[1].backend = crate::managed_agents::BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + let visited = std::cell::RefCell::new(Vec::new()); + + validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |record| { + visited.borrow_mut().push(record.pubkey.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(*visited.borrow(), vec!["pubkey-Paul"]); + + visited.borrow_mut().clear(); + validate_persona_profile_updates_with(&records, "persona-1", "Paul", false, true, |record| { + visited.borrow_mut().push(record.pubkey.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(*visited.borrow(), vec!["pubkey-Paul", "pubkey-Birch"]); +} + +#[test] +fn persona_profile_preflight_failure_happens_before_name_mutation() { + let records = vec![agent("persona-1", "Paul", Some("Paul"))]; + let result = + validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |_| { + Err("blocked".into()) + }); + assert_eq!(result.unwrap_err(), "blocked"); + assert_eq!(records[0].name, "Paul"); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index a9674cf6db..d0e0f658cb 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -288,6 +288,20 @@ pub async fn preview_agent_snapshot_import( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(super) fn validate_snapshot_import_relay_with( + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, +{ + validate( + &crate::managed_agents::BackendKind::Local, + "", + workspace_relay_url, + ) +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. @@ -321,6 +335,13 @@ pub async fn confirm_agent_snapshot_import( return Err("Snapshot display name is empty.".to_string()); } + // Snapshot imports always mint a local agent with an empty relay pin. Reject + // a disallowed effective workspace relay before key generation or store I/O. + let workspace_relay_url = relay_ws_url_with_override(&state); + validate_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + })?; + // ── Resolve behavioral defaults ────────────────────────────────────────── let minted = resolve_snapshot_import_behavior( snapshot.definition.respond_to.as_deref(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6..f3d7bd828a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + validate_snapshot_import_relay_with, AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, + MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -969,3 +970,18 @@ fn validate_encode_size_png_over_boundary_is_rejected() { "error must mention size limit, got: {err}" ); } + +#[test] +fn individual_snapshot_import_preflights_empty_pin_before_mint_or_store() { + let calls = std::cell::Cell::new(0); + let result = + validate_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { + calls.set(calls.get() + 1); + assert_eq!(backend, &BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(relay, "wss://public.example"); + Err("blocked before mutation".into()) + }); + assert_eq!(result.unwrap_err(), "blocked before mutation"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 268b315832..2b3ab4fc6b 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -496,6 +496,20 @@ pub async fn preview_team_snapshot_import( /// /// Importing the same file twice yields two distinct teams with different /// agent keypairs (same as individual agent import). +fn validate_team_snapshot_import_relay_with( + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, +{ + validate( + &crate::managed_agents::BackendKind::Local, + "", + workspace_relay_url, + ) +} + #[tauri::command] pub async fn confirm_team_snapshot_import( input: TeamSnapshotImportConfirm, @@ -506,6 +520,13 @@ pub async fn confirm_team_snapshot_import( let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; let now = now_iso(); + // Team snapshots mint only empty-pin local agents. Preflight the workspace + // relay once before generating any member key or mutating any store. + let workspace_relay_url = relay_ws_url_with_override(&state); + validate_team_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + })?; + // Resolve behavioral defaults for every member before any key generation. let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?; let persona_ids: Vec = definitions.iter().map(|d| d.id.clone()).collect(); diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830..dd085f0481 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -724,3 +724,18 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +#[test] +fn team_snapshot_import_preflights_empty_pin_once_before_mint_or_store() { + let calls = std::cell::Cell::new(0); + let result = + validate_team_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { + calls.set(calls.get() + 1); + assert_eq!(backend, &crate::managed_agents::BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(relay, "wss://public.example"); + Err("blocked before mutation".into()) + }); + assert_eq!(result.unwrap_err(), "blocked before mutation"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12b..8b2d601d0e 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -422,6 +422,7 @@ pub async fn restore_managed_agents_on_launch( pubkey.clone(), crate::commands::ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), + backend: record.backend.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), From eb7354dd80e0a3205a1c52b4fcbfe48e6ddd6654 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 13:55:47 -0700 Subject: [PATCH 6/7] Keep snapshot imports on validated relay Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- .../src/commands/personas/snapshot/import.rs | 26 ++++++++------- .../src/commands/personas/snapshot/tests.rs | 33 ++++++++++++------- .../src-tauri/src/commands/team_snapshot.rs | 26 +++++++++------ .../src/commands/team_snapshot/tests.rs | 29 ++++++++++++++-- 4 files changed, 79 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d0e0f658cb..cb0a6bd3a5 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -288,18 +288,21 @@ pub async fn preview_agent_snapshot_import( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -pub(super) fn validate_snapshot_import_relay_with( - workspace_relay_url: &str, +pub(super) fn validated_snapshot_import_relay_with( + read_workspace_relay: R, validate: F, -) -> Result<(), String> +) -> Result where + R: FnOnce() -> String, F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, { + let workspace_relay_url = read_workspace_relay(); validate( &crate::managed_agents::BackendKind::Local, "", - workspace_relay_url, - ) + &workspace_relay_url, + )?; + Ok(workspace_relay_url) } // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── @@ -337,10 +340,12 @@ pub async fn confirm_agent_snapshot_import( // Snapshot imports always mint a local agent with an empty relay pin. Reject // a disallowed effective workspace relay before key generation or store I/O. - let workspace_relay_url = relay_ws_url_with_override(&state); - validate_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { - crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) - })?; + let workspace_relay_url = validated_snapshot_import_relay_with( + || relay_ws_url_with_override(&state), + |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + }, + )?; // ── Resolve behavioral defaults ────────────────────────────────────────── let minted = resolve_snapshot_import_behavior( @@ -527,8 +532,7 @@ pub async fn confirm_agent_snapshot_import( }; // ── Phase 3b: publish kind:0 profile (async, outside lock) ─────────────── - let relay_url = - effective_agent_relay_url(&record.relay_url, &relay_ws_url_with_override(&state)); + let relay_url = effective_agent_relay_url(&record.relay_url, &workspace_relay_url); let profile_sync_error = sync_managed_agent_profile( &state, &relay_url, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index f3d7bd828a..5fe06c8997 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,6 @@ use super::import::{ decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - validate_snapshot_import_relay_with, AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, + validated_snapshot_import_relay_with, AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; use super::*; @@ -972,16 +972,27 @@ fn validate_encode_size_png_over_boundary_is_rejected() { } #[test] -fn individual_snapshot_import_preflights_empty_pin_before_mint_or_store() { - let calls = std::cell::Cell::new(0); - let result = - validate_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { - calls.set(calls.get() + 1); +fn individual_snapshot_import_carries_the_validated_relay_snapshot_to_io() { + let reads = std::cell::Cell::new(0); + let relay = validated_snapshot_import_relay_with( + || { + reads.set(reads.get() + 1); + "wss://allowed.example".into() + }, + |backend, pin, relay| { assert_eq!(backend, &BackendKind::Local); assert!(pin.is_empty()); - assert_eq!(relay, "wss://public.example"); - Err("blocked before mutation".into()) - }); - assert_eq!(result.unwrap_err(), "blocked before mutation"); - assert_eq!(calls.get(), 1); + assert_eq!(relay, "wss://allowed.example"); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(relay, "wss://allowed.example"); + assert_eq!(reads.get(), 1); + + let blocked = validated_snapshot_import_relay_with( + || "wss://public.example".into(), + |_, _, _| Err("blocked before mutation".into()), + ); + assert_eq!(blocked.unwrap_err(), "blocked before mutation"); } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 2b3ab4fc6b..668247d4c6 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -496,18 +496,21 @@ pub async fn preview_team_snapshot_import( /// /// Importing the same file twice yields two distinct teams with different /// agent keypairs (same as individual agent import). -fn validate_team_snapshot_import_relay_with( - workspace_relay_url: &str, +fn validated_team_snapshot_import_relay_with( + read_workspace_relay: R, validate: F, -) -> Result<(), String> +) -> Result where + R: FnOnce() -> String, F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, { + let workspace_relay_url = read_workspace_relay(); validate( &crate::managed_agents::BackendKind::Local, "", - workspace_relay_url, - ) + &workspace_relay_url, + )?; + Ok(workspace_relay_url) } #[tauri::command] @@ -522,10 +525,12 @@ pub async fn confirm_team_snapshot_import( // Team snapshots mint only empty-pin local agents. Preflight the workspace // relay once before generating any member key or mutating any store. - let workspace_relay_url = relay_ws_url_with_override(&state); - validate_team_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { - crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) - })?; + let workspace_relay_url = validated_team_snapshot_import_relay_with( + || relay_ws_url_with_override(&state), + |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + }, + )?; // Resolve behavioral defaults for every member before any key generation. let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?; @@ -775,7 +780,8 @@ pub async fn confirm_team_snapshot_import( }; // ── Phase 4 & 5: profile sync + memory restore (async, outside lock) ──── - let relay_ws = relay_ws_url_with_override(&state); + // Use the relay snapshot validated before mint/store for all agent-keyed I/O. + let relay_ws = workspace_relay_url; let mut member_results: Vec = Vec::with_capacity(minted.len()); for (m, snap_member) in minted.iter().zip(snapshot.members.iter()) { diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index dd085f0481..115647abbb 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -725,17 +725,40 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert_eq!(errors.len(), 1, "only the teams-write error"); } +#[test] +fn team_snapshot_import_carries_the_validated_relay_snapshot_to_io() { + let workspace_reads = std::cell::Cell::new(0); + let validated_relay = validated_team_snapshot_import_relay_with( + || { + workspace_reads.set(workspace_reads.get() + 1); + "wss://allowed.example".to_string() + }, + |backend, pin, relay| { + assert_eq!(backend, &crate::managed_agents::BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(relay, "wss://allowed.example"); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(validated_relay, "wss://allowed.example"); + assert_eq!(workspace_reads.get(), 1); +} + #[test] fn team_snapshot_import_preflights_empty_pin_once_before_mint_or_store() { let calls = std::cell::Cell::new(0); - let result = - validate_team_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { + let result = validated_team_snapshot_import_relay_with( + || "wss://public.example".to_string(), + |backend, pin, relay| { calls.set(calls.get() + 1); assert_eq!(backend, &crate::managed_agents::BackendKind::Local); assert!(pin.is_empty()); assert_eq!(relay, "wss://public.example"); Err("blocked before mutation".into()) - }); + }, + ); assert_eq!(result.unwrap_err(), "blocked before mutation"); assert_eq!(calls.get(), 1); } From 82224daf829aeea7cb0bcfcb32c5e4f3acd31004 Mon Sep 17 00:00:00 2001 From: "7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz" <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 14:37:44 -0700 Subject: [PATCH 7/7] Fix snapshot import doc indentation Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/team_snapshot.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 668247d4c6..350a6de64b 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -483,12 +483,12 @@ pub async fn preview_team_snapshot_import( /// If ANY generation fails, return immediately — zero writes. /// 3. Store — inside `managed_agents_store_lock`: write all `AgentDefinition`s /// + all `ManagedAgentRecord`s (with `team_id` set) + `TeamRecord`. -/// Both store files are snapshotted (or noted absent) before the first -/// write. On any write error the pre-import state is restored — including -/// deleting a file that was absent, cleaning minted keyring entries, and -/// surfacing rollback failures alongside the original error. This makes -/// the store phase all-or-none for ordinary application errors; a process -/// crash between atomic file commits is NOT covered. +/// Both store files are snapshotted (or noted absent) before the first +/// write. On any write error the pre-import state is restored — including +/// deleting a file that was absent, cleaning minted keyring entries, and +/// surfacing rollback failures alongside the original error. This makes +/// the store phase all-or-none for ordinary application errors; a process +/// crash between atomic file commits is NOT covered. /// 4. Profile sync — for each member, call `sync_managed_agent_profile`. /// Best-effort; errors are collected per member. /// 5. Memory restore — for each member with non-empty snapshot memory,