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:
Taylor Ho
2026-06-24 14:57:20 -07:00
committed by GitHub
parent a9ce477a04
commit e993b9e741
12 changed files with 386 additions and 79 deletions

View File

@@ -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(),
));
}
}

View File

@@ -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!({

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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" ? (

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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"] = {

View File

@@ -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,
}) => {