Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2865d3f
feat(agents): gate activity by relay ownership
tellaho Jun 15, 2026
6e79470
feat(agents): refine activity visibility ownership checks
tellaho Jun 15, 2026
c124993
feat(agents): improve activity transcript observability
tellaho Jun 15, 2026
6b64b70
chore(agents): add dev-only ACP source labels on transcript rows
tellaho Jun 15, 2026
baba54d
feat(agents): add raw ACP activity view toggle
tellaho Jun 15, 2026
27a5228
feat(agents): group ACP turn prompts into a user-first transcript bundle
tellaho Jun 15, 2026
e731798
feat(agents): show agent avatars in activity transcripts
tellaho Jun 15, 2026
001236c
refactor(agents): standardize activity transcript typography to text-xs
tellaho Jun 15, 2026
558d842
fix(agents): classify agent members for activity ingress
tellaho Jun 15, 2026
b839af2
refactor(agents): move transcript context toggle styling into shared …
tellaho Jun 15, 2026
dda9198
fix(agents): simplify shell command transcript rows
tellaho Jun 15, 2026
d49f05a
fix(agents): remove activity transcript now summary
tellaho Jun 15, 2026
ed4b37c
fix(agents): add compact summaries for developer MCP tool rows
tellaho Jun 15, 2026
427bcca
fix(agents): preview view_image tools with inline thumbnails and ligh…
tellaho Jun 15, 2026
102e332
fix(agents): right-align user transcript messages
tellaho Jun 15, 2026
2b0627a
fix(agents): hide setup-only activity transcript rows
tellaho Jun 15, 2026
fcf0105
fix(agents): keep live activity styling in the header
tellaho Jun 15, 2026
84a8f0c
fix(agents): group activity tools and hide orphan prompt context
tellaho Jun 15, 2026
9f65106
refactor(agents): drop transcript compact mode and simplify row spacing
tellaho Jun 15, 2026
781a286
fix(desktop): make compact tool label map type honest
Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions crates/buzz-relay/src/api/agents.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! 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};

#[derive(Debug, Serialize)]
pub struct AgentOwnershipResponse {
pub agent_pubkey: String,
pub owner_pubkey: Option<String>,
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,
}))
}
6 changes: 3 additions & 3 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>)> {
Expand All @@ -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('/')
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions desktop/src-tauri/src/commands/agent_ownership.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod agent_discovery;
mod agent_models;
mod agent_ownership;
mod agent_settings;
mod agents;
mod canvas;
Expand Down Expand Up @@ -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::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,7 @@ pub fn build_managed_agent_summary(
max_turn_duration_seconds: record.max_turn_duration_seconds,
parallelism: record.parallelism,
system_prompt: effective_prompt,
avatar_url: record.avatar_url.clone(),
model: effective_model,
mcp_toolsets: record.mcp_toolsets.clone(),
env_vars: record.env_vars.clone(),
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ pub struct ManagedAgentSummary {
pub max_turn_duration_seconds: Option<u64>,
pub parallelism: u32,
pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub model: Option<String>,
pub mcp_toolsets: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
Expand Down
26 changes: 26 additions & 0 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>"`.
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/features/agents/hooks/useCanViewAgentActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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,
isOwnershipError: ownershipQuery.isError,
isManagedLoading: isManagedAgent === undefined,
});
}
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading