diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d17852e17..e7cd6a2a1 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 3e9135e43..1a5c1008f 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 9f5113b85..9865d6f17 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 1ae6739e0..ace593481 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 bbc8a2c45..a82308473 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 6e0462ed7..4600ccdad 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(()) } +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 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 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(); + 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 2b3c4ef43..ae73d5981 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 000000000..aea1a4a81 --- /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 000000000..9ee26adba --- /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 2032eda2d..8fb549deb 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 4e24e6598..0b6c1f63a 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(); });