Skip to content
Open
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
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod personas;
mod process_lifecycle;
pub(crate) mod readiness;
pub(crate) mod reconcile;
mod relay_agent_info;
mod relay_mesh;
mod repos;
mod restore;
Expand Down Expand Up @@ -70,6 +71,7 @@ pub(crate) use readiness::{
agent_readiness, resolve_effective_agent_env, resolve_effective_harness_descriptor,
AgentReadiness, Requirement,
};
pub use relay_agent_info::*;
pub use relay_mesh::*;
pub use repos::{
effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir,
Expand Down
47 changes: 47 additions & 0 deletions desktop/src-tauri/src/managed_agents/relay_agent_info.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};

/// Tolerant wire view of an agent profile advertised by the relay.
///
/// `respond_to` deliberately remains an opaque string: third-party harnesses
/// may publish future modes, and one unknown string must not fail the entire
/// relay directory. Desktop interprets only the modes it understands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayAgentInfo {
pub pubkey: String,
pub name: String,
pub agent_type: String,
pub channels: Vec<String>,
#[serde(default)]
pub channel_ids: Vec<String>,
pub capabilities: Vec<String>,
pub status: String,
#[serde(default)]
pub respond_to: Option<String>,
#[serde(default)]
pub respond_to_allowlist: Vec<String>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn future_respond_to_mode_does_not_fail_its_directory_siblings() {
let parsed: Vec<RelayAgentInfo> = serde_json::from_value(serde_json::json!([
{
"pubkey": "a", "name": "Known", "agent_type": "agent",
"channels": [], "capabilities": [], "status": "online",
"respond_to": "anyone"
},
{
"pubkey": "b", "name": "Future", "agent_type": "agent",
"channels": [], "capabilities": [], "status": "online",
"respond_to": "future-mode"
}
]))
.unwrap();

assert_eq!(parsed.len(), 2);
assert_eq!(parsed[1].respond_to.as_deref(), Some("future-mode"));
}
}
15 changes: 0 additions & 15 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,21 +193,6 @@ impl ManagedAgentRecord {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayAgentInfo {
pub pubkey: String,
pub name: String,
pub agent_type: String,
pub channels: Vec<String>,
#[serde(default)]
pub channel_ids: Vec<String>,
pub capabilities: Vec<String>,
pub status: String,
#[serde(default)]
pub respond_to: Option<RespondTo>,
#[serde(default)]
pub respond_to_allowlist: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ManagedAgentRecord {
pub pubkey: String,
Expand Down
49 changes: 17 additions & 32 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,38 +929,23 @@ mod tests {
}

#[test]
fn agents_preserves_public_respond_to_mode_for_directory_parse() {
let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let agents = v.get("agents").cloned().unwrap();
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(agents).unwrap();

assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].respond_to,
Some(crate::managed_agents::RespondTo::Anyone)
);
}

#[test]
fn agents_preserves_allowlist_metadata_for_directory_parse() {
let e = ev(
10100,
r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
vec![],
);
let v = agents_from_events(std::slice::from_ref(&e));
let agents = v.get("agents").cloned().unwrap();
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(agents).unwrap();

assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].respond_to,
Some(crate::managed_agents::RespondTo::Allowlist)
);
assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]);
fn agents_preserve_relay_respond_to_modes_for_directory_parse() {
for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
let allowlist = if mode == "allowlist" {
format!(r#", "respond_to_allowlist":["{}"]"#, "a".repeat(64))
} else {
String::new()
};
let content = format!(r#"{{"name":"Scout","respond_to":"{mode}"{allowlist}}}"#);
let event = ev(10100, &content, vec![]);
let value = agents_from_events(std::slice::from_ref(&event));
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(value["agents"].clone()).unwrap();
assert_eq!(parsed[0].respond_to.as_deref(), Some(mode));
if mode == "allowlist" {
assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]);
}
}
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
coalesceAgentAutocompleteCandidates,
filterCachedAgentSuggestions,
getChannelOwnedAgentPubkeys,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInAllowedList,
Expand Down Expand Up @@ -201,6 +202,141 @@ test("getMentionableAgentPubkeys: scopes channel composers and fails closed with
);
});

test("getMentionableAgentPubkeys: admits same-owner remote agents only in the active channel", () => {
const base = {
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys: [PUB_A],
channelOwnedAgentPubkeys: [PUB_B],
relayAgents: [],
sharedChannelIds: new Set(["general"]),
};

assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "channel", channelId: "general" },
}),
new Set([PUB_A, PUB_B]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "community" },
}),
new Set([PUB_A]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "managed-only" },
}),
new Set([PUB_A]),
);
});

test("getMentionableAgentPubkeys: owner admission overrides ordinary modes but not nobody", () => {
assert.deepEqual(
getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
eligibilityScope: { type: "channel", channelId: "general" },
managedAgentPubkeys: [],
channelOwnedAgentPubkeys: [PUB_B],
relayAgents: undefined,
sharedChannelIds: new Set(["general"]),
}),
new Set(),
"owner admission must wait for a successful relay-directory response",
);

for (const respondTo of [
undefined,
null,
"owner-only",
"allowlist",
"anyone",
"future-mode",
]) {
const relayAgents =
respondTo === undefined
? []
: [
{
pubkey: PUB_B,
respondTo,
respondToAllowlist: [],
channelIds: ["other"],
},
];
assert.deepEqual(
getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
eligibilityScope: { type: "channel", channelId: "general" },
managedAgentPubkeys: [],
channelOwnedAgentPubkeys: [PUB_B.toUpperCase()],
relayAgents,
sharedChannelIds: new Set(["general"]),
}),
new Set([PUB_B]),
`owner admission should allow ${String(respondTo)}`,
);
}

