diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index dcac491b1..6ec7b76a9 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -6,7 +6,10 @@ use crate::{ events, models::ChannelInfo, nostr_convert, - relay::{parse_command_response, query_relay, submit_event}, + relay::{ + assert_expected_relay_scope, parse_command_response, query_relay_at, submit_event, + submit_event_at_with_keys, + }, }; #[derive(Deserialize)] @@ -17,18 +20,30 @@ struct OpenDmAck { #[tauri::command] pub async fn open_dm( pubkeys: Vec, + expected_relay_url: Option, state: State<'_, AppState>, ) -> Result { + // Resolve the relay once for the open + metadata read pair. Callers with + // a captured tenant scope (Projects agent sends) pass + // `expected_relay_url`; a mismatch means the active community changed + // while their callback was suspended, and opening a DM on the new tenant + // would hand the stale callback a channel in the wrong community — fail + // closed instead. + let api_base_url = crate::relay::relay_api_base_url_with_override(&state); + assert_expected_relay_scope(expected_relay_url.as_deref(), &api_base_url)?; + // Submit a kind:41010 dm-open event; the relay replies with the channel id // in its OK message payload. let builder = events::build_dm_open(&pubkeys)?; - let result = submit_event(builder, &state).await?; + let keys = state.signing_keys()?; + let result = submit_event_at_with_keys(builder, &state, &api_base_url, &keys).await?; let ack: OpenDmAck = parse_command_response(&result.message)?; // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` - // shape as `get_channel_details`. - let metadata = query_relay( + // shape as `get_channel_details` — through the same scope-checked base. + let metadata = query_relay_at( &state, + &api_base_url, &[serde_json::json!({ "kinds": [39000], "#d": [ack.channel_id], diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1ef95ee4b..8d61ce19e 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -18,7 +18,8 @@ use crate::{ }, nostr_convert, relay::{ - query_relay, submit_event, submit_event_with_created_at, submit_event_with_keys_created_at, + assert_expected_relay_scope, query_relay, submit_event, submit_event_at_created_at, + submit_event_with_keys_created_at, }, }; @@ -433,54 +434,8 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result Result { - let parent_eid = - EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; - - let evs = query_relay( - state, - &[serde_json::json!({ - "ids": [parent_event_id], - "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], - "limit": 1 - })], - ) - .await?; - - let parent = evs - .first() - .ok_or_else(|| "parent event not found".to_string())?; - - // Walk tags looking for NIP-10 root/reply markers. - let (mut root, mut reply) = (None, None); - for tag in parent.tags.iter() { - let s = tag.as_slice(); - if s.len() >= 4 && s[0] == "e" { - match s[3].as_str() { - "root" => root = Some(s[1].clone()), - "reply" => reply = Some(s[1].clone()), - _ => {} - } - } - } - let root_hex = root.or(reply); - - let root_eid = match root_hex { - Some(hex) if hex != parent_event_id => { - EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))? - } - _ => parent_eid, - }; - - Ok(events::ThreadRef { - root_event_id: root_eid, - parent_event_id: parent_eid, - }) -} +mod thread_ref; +use thread_ref::resolve_thread_ref; #[tauri::command] #[allow(clippy::too_many_arguments)] @@ -495,6 +450,7 @@ pub async fn send_channel_message( sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, + expected_relay_url: Option, state: State<'_, AppState>, ) -> Result { let channel_uuid = uuid::Uuid::parse_str(&channel_id) @@ -505,7 +461,13 @@ pub async fn send_channel_message( let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); let link_previews = link_preview_tags.unwrap_or_default(); + // Resolve the relay once and use it for every read and the submission. + // Callers that captured a tenant scope before an await (Projects agent + // sends) pass `expected_relay_url`; a mismatch means the active community + // changed mid-flight and the send must fail closed rather than publish + // the captured tenant's content to the new tenant's relay. let relay_base = crate::relay::relay_api_base_url_with_override(&state); + assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?; let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { return Err("sent-from-thread provenance requires a stream message".into()); @@ -525,7 +487,7 @@ pub async fn send_channel_message( let parent_id = parent_event_id .as_deref() .ok_or("forum comment requires parent_event_id")?; - let thread_ref = resolve_thread_ref(parent_id, &state).await?; + let thread_ref = resolve_thread_ref(parent_id, &state, &relay_base).await?; resolved_root = Some(thread_ref.root_event_id.to_hex()); events::build_forum_comment( channel_uuid, @@ -539,7 +501,7 @@ pub async fn send_channel_message( _ => { let thread_ref = match parent_event_id.as_deref() { Some(pid) => { - let tr = resolve_thread_ref(pid, &state).await?; + let tr = resolve_thread_ref(pid, &state, &relay_base).await?; resolved_root = Some(tr.root_event_id.to_hex()); Some(tr) } @@ -562,7 +524,9 @@ pub async fn send_channel_message( // `created_at` is the signed event's own second, not a post-publication // clock read — persisted as an event cursor by the Projects opener. - let (result, created_at) = submit_event_with_created_at(builder, &state).await?; + // Submit through the base resolved (and scope-checked) above — a + // re-resolve here would reopen the mid-command switch window. + let (result, created_at) = submit_event_at_created_at(builder, &state, &relay_base).await?; let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, @@ -779,7 +743,17 @@ pub async fn send_managed_agent_channel_message( let submission_auth_tag = managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?; let thread_ref = match parent_event_id.as_deref() { - Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?), + Some(parent_id) => Some( + // Same active-relay resolution as before — this path has no + // caller-captured tenant scope (yet), so resolve the override + // here and read through it. + resolve_thread_ref( + parent_id, + &state, + &crate::relay::relay_api_base_url_with_override(&state), + ) + .await?, + ), None => None, }; diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs new file mode 100644 index 000000000..a9a9bcb91 --- /dev/null +++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs @@ -0,0 +1,58 @@ +use nostr::EventId; + +use crate::{app_state::AppState, events, relay::query_relay_at}; + +/// Fetch a parent event and extract the thread root from its NIP-10 e-tags. +/// +/// Reads through the explicit `api_base_url` the calling command resolved — +/// never re-resolving the workspace override — so a mid-command community +/// switch cannot split one logical send across two relays. +pub(super) async fn resolve_thread_ref( + parent_event_id: &str, + state: &AppState, + api_base_url: &str, +) -> Result { + let parent_eid = + EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; + + let evs = query_relay_at( + state, + api_base_url, + &[serde_json::json!({ + "ids": [parent_event_id], + "kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], + "limit": 1 + })], + ) + .await?; + + let parent = evs + .first() + .ok_or_else(|| "parent event not found".to_string())?; + + // Walk tags looking for NIP-10 root/reply markers. + let (mut root, mut reply) = (None, None); + for tag in parent.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 4 && s[0] == "e" { + match s[3].as_str() { + "root" => root = Some(s[1].clone()), + "reply" => reply = Some(s[1].clone()), + _ => {} + } + } + } + let root_hex = root.or(reply); + + let root_eid = match root_hex { + Some(hex) if hex != parent_event_id => { + EventId::from_hex(&hex).map_err(|e| format!("invalid root event ID: {e}"))? + } + _ => parent_eid, + }; + + Ok(events::ThreadRef { + root_event_id: root_eid, + parent_event_id: parent_eid, + }) +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 830128dbb..c032fbef0 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -84,6 +84,9 @@ pub fn relay_http_base_url(relay_url: &str) -> String { trimmed.to_string() } +mod scope; +pub use scope::assert_expected_relay_scope; + pub fn relay_api_base_url() -> String { if let Some(base) = configured_env_var("BUZZ_RELAY_HTTP") { return base.trim_end_matches('/').to_string(); @@ -537,7 +540,7 @@ pub use get::get_relay_json; mod submit; pub use submit::{ - submit_event, submit_event_at_with_keys, submit_event_with_created_at, + submit_event, submit_event_at_created_at, submit_event_at_with_keys, submit_event_with_keys_created_at, submit_signed_event_at_with_keys, SubmitEventResponse, }; diff --git a/desktop/src-tauri/src/relay/scope.rs b/desktop/src-tauri/src/relay/scope.rs new file mode 100644 index 000000000..1189b5d92 --- /dev/null +++ b/desktop/src-tauri/src/relay/scope.rs @@ -0,0 +1,60 @@ +use super::relay_http_base_url; + +/// Fail closed when a caller-captured relay scope no longer matches the +/// relay a command actually resolved. +/// +/// Long-lived UI callbacks (e.g. the Projects agent submit flow) capture the +/// community relay before their first await; a workspace switch during that +/// await would otherwise retarget the eventual publication to the new +/// tenant's relay. Callers pass the captured scope as a ws(s) URL; it is +/// normalized through [`relay_http_base_url`] and compared against the base +/// the command resolved once and uses for every side effect. `None` preserves +/// the unscoped behavior for callers without a tenant boundary. +pub fn assert_expected_relay_scope( + expected_relay_url: Option<&str>, + resolved_api_base_url: &str, +) -> Result<(), String> { + let Some(expected) = expected_relay_url.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(()); + }; + let expected_base = relay_http_base_url(expected); + if expected_base != resolved_api_base_url.trim().trim_end_matches('/') { + return Err( + "active community changed before the message was submitted; not sent".to_string(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::assert_expected_relay_scope; + + #[test] + fn matching_scope_passes_across_ws_http_normalization() { + assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-a.example") + .unwrap(); + assert_expected_relay_scope(Some("ws://localhost:3000"), "http://localhost:3000").unwrap(); + // Trailing-slash and whitespace tolerance mirrors relay_http_base_url. + assert_expected_relay_scope( + Some(" wss://tenant-a.example/ "), + "https://tenant-a.example/", + ) + .unwrap(); + } + + #[test] + fn changed_scope_fails_closed() { + let error = + assert_expected_relay_scope(Some("wss://tenant-a.example"), "https://tenant-b.example") + .unwrap_err(); + assert!(error.contains("active community changed"), "{error}"); + } + + #[test] + fn absent_scope_preserves_unscoped_sends() { + assert_expected_relay_scope(None, "https://anything.example").unwrap(); + assert_expected_relay_scope(Some(""), "https://anything.example").unwrap(); + assert_expected_relay_scope(Some(" "), "https://anything.example").unwrap(); + } +} diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 52c552523..ab4241445 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -77,28 +77,35 @@ pub async fn submit_event( submit_event_at_with_keys(builder, state, &api_base_url, &keys).await } -/// Like [`submit_event`], but also returns the signed event's `created_at`. +/// Sign, submit to an explicit HTTP API base URL, and also return the signed +/// event's `created_at`. /// /// Callers that persist a timestamp as an event cursor (e.g. the Projects /// conversation opener) need the signed event's own second — a /// post-publication clock read can land a second later and permanently /// exclude other events stamped in the event's real second. -pub async fn submit_event_with_created_at( +/// +/// The explicit base (rather than a re-read of the workspace override at +/// submit time) matters for the same callers: they validated a tenant scope +/// against the resolved base earlier in the same command, and re-resolving +/// here would reopen the window where a workspace switch retargets the event +/// after the check passed. +pub async fn submit_event_at_created_at( builder: nostr::EventBuilder, state: &AppState, + api_base_url: &str, ) -> Result<(SubmitEventResponse, i64), String> { - let api_base_url = relay_api_base_url_with_override(state); let keys = state.signing_keys()?; let event = builder .sign_with_keys(&keys) .map_err(|e| format!("failed to sign event: {e}"))?; let created_at = event.created_at.as_secs() as i64; - let result = submit_signed_event_at_with_keys(&event, state, &api_base_url, &keys).await?; + let result = submit_signed_event_at_with_keys(&event, state, api_base_url, &keys).await?; Ok((result, created_at)) } /// Like `submit_event_with_keys`, but also returns the signed event's -/// `created_at` — same cursor rationale as [`submit_event_with_created_at`]. +/// `created_at` — same cursor rationale as [`submit_event_at_created_at`]. pub async fn submit_event_with_keys_created_at( builder: nostr::EventBuilder, state: &AppState, diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index fb2b6864b..048072fa3 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -27,11 +27,11 @@ import type { Channel, ChannelDetail, CreateChannelInput, - OpenDmInput, SetChannelPurposeInput, SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { useCommunities } from "@/features/communities/useCommunities"; diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 5d5d929b1..0d1f88268 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -5,6 +5,7 @@ import { isAtOrAfterConversationOpener, mergeProjectAgentConversationEvents, restoreProjectsAgentConversation, + submitProjectAgentMessage, visibleConversationMessages, } from "./projectAgentConversation.ts"; import { @@ -287,3 +288,157 @@ test("storage round-trips opener-anchored pointers and clears them", () => { clearStoredProjectsAgentConversation(WORKSPACE_ID); assert.equal(readStoredProjectsAgentConversation(WORKSPACE_ID), null); }); + +// ── submitProjectAgentMessage ─────────────────────────────────────────────── + +/** Models the backend's fail-closed scope check: commands resolve the active + * relay when they run and reject when a caller-captured scope no longer + * matches. `active` is mutable so tests can switch communities mid-flight. */ +function makeScopedBackend(active) { + const state = { active, dmOpens: [], sends: [] }; + const assertScope = (expectedRelayUrl) => { + if (expectedRelayUrl !== undefined && expectedRelayUrl !== state.active) { + throw new Error( + "active community changed before the message was submitted; not sent", + ); + } + }; + return { + state, + openDm: async (input) => { + assertScope(input.expectedRelayUrl); + state.dmOpens.push({ relay: state.active, input }); + return { id: `dm-on-${state.active}` }; + }, + send: async (request) => { + assertScope(request.expectedRelayUrl); + state.sends.push({ relay: state.active, request }); + return { eventId: `f${"0".repeat(63)}`, createdAt: PROMPT_AT }; + }, + }; +} + +test("a community switch during agent startup publishes nothing to either tenant", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + let releaseStart; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + + const pending = submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: true, isActive: false }, + conversation: null, + content: "tenant A repo context", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + startAgent: () => startGate, + openDm: backend.openDm, + send: backend.send, + }); + + // The user switches communities while the callback is suspended on the + // managed-agent startup await. Remounting removed the panel, but this + // callback keeps running. + backend.state.active = "wss://tenant-b.example"; + releaseStart(); + + await assert.rejects(pending, /active community changed/); + assert.deepEqual(backend.state.dmOpens, []); + assert.deepEqual(backend.state.sends, []); +}); + +test("a community switch during the DM open fails the send closed", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + const scopedOpenDm = backend.openDm; + const pending = submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: null, + content: "tenant A repo context", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + startAgent: () => { + throw new Error("inactive relay agents are not startable"); + }, + openDm: async (input) => { + const channel = await scopedOpenDm(input); + // The switch lands after the DM was opened on tenant A but before the + // message submit — the narrowest window Carl's finding names. + backend.state.active = "wss://tenant-b.example"; + return channel; + }, + send: backend.send, + }); + + await assert.rejects(pending, /active community changed/); + // The DM was legitimately opened while tenant A was still active… + assert.equal(backend.state.dmOpens.length, 1); + assert.equal(backend.state.dmOpens[0].relay, "wss://tenant-a.example"); + // …but nothing was ever published anywhere. + assert.deepEqual(backend.state.sends, []); +}); + +test("the captured scope rides every relay side effect of a first send", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + const result = await submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: null, + content: "opener", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + startAgent: async () => {}, + openDm: backend.openDm, + send: backend.send, + }); + + assert.equal( + backend.state.dmOpens[0].input.expectedRelayUrl, + "wss://tenant-a.example", + ); + assert.equal( + backend.state.sends[0].request.expectedRelayUrl, + "wss://tenant-a.example", + ); + // The opener is a thread root: no parent reference. + assert.equal(backend.state.sends[0].request.parentEventId, undefined); + assert.equal(result.channel.id, "dm-on-wss://tenant-a.example"); +}); + +test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + await submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: { channel: EXISTING_DM, opener: OPENER }, + content: "follow-up in the opener's second", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + startAgent: async () => {}, + openDm: () => { + throw new Error("an existing conversation must reuse its channel"); + }, + send: backend.send, + }); + + const request = backend.state.sends[0].request; + assert.equal(request.channelId, EXISTING_DM.id); + assert.equal(request.parentEventId, OPENER.eventId); + + // Carl's exact-head probe: a same-second follow-up whose random id lands on + // the rejected side of the id tiebreak (`e… > d…`). As an unreferenced root + // it would vanish; as the reply the submit path now sends, it is admitted. + const rejectedSideId = `e${"0".repeat(63)}`; + const asUnreferencedRoot = { + created_at: OPENER.createdAt, + id: rejectedSideId, + tags: [], + }; + assert.equal( + isAtOrAfterConversationOpener(asUnreferencedRoot, OPENER), + false, + ); + const asSentReply = { + created_at: OPENER.createdAt, + id: rejectedSideId, + tags: [["e", OPENER.eventId, "", "reply"]], + }; + assert.equal(isAtOrAfterConversationOpener(asSentReply, OPENER), true); +}); diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index 878de3575..487d1c0fb 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -118,3 +118,75 @@ export function mergeProjectAgentConversationEvents< ).values(), ].sort((left, right) => left.created_at - right.created_at); } + +/** + * Runs the Projects agent submit sequence with every relay side effect bound + * to the tenant scope the caller captured before the first await. + * + * The sequence suspends twice (managed-agent startup, DM open) and a + * community switch during either suspension does not cancel this callback — + * remounting only removes the UI. Binding is therefore delegated to the + * scoped APIs themselves: `openDm` and `send` receive `expectedRelayUrl` + * and the backing commands fail closed when the active community no longer + * matches, so a stale callback can neither open a DM in the new tenant nor + * publish the captured tenant's context to it. This function never re-reads + * the active scope after capture — doing so would race the very switch it + * guards against. + * + * Follow-up sends carry `parentEventId = opener.eventId`: a follow-up signed + * in the opener's own second gets a random event id, and roughly half of + * those sort on the rejected side of the same-second id tiebreak in + * `isAtOrAfterConversationOpener`. The causal reply reference — which the + * comparator always admits — is what keeps an immediate follow-up visible; + * id luck cannot. + */ +export async function submitProjectAgentMessage({ + agent, + conversation, + content, + mentionPubkeys, + mediaTags, + relayScope, + startAgent, + openDm, + send, +}: { + agent: { pubkey: string; isManaged: boolean; isActive: boolean }; + conversation: { channel: Ch; opener: ProjectsConversationOpener } | null; + content: string; + mentionPubkeys: string[]; + mediaTags?: string[][]; + /** Community relay captured before the first await; null when the + * community has no relay identity (no tenant boundary to protect). */ + relayScope: string | null; + startAgent: (agentPubkey: string) => Promise; + openDm: (input: { + pubkeys: string[]; + expectedRelayUrl?: string; + }) => Promise; + send: (request: { + channelId: string; + content: string; + mentionPubkeys: string[]; + mediaTags?: string[][]; + parentEventId?: string; + expectedRelayUrl?: string; + }) => Promise<{ eventId: string; createdAt: number }>; +}): Promise<{ channel: Ch; sent: { eventId: string; createdAt: number } }> { + const expectedRelayUrl = relayScope ?? undefined; + if (agent.isManaged && !agent.isActive) { + await startAgent(agent.pubkey); + } + const channel = + conversation?.channel ?? + (await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl })); + const sent = await send({ + channelId: channel.id, + content, + mentionPubkeys, + mediaTags, + parentEventId: conversation?.opener.eventId, + expectedRelayUrl, + }); + return { channel, sent }; +} diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 60941c25e..be1fd6118 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -9,7 +9,10 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; -import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation"; +import { + restoreProjectsAgentConversation, + submitProjectAgentMessage, +} from "@/features/projects/lib/projectAgentConversation"; import { clearStoredProjectsAgentConversation, type ProjectsConversationOpener, @@ -20,7 +23,7 @@ import { import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { sendChannelMessage } from "@/shared/api/tauri"; +import { sendChannelMessage } from "@/shared/api/tauriMessages"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -126,21 +129,37 @@ export function ProjectAgentChatPanel({ if (!trimmed || !selectedAgent || isSending) return; setIsSending(true); try { - if (selectedAgent.isManaged && !selectedAgent.isActive) { - await startAgentMutation.mutateAsync(selectedAgent.pubkey); - } - const channel = - conversation?.channel ?? - (await openDmMutation.mutateAsync({ - pubkeys: [selectedAgent.pubkey], - })); - const sent = await sendChannelMessage( - channel.id, - `${trimmed}${contextPayload}`, - undefined, + // The awaits below suspend across a possible community switch; + // `submitProjectAgentMessage` binds every relay side effect to the + // scope captured here (fail closed), and threads follow-ups onto the + // opener so a same-second follow-up cannot be hidden by id ordering. + const { channel, sent } = await submitProjectAgentMessage({ + agent: selectedAgent, + conversation, + content: `${trimmed}${contextPayload}`, + mentionPubkeys: [ + ...new Set([...mentionPubkeys, selectedAgent.pubkey]), + ], mediaTags, - [...new Set([...mentionPubkeys, selectedAgent.pubkey])], - ); + relayScope, + startAgent: (agentPubkey) => + startAgentMutation.mutateAsync(agentPubkey), + openDm: (input) => openDmMutation.mutateAsync(input), + send: (request) => + sendChannelMessage( + request.channelId, + request.content, + request.parentEventId, + request.mediaTags, + request.mentionPubkeys, + undefined, + undefined, + undefined, + undefined, + undefined, + request.expectedRelayUrl, + ), + }); if (!conversation) { // Anchor the conversation to the exact accepted opener event: a // bare timestamp cannot isolate it from unrelated same-second DM @@ -176,6 +195,7 @@ export function ProjectAgentChatPanel({ conversation, isSending, openDmMutation, + relayScope, selectedAgent, startAgentMutation, storageScope, diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index df62a924f..78528a470 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -21,6 +21,8 @@ import { } from "@/features/agents/lib/agentAutocompleteEligibility"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; +import { normalizeRelayUrl } from "@/features/communities/communityStorage"; +import { useCommunities } from "@/features/communities/useCommunities"; import { useChannelMessagesQuery, useChannelSubscription, @@ -48,6 +50,7 @@ import { isAtOrAfterConversationOpener, mergeProjectAgentConversationEvents, restoreProjectsAgentConversation, + submitProjectAgentMessage, } from "@/features/projects/lib/projectAgentConversation"; import { clearStoredProjectsAgentConversation, @@ -57,7 +60,7 @@ import { writeStoredProjectsAgentConversation, } from "@/features/projects/lib/projectAgentConversationStorage"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { sendChannelMessage } from "@/shared/api/tauri"; +import { sendChannelMessage } from "@/shared/api/tauriMessages"; import type { Channel } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE, @@ -385,6 +388,14 @@ export function ProjectsAgentPromptPage({ const channelsQuery = useChannelsQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); + const { activeCommunity } = useCommunities(); + // Tenant scope for the submit sequence below: captured per render, so the + // value the callback closes over is the community that was active when the + // user pressed Ask — the backing commands fail closed if it changes while + // the callback is suspended. + const relayScope = activeCommunity?.relayUrl + ? normalizeRelayUrl(activeCommunity.relayUrl) + : null; const candidatePubkeys = React.useMemo( () => candidates.map((candidate) => candidate.pubkey), @@ -468,25 +479,39 @@ export function ProjectsAgentPromptPage({ setIsSending(true); try { - if (selectedAgent.isManaged && !selectedAgent.isActive) { - await startAgentMutation.mutateAsync(selectedAgent.pubkey); - } - const channel = - conversation?.channel ?? - (await openDmMutation.mutateAsync({ - pubkeys: [selectedAgent.pubkey], - })); // Repo context rides only on the conversation opener. const content = conversation ? trimmed : `${trimmed}${repoContextPayload}`; - const sent = await sendChannelMessage( - channel.id, + // The awaits inside suspend across a possible community switch; + // `submitProjectAgentMessage` binds every relay side effect to the + // scope captured at render (fail closed) and threads follow-ups onto + // the opener so a same-second follow-up cannot be hidden by id + // ordering. + const { channel, sent } = await submitProjectAgentMessage({ + agent: selectedAgent, + conversation, content, - undefined, - undefined, - [selectedAgent.pubkey], - ); + mentionPubkeys: [selectedAgent.pubkey], + relayScope, + startAgent: (agentPubkey) => + startAgentMutation.mutateAsync(agentPubkey), + openDm: (input) => openDmMutation.mutateAsync(input), + send: (request) => + sendChannelMessage( + request.channelId, + request.content, + request.parentEventId, + undefined, + request.mentionPubkeys, + undefined, + undefined, + undefined, + undefined, + undefined, + request.expectedRelayUrl, + ), + }); if (!conversation) { // Anchor the conversation to the exact accepted opener event: a bare // timestamp cannot isolate it from unrelated same-second DM history. @@ -521,6 +546,7 @@ export function ProjectsAgentPromptPage({ conversation, isSending, openDmMutation, + relayScope, repoContextPayload, richText.clearContent, richText.getMarkdown, diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81..9ee8b512c 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -7,7 +7,6 @@ import { fromRawInstallRuntimeResult, type RawInstallRuntimeResult, } from "@/shared/api/installTypes"; -import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes"; import type { AddChannelMembersInput, AddChannelMembersResult, @@ -26,7 +25,6 @@ import type { RelayEvent, SearchMessagesInput, SearchMessagesResponse, - SendChannelMessageResult, SetCanvasInput, SetCanvasResult, ThreadCursor, @@ -44,6 +42,7 @@ import type { } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; +export { sendChannelMessage } from "@/shared/api/tauriMessages"; type RawPresenceLookup = Record; @@ -533,42 +532,6 @@ export async function getThreadReplies( }; } -export async function sendChannelMessage( - channelId: string, - content: string, - parentEventId?: string | null, - mediaTags?: string[][], - mentionPubkeys?: string[], - kind?: number, - emojiTags?: string[][], - mentionTags?: string[][], - linkPreviewTags?: string[][], - sentFromThreadTag?: string[], -): Promise { - const response = await invokeTauri( - "send_channel_message", - { - channelId, - content, - parentEventId, - mediaTags: mediaTags ?? null, - emojiTags: emojiTags ?? null, - mentionTags: mentionTags ?? null, - linkPreviewTags, - sentFromThreadTag: sentFromThreadTag ?? null, - mentionPubkeys: mentionPubkeys ?? null, - kind: kind ?? null, - }, - ); - return { - eventId: response.event_id, - parentEventId: response.parent_event_id, - rootEventId: response.root_event_id, - depth: response.depth, - createdAt: response.created_at, - }; -} - export type BlobDescriptor = { url: string; sha256: string; diff --git a/desktop/src/shared/api/tauriChannels.ts b/desktop/src/shared/api/tauriChannels.ts index 7dde0be08..e17a81719 100644 --- a/desktop/src/shared/api/tauriChannels.ts +++ b/desktop/src/shared/api/tauriChannels.ts @@ -6,7 +6,6 @@ import type { ChannelPageCursor, ChannelType, CreateChannelInput, - OpenDmInput, SetChannelPurposeInput, SetChannelTopicInput, UpdateChannelInput, @@ -156,6 +155,17 @@ export async function ensureStarterChannels(): Promise { ); } +export type OpenDmInput = { + pubkeys: string[]; + /** + * Tenant scope captured by the caller before its first await (community + * relay URL). The backend fails closed when the active community no longer + * matches, so a suspended callback can never open a DM in the wrong + * community. Omit for callers without a tenant boundary. + */ + expectedRelayUrl?: string; +}; + export async function openDm(input: OpenDmInput): Promise { return fromRawChannel(await invokeTauri("open_dm", input)); } diff --git a/desktop/src/shared/api/tauriMessages.ts b/desktop/src/shared/api/tauriMessages.ts new file mode 100644 index 000000000..f33d615d2 --- /dev/null +++ b/desktop/src/shared/api/tauriMessages.ts @@ -0,0 +1,43 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { RawSendChannelMessageResult } from "@/shared/api/tauriMessageTypes"; +import type { SendChannelMessageResult } from "@/shared/api/types"; + +export async function sendChannelMessage( + channelId: string, + content: string, + parentEventId?: string | null, + mediaTags?: string[][], + mentionPubkeys?: string[], + kind?: number, + emojiTags?: string[][], + mentionTags?: string[][], + linkPreviewTags?: string[][], + sentFromThreadTag?: string[], + expectedRelayUrl?: string, +): Promise { + const response = await invokeTauri( + "send_channel_message", + { + channelId, + content, + parentEventId, + mediaTags: mediaTags ?? null, + emojiTags: emojiTags ?? null, + mentionTags: mentionTags ?? null, + linkPreviewTags, + sentFromThreadTag: sentFromThreadTag ?? null, + mentionPubkeys: mentionPubkeys ?? null, + kind: kind ?? null, + // Tenant scope captured by the caller before its first await; the + // backend fails closed when the active community no longer matches. + expectedRelayUrl: expectedRelayUrl ?? null, + }, + ); + return { + eventId: response.event_id, + parentEventId: response.parent_event_id, + rootEventId: response.root_event_id, + depth: response.depth, + createdAt: response.created_at, + }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef62578..c846cfcf0 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -50,10 +50,6 @@ export type CreateChannelInput = { ttlSeconds?: number; }; -export type OpenDmInput = { - pubkeys: string[]; -}; - export type UpdateChannelInput = { channelId: string; name?: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e5b9445e6..a54a8e4a2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3786,6 +3786,51 @@ function getRelayWsUrl(config: E2eConfig | undefined): string { return config?.relayWsUrl ?? DEFAULT_RELAY_WS_URL; } +/** + * Mirror of the backend's `assert_expected_relay_scope`: a caller-captured + * tenant scope must still match the active community when the command runs. + * The mock's "active relay" is the active community's relayUrl in + * localStorage (specs switch communities by rewriting it), falling back to + * the configured mock relay. Lets specs drive the mid-flight community + * switch with `openDmDelayMs` / `sendMessageDelayMs` and prove the send + * fails closed. + */ +function assertExpectedRelayScope( + expectedRelayUrl: string | null | undefined, + config: E2eConfig | undefined, +): void { + const expected = expectedRelayUrl?.trim(); + if (!expected) return; + let active: string | null = null; + try { + const activeId = window.localStorage.getItem("buzz-active-community-id"); + const communities = JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ) as { id: string; relayUrl: string }[]; + active = + communities.find((community) => community.id === activeId)?.relayUrl ?? + null; + } catch { + active = null; + } + if ( + normalizeMockRelayUrl(active ?? getRelayWsUrl(config)) !== + normalizeMockRelayUrl(expected) + ) { + throw new Error( + "active community changed before the message was submitted; not sent", + ); + } +} + +/** Same ws(s) normalization the app applies to community relay URLs. */ +function normalizeMockRelayUrl(url: string): string { + if (!url.startsWith("ws://") && !url.startsWith("wss://")) { + return `wss://${url}`; + } + return url; +} + function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined { if (!isRelayMode(config)) { return undefined; @@ -6492,6 +6537,7 @@ async function handleCreateChannel( async function handleOpenDm( args: { pubkeys: string[]; + expectedRelayUrl?: string | null; }, config: E2eConfig | undefined, ) { @@ -6499,6 +6545,9 @@ async function handleOpenDm( if (delayMs > 0) { await new Promise((resolve) => window.setTimeout(resolve, delayMs)); } + // After the injected delay, like the real command's post-await check: a + // caller-captured tenant scope must still match the active community. + assertExpectedRelayScope(args.expectedRelayUrl, config); const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys); if (normalizedPubkeys.length === 0) { @@ -9160,6 +9209,7 @@ async function handleSendChannelMessage( linkPreviewTags?: string[][] | null; sentFromThreadTag?: string[] | null; suppressLinkPreviews?: boolean; + expectedRelayUrl?: string | null; }, config: E2eConfig | undefined, ): Promise { @@ -9170,6 +9220,9 @@ async function handleSendChannelMessage( window.setTimeout(resolve, sendMessageDelayMs), ); } + // After the injected delay, like the real command's post-await check: a + // caller-captured tenant scope must still match the active community. + assertExpectedRelayScope(args.expectedRelayUrl, config); // Mirror the WebSocket send path's failure injection so specs that route // the first message through the acknowledged HTTP transport still exercise