Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions crates/buzz-relay/src/api/agents.rs
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: get_agent_channel_policy already returned owner just above, so is_owner could be derived as owner_pubkey == Some(actor_bytes) instead of a second query. Totally minor — the SQL is parameterized and indexed and the comment on is_agent_owner justifies the narrower query — just flagging for minimalism. Fine to leave as-is.

.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 @@ -30,6 +31,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
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