assert.deepEqual(
getMentionableAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
eligibilityScope: { type: "channel", channelId: "general" },
managedAgentPubkeys: [],
channelOwnedAgentPubkeys: [PUB_B],
relayAgents: [
{
pubkey: PUB_B,
respondTo: "nobody",
respondToAllowlist: [],
channelIds: ["general"],
},
],
sharedChannelIds: new Set(["general"]),
}),
new Set(),
);
});

test("getChannelOwnedAgentPubkeys: requires verified matching ownership and an agent signal", () => {
assert.deepEqual(
getChannelOwnedAgentPubkeys({
currentPubkey: CURRENT_PUBKEY.toUpperCase(),
channelMembers: [
{ pubkey: PUB_A.toUpperCase(), role: "bot", isAgent: false },
{ pubkey: PUB_B, role: "member", isAgent: true },
{ pubkey: PUB_C, role: "member", isAgent: false },
{ pubkey: PUB_D, role: "member", isAgent: false },
],
profiles: {
[PUB_A]: { ownerPubkey: CURRENT_PUBKEY },
[PUB_B]: { ownerPubkey: CURRENT_PUBKEY },
[PUB_C]: { ownerPubkey: CURRENT_PUBKEY, isAgent: true },
[PUB_D]: { ownerPubkey: OTHER_OWNER_PUBKEY },
},
}),
new Set([PUB_A, PUB_B, PUB_C]),
);

assert.deepEqual(
getChannelOwnedAgentPubkeys({
currentPubkey: CURRENT_PUBKEY,
channelMembers: [
{ pubkey: PUB_A, role: "member", isAgent: false },
{ pubkey: PUB_B, role: "bot", isAgent: true },
],
profiles: {
[PUB_A]: { ownerPubkey: CURRENT_PUBKEY, isAgent: false },
[PUB_B]: { ownerPubkey: OTHER_OWNER_PUBKEY, isAgent: true },
},
}),
new Set(),
);
});

test("autocomplete helper extraction preserves safe filtering and labels", () => {
assert.equal(isAgentMentionChannelType("stream"), true);
assert.equal(isAgentMentionChannelType("forum"), true);
Expand Down
75 changes: 75 additions & 0 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,71 @@ export type AgentEligibilityScope =
| { type: "channel"; channelId: string }
| { type: "managed-only" };

/**
* Returns agent identities that are both present in the active channel and
* cryptographically owned by the viewer. The caller must supply ownerPubkey
* values verified from NIP-OA profile events; display names and local persona
* cards are never ownership evidence.
*
* This set may override normal relay response policy only for channel mention
* routing after the relay directory has loaded successfully. It must not be
* used to seed community search or direct messages.
*/
export function getChannelOwnedAgentPubkeys({
channelMembers,
currentPubkey,
profiles,
}: {
channelMembers:
| readonly {
pubkey: string;
role?: string | null;
isAgent?: boolean;
}[]
| undefined;
currentPubkey?: string | null;
profiles:
| Readonly<
Record<string, { ownerPubkey?: string | null; isAgent?: boolean }>
>
| undefined;
}) {
const ownedAgentPubkeys = new Set<string>();
if (!currentPubkey) return ownedAgentPubkeys;

const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
for (const member of channelMembers ?? []) {
const pubkey = normalizePubkey(member.pubkey);
const profile = profiles?.[pubkey];
const isAgent =
member.role === "bot" ||
member.isAgent === true ||
profile?.isAgent === true;
if (
isAgent &&
profile?.ownerPubkey &&
normalizePubkey(profile.ownerPubkey) === normalizedCurrentPubkey
) {
ownedAgentPubkeys.add(pubkey);
}
}

return ownedAgentPubkeys;
}

function relayAgentIsHeartbeatOnly(agent: Pick<RelayAgent, "respondTo">) {
return agent.respondTo === "nobody";
}

export function getMentionableAgentPubkeys({
channelOwnedAgentPubkeys,
currentPubkey,
eligibilityScope,
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
}: {
channelOwnedAgentPubkeys?: Iterable<string>;
currentPubkey?: string | null;
eligibilityScope: AgentEligibilityScope;
managedAgentPubkeys: Iterable<string>;
Expand All @@ -63,6 +121,23 @@ export function getMentionableAgentPubkeys({
[...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)),
);

// A missing directory is not equivalent to an empty one: until a successful
// response proves there is no `nobody` entry, remote-owner admission must
// fail closed. Locally managed identities remain available above.
if (eligibilityScope.type === "channel" && relayAgents !== undefined) {
const heartbeatOnlyPubkeys = new Set(
(relayAgents ?? [])
.filter(relayAgentIsHeartbeatOnly)
.map((agent) => normalizePubkey(agent.pubkey)),
);
for (const pubkey of channelOwnedAgentPubkeys ?? []) {
const normalized = normalizePubkey(pubkey);
if (!heartbeatOnlyPubkeys.has(normalized)) {
pubkeys.add(normalized);
}
}
}

for (const agent of relayAgents ?? []) {
const isAllowed =
eligibilityScope.type === "managed-only"
Expand Down
Loading