feat(agents): gate activity by relay ownership

- Add a relay ownership endpoint backed by agent_owner_pubkey and is_agent_owner so desktop activity visibility uses the same source of truth as observer telemetry authorization.
- Add a Tauri ownership command, frontend API wrapper, and useCanViewAgentActivity hook with local managed-agent optimism only while relay ownership is loading.
- Replace profile popover, profile panel, and members sidebar activity gates so owned agents can open activity across different builds and worktrees without relying on local managed-agent lists.
- Refactor channel agent session candidate resolution so owned agents can keep an activity panel open when channel metadata is stale, while preserving local-only lifecycle controls.
- Add focused desktop unit coverage for the ownership predicate and stale-metadata session resolution path, plus E2E bridge support for ownership mocks.
This commit is contained in:
Taylor Ho
2026-06-15 15:35:51 -07:00
parent 89ae31d203
commit d7402d5946
22 changed files with 684 additions and 170 deletions
+75
View File
@@ -0,0 +1,75 @@
//! Agent ownership lookup — GET /api/agents/:pubkey/ownership (NIP-98 auth).
//!
//! Returns the relay-authoritative `agent_owner_pubkey` mapping and whether
//! the authenticated caller is the registered owner. Used by the desktop to
//! gate observer activity visibility without relying on channel membership or
//! local managed-agent store state.
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use serde::Serialize;
use crate::state::AppState;
use super::bridge::{canonical_url, check_nip98_replay, verify_bridge_auth};
use super::{api_error, internal_error};
/// Response body for the agent-ownership lookup endpoint.
#[derive(Debug, Serialize)]
pub struct AgentOwnershipResponse {
/// Hex-encoded pubkey of the agent whose ownership was queried.
pub agent_pubkey: String,
/// Hex-encoded pubkey of the registered owner, if one is set.
pub owner_pubkey: Option<String>,
/// Whether the authenticated caller is the registered owner of the agent.
pub is_owner: bool,
}
/// Resolve whether the authenticated user owns `agent_pubkey` per relay DB.
pub async fn get_agent_ownership(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(agent_pubkey): Path<String>,
) -> Result<Json<AgentOwnershipResponse>, (StatusCode, Json<serde_json::Value>)> {
let agent_hex = agent_pubkey.trim().to_ascii_lowercase();
if agent_hex.len() != 64 || !agent_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey"));
}
let agent_bytes = hex::decode(&agent_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid agent pubkey hex"))?;
let path = format!("/api/agents/{agent_hex}/ownership");
let url = canonical_url(&state.config.relay_url, &path);
let (actor_pubkey, event_id_bytes) =
verify_bridge_auth(&headers, "GET", &url, None, state.config.require_auth_token)?;
check_nip98_replay(&state, event_id_bytes)?;
let actor_bytes = actor_pubkey.to_bytes().to_vec();
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(&state, &actor_bytes, auth_tag).await?;
let owner_pubkey = state
.db
.get_agent_channel_policy(&agent_bytes)
.await
.map_err(|e| internal_error(&format!("ownership lookup failed: {e}")))?
.and_then(|(_policy, owner)| owner);
let is_owner = state
.db
.is_agent_owner(&agent_bytes, &actor_bytes)
.await
.map_err(|e| internal_error(&format!("ownership check failed: {e}")))?;
Ok(Json(AgentOwnershipResponse {
agent_pubkey: agent_hex,
owner_pubkey: owner_pubkey.map(hex::encode),
is_owner,
}))
}
+3 -3
View File
@@ -24,7 +24,7 @@ use super::{api_error, internal_error, not_found};
///
/// Returns the authenticated public key and an event ID for replay detection.
/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern).
fn verify_bridge_auth(
pub(crate) fn verify_bridge_auth(
headers: &HeaderMap,
method: &str,
url: &str,
@@ -73,7 +73,7 @@ fn verify_bridge_auth(
///
/// Uses moka's `entry` API for atomic insert-if-absent — no race window
/// between "check if seen" and "mark as seen".
fn check_nip98_replay(
pub(crate) fn check_nip98_replay(
state: &AppState,
event_id_bytes: [u8; 32],
) -> Result<(), (StatusCode, Json<Value>)> {
@@ -95,7 +95,7 @@ fn check_nip98_replay(
}
/// Reconstruct the canonical URL for NIP-98 verification from the relay config.
fn canonical_url(relay_url: &str, path: &str) -> String {
pub(crate) fn canonical_url(relay_url: &str, path: &str) -> String {
let base = relay_url
.trim()
.trim_end_matches('/')
+1
View File
@@ -1,5 +1,6 @@
//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.
pub mod agents;
pub mod bridge;
pub mod events;
pub mod git;
+4
View File
@@ -64,6 +64,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/events", post(api::bridge::submit_event))
.route("/query", post(api::bridge::query_events))
.route("/count", post(api::bridge::count_events))
.route(
"/api/agents/{pubkey}/ownership",
get(api::agents::get_agent_ownership),
)
// Webhook trigger (secret-authenticated, no NIP-98)
.route("/hooks/{id}", post(api::bridge::workflow_webhook))
// Huddle audio WebSocket route
@@ -0,0 +1,38 @@
//! Relay-authoritative agent ownership lookup for activity visibility gates.
use reqwest::Method;
use serde::Serialize;
use tauri::State;
use crate::{
app_state::AppState,
relay::{get_relay_json, relay_api_base_url_with_override},
};
#[derive(Debug, Serialize, serde::Deserialize)]
pub struct AgentOwnershipStatus {
/// Lowercase hex pubkey of the queried agent.
pub agent_pubkey: String,
/// Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set.
pub owner_pubkey: Option<String>,
/// True iff the current workspace identity is the relay-recorded owner.
pub is_owner: bool,
}
/// Resolve whether the current identity owns `agent_pubkey` per relay DB.
#[tauri::command]
pub async fn resolve_agent_ownership(
agent_pubkey: String,
state: State<'_, AppState>,
) -> Result<AgentOwnershipStatus, String> {
let agent_hex = agent_pubkey.trim().to_ascii_lowercase();
if agent_hex.len() != 64 {
return Err("agent pubkey must be 64 hex characters".to_string());
}
let api_base = relay_api_base_url_with_override(&state);
let path = format!("/api/agents/{agent_hex}/ownership");
let url = format!("{api_base}{path}");
get_relay_json::<AgentOwnershipStatus>(&state, Method::GET, &url, &[]).await
}
+2
View File
@@ -1,5 +1,6 @@
mod agent_discovery;
mod agent_models;
mod agent_ownership;
mod agent_settings;
mod agents;
mod canvas;
@@ -29,6 +30,7 @@ mod workspace;
pub use agent_discovery::*;
pub use agent_models::*;
pub use agent_ownership::*;
pub use agent_settings::*;
pub use agents::*;
pub use canvas::*;
+1
View File
@@ -749,6 +749,7 @@ pub fn run() {
unarchive_identity,
list_archived_identities,
resolve_oa_owner,
resolve_agent_ownership,
list_relay_agents,
list_managed_agents,
create_managed_agent,
+26
View File
@@ -281,6 +281,32 @@ pub async fn query_relay_at(
parse_json_response(response).await
}
// ── HTTP bridge: GET (JSON) ─────────────────────────────────────────────────
/// Execute an authenticated GET against the relay HTTP API and deserialize JSON.
pub async fn get_relay_json<T: DeserializeOwned>(
state: &AppState,
method: Method,
url: &str,
body: &[u8],
) -> Result<T, String> {
let auth = build_nip98_auth_header(&method, url, body, state)?;
let response = state
.http_client
.request(method, url)
.header("Authorization", auth)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
parse_json_response(response).await
}
// ── Command response parsing ────────────────────────────────────────────────
/// Parse a command-event OK message of the form `"response:<json>"`.
@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { resolveCanViewAgentActivity } from "@/features/agents/lib/canViewAgentActivity";
import { resolveAgentOwnership } from "@/shared/api/tauriAgentOwnership";
export const agentOwnershipQueryKey = (agentPubkey: string) =>
["agentOwnership", agentPubkey.toLowerCase()] as const;
export function useAgentOwnershipQuery(
agentPubkey: string | null | undefined,
enabled = true,
) {
return useQuery({
enabled: enabled && Boolean(agentPubkey),
queryKey: agentOwnershipQueryKey(agentPubkey ?? ""),
queryFn: () => resolveAgentOwnership(agentPubkey as string),
staleTime: 60_000,
});
}
/**
* Relay-authoritative gate for observer activity visibility.
*
* Returns `{ canView, isLoading }`. While ownership is loading, locally
* managed agents may show activity optimistically; the final answer always
* comes from relay `is_agent_owner`.
*/
export function useCanViewAgentActivity(
agentPubkey: string | null | undefined,
options?: { enabled?: boolean },
) {
const enabled = (options?.enabled ?? true) && Boolean(agentPubkey);
const ownershipQuery = useAgentOwnershipQuery(agentPubkey, enabled);
const isManagedAgent = useIsManagedAgent(enabled ? agentPubkey : null);
return resolveCanViewAgentActivity({
relayOwnership: ownershipQuery.data,
isManagedAgent,
isOwnershipLoading: ownershipQuery.isLoading,
isManagedLoading: isManagedAgent === undefined,
});
}
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getChannelAgentSessionAgents,
resolveOpenAgentSessionAgent,
} from "../../channels/lib/agentSessionCandidates.ts";
const agent = (pubkey, source) => ({
pubkey,
name: pubkey.slice(0, 8),
status: "deployed",
agentSource: source,
canInterruptTurn: source === "managed",
});
test("resolveOpenAgentSessionAgent prefers channel-scoped candidate", () => {
const channelAgent = agent("aa".repeat(32), "managed");
const otherAgent = agent("bb".repeat(32), "relay");
const resolved = resolveOpenAgentSessionAgent({
allAgentCandidates: [channelAgent, otherAgent],
channelAgentSessionAgents: [channelAgent],
openAgentSessionPubkey: channelAgent.pubkey,
});
assert.equal(resolved, channelAgent);
});
test("resolveOpenAgentSessionAgent falls back to owned agent outside channel list", () => {
const ownedAgent = agent("cc".repeat(32), "relay");
const resolved = resolveOpenAgentSessionAgent({
allAgentCandidates: [ownedAgent],
channelAgentSessionAgents: [],
openAgentSessionPubkey: ownedAgent.pubkey,
});
assert.equal(resolved, ownedAgent);
});
test("resolveOpenAgentSessionAgent synthesizes minimal agent when metadata is stale", () => {
const pubkey = "dd".repeat(32);
const resolved = resolveOpenAgentSessionAgent({
allAgentCandidates: [],
channelAgentSessionAgents: [],
openAgentSessionPubkey: pubkey,
});
assert.deepEqual(resolved, {
pubkey,
name: pubkey.slice(0, 8),
status: "deployed",
agentSource: "relay",
canInterruptTurn: false,
});
});
test("getChannelAgentSessionAgents keeps managed agents visible in channel membership", () => {
const activeChannel = {
id: "channel-1",
name: "general",
};
const candidates = [agent("ee".repeat(32), "managed")];
const visible = getChannelAgentSessionAgents({
activeChannel,
activeChannelId: activeChannel.id,
agents: candidates,
channelMembers: [
{
pubkey: candidates[0].pubkey,
role: "bot",
displayName: "Scout",
},
],
});
assert.equal(visible.length, 1);
assert.equal(visible[0]?.pubkey, candidates[0].pubkey);
});
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveCanViewAgentActivity } from "./canViewAgentActivity.ts";
test("resolveCanViewAgentActivity returns true when relay confirms ownership", () => {
const result = resolveCanViewAgentActivity({
relayOwnership: {
agentPubkey: "aa".repeat(32),
ownerPubkey: "bb".repeat(32),
isOwner: true,
},
isManagedAgent: false,
isOwnershipLoading: false,
isManagedLoading: false,
});
assert.equal(result.canView, true);
assert.equal(result.isLoading, false);
});
test("resolveCanViewAgentActivity returns false when relay denies ownership", () => {
const result = resolveCanViewAgentActivity({
relayOwnership: {
agentPubkey: "aa".repeat(32),
ownerPubkey: "bb".repeat(32),
isOwner: false,
},
isManagedAgent: true,
isOwnershipLoading: false,
isManagedLoading: false,
});
assert.equal(result.canView, false);
assert.equal(result.isLoading, false);
});
test("resolveCanViewAgentActivity optimistically allows locally managed agents while loading", () => {
const result = resolveCanViewAgentActivity({
relayOwnership: undefined,
isManagedAgent: true,
isOwnershipLoading: true,
isManagedLoading: false,
});
assert.equal(result.canView, true);
assert.equal(result.isLoading, true);
});
test("resolveCanViewAgentActivity stays closed for non-managed agents while loading", () => {
const result = resolveCanViewAgentActivity({
relayOwnership: undefined,
isManagedAgent: false,
isOwnershipLoading: true,
isManagedLoading: false,
});
assert.equal(result.canView, false);
assert.equal(result.isLoading, true);
});
@@ -0,0 +1,43 @@
import type { AgentOwnershipStatus } from "@/shared/api/tauriAgentOwnership";
export type CanViewAgentActivityInput = {
relayOwnership: AgentOwnershipStatus | undefined;
isManagedAgent: boolean | undefined;
isOwnershipLoading: boolean;
isManagedLoading: boolean;
};
export type CanViewAgentActivityResult = {
canView: boolean;
isLoading: boolean;
};
/**
* Unified predicate for Show Activity / Activity log ingresses.
*
* Final permission comes from relay `is_agent_owner`. While the relay lookup
* is in flight, locally managed agents may show activity optimistically.
*/
export function resolveCanViewAgentActivity({
relayOwnership,
isManagedAgent,
isOwnershipLoading,
isManagedLoading,
}: CanViewAgentActivityInput): CanViewAgentActivityResult {
if (relayOwnership?.isOwner === true) {
return { canView: true, isLoading: false };
}
if (relayOwnership?.isOwner === false) {
return { canView: false, isLoading: false };
}
const isLoading =
isOwnershipLoading || (isManagedAgent === undefined && isManagedLoading);
if (isManagedAgent === true && isOwnershipLoading) {
return { canView: true, isLoading: true };
}
return { canView: false, isLoading };
}
@@ -0,0 +1,162 @@
import type {
Channel,
ChannelMember,
ManagedAgent,
RelayAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ChannelAgentSessionAgent = Pick<
ManagedAgent,
"pubkey" | "name" | "status"
> & {
agentSource: "managed" | "member-bot" | "relay";
canInterruptTurn: boolean;
channelIds?: string[];
channels?: string[];
};
function relayStatusToManagedStatus(
status: RelayAgent["status"],
): ManagedAgent["status"] {
return status === "offline" ? "stopped" : "deployed";
}
export function buildChannelAgentSessionCandidates({
channelMembers,
managedAgents,
relayAgents,
}: {
channelMembers?: ChannelMember[];
managedAgents: ManagedAgent[];
relayAgents: RelayAgent[];
}): ChannelAgentSessionAgent[] {
const byPubkey = new Map<string, ChannelAgentSessionAgent>();
for (const agent of relayAgents) {
byPubkey.set(normalizePubkey(agent.pubkey), {
pubkey: agent.pubkey,
name: agent.name,
status: relayStatusToManagedStatus(agent.status),
agentSource: "relay",
canInterruptTurn: false,
channelIds: agent.channelIds,
channels: agent.channels,
});
}
for (const agent of managedAgents) {
const key = normalizePubkey(agent.pubkey);
const existing = byPubkey.get(key);
byPubkey.set(key, {
pubkey: agent.pubkey,
name: agent.name,
status: agent.status,
agentSource: "managed",
canInterruptTurn: true,
channelIds: existing?.channelIds,
channels: existing?.channels,
});
}
for (const member of channelMembers ?? []) {
const key = normalizePubkey(member.pubkey);
if (member.role !== "bot" || byPubkey.has(key)) {
continue;
}
byPubkey.set(key, {
pubkey: member.pubkey,
name: member.displayName ?? member.pubkey.slice(0, 8),
status: "deployed",
agentSource: "member-bot",
canInterruptTurn: false,
});
}
return [...byPubkey.values()];
}
export function getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents,
channelMembers,
}: {
activeChannel: Channel | null;
activeChannelId: string | null;
agents: ChannelAgentSessionAgent[];
channelMembers?: ChannelMember[];
}): ChannelAgentSessionAgent[] {
if (!activeChannelId || !activeChannel) {
return [];
}
const memberPubkeys = channelMembers
? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey)))
: null;
const botMemberPubkeys = channelMembers
? new Set(
channelMembers
.filter((member) => member.role === "bot")
.map((member) => normalizePubkey(member.pubkey)),
)
: null;
return agents.filter((agent) => {
const normalizedPubkey = normalizePubkey(agent.pubkey);
const channelIds = agent.channelIds ?? [];
const channels = agent.channels ?? [];
const hasDeclaredChannelScope =
channelIds.length > 0 || channels.length > 0;
const matchesDeclaredChannel =
channelIds.includes(activeChannelId) ||
channels.includes(activeChannel.name);
if (agent.agentSource === "member-bot") {
return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (agent.agentSource === "managed") {
return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (matchesDeclaredChannel) {
return true;
}
return (
!hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey))
);
});
}
export function resolveOpenAgentSessionAgent({
allAgentCandidates,
channelAgentSessionAgents,
openAgentSessionPubkey,
}: {
allAgentCandidates: ChannelAgentSessionAgent[];
channelAgentSessionAgents: ChannelAgentSessionAgent[];
openAgentSessionPubkey: string | null;
}): ChannelAgentSessionAgent | null {
if (!openAgentSessionPubkey) {
return null;
}
const normalized = normalizePubkey(openAgentSessionPubkey);
return (
channelAgentSessionAgents.find(
(agent) => normalizePubkey(agent.pubkey) === normalized,
) ??
allAgentCandidates.find(
(agent) => normalizePubkey(agent.pubkey) === normalized,
) ?? {
pubkey: openAgentSessionPubkey,
name: openAgentSessionPubkey.slice(0, 8),
status: "deployed",
agentSource: "relay",
canInterruptTurn: false,
}
);
}
@@ -124,6 +124,7 @@ type ChannelPaneProps = {
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
openAgentSessionAgent: ChannelAgentSessionAgent | null;
openAgentSessionPubkey: string | null;
profilePanelPubkey?: string | null;
threadHeadMessage: TimelineMessage | null;
@@ -246,6 +247,7 @@ export const ChannelPane = React.memo(function ChannelPane({
personaLookup,
profiles,
openThreadHeadId,
openAgentSessionAgent,
openAgentSessionPubkey,
profilePanelPubkey,
targetMessageId,
@@ -606,16 +608,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const selectedAgent = React.useMemo(
() =>
openAgentSessionPubkey
? (agentSessionAgents.find(
(agent) => agent.pubkey === openAgentSessionPubkey,
) ?? null)
: null,
[agentSessionAgents, openAgentSessionPubkey],
);
const selectedAgent = openAgentSessionAgent;
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
{!isSinglePanelView ? (
@@ -61,7 +61,10 @@ import {
mergeAgentNamesIntoProfiles,
useChannelActivityTyping,
} from "./useChannelActivityTyping";
import { useChannelAgentSessions } from "./useChannelAgentSessions";
import {
buildChannelAgentSessionCandidates,
useChannelAgentSessions,
} from "./useChannelAgentSessions";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import type { ChannelScreenProps } from "./ChannelScreen.types";
@@ -222,6 +225,15 @@ export function ChannelScreen({
}
return pubkeys;
}, [channelMembers, managedAgents, relayAgents]);
const allAgentSessionCandidates = React.useMemo(
() =>
buildChannelAgentSessionCandidates({
channelMembers,
managedAgents,
relayAgents,
}),
[channelMembers, managedAgents, relayAgents],
);
const {
botTypingEntries,
channelAgentSessionAgents: activeChannelAgentSessionAgents,
@@ -414,14 +426,15 @@ export function ChannelScreen({
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
openAgentSession: handleOpenAgentSession,
openAgentSessionAgent,
openAgentSessionPubkey,
openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession,
} = useChannelAgentSessions({
activeChannel,
activeChannelId,
agentCandidates: allAgentSessionCandidates,
channelMembers,
handleOpenThread,
managedAgents: activeChannelAgentSessionAgents,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -653,6 +666,7 @@ export function ChannelScreen({
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onToggleReaction={effectiveToggleReaction}
openAgentSessionAgent={openAgentSessionAgent}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
profilePanelPubkey={profilePanelPubkey}
@@ -10,6 +10,7 @@ import {
Trash2,
} from "lucide-react";
import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity";
import {
getManagedAgentPrimaryActionLabel,
isManagedAgentActive,
@@ -110,13 +111,13 @@ export function MembersSidebarMemberCard({
}: MembersSidebarMemberCardProps) {
const roleLabel = formatRoleLabel(member, memberIsBot);
const disabled = isActionPending || isArchived;
const canViewActivity =
memberIsBot &&
managedAgent?.backend.type === "local" &&
Boolean(onViewActivity);
const { canView: canViewActivity } = useCanViewAgentActivity(member.pubkey, {
enabled: Boolean(onViewActivity),
});
const canShowActivity = canViewActivity && Boolean(onViewActivity);
const hasActions = memberIsBot
? Boolean(managedAgent) || canRemoveMember || canViewActivity
: canRemoveMember || canChangeRole;
? Boolean(managedAgent) || canRemoveMember || canShowActivity
: canRemoveMember || canChangeRole || canShowActivity;
const memberIdentity = (
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 items-center gap-3">
@@ -207,7 +208,7 @@ export function MembersSidebarMemberCard({
<MemberActionsMenu
canChangeRole={canChangeRole}
canRemoveMember={canRemoveMember}
canViewActivity={canViewActivity}
canViewActivity={canShowActivity}
disabled={disabled}
managedAgent={managedAgent}
member={member}
@@ -12,7 +12,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
import {
buildChannelAgentSessionCandidates,
getChannelAgentSessionAgents,
} from "./useChannelAgentSessions";
} from "../lib/agentSessionCandidates";
export function useChannelActivityTyping({
activeChannel,
@@ -1,30 +1,29 @@
import * as React from "react";
import { useAgentOwnershipQuery } from "@/features/agents/hooks/useCanViewAgentActivity";
import type { TimelineMessage } from "@/features/messages/types";
import type {
Channel,
ChannelMember,
ManagedAgent,
RelayAgent,
} from "@/shared/api/types";
import type { Channel, ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ChannelAgentSessionAgent = Pick<
ManagedAgent,
"pubkey" | "name" | "status"
> & {
agentSource: "managed" | "member-bot" | "relay";
canInterruptTurn: boolean;
channelIds?: string[];
channels?: string[];
};
import {
type ChannelAgentSessionAgent,
getChannelAgentSessionAgents,
resolveOpenAgentSessionAgent,
} from "../lib/agentSessionCandidates";
export type { ChannelAgentSessionAgent } from "../lib/agentSessionCandidates";
export {
buildChannelAgentSessionCandidates,
getChannelAgentSessionAgents,
resolveOpenAgentSessionAgent,
} from "../lib/agentSessionCandidates";
type UseChannelAgentSessionsOptions = {
activeChannel: Channel | null;
activeChannelId: string | null;
agentCandidates: ChannelAgentSessionAgent[];
channelMembers?: ChannelMember[];
handleOpenThread: (message: TimelineMessage) => void;
managedAgents: ChannelAgentSessionAgent[];
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenThreadHeadId: (value: string | null) => void;
setProfilePanelPubkey: (value: string | null) => void;
@@ -32,127 +31,12 @@ type UseChannelAgentSessionsOptions = {
setThreadScrollTargetId: (value: string | null) => void;
};
function relayStatusToManagedStatus(
status: RelayAgent["status"],
): ManagedAgent["status"] {
return status === "offline" ? "stopped" : "deployed";
}
export function buildChannelAgentSessionCandidates({
channelMembers,
managedAgents,
relayAgents,
}: {
channelMembers?: ChannelMember[];
managedAgents: ManagedAgent[];
relayAgents: RelayAgent[];
}): ChannelAgentSessionAgent[] {
const byPubkey = new Map<string, ChannelAgentSessionAgent>();
for (const agent of relayAgents) {
byPubkey.set(normalizePubkey(agent.pubkey), {
pubkey: agent.pubkey,
name: agent.name,
status: relayStatusToManagedStatus(agent.status),
agentSource: "relay",
canInterruptTurn: false,
channelIds: agent.channelIds,
channels: agent.channels,
});
}
for (const agent of managedAgents) {
const key = normalizePubkey(agent.pubkey);
const existing = byPubkey.get(key);
byPubkey.set(key, {
pubkey: agent.pubkey,
name: agent.name,
status: agent.status,
agentSource: "managed",
canInterruptTurn: true,
channelIds: existing?.channelIds,
channels: existing?.channels,
});
}
for (const member of channelMembers ?? []) {
const key = normalizePubkey(member.pubkey);
if (member.role !== "bot" || byPubkey.has(key)) {
continue;
}
byPubkey.set(key, {
pubkey: member.pubkey,
name: member.displayName ?? member.pubkey.slice(0, 8),
status: "deployed",
agentSource: "member-bot",
canInterruptTurn: false,
});
}
return [...byPubkey.values()];
}
export function getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents,
channelMembers,
}: {
activeChannel: Channel | null;
activeChannelId: string | null;
agents: ChannelAgentSessionAgent[];
channelMembers?: ChannelMember[];
}): ChannelAgentSessionAgent[] {
if (!activeChannelId || !activeChannel) {
return [];
}
const memberPubkeys = channelMembers
? new Set(channelMembers.map((member) => normalizePubkey(member.pubkey)))
: null;
const botMemberPubkeys = channelMembers
? new Set(
channelMembers
.filter((member) => member.role === "bot")
.map((member) => normalizePubkey(member.pubkey)),
)
: null;
return agents.filter((agent) => {
const normalizedPubkey = normalizePubkey(agent.pubkey);
const channelIds = agent.channelIds ?? [];
const channels = agent.channels ?? [];
const hasDeclaredChannelScope =
channelIds.length > 0 || channels.length > 0;
const matchesDeclaredChannel =
channelIds.includes(activeChannelId) ||
channels.includes(activeChannel.name);
if (agent.agentSource === "member-bot") {
return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (agent.agentSource === "managed") {
return memberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel;
}
if (matchesDeclaredChannel) {
return true;
}
return (
!hasDeclaredChannelScope && Boolean(memberPubkeys?.has(normalizedPubkey))
);
});
}
export function useChannelAgentSessions({
activeChannel,
activeChannelId,
agentCandidates,
channelMembers,
handleOpenThread,
managedAgents,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -168,10 +52,22 @@ export function useChannelAgentSessions({
getChannelAgentSessionAgents({
activeChannel,
activeChannelId,
agents: managedAgents,
agents: agentCandidates,
channelMembers,
}),
[activeChannel, activeChannelId, channelMembers, managedAgents],
[activeChannel, activeChannelId, agentCandidates, channelMembers],
);
const ownershipQuery = useAgentOwnershipQuery(openAgentSessionPubkey);
const openAgentSessionAgent = React.useMemo(
() =>
resolveOpenAgentSessionAgent({
allAgentCandidates: agentCandidates,
channelAgentSessionAgents,
openAgentSessionPubkey,
}),
[agentCandidates, channelAgentSessionAgents, openAgentSessionPubkey],
);
const closeAgentSession = React.useCallback(() => {
@@ -210,22 +106,38 @@ export function useChannelAgentSessions({
);
React.useEffect(() => {
if (
openAgentSessionPubkey &&
!channelAgentSessionAgents.some(
(agent) =>
normalizePubkey(agent.pubkey) ===
normalizePubkey(openAgentSessionPubkey),
)
) {
if (!openAgentSessionPubkey) {
return;
}
const inChannelList = channelAgentSessionAgents.some(
(agent) =>
normalizePubkey(agent.pubkey) ===
normalizePubkey(openAgentSessionPubkey),
);
if (inChannelList) {
return;
}
if (ownershipQuery.isLoading || ownershipQuery.data === undefined) {
return;
}
if (!ownershipQuery.data.isOwner) {
setOpenAgentSessionPubkey(null);
}
}, [channelAgentSessionAgents, openAgentSessionPubkey]);
}, [
channelAgentSessionAgents,
openAgentSessionPubkey,
ownershipQuery.data,
ownershipQuery.isLoading,
]);
return {
channelAgentSessionAgents,
closeAgentSession,
openAgentSession,
openAgentSessionAgent,
openAgentSessionPubkey,
openThreadAndCloseAgentSession,
selectAgentSession,
@@ -6,6 +6,7 @@ import {
useAgentMemoryQuery,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity";
import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection";
import {
useRelayAgentsQuery,
@@ -178,6 +179,9 @@ export function UserProfilePanel({
);
const isBot = Boolean(relayAgent || managedAgent);
const isOwner = useIsManagedAgent(isBot ? pubkey : null);
const { canView: canViewActivity } = useCanViewAgentActivity(pubkey, {
enabled: Boolean(onOpenAgentSession),
});
// Populate the active-turns store for this agent so useActiveAgentTurns works
// even if the Agents page hasn't been visited yet.
@@ -196,7 +200,6 @@ export function UserProfilePanel({
});
const isSelf =
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
const canViewActivity = isOwner === true && Boolean(onOpenAgentSession);
const isFollowing =
!isSelf &&
(contactListQuery.data?.contacts.some(
@@ -317,7 +320,7 @@ export function UserProfilePanel({
{view === "summary" ? (
<ProfileSummaryView
canEditAgent={canEditAgent}
canViewActivity={canViewActivity}
canViewActivity={canViewActivity && Boolean(onOpenAgentSession)}
channelCount={profileChannels.length}
channelIdToName={channelIdToName}
channelsLoading={channelsQuery.isLoading}
@@ -1,6 +1,7 @@
import * as React from "react";
import { Activity } from "lucide-react";
import { useCanViewAgentActivity } from "@/features/agents/hooks/useCanViewAgentActivity";
import { useUserProfileQuery } from "@/features/profile/hooks";
import {
useRelayAgentsQuery,
@@ -86,11 +87,14 @@ export function UserProfilePopover({
const { onOpenAgentSession } = useAgentSession();
const { openProfilePanel } = useProfilePanel();
const { canView: canViewActivity } = useCanViewAgentActivity(
open ? pubkey : null,
{ enabled: open && Boolean(onOpenAgentSession) },
);
const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey);
const managedAgent = managedAgentsQuery.data?.find(
(a) => a.pubkey === pubkey,
);
const canViewActivity = role === "bot" && Boolean(onOpenAgentSession);
const profile = profileQuery.data;
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()];
@@ -274,7 +278,7 @@ export function UserProfilePopover({
</p>
) : null}
{canViewActivity ? (
{canViewActivity && onOpenAgentSession ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
@@ -0,0 +1,34 @@
import { invokeTauri } from "@/shared/api/tauri";
export type AgentOwnershipStatus = {
/** Lowercase hex pubkey of the queried agent. */
agentPubkey: string;
/** Lowercase hex owner pubkey from relay `agent_owner_pubkey`, if set. */
ownerPubkey: string | null;
/** True iff the current workspace identity is the relay-recorded owner. */
isOwner: boolean;
};
type RawAgentOwnershipStatus = {
agent_pubkey: string;
owner_pubkey: string | null;
is_owner: boolean;
};
/**
* Resolve whether the current identity owns `agentPubkey` per relay DB.
* Authoritative gate for observer activity visibility.
*/
export async function resolveAgentOwnership(
agentPubkey: string,
): Promise<AgentOwnershipStatus> {
const raw = await invokeTauri<RawAgentOwnershipStatus>(
"resolve_agent_ownership",
{ agentPubkey },
);
return {
agentPubkey: raw.agent_pubkey,
ownerPubkey: raw.owner_pubkey,
isOwner: raw.is_owner,
};
}
+16
View File
@@ -85,6 +85,8 @@ type E2eConfig = {
// - `resetMockRelayMembers` (relayRole)
archivedIdentities?: string[];
oaOwnerIsMe?: boolean;
/** Drives `resolve_agent_ownership` for activity visibility gates. */
agentOwnerIsMe?: boolean;
relayRole?: "owner" | "admin" | "member" | null;
// Descriptors returned by the mocked `pick_and_upload_media` /
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
@@ -6562,6 +6564,20 @@ export function maybeInstallE2eTauriMocks() {
: "ff".repeat(32);
return { owner, is_me: isMe };
}
case "resolve_agent_ownership": {
const agentPubkey =
(payload as { agentPubkey?: string }).agentPubkey?.toLowerCase() ??
"aa".repeat(32);
const isOwner = activeConfig?.mock?.agentOwnerIsMe ?? false;
const owner = isOwner
? (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey)
: "ff".repeat(32);
return {
agent_pubkey: agentPubkey,
owner_pubkey: owner,
is_owner: isOwner,
};
}
case "list_archived_identities": {
const archived = activeConfig?.mock?.archivedIdentities ?? [];
return { archived };