diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index d1784c01fa..d68c6d5d56 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,14 +2,13 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16, AtomicU8}, + atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8}, Arc, Mutex, }, }; use nostr::{Keys, ToBech32}; use tauri::{AppHandle, Manager}; -#[cfg(feature = "mesh-llm")] use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; @@ -35,6 +34,11 @@ pub struct AppState { /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, + /// Serializes workspace mutation transactions. Each caller also takes a + /// generation ticket before queueing so an older queued apply fails closed + /// rather than running after a newer workspace has won. + pub workspace_apply_lock: AsyncMutex<()>, + pub workspace_apply_generation: AtomicU64, /// Set during backend setup when managed agents are eligible for launch /// restore. `apply_workspace` consumes it after installing the workspace /// relay and identity, so agents never start against the fallback relay. @@ -206,6 +210,8 @@ pub fn build_app_state() -> AppState { header across origins (redirect-hop SSRF)", ), relay_url_override: Mutex::new(None), + workspace_apply_lock: AsyncMutex::new(()), + workspace_apply_generation: AtomicU64::new(0), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 224c544fc5..24c1b5757f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -206,9 +206,9 @@ pub(super) async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, - owner_hex: &str, allow_fresh_create_start: bool, expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, ) -> Result { let record_snapshot = { let _store_guard = state @@ -256,6 +256,11 @@ pub(super) async fn start_local_agent_with_preflight( expected_relay_url, crate::relay::relay_ws_url_with_override(state), )?; + // Bind the active owner after the same final await as the relay. A + // same-relay identity replacement during mesh preflight must not release + // the stale preflight owner to spawn. + let workspace_owner = + crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; let _store_guard = state .managed_agents_store_lock @@ -295,7 +300,7 @@ pub(super) async fn start_local_agent_with_preflight( app, record, &mut runtimes, - Some(owner_hex), + Some(workspace_owner.as_str()), &workspace_relay_url, )?; save_managed_agents(app, &records)?; @@ -411,10 +416,6 @@ 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)?; - // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state @@ -747,8 +748,7 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true, None).await - { + match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await { Ok(agent) => agent, Err(error) => { let _store_guard = state @@ -957,9 +957,9 @@ pub async fn start_managed_agent( &app, &state, &pubkey, - &owner_hex, false, expected_relay_url.as_deref(), + expected_signer_pubkey.as_deref(), ) .await } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 89e6e29cec..d248377b48 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -10,6 +10,23 @@ use crate::managed_agents::{ }; use crate::relay; +const WORKSPACE_APPLY_SUPERSEDED: &str = "workspace apply superseded by a newer request"; + +fn next_apply_generation(generation: &std::sync::atomic::AtomicU64) -> u64 { + generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1) +} + +fn assert_current_apply_generation( + generation: &std::sync::atomic::AtomicU64, + ticket: u64, +) -> Result<(), String> { + if generation.load(Ordering::Acquire) == ticket { + Ok(()) + } else { + Err(WORKSPACE_APPLY_SUPERSEDED.to_string()) + } +} + /// Adopt the pre-scoping global retention database's pending rows into `scope`. /// /// Best-effort: a failure is logged and the boot proceeds. The migration's own @@ -131,11 +148,18 @@ pub async fn apply_workspace( agent_managed_profiles: Option, app: AppHandle, ) -> Result<(), String> { + let state = app.state::(); + let apply_generation = next_apply_generation(&state.workspace_apply_generation); + let _apply_guard = state.workspace_apply_lock.lock().await; + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + let restore_app = app.clone(); + let apply_app = app.clone(); // Capture the caller's relay before the blocking apply. Reading shared // state afterward could pick up a newer concurrent community switch. let profile_reconcile_relay = relay_url.clone(); tokio::task::spawn_blocking(move || { + let app = apply_app; let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -166,6 +190,11 @@ pub async fn apply_workspace( None => None, }; + // A newer caller may have taken a generation ticket while this apply + // was validating paths off-thread. Fail before the first mutation so + // the stale apply cannot transiently overwrite the newer workspace. + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; @@ -214,6 +243,8 @@ pub async fn apply_workspace( .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + let state = restore_app.state::(); super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; // The Bumble→Pollen migration may have renamed stopped agents. Reconcile @@ -316,5 +347,34 @@ pub async fn apply_workspace( }); } + assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicU64; + + use super::{assert_current_apply_generation, next_apply_generation}; + + #[test] + fn newer_workspace_apply_supersedes_older_ticket_before_mutation() { + let generation = AtomicU64::new(0); + let delayed_a = next_apply_generation(&generation); + let fast_b = next_apply_generation(&generation); + + let error = assert_current_apply_generation(&generation, delayed_a).unwrap_err(); + assert!(error.contains("superseded"), "{error}"); + assert_current_apply_generation(&generation, fast_b).unwrap(); + } + + #[test] + fn sequential_workspace_applies_each_hold_current_ticket() { + let generation = AtomicU64::new(0); + let first = next_apply_generation(&generation); + assert_current_apply_generation(&generation, first).unwrap(); + let second = next_apply_generation(&generation); + assert_current_apply_generation(&generation, second).unwrap(); + } +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index c35bc808b0..07ad17d35d 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -87,7 +87,7 @@ pub fn relay_http_base_url(relay_url: &str) -> String { mod scope; pub use scope::{ assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope, - ScopedWorkspaceRelay, + bind_expected_signer, ScopedWorkspaceRelay, }; pub fn relay_api_base_url() -> String { diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs index 9054dc2727..b9c73328af 100644 --- a/desktop/src-tauri/src/relay/scope.rs +++ b/desktop/src-tauri/src/relay/scope.rs @@ -88,9 +88,36 @@ pub fn assert_expected_signer( Ok(()) } +/// A workspace-signer read that has passed the caller-captured identity check. +/// +/// The only constructor is [`bind_expected_signer`], so side effects consume +/// the exact owner read that was validated rather than a stale pre-await value. +#[derive(Debug)] +pub struct ScopedWorkspaceSigner(String); + +impl ScopedWorkspaceSigner { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Validate a caller-captured signer against one active-owner read and bind +/// that exact read for the side effect to consume. `None` preserves unscoped +/// callers while still making the owner input single-read. +pub fn bind_expected_signer( + expected_signer_pubkey: Option<&str>, + actual_signer_hex: String, +) -> Result { + assert_expected_signer(expected_signer_pubkey, &actual_signer_hex)?; + Ok(ScopedWorkspaceSigner(actual_signer_hex)) +} + #[cfg(test)] mod tests { - use super::{assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope}; + use super::{ + assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope, + bind_expected_signer, + }; #[test] fn matching_scope_passes_across_ws_http_normalization() { @@ -183,6 +210,25 @@ mod tests { assert!(error.contains("active identity changed"), "{error}"); } + #[test] + fn signer_bind_fails_closed_after_same_relay_identity_switch() { + let captured = nostr::Keys::generate().public_key().to_hex(); + let switched = nostr::Keys::generate().public_key().to_hex(); + let error = bind_expected_signer(Some(&captured), switched).unwrap_err(); + assert!(error.contains("active identity changed"), "{error}"); + } + + #[test] + fn signer_bind_returns_exact_read_for_scoped_and_unscoped_callers() { + let actual = nostr::Keys::generate().public_key().to_hex(); + let scoped = bind_expected_signer(Some(&actual), actual.clone()).unwrap(); + assert_eq!(scoped.as_str(), actual); + + let unscoped_actual = nostr::Keys::generate().public_key().to_hex(); + let unscoped = bind_expected_signer(None, unscoped_actual.clone()).unwrap(); + assert_eq!(unscoped.as_str(), unscoped_actual); + } + #[test] fn absent_signer_preserves_unscoped_sends() { let hex = nostr::Keys::generate().public_key().to_hex(); diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 9be44b94fd..5c620c7394 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -10,6 +10,7 @@ import { } from "./projectAgentConversation.ts"; import { clearStoredProjectsAgentConversation, + projectsConversationScope, readStoredProjectsAgentConversation, writeStoredProjectsAgentConversation, } from "./projectAgentConversationStorage.ts"; @@ -51,6 +52,50 @@ globalThis.localStorage = { beforeEach(() => store.clear()); +test("conversation scopes isolate same-relay identities and restore on return", () => { + const relay = "wss://relay.example.com"; + const resource = "30617:owner:buzz"; + const identityA = "a".repeat(64); + const identityB = "b".repeat(64); + const scopeA = projectsConversationScope( + "detail", + relay, + identityA, + resource, + ); + const scopeB = projectsConversationScope( + "detail", + relay, + identityB, + resource, + ); + assert.notEqual(scopeA, scopeB); + + const pointer = { + agentPubkey: AGENT_PUBKEY, + channelId: EXISTING_DM.id, + opener: OPENER, + }; + writeStoredProjectsAgentConversation(scopeA, pointer); + assert.equal(readStoredProjectsAgentConversation(scopeB), null); + assert.deepEqual(readStoredProjectsAgentConversation(scopeA), pointer); +}); + +test("conversation scopes fail closed without relay, signer, or resource", () => { + assert.equal( + projectsConversationScope("detail", null, SELF_PUBKEY, "repo"), + null, + ); + assert.equal( + projectsConversationScope("detail", WORKSPACE_ID, null, "repo"), + null, + ); + assert.equal( + projectsConversationScope("detail", WORKSPACE_ID, SELF_PUBKEY, ""), + null, + ); +}); + test("an existing agent DM is never auto-restored without a stored pointer", () => { const restored = restoreProjectsAgentConversation({ stored: null, diff --git a/desktop/src/features/projects/lib/projectAgentConversationStorage.ts b/desktop/src/features/projects/lib/projectAgentConversationStorage.ts index c4296b04b8..4d3a044a1c 100644 --- a/desktop/src/features/projects/lib/projectAgentConversationStorage.ts +++ b/desktop/src/features/projects/lib/projectAgentConversationStorage.ts @@ -1,5 +1,16 @@ const CONVERSATION_STORAGE_PREFIX = "buzz.projects.agentConversation"; +/** Builds the identity boundary for Projects conversation pointers and drafts. */ +export function projectsConversationScope( + surface: string, + relayUrl: string | null, + signerPubkey: string | null, + resource: string, +): string | null { + if (!relayUrl || !signerPubkey || !resource) return null; + return `${surface}:${relayUrl}:${signerPubkey.toLowerCase()}:${resource}`; +} + /** * The exact opening prompt of an inline Projects conversation, identified by * the signed event the relay accepted. `createdAt` alone (epoch seconds) diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 45334c35fc..9a41396c80 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -16,6 +16,7 @@ import { import { clearStoredProjectsAgentConversation, type ProjectsConversationOpener, + projectsConversationScope, readStoredProjectsAgentConversation, type StoredProjectsAgentConversation, writeStoredProjectsAgentConversation, @@ -57,6 +58,7 @@ export function ProjectAgentChatPanel({ widthPx: number; }) { const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); // Repository coordinates (`kind:owner:dtag`) are not globally unique — the // same address can exist on two relays. Scope persistence, drafts, and // restore to the community's relay identity so a community switch can @@ -66,9 +68,15 @@ export function ProjectAgentChatPanel({ const relayScope = activeCommunity?.relayUrl ? normalizeRelayUrl(activeCommunity.relayUrl) : null; - const storageScope = relayScope - ? `detail:${relayScope}:${context.repoAddress}` + const signerScope = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) : null; + const storageScope = projectsConversationScope( + "detail", + relayScope, + signerScope, + context.repoAddress, + ); const [isSending, setIsSending] = React.useState(false); const [storedConversation, setStoredConversation] = React.useState(() => @@ -78,7 +86,6 @@ export function ProjectAgentChatPanel({ React.useState(null); const candidates = useAgentCandidates(); const channelsQuery = useChannelsQuery(); - const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); diff --git a/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx b/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx index 3fbf8d7a13..bd5a523f4c 100644 --- a/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx @@ -2,6 +2,8 @@ import type * as React from "react"; import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel"; import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel"; @@ -24,6 +26,7 @@ export function ProjectDetailRightPanel({ sharedHeaderBackdrop?: boolean; }) { const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); if (mode === "chat") { // Remount on community identity as well as repository address: the same // repo coordinate can exist in two communities, and retained panel state @@ -31,11 +34,14 @@ export function ProjectDetailRightPanel({ const relayScope = activeCommunity?.relayUrl ? normalizeRelayUrl(activeCommunity.relayUrl) : ""; + const signerScope = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : ""; return ( (() => - readStoredProjectsAgentConversation(workspaceId), - ); + React.useState(null); const [selectedPubkey, setSelectedPubkey] = React.useState( () => storedConversation?.agentPubkey ?? null, ); @@ -396,6 +395,15 @@ export function ProjectsAgentPromptPage({ const relayScope = activeCommunity?.relayUrl ? normalizeRelayUrl(activeCommunity.relayUrl) : null; + const signerScope = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : null; + const scopedWorkspaceId = projectsConversationScope( + "workspace", + relayScope, + signerScope, + workspaceId ?? "", + ); const candidatePubkeys = React.useMemo( () => candidates.map((candidate) => candidate.pubkey), @@ -454,6 +462,15 @@ export function ProjectsAgentPromptPage({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; + React.useEffect(() => { + const stored = readStoredProjectsAgentConversation(scopedWorkspaceId); + setStoredConversation(stored); + setConversation(null); + setSelectedPubkey(stored?.agentPubkey ?? null); + setPrompt(""); + richText.clearContent(); + }, [richText.clearContent, scopedWorkspaceId]); + React.useEffect(() => { if (!richText.editor) return; const frame = window.requestAnimationFrame(richText.focusPreserve); @@ -532,7 +549,7 @@ export function ProjectsAgentPromptPage({ }; setConversation(nextConversation); setStoredConversation(stored); - writeStoredProjectsAgentConversation(workspaceId, stored); + writeStoredProjectsAgentConversation(scopedWorkspaceId, stored); } setPrompt(""); richText.clearContent(); @@ -554,20 +571,20 @@ export function ProjectsAgentPromptPage({ richText.getMarkdown, selectedAgent, startAgentMutation, - workspaceId, + scopedWorkspaceId, ]); submitPromptRef.current = () => { void handleSubmit(); }; const handleClearConversation = React.useCallback(() => { - clearStoredProjectsAgentConversation(workspaceId); + clearStoredProjectsAgentConversation(scopedWorkspaceId); setStoredConversation(null); setConversation(null); setSelectedPubkey(null); setPrompt(""); richText.clearContent(); - }, [richText.clearContent, workspaceId]); + }, [richText.clearContent, scopedWorkspaceId]); const promptBox = ( <>