-
Notifications
You must be signed in to change notification settings - Fork 28
feat(agents): gate activity-feed ingress by relay ownership #1060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d7402d5
feat(agents): gate activity by relay ownership
tellaho c8d85ef
feat(agents): refine activity visibility ownership checks
tellaho 0851322
fix(agents): classify agent members for activity ingress
tellaho 438aa6e
Merge origin/main into tho/activity-ingress-ownership
wesbillman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| .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, | ||
| })) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
desktop/src/features/agents/hooks/useCanViewAgentActivity.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }); | ||
| } |
82 changes: 82 additions & 0 deletions
82
desktop/src/features/agents/lib/agentSessionOwnershipResolution.test.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
get_agent_channel_policyalready returnedownerjust above, sois_ownercould be derived asowner_pubkey == Some(actor_bytes)instead of a second query. Totally minor — the SQL is parameterized and indexed and the comment onis_agent_ownerjustifies the narrower query — just flagging for minimalism. Fine to leave as-is.