From 4c0b05e0ee9657952c6ce010bed8b8f4988a04d0 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 29 Jun 2026 11:03:02 -0700 Subject: [PATCH] fix(sidebar): resolve working agents from observer frames Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/agents/observerRelayStore.ts | 88 +++++++++++++++++-- .../lib/useActiveWorkingChannelsById.ts | 34 +++++-- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index f2eb22f50c..b96f0404f5 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -37,6 +37,11 @@ const listeners = new Set<() => void>(); const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +const observerCandidatePubkeys = new Set(); +let cachedObserverCandidatePubkeys: string[] | null = null; +const pendingEventsByAgent = new Map(); +const MAX_PENDING_EVENTS_PER_AGENT = 100; +const EMPTY_PUBKEYS: string[] = []; // Normalized pubkeys of agents we are actively managing. Only events whose // "agent" tag matches an entry here will be decrypted (defense-in-depth). @@ -49,6 +54,38 @@ const snapshotByAgent = new Map(); const knownAgentPubkeys = new Set(); const knownAgentsBySubscription = new Map>(); +function registerObserverCandidate(agentPubkey: string) { + const key = normalizePubkey(agentPubkey); + if (observerCandidatePubkeys.has(key)) return; + observerCandidatePubkeys.add(key); + cachedObserverCandidatePubkeys = null; + notifyListeners(); +} + +function flushPendingEventsForAgent( + agentPubkey: string, + activeGeneration: number, +) { + const key = normalizePubkey(agentPubkey); + const pendingEvents = pendingEventsByAgent.get(key); + if (!pendingEvents || pendingEvents.length === 0) return; + pendingEventsByAgent.delete(key); + for (const event of pendingEvents) { + eventProcessingQueue = eventProcessingQueue + .then(() => handleRelayObserverEvent(event, activeGeneration)) + .catch((error) => { + if (activeGeneration !== generation) { + return; + } + setConnectionState( + "error", + error instanceof Error + ? `Observer event handling failed: ${error.message}` + : "Observer event handling failed.", + ); + }); + } +} function recomputeKnownAgentPubkeys() { knownAgentPubkeys.clear(); for (const subscriptionAgents of knownAgentsBySubscription.values()) { @@ -172,15 +209,33 @@ async function handleRelayObserverEvent( return; } - // Verify agent is known/trusted before decrypting. - // Silently drop events from agents we are not managing. - if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + // Defense-in-depth: verify the event sender matches the claimed agent pubkey + // before surfacing it as an owner-resolution candidate. The relay gates on + // is_agent_owner, but a compromised relay could misroute. + if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { return; } - // Defense-in-depth: verify the event sender matches the claimed agent pubkey. - // The relay gates on is_agent_owner, but a compromised relay could misroute. - if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { + // Surface every routed agent pubkey to owner-gated callers before the trusted + // known-agent check. The relay subscription is already scoped to the viewer's + // pubkey, so these are candidates for the same declared-owner gate used by + // profile surfaces. We still defer decryption until a caller explicitly adds + // the agent to `knownAgentPubkeys` below. + registerObserverCandidate(agentPubkey); + + // Verify agent is known/trusted before decrypting. + // Silently hold events from agents we have not yet resolved as owned. + if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + const key = normalizePubkey(agentPubkey); + const pendingEvents = pendingEventsByAgent.get(key) ?? []; + pendingEvents.push(event); + if (pendingEvents.length > MAX_PENDING_EVENTS_PER_AGENT) { + pendingEvents.splice( + 0, + pendingEvents.length - MAX_PENDING_EVENTS_PER_AGENT, + ); + } + pendingEventsByAgent.set(key, pendingEvents); return; } @@ -325,6 +380,9 @@ export function useManagedAgentObserverBridge( // a co-mounted caller no longer wipes out this caller's agents. React.useEffect(() => { registerKnownAgents(subscriptionId, agentPubkeys); + for (const agentPubkey of agentPubkeys) { + flushPendingEventsForAgent(agentPubkey, generation); + } return () => { unregisterKnownAgents(subscriptionId); }; @@ -338,6 +396,21 @@ export function useManagedAgentObserverBridge( }, [hasActiveAgent]); } +export function useObserverCandidatePubkeys(): string[] { + React.useEffect(() => { + void ensureRelayObserverSubscription(); + }, []); + + const getSnapshot = React.useCallback(() => { + if (observerCandidatePubkeys.size === 0) return EMPTY_PUBKEYS; + if (cachedObserverCandidatePubkeys) return cachedObserverCandidatePubkeys; + cachedObserverCandidatePubkeys = [...observerCandidatePubkeys].sort(); + return cachedObserverCandidatePubkeys; + }, []); + + return React.useSyncExternalStore(subscribeAgentObserverStore, getSnapshot); +} + export function resetAgentObserverStore() { generation += 1; const unsubscribe = unsubscribeRelay; @@ -349,6 +422,9 @@ export function resetAgentObserverStore() { snapshotByAgent.clear(); knownAgentPubkeys.clear(); knownAgentsBySubscription.clear(); + observerCandidatePubkeys.clear(); + cachedObserverCandidatePubkeys = null; + pendingEventsByAgent.clear(); connectionState = "idle"; errorMessage = null; notifyListeners(); diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts index d3d57e552f..218f715206 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts @@ -9,7 +9,10 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { + useManagedAgentObserverBridge, + useObserverCandidatePubkeys, +} from "@/features/agents/observerRelayStore"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; @@ -32,13 +35,21 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< () => relayAgentsQuery.data ?? [], [relayAgentsQuery.data], ); + const observerCandidatePubkeys = useObserverCandidatePubkeys(); const relayAgentPubkeys = React.useMemo( () => relayAgents.map((agent) => agent.pubkey), [relayAgents], ); - const relayAgentProfilesQuery = useUsersBatchQuery(relayAgentPubkeys, { - enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, - }); + const ownerCandidatePubkeys = React.useMemo( + () => [...new Set([...relayAgentPubkeys, ...observerCandidatePubkeys])], + [observerCandidatePubkeys, relayAgentPubkeys], + ); + const ownerCandidateProfilesQuery = useUsersBatchQuery( + ownerCandidatePubkeys, + { + enabled: Boolean(currentPubkey) && ownerCandidatePubkeys.length > 0, + }, + ); const bridgeAgents = React.useMemo(() => { const agentsByPubkey = new Map(); for (const agent of managedAgents) { @@ -50,21 +61,26 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< if (currentPubkey) { const currentPubkeyLower = currentPubkey.toLowerCase(); - const profiles = relayAgentProfilesQuery.data?.profiles ?? {}; - for (const agent of relayAgents) { - const key = agent.pubkey.toLowerCase(); + const profiles = ownerCandidateProfilesQuery.data?.profiles ?? {}; + for (const pubkey of ownerCandidatePubkeys) { + const key = pubkey.toLowerCase(); const ownerPubkey = profiles[key]?.ownerPubkey; if (ownerPubkey?.toLowerCase() !== currentPubkeyLower) continue; if (agentsByPubkey.has(key)) continue; agentsByPubkey.set(key, { - pubkey: agent.pubkey, + pubkey, status: "deployed", }); } } return [...agentsByPubkey.values()]; - }, [currentPubkey, managedAgents, relayAgentProfilesQuery.data, relayAgents]); + }, [ + currentPubkey, + managedAgents, + ownerCandidateProfilesQuery.data, + ownerCandidatePubkeys, + ]); useManagedAgentObserverBridge(bridgeAgents); useActiveAgentTurnsBridge(bridgeAgents);