mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
fix(observability): gate agent observability on declared ownership, not key custody (#1229)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -9,9 +9,12 @@
|
||||
//! Why this shape:
|
||||
//! - Owner secret key never leaves Rust. The desktop signs in as the owner
|
||||
//! and decrypts via `agent ↔ owner` NIP-44 conversation key.
|
||||
//! - Owner gating is enforced here: the requested agent pubkey MUST exist
|
||||
//! in the local `managed_agents` store. If not, the command refuses.
|
||||
//! The UI hides the section anyway, but defense in depth.
|
||||
//! - Owner gating is enforced here: the request is accepted if the agent is
|
||||
//! in the local `managed_agents` store OR the agent's live `kind:0`
|
||||
//! cryptographically declares the viewer as its NIP-OA owner. Either way
|
||||
//! the engrams are NIP-44 encrypted to the viewer's own pubkey, so the
|
||||
//! encryption is the real boundary; this gate just decides whether to try.
|
||||
//! The UI hides the section for non-owners anyway, but defense in depth.
|
||||
//! - One call returns everything because the orphans view requires the
|
||||
//! full set anyway. Lazy/per-node decrypt is deferred to IXI-60.
|
||||
|
||||
@@ -25,6 +28,7 @@ use tauri::{AppHandle, State};
|
||||
use buzz_core_pkg::engram::{self, extract_refs, select_head, validate_and_decrypt, Body};
|
||||
use buzz_core_pkg::kind::KIND_AGENT_ENGRAM;
|
||||
|
||||
use crate::commands::identity_archive::{extract_oa_owner, fetch_kind0};
|
||||
use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay};
|
||||
|
||||
/// Hard cap on engrams returned per (agent, owner) pair. Matches the CLI
|
||||
@@ -67,6 +71,26 @@ pub struct AgentMemoryListing {
|
||||
pub fetched_at: u64,
|
||||
}
|
||||
|
||||
/// Does `kind0` cryptographically declare `viewer_pubkey` as the NIP-OA owner
|
||||
/// of the agent it belongs to?
|
||||
///
|
||||
/// This is the remote-owner gate predicate for [`get_agent_memory`]. It runs
|
||||
/// the live `kind:0` through [`extract_oa_owner`] (which verifies the auth tag
|
||||
/// against the agent's own pubkey via `nip_oa::verify_auth_tag`) and compares
|
||||
/// the recovered owner to the viewer, case-insensitively. Returns `false` for
|
||||
/// a missing `kind:0`, a `kind:0` without a verifiable auth tag, or an owner
|
||||
/// that doesn't match the viewer — so a forged/mismatched declaration never
|
||||
/// opens the gate. Pure over its inputs so the auth branch can be unit-tested
|
||||
/// without the relay/Tauri machinery.
|
||||
fn kind0_declares_viewer_owner(kind0: Option<&nostr::Event>, viewer_pubkey: &str) -> bool {
|
||||
match kind0 {
|
||||
Some(kind0) => extract_oa_owner(kind0)
|
||||
.map(|(owner_hex, _tag)| owner_hex.eq_ignore_ascii_case(viewer_pubkey))
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `get_agent_memory` — owner-gated single-payload engram listing.
|
||||
///
|
||||
/// Returns the full decrypted set for the (agent, owner) pair where
|
||||
@@ -85,29 +109,53 @@ pub async fn get_agent_memory(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AgentMemoryListing, String> {
|
||||
// ── Owner gating ────────────────────────────────────────────────────
|
||||
// The viewer (this desktop's identity) is the prospective owner. We
|
||||
// accept the request only if the agent appears in our managed_agents
|
||||
// store — that's the local source of truth for "agents I own". The UI
|
||||
// must already have hidden the section for non-owners; this is just
|
||||
// defense in depth.
|
||||
// The viewer (this desktop's identity) is the prospective owner. The
|
||||
// relay query below is `#p`-tagged for the viewer's OWN pubkey and every
|
||||
// engram is NIP-44 encrypted to that pubkey, so the viewer decrypts with
|
||||
// their own key — the agent's seckey is never needed. Encryption + the
|
||||
// relay's server-side `#p` scoping are the real boundary; this gate only
|
||||
// decides whether we bother attempting (and avoids a needless roundtrip
|
||||
// for agents the viewer plainly doesn't own).
|
||||
//
|
||||
// Why `managed_agents` rather than a NIP-OA `kind:0` lookup (the gate
|
||||
// the archive command uses): the question this surface needs to answer
|
||||
// is "do I have the seckey to decrypt this agent's engrams?", not
|
||||
// "does this agent's `kind:0` declare me as its OA owner?". The former
|
||||
// is what `managed_agents` records — it can't lie, you either have the
|
||||
// key locally or you don't. The latter is forgeable (a malicious agent
|
||||
// can put any pubkey in their `auth` tag) and would also require a
|
||||
// relay roundtrip on every panel open. Keep this gate; don't swap it
|
||||
// back to `resolve_oa_owner` "for consistency" — they're answering
|
||||
// different questions.
|
||||
// We accept the request on either of two owner signals, mirroring the UI's
|
||||
// `viewerIsOwner = isCurrentUserOwner || isOwner`:
|
||||
// 1. The agent is in this desktop's `managed_agents` store — local
|
||||
// fast-path, no roundtrip. Covers locally-run agents (and older
|
||||
// agents that never advertised an owner).
|
||||
// 2. The agent's live `kind:0` cryptographically declares the viewer as
|
||||
// its NIP-OA owner (verified via `extract_oa_owner`). This is the
|
||||
// remote-owner case: the owner runs the agent on another desktop, so
|
||||
// holds no local seckey, but legitimately owns it.
|
||||
//
|
||||
// (Historical note: this gate used to refuse anything not in
|
||||
// `managed_agents`, on the theory that key-custody was the only safe
|
||||
// proxy for ownership. That conflated "do I hold the seckey?" with "am I
|
||||
// the owner?" — the two diverge exactly for a remote-owned agent, which
|
||||
// wrongly locked legitimate owners out of their own memory. The declared-
|
||||
// owner path is cleared (PR #917 author signed off); decryption still
|
||||
// does the real guarding.)
|
||||
let agent = PublicKey::from_hex(&agent_pubkey)
|
||||
.map_err(|e| format!("agent pubkey must be 64-hex: {e}"))?;
|
||||
|
||||
let viewer_pubkey = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
keys.public_key().to_hex()
|
||||
};
|
||||
|
||||
let managed = load_managed_agents(&app)?;
|
||||
if !managed.iter().any(|m| m.pubkey == agent_pubkey) {
|
||||
let is_managed = managed.iter().any(|m| m.pubkey == agent_pubkey);
|
||||
let is_declared_owner = if is_managed {
|
||||
false // already authorized; skip the relay roundtrip
|
||||
} else {
|
||||
// Verify the agent's live `kind:0` declares the viewer as owner.
|
||||
let kind0 = fetch_kind0(&state, &agent_pubkey).await?;
|
||||
kind0_declares_viewer_owner(kind0.as_ref(), &viewer_pubkey)
|
||||
};
|
||||
|
||||
if !is_managed && !is_declared_owner {
|
||||
return Err(format!(
|
||||
"not the owner of agent {agent_pubkey} (no managed-agent record)"
|
||||
"not the owner of agent {agent_pubkey} (no managed-agent record \
|
||||
and no verified NIP-OA owner declaration)"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -225,3 +273,100 @@ pub async fn get_agent_memory(
|
||||
fetched_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Build a `kind:0` carrying a valid NIP-OA `auth` tag declaring `owner`
|
||||
/// as the owner of `agent`. Mirrors the helper in `identity_archive`'s
|
||||
/// tests — same `compute_auth_tag` → `parse_auth_tag` bridge.
|
||||
fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event {
|
||||
let agent_hex = agent.public_key().to_hex();
|
||||
let agent_compat = nostr::PublicKey::from_hex(&agent_hex).unwrap();
|
||||
let owner_compat_secret =
|
||||
nostr::SecretKey::from_slice(owner.secret_key().as_secret_bytes()).unwrap();
|
||||
let owner_compat_keys = nostr::Keys::new(owner_compat_secret);
|
||||
let tag_json =
|
||||
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_compat_keys, &agent_compat, "")
|
||||
.expect("compute_auth_tag");
|
||||
let compat_tag = buzz_sdk_pkg::nip_oa::parse_auth_tag(&tag_json).unwrap();
|
||||
let tag = Tag::parse(compat_tag.as_slice()).unwrap();
|
||||
EventBuilder::new(Kind::Metadata, "{}")
|
||||
.tags([tag])
|
||||
.sign_with_keys(agent)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The declared-owner gate branch opens when a verified `kind:0` names the
|
||||
/// viewer as the agent's NIP-OA owner. This is the remote-owner case that
|
||||
/// the old key-custody gate wrongly refused (PR #917 migration).
|
||||
#[test]
|
||||
fn declared_owner_gate_opens_for_verified_owner_kind0() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let kind0 = kind0_with_auth(&agent, &owner);
|
||||
|
||||
assert!(kind0_declares_viewer_owner(
|
||||
Some(&kind0),
|
||||
&owner.public_key().to_hex(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Owner match is case-insensitive — a verified declaration still opens the
|
||||
/// gate when the viewer pubkey is supplied in a different hex case.
|
||||
#[test]
|
||||
fn declared_owner_gate_is_case_insensitive() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let kind0 = kind0_with_auth(&agent, &owner);
|
||||
|
||||
assert!(kind0_declares_viewer_owner(
|
||||
Some(&kind0),
|
||||
&owner.public_key().to_hex().to_uppercase(),
|
||||
));
|
||||
}
|
||||
|
||||
/// A verified declaration for a *different* owner must NOT open the gate for
|
||||
/// some other viewer — the recovered owner has to equal the viewer.
|
||||
#[test]
|
||||
fn declared_owner_gate_refuses_non_owner_viewer() {
|
||||
let owner = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let stranger = Keys::generate();
|
||||
let kind0 = kind0_with_auth(&agent, &owner);
|
||||
|
||||
assert!(!kind0_declares_viewer_owner(
|
||||
Some(&kind0),
|
||||
&stranger.public_key().to_hex(),
|
||||
));
|
||||
}
|
||||
|
||||
/// A `kind:0` with no auth tag carries no owner claim, so the gate stays
|
||||
/// shut even for the agent's own would-be owner.
|
||||
#[test]
|
||||
fn declared_owner_gate_refuses_kind0_without_auth_tag() {
|
||||
let agent = Keys::generate();
|
||||
let viewer = Keys::generate();
|
||||
let kind0 = EventBuilder::new(Kind::Metadata, "{}")
|
||||
.sign_with_keys(&agent)
|
||||
.unwrap();
|
||||
|
||||
assert!(!kind0_declares_viewer_owner(
|
||||
Some(&kind0),
|
||||
&viewer.public_key().to_hex(),
|
||||
));
|
||||
}
|
||||
|
||||
/// No live `kind:0` at all (relay returned nothing) means no declared
|
||||
/// owner — the gate can only open via the local managed-agent fast path.
|
||||
#[test]
|
||||
fn declared_owner_gate_refuses_missing_kind0() {
|
||||
let viewer = Keys::generate();
|
||||
assert!(!kind0_declares_viewer_owner(
|
||||
None,
|
||||
&viewer.public_key().to_hex(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::{
|
||||
/// preimage subject is the *target* pubkey, not the request signer). The
|
||||
/// `buzz-sdk` lives on nostr 0.36; the desktop is on 0.37, so we bridge
|
||||
/// via hex round-trip exactly like `relay::build_profile_event` does.
|
||||
fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])> {
|
||||
pub(crate) fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])> {
|
||||
let target_hex = target_kind0.pubkey.to_hex();
|
||||
let target_compat = nostr::PublicKey::from_hex(&target_hex).ok()?;
|
||||
|
||||
@@ -55,7 +55,10 @@ fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [String; 4])
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_kind0(state: &AppState, pubkey: &str) -> Result<Option<nostr::Event>, String> {
|
||||
pub(crate) async fn fetch_kind0(
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<nostr::Event>, String> {
|
||||
let events = query_relay(
|
||||
state,
|
||||
&[serde_json::json!({
|
||||
|
||||
@@ -12,25 +12,26 @@ export const agentMemoryQueryKey = (agentPubkey: string) =>
|
||||
["agent-memory", agentPubkey.toLowerCase()] as const;
|
||||
|
||||
/**
|
||||
* Synchronous gate: does this desktop manage the agent? Used by the profile
|
||||
* panel to hide the Memory section entirely for non-owners.
|
||||
* Synchronous gate: does this desktop manage the agent (i.e. hold its
|
||||
* seckey locally)? This is the *local fast-path* owner signal — one of two
|
||||
* inputs to `viewerIsOwner`. The other is the declared NIP-OA owner signal
|
||||
* (`isCurrentUserOwner`, computed in the profile panel from the agent's
|
||||
* `kind:0`). Callers OR the two: `viewerIsOwner = isCurrentUserOwner || isOwner`.
|
||||
*
|
||||
* Returns `boolean | undefined`:
|
||||
* - `undefined` is the *loading* state (managed-agent list still resolving).
|
||||
* Callers should defer rendering, not show an error.
|
||||
* - `true` / `false` once the list is known.
|
||||
*
|
||||
* Why `managed_agents` (not NIP-OA `kind:0` via `useOaOwnerQuery`)?
|
||||
* The archive button gates on `useOaOwnerQuery` because publishing a
|
||||
* `kind:9035` requires verifying NIP-OA cryptographically — the action is
|
||||
* *signing as the OA owner*. The memory viewer's question is different:
|
||||
* "do I have the seckey to decrypt this agent's engrams?" `managed_agents`
|
||||
* answers exactly that — it's the local source of truth for "agents whose
|
||||
* keys this desktop holds." NIP-OA on its own is weaker for this surface:
|
||||
* a malicious agent can forge an `auth` tag in their `kind:0` pointing at
|
||||
* any pubkey, but only the desktop that actually holds the seckey can
|
||||
* decrypt. Don't "fix" this back to `useOaOwnerQuery` — it would replace
|
||||
* a precise predicate with a weaker one and add a relay roundtrip.
|
||||
* Why not gate the Memory section on this alone? It answers "do I hold the
|
||||
* seckey?", which used to stand in for "am I the owner?". Those two diverge
|
||||
* exactly for a remote-owned agent — the owner runs it on another desktop,
|
||||
* holds no local seckey, but legitimately owns it. Engrams are NIP-44
|
||||
* encrypted to the *owner's* pubkey and decrypted with the owner's own key
|
||||
* (never the agent's seckey), so a declared owner can read their own memory
|
||||
* regardless of where the agent runs. The encryption is the real boundary;
|
||||
* this predicate is just the no-roundtrip half of the owner check. The
|
||||
* declared-owner half (`isCurrentUserOwner`) covers the remote case.
|
||||
*
|
||||
* Lowercase compare because pubkeys can arrive from either side in mixed
|
||||
* case via Nostr libs; the underlying store stores them as-given.
|
||||
@@ -49,8 +50,8 @@ export function useIsManagedAgent(
|
||||
* Fetch + decrypt the engram listing for one agent. Owner-gated at the
|
||||
* Rust layer; if the viewer isn't the agent's owner the underlying call
|
||||
* returns an `Err` (we surface it as `query.isError`). The UI must hide
|
||||
* the section for non-owners — see {@link useIsManagedAgent} — but this
|
||||
* hook is robust to a misuse there.
|
||||
* the section for non-owners (the caller passes `viewerIsOwner` down to
|
||||
* {@link MemorySection}), but this hook is robust to a misuse there.
|
||||
*
|
||||
* `staleTime: 30s`: engrams change rarely (each write is a deliberate
|
||||
* agent action). 30s keeps profile re-opens snappy without going so far
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
|
||||
import {
|
||||
useAgentMemoryGraph,
|
||||
useIsManagedAgent,
|
||||
} from "@/features/agent-memory/hooks";
|
||||
import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
|
||||
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
|
||||
import type { EngramEntry } from "@/shared/api/tauriEngrams";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -23,9 +20,11 @@ const MEMORY_DANGLING_REF_TOOLTIP =
|
||||
/**
|
||||
* Memory section — IXI-7 phase 1 read-only viewer.
|
||||
*
|
||||
* Owner-gated: returns `null` for non-owners (and while the managed-agent
|
||||
* list is still loading, to avoid a one-frame flash of the section before
|
||||
* we know the viewer's owner status).
|
||||
* Owner-gated by the caller: the parent passes `viewerIsOwner` (the
|
||||
* `isCurrentUserOwner || isOwner` signal computed in the profile panel from
|
||||
* the agent's declared NIP-OA owner OR local key custody). We return `null`
|
||||
* for non-owners. Engrams decrypt with the viewer's OWN key, so a declared
|
||||
* owner whose agent runs elsewhere sees this section just the same.
|
||||
*
|
||||
* The whole thing is contained: skeleton/error/empty live *inside* this
|
||||
* section so the rest of the profile panel stays interactive while we
|
||||
@@ -42,35 +41,35 @@ const MEMORY_DANGLING_REF_TOOLTIP =
|
||||
*/
|
||||
export function MemorySection({
|
||||
agentPubkey,
|
||||
viewerIsOwner,
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
viewerIsOwner: boolean;
|
||||
}): React.ReactElement | null {
|
||||
const isOwner = useIsManagedAgent(agentPubkey);
|
||||
|
||||
// Hide entirely for non-owners. Defer (`null`) while the managed-agent
|
||||
// list is still loading rather than render a placeholder we'll yank.
|
||||
if (isOwner !== true) return null;
|
||||
// Hide entirely for non-owners.
|
||||
if (!viewerIsOwner) return null;
|
||||
|
||||
return <MemorySectionForOwner agentPubkey={agentPubkey} />;
|
||||
}
|
||||
|
||||
export function MemoryRefreshButton({
|
||||
agentPubkey,
|
||||
viewerIsOwner,
|
||||
className,
|
||||
iconClassName,
|
||||
variant = "ghost",
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
viewerIsOwner: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
variant?: ButtonProps["variant"];
|
||||
}): React.ReactElement | null {
|
||||
const isOwner = useIsManagedAgent(agentPubkey);
|
||||
const { query } = useAgentMemoryGraph(agentPubkey, {
|
||||
enabled: isOwner === true,
|
||||
enabled: viewerIsOwner,
|
||||
});
|
||||
|
||||
if (isOwner !== true || !query.data) return null;
|
||||
if (!viewerIsOwner || !query.data) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -40,7 +40,40 @@ const snapshotByAgent = new Map<string, ObserverSnapshot>();
|
||||
|
||||
// Normalized pubkeys of agents we are actively managing. Only events whose
|
||||
// "agent" tag matches an entry here will be decrypted (defense-in-depth).
|
||||
//
|
||||
// This set is the *union* of every active subscriber's contribution. Multiple
|
||||
// callers of `useManagedAgentObserverBridge` (e.g. the channel screen and the
|
||||
// profile panel) can be mounted at once, each tracking a different agent list.
|
||||
// We key each subscriber's contribution in `knownAgentsBySubscription` and
|
||||
// recompute the union, so co-mounted callers no longer clobber each other.
|
||||
const knownAgentPubkeys = new Set<string>();
|
||||
const knownAgentsBySubscription = new Map<string, Set<string>>();
|
||||
|
||||
function recomputeKnownAgentPubkeys() {
|
||||
knownAgentPubkeys.clear();
|
||||
for (const subscriptionAgents of knownAgentsBySubscription.values()) {
|
||||
for (const pubkey of subscriptionAgents) {
|
||||
knownAgentPubkeys.add(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerKnownAgents(
|
||||
subscriptionId: string,
|
||||
pubkeys: readonly string[],
|
||||
) {
|
||||
knownAgentsBySubscription.set(
|
||||
subscriptionId,
|
||||
new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey))),
|
||||
);
|
||||
recomputeKnownAgentPubkeys();
|
||||
}
|
||||
|
||||
function unregisterKnownAgents(subscriptionId: string) {
|
||||
if (knownAgentsBySubscription.delete(subscriptionId)) {
|
||||
recomputeKnownAgentPubkeys();
|
||||
}
|
||||
}
|
||||
|
||||
let connectionState: ConnectionState = "idle";
|
||||
let errorMessage: string | null = null;
|
||||
@@ -275,6 +308,7 @@ export function getAgentTranscript(
|
||||
export function useManagedAgentObserverBridge(
|
||||
agents: readonly Pick<ManagedAgent, "pubkey" | "status">[],
|
||||
) {
|
||||
const subscriptionId = React.useId();
|
||||
const hasActiveAgent = React.useMemo(
|
||||
() =>
|
||||
agents.some(
|
||||
@@ -283,13 +317,20 @@ export function useManagedAgentObserverBridge(
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Keep the trusted-pubkey set in sync with the current managed agent list.
|
||||
const agentPubkeys = React.useMemo(
|
||||
() => agents.map((agent) => agent.pubkey),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Keep this subscriber's slice of the trusted-pubkey set in sync with its
|
||||
// own agent list. The store recomputes the union across all subscribers, so
|
||||
// a co-mounted caller no longer wipes out this caller's agents.
|
||||
React.useEffect(() => {
|
||||
knownAgentPubkeys.clear();
|
||||
for (const agent of agents) {
|
||||
knownAgentPubkeys.add(normalizePubkey(agent.pubkey));
|
||||
}
|
||||
}, [agents]);
|
||||
registerKnownAgents(subscriptionId, agentPubkeys);
|
||||
return () => {
|
||||
unregisterKnownAgents(subscriptionId);
|
||||
};
|
||||
}, [subscriptionId, agentPubkeys]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasActiveAgent) {
|
||||
@@ -309,6 +350,7 @@ export function resetAgentObserverStore() {
|
||||
transcriptByAgent.clear();
|
||||
snapshotByAgent.clear();
|
||||
knownAgentPubkeys.clear();
|
||||
knownAgentsBySubscription.clear();
|
||||
connectionState = "idle";
|
||||
errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
@@ -573,6 +573,13 @@ export function MembersSidebar({
|
||||
}
|
||||
|
||||
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
|
||||
const memberProfile =
|
||||
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()];
|
||||
const viewerIsOwner = Boolean(
|
||||
memberProfile?.ownerPubkey &&
|
||||
currentPubkey &&
|
||||
memberProfile.ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(),
|
||||
);
|
||||
return (
|
||||
<div className="content-visibility-auto" key={member.pubkey}>
|
||||
<MembersSidebarMemberCard
|
||||
@@ -609,10 +616,8 @@ export function MembersSidebar({
|
||||
presenceStatus={
|
||||
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
|
||||
}
|
||||
profileAvatarUrl={
|
||||
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
|
||||
?.avatarUrl ?? null
|
||||
}
|
||||
profileAvatarUrl={memberProfile?.avatarUrl ?? null}
|
||||
viewerIsOwner={viewerIsOwner}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -52,6 +52,7 @@ type MembersSidebarMemberCardProps = {
|
||||
onViewActivity?: (pubkey: string) => void;
|
||||
presenceStatus?: PresenceStatus | null;
|
||||
profileAvatarUrl?: string | null;
|
||||
viewerIsOwner: boolean;
|
||||
};
|
||||
|
||||
const MEMBER_ROW_INSET_DIVIDER_CLASS =
|
||||
@@ -111,12 +112,13 @@ export function MembersSidebarMemberCard({
|
||||
onViewActivity,
|
||||
presenceStatus,
|
||||
profileAvatarUrl,
|
||||
viewerIsOwner,
|
||||
}: MembersSidebarMemberCardProps) {
|
||||
const roleLabel = formatRoleLabel(member, memberIsBot);
|
||||
const disabled = isActionPending || isArchived;
|
||||
const canViewActivity =
|
||||
memberIsBot &&
|
||||
managedAgent?.backend.type === "local" &&
|
||||
(viewerIsOwner || managedAgent?.backend.type === "local") &&
|
||||
Boolean(onViewActivity);
|
||||
const hasActions = memberIsBot
|
||||
? Boolean(managedAgent) || canRemoveMember || canViewActivity
|
||||
|
||||
@@ -187,7 +187,22 @@ export function UserProfilePanel({
|
||||
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
|
||||
);
|
||||
const isBot = Boolean(relayAgent || managedAgent);
|
||||
// Does THIS desktop hold the agent's seckey? Gates edit (which needs the key)
|
||||
// and grants owner access when the agent is managed locally.
|
||||
const isOwner = useIsManagedAgent(isBot ? pubkey : null);
|
||||
// Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This
|
||||
// is the right signal for viewing owner-scoped data (activity feed, memory):
|
||||
// the relay routes and the client decrypts those frames with the owner's OWN
|
||||
// key, so the agent's seckey is never needed. Computed here (before the gates
|
||||
// that consume it) so visibility keys off declared ownership, not key custody.
|
||||
const isCurrentUserOwner =
|
||||
currentPubkey !== undefined &&
|
||||
ownerPubkey !== null &&
|
||||
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
// The viewer may see owner-scoped data if they declared-own the agent OR they
|
||||
// manage it locally (older agents may not advertise an owner pubkey). Every
|
||||
// real boundary is server-side, so this only controls what UI we paint.
|
||||
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
|
||||
|
||||
// Populate the active-turns store for this agent so useActiveAgentTurns works
|
||||
// even if the Agents page hasn't been visited yet.
|
||||
@@ -198,15 +213,36 @@ export function UserProfilePanel({
|
||||
: [],
|
||||
[managedAgent],
|
||||
);
|
||||
// The observer bridge subscribes on the OWNER's own pubkey and decrypts the
|
||||
// agent's telemetry with the owner's key — no agent seckey needed. It only
|
||||
// decrypts frames whose agent pubkey is "known", and only subscribes when an
|
||||
// agent is running/deployed. For a remote agent we own but don't manage
|
||||
// locally, `managedAgent` is undefined, so we seed the bridge from the relay
|
||||
// agent (treated as "deployed") when the viewer is the declared owner. This
|
||||
// mirrors what the composer-area ingress already does in ChannelScreen.
|
||||
const observerBridgeAgents = React.useMemo(() => {
|
||||
if (managedAgent) {
|
||||
return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }];
|
||||
}
|
||||
if (viewerIsOwner && relayAgent) {
|
||||
return [
|
||||
{
|
||||
pubkey: relayAgent.pubkey,
|
||||
status: "deployed" as ManagedAgent["status"],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}, [managedAgent, relayAgent, viewerIsOwner]);
|
||||
useActiveAgentTurnsBridge(bridgeAgents);
|
||||
useManagedAgentObserverBridge(bridgeAgents);
|
||||
useManagedAgentObserverBridge(observerBridgeAgents);
|
||||
const canEditAgent = isOwner === true && managedAgent !== undefined;
|
||||
const memoryQuery = useAgentMemoryQuery(pubkey, {
|
||||
enabled: isOwner === true,
|
||||
enabled: viewerIsOwner,
|
||||
});
|
||||
const isSelf =
|
||||
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
|
||||
const canViewActivity = isOwner === true && Boolean(onOpenAgentSession);
|
||||
const canViewActivity = viewerIsOwner && Boolean(onOpenAgentSession);
|
||||
const isFollowing =
|
||||
!isSelf &&
|
||||
(contactListQuery.data?.contacts.some(
|
||||
@@ -282,10 +318,6 @@ export function UserProfilePanel({
|
||||
ownerProfileQuery.data,
|
||||
ownerPubkey,
|
||||
]);
|
||||
const isCurrentUserOwner =
|
||||
currentPubkey !== undefined &&
|
||||
ownerPubkey !== null &&
|
||||
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
const ownerDisplayName = ownerHandle
|
||||
? isCurrentUserOwner || (!ownerPubkey && isOwner === true)
|
||||
? `${ownerHandle} (you)`
|
||||
@@ -317,8 +349,12 @@ export function UserProfilePanel({
|
||||
|
||||
const headerActions = (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{view === "memories" && isOwner === true ? (
|
||||
<MemoryRefreshButton agentPubkey={pubkey} variant="outline" />
|
||||
{view === "memories" && viewerIsOwner ? (
|
||||
<MemoryRefreshButton
|
||||
agentPubkey={pubkey}
|
||||
variant="outline"
|
||||
viewerIsOwner={viewerIsOwner}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close profile"
|
||||
@@ -355,7 +391,7 @@ export function UserProfilePanel({
|
||||
handleOpenActivity={handleOpenActivity}
|
||||
isBot={isBot}
|
||||
isFollowing={isFollowing}
|
||||
isOwner={isOwner}
|
||||
isOwner={viewerIsOwner}
|
||||
isSelf={isSelf}
|
||||
managedAgent={managedAgent}
|
||||
memoriesLoading={memoryQuery.isLoading}
|
||||
@@ -383,7 +419,7 @@ export function UserProfilePanel({
|
||||
) : null}
|
||||
|
||||
{view === "memories" ? (
|
||||
<MemoryFocusedView agentPubkey={pubkey} isOwner={isOwner} />
|
||||
<MemoryFocusedView agentPubkey={pubkey} viewerIsOwner={viewerIsOwner} />
|
||||
) : null}
|
||||
|
||||
{view === "channels" ? (
|
||||
|
||||
@@ -898,18 +898,18 @@ function ProfileIngressRow({
|
||||
|
||||
export function MemoryFocusedView({
|
||||
agentPubkey,
|
||||
isOwner,
|
||||
viewerIsOwner,
|
||||
}: {
|
||||
agentPubkey: string;
|
||||
isOwner: boolean | undefined;
|
||||
viewerIsOwner: boolean | undefined;
|
||||
}) {
|
||||
if (isOwner !== true) {
|
||||
if (viewerIsOwner !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<MemorySection agentPubkey={agentPubkey} />
|
||||
<MemorySection agentPubkey={agentPubkey} viewerIsOwner={viewerIsOwner} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
useRelayAgentsQuery,
|
||||
useManagedAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
@@ -91,8 +93,23 @@ export function UserProfilePopover({
|
||||
const managedAgent = managedAgentsQuery.data?.find(
|
||||
(a) => a.pubkey === pubkey,
|
||||
);
|
||||
const canViewActivity = role === "bot" && Boolean(onOpenAgentSession);
|
||||
const profile = profileQuery.data;
|
||||
// Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent
|
||||
// runs elsewhere holds no local seckey, so key custody (`isOwner`) alone
|
||||
// wrongly hides the affordance from them — and gating on bot-ness alone shows
|
||||
// it to every viewer. Combine declared ownership with local management, same
|
||||
// shape as the pane/sidebar/memory fixes. Every real boundary is server-side;
|
||||
// this only decides whether to paint the "View activity log" button.
|
||||
const isOwner = useIsManagedAgent(role === "bot" ? pubkey : null);
|
||||
const ownerPubkey = profile?.ownerPubkey ?? null;
|
||||
const currentPubkey = useIdentityQuery().data?.pubkey;
|
||||
const isCurrentUserOwner =
|
||||
currentPubkey !== undefined &&
|
||||
ownerPubkey !== null &&
|
||||
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
|
||||
const canViewActivity =
|
||||
role === "bot" && viewerIsOwner && Boolean(onOpenAgentSession);
|
||||
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
|
||||
const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
|
||||
const activeTurns = useActiveAgentTurns(role === "bot" ? pubkey : null);
|
||||
|
||||
@@ -720,6 +720,12 @@ const OUTSIDER_PUBKEY =
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
|
||||
const PROFILE_ONLY_AGENT_PUBKEY =
|
||||
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
||||
// A relay-classified bot agent whose declared NIP-OA owner is the mock viewer,
|
||||
// but which is NOT locally managed. This is the fixture that exercises the
|
||||
// sidebar's owner-gate path (`viewerIsOwner`), distinct from the local-managed
|
||||
// path that `mira` (profile-only) and managed-agent fixtures cover.
|
||||
const OWNED_RELAY_AGENT_PUBKEY =
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
|
||||
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
|
||||
|
||||
const mockDisplayNames = new Map<string, string>([
|
||||
@@ -728,6 +734,7 @@ const mockDisplayNames = new Map<string, string>([
|
||||
[BOB_PUBKEY, "bob"],
|
||||
[CHARLIE_PUBKEY, "charlie"],
|
||||
[PROFILE_ONLY_AGENT_PUBKEY, "mira"],
|
||||
[OWNED_RELAY_AGENT_PUBKEY, "nadia"],
|
||||
[OUTSIDER_PUBKEY, "outsider"],
|
||||
[DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username],
|
||||
]);
|
||||
@@ -735,6 +742,7 @@ const mockAgentPubkeys = new Set([
|
||||
ALICE_PUBKEY,
|
||||
CHARLIE_PUBKEY,
|
||||
PROFILE_ONLY_AGENT_PUBKEY,
|
||||
OWNED_RELAY_AGENT_PUBKEY,
|
||||
]);
|
||||
|
||||
function isoMinutesAgo(minutesAgo: number): string {
|
||||
@@ -1429,6 +1437,7 @@ const mockChannels: MockChannel[] = [
|
||||
members: [
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1000),
|
||||
createMockMember(CHARLIE_PUBKEY, "bot", 800),
|
||||
createMockMember(OWNED_RELAY_AGENT_PUBKEY, "member", 600),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
@@ -1633,6 +1642,16 @@ const defaultMockRelayAgents: RawRelayAgent[] = [
|
||||
respond_to: "anyone",
|
||||
respond_to_allowlist: [],
|
||||
},
|
||||
{
|
||||
pubkey: OWNED_RELAY_AGENT_PUBKEY,
|
||||
name: "nadia",
|
||||
agent_type: "goose",
|
||||
channels: ["agents"],
|
||||
channel_ids: ["94a444a4-c0a3-5966-ab05-530c6ddc2301"],
|
||||
capabilities: ["search", "summaries"],
|
||||
status: "online",
|
||||
respond_to: "anyone",
|
||||
},
|
||||
];
|
||||
let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({
|
||||
...agent,
|
||||
@@ -1886,6 +1905,18 @@ const mockProfiles = new Map<string, RawProfile>([
|
||||
is_agent: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
OWNED_RELAY_AGENT_PUBKEY,
|
||||
{
|
||||
pubkey: OWNED_RELAY_AGENT_PUBKEY,
|
||||
display_name: "nadia",
|
||||
avatar_url: null,
|
||||
about: null,
|
||||
nip05_handle: null,
|
||||
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
||||
is_agent: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const mockPresence = new Map<string, PresenceStatus>([
|
||||
[MOCK_IDENTITY_PUBKEY, "offline"],
|
||||
@@ -1894,6 +1925,7 @@ const mockPresence = new Map<string, PresenceStatus>([
|
||||
[BOB_PUBKEY, "away"],
|
||||
[CHARLIE_PUBKEY, "online"],
|
||||
[PROFILE_ONLY_AGENT_PUBKEY, "online"],
|
||||
[OWNED_RELAY_AGENT_PUBKEY, "online"],
|
||||
[OUTSIDER_PUBKEY, "offline"],
|
||||
]);
|
||||
const mockFeedOverrides: RawHomeFeedResponse["feed"] = {
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from "../helpers/bridge";
|
||||
|
||||
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
|
||||
// Relay-only agent owned by the mock viewer (see e2eBridge.ts
|
||||
// OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and
|
||||
// owned-by-viewer via its mockProfiles owner_pubkey, so the sidebar
|
||||
// view-activity gate (memberIsBot && viewerIsOwner) opens for it.
|
||||
const OWNED_RELAY_AGENT_PUBKEY =
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
|
||||
|
||||
async function openChannelManagement(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -951,6 +957,25 @@ test("shows and clears activity indicators for active channel agents", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await openMembersSidebar(page, "agents");
|
||||
|
||||
// nadia is a relay-only agent (no local managed record) owned by the mock
|
||||
// viewer. The view-activity affordance must open via the declared-owner
|
||||
// path, not the local-managed path.
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-member-${OWNED_RELAY_AGENT_PUBKEY}`),
|
||||
).toBeVisible();
|
||||
await openMemberMenu(page, OWNED_RELAY_AGENT_PUBKEY);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("typing indicator shows avatars and maintains stable name order", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user