Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default defineConfig({
"**/mention-clipboard.spec.ts",
"**/cloud-provenance.spec.ts",
"**/mention-recipients.spec.ts",
"**/remote-owned-mentions.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,11 @@ with a TypeScript lookup table or an id comparison in a component.
managed agent. Independently operated relay agents with NIP-OA ownership
remain eligible in every build when their verified owner's signed
`respond_to` policy admits the viewer and relay membership includes the
target channel. Marked builds require that verified owner coordinate but do
target channel at publication. Owned nonmembers may be offered for preparation
and Invite; this is not permission to publish. Final authorization refreshes
the exact destination and retains captured selected identities across uploads
and edits. Denial preserves the draft, never silently removes a selected key.
See `docs/remote-mention-routing.md`. Marked builds require that verified owner coordinate but do
not require it to equal the viewer; OSS builds retain compatibility with
self-authored legacy directory records. Keep native discovery and send-time
revalidation fail closed on invalid ownership or managed policy evidence,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,92 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => {

assert.deepEqual(coalesce([first, second]), [first, second]);
});

test("owners remain admitted by allowlist policy without listing themselves", () => {
assert.equal(
relayAgentCanRespondInChannel(
{
ownerPubkey: CURRENT_PUBKEY,
respondTo: "allowlist",
respondToAllowlist: [],
channelIds: ["general"],
},
"general",
CURRENT_PUBKEY,
),
true,
);
});

test("owned discovery does not require a shared channel, but sending does", () => {
for (const respondTo of ["owner-only", "allowlist", "anyone"]) {
const agent = {
pubkey: PUB_B,
ownerPubkey: CURRENT_PUBKEY,
respondTo,
respondToAllowlist: [],
channelIds: [],
};
assert.equal(
relayAgentIsSharedWithUser(agent, new Set(), CURRENT_PUBKEY),
true,
);
assert.equal(
relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY),
false,
);
}
});

test("DM ownership is independent of local configuration and still requires membership", () => {
const base = {
currentPubkey: CURRENT_PUBKEY,
managedAgentPubkeys: [PUB_A],
sharedChannelIds: new Set(),
relayAgents: [
{
pubkey: PUB_B,
ownerPubkey: CURRENT_PUBKEY,
respondTo: "allowlist",
respondToAllowlist: [],
channelIds: ["dm"],
},
{
pubkey: PUB_C,
ownerPubkey: OTHER_OWNER_PUBKEY,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["dm"],
},
{
pubkey: PUB_D,
ownerPubkey: CURRENT_PUBKEY,
respondTo: "nobody",
respondToAllowlist: [],
channelIds: ["dm"],
},
],
};
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "owned", channelId: "dm" },
}),
new Set([PUB_A, PUB_B]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "owned", channelId: "other" },
}),
new Set([PUB_A]),
);
assert.deepEqual(
getMentionableAgentPubkeys({
...base,
eligibilityScope: { type: "owned", channelId: null },
phase: "prepare",
}),
new Set([PUB_A, PUB_B]),
);
});
53 changes: 43 additions & 10 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,17 @@ export function relayAgentIsSharedWithUser(
? normalizePubkey(currentPubkey)
: null;

// Ownership is relay identity, not local key custody. Like the harness's
// author gate, every supported policy except nobody admits the owner.
if (
agent.respondTo === "owner-only" &&
(agent.respondTo === "owner-only" ||
agent.respondTo === "allowlist" ||
agent.respondTo === "anyone") &&
normalizedCurrentPubkey &&
agent.ownerPubkey
agent.ownerPubkey &&
normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey
) {
return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
return true;
}

if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
Expand Down Expand Up @@ -71,6 +76,7 @@ export function relayAgentCanRespondInChannel(
export type AgentEligibilityScope =
| { type: "community" }
| { type: "channel"; channelId: string }
| { type: "owned"; channelId: string | null }
| { type: "managed-only" };

export function getMentionableAgentPubkeys({
Expand All @@ -79,9 +85,11 @@ export function getMentionableAgentPubkeys({
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
phase = "publish",
}: {
currentPubkey?: string | null;
eligibilityScope: AgentEligibilityScope;
phase?: "prepare" | "publish";
managedAgentPubkeys: Iterable<string>;
relayAgents: readonly RelayAgent[] | undefined;
sharedChannelIds: ReadonlySet<string>;
Expand All @@ -94,13 +102,38 @@ export function getMentionableAgentPubkeys({
const isAllowed =
eligibilityScope.type === "managed-only"
? false
: eligibilityScope.type === "community"
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
: relayAgentCanRespondInChannel(
agent,
eligibilityScope.channelId,
currentPubkey,
);
: eligibilityScope.type === "owned"
? Boolean(
currentPubkey &&
agent.ownerPubkey &&
normalizePubkey(agent.ownerPubkey) ===
normalizePubkey(currentPubkey) &&
relayAgentIsSharedWithUser(
agent,
sharedChannelIds,
currentPubkey,
) &&
(phase === "prepare" ||
(eligibilityScope.channelId !== null &&
agent.channelIds.includes(eligibilityScope.channelId))),
)
: eligibilityScope.type === "community"
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
: phase === "prepare" &&
currentPubkey &&
agent.ownerPubkey &&
normalizePubkey(agent.ownerPubkey) ===
normalizePubkey(currentPubkey)
? relayAgentIsSharedWithUser(
agent,
sharedChannelIds,
currentPubkey,
)
: relayAgentCanRespondInChannel(
agent,
eligibilityScope.channelId,
currentPubkey,
);
if (isAllowed) {
pubkeys.add(normalizePubkey(agent.pubkey));
}
Expand Down
Loading
Loading