Merge remote-tracking branch 'origin/main' into projects-v5-squashed-fixes

Signed-off-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-08-17 23:54:49 -04:00
20 changed files with 422 additions and 279 deletions
@@ -77,10 +77,22 @@ async fn query_all_relay_pages(
}
}
fn owner_only_relay_directory() -> bool {
crate::managed_agents::owner_only_access_build()
}
fn retain_verified_owner(
verified_owners: &mut std::collections::HashMap<String, String>,
required_owner: &str,
) {
verified_owners.retain(|_, owner| owner.eq_ignore_ascii_case(required_owner));
}
pub(crate) async fn list_relay_agents_for_state(
state: &AppState,
) -> Result<Vec<RelayAgentInfo>, String> {
let viewer_pubkey = current_user_pubkey(state)?;
let owner_only = owner_only_relay_directory();
let relay_pubkey = identity_archive::fetch_relay_self(state)
.await?
.ok_or_else(|| "relay agent membership authority is unavailable".to_string())?;
@@ -128,7 +140,14 @@ pub(crate) async fn list_relay_agents_for_state(
// query. Each exact `(owner, d=agent)` filter returns at most one current
// replaceable event, so forged 30177 coordinates cannot amplify or crowd
// the authentic policy out of a bounded result page.
let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events);
let mut verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events);
// The internal capability narrows the remote directory to cryptographically
// verified agents owned by the active user. Same-owner siblings remain
// mentionable because they are inside the harness's owner-only boundary;
// all cross-owner coordinates are discarded before policy lookup.
if owner_only {
retain_verified_owner(&mut verified_owners, &viewer_pubkey);
}
let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners);
let mut managed_agent_events = Vec::new();
for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) {
@@ -144,6 +163,14 @@ pub(crate) async fn list_relay_agents_for_state(
&managed_agent_events,
&profile_events,
);
if owner_only {
agents.retain(|agent| {
agent
.owner_pubkey
.as_deref()
.is_some_and(|owner| owner.eq_ignore_ascii_case(&viewer_pubkey))
});
}
agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey));
for agent in &mut agents {
agent.channel_ids = member_agent_channel_ids
@@ -163,6 +190,25 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAg
mod tests {
use super::*;
#[test]
fn owner_only_directory_keeps_only_verified_same_owner_coordinates() {
let viewer = "a".repeat(64);
let other_owner = "b".repeat(64);
let same_owner_agent = "c".repeat(64);
let other_owner_agent = "d".repeat(64);
let mut owners = std::collections::HashMap::from([
(same_owner_agent.clone(), viewer.to_uppercase()),
(other_owner_agent, other_owner),
]);
retain_verified_owner(&mut owners, &viewer);
assert_eq!(
owners,
std::collections::HashMap::from([(same_owner_agent, viewer.to_uppercase())])
);
}
#[test]
fn exact_author_queries_prevent_noisy_agent_crowd_out() {
let pubkeys = vec!["a".repeat(64), "b".repeat(64)];
@@ -12,18 +12,35 @@ fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() {
&[],
RespondTo::OwnerOnly,
&[],
false,
));
assert!(managed_agent_access_policy_changed(
RespondTo::Allowlist,
&allowlist_a,
RespondTo::Allowlist,
&allowlist_b,
false,
));
assert!(!managed_agent_access_policy_changed(
RespondTo::OwnerOnly,
&allowlist_a,
RespondTo::OwnerOnly,
&allowlist_b,
false,
));
assert!(!managed_agent_access_policy_changed(
RespondTo::Anyone,
&[],
RespondTo::OwnerOnly,
&[],
true,
));
assert!(!managed_agent_access_policy_changed(
RespondTo::Allowlist,
&allowlist_a,
RespondTo::Allowlist,
&allowlist_b,
true,
));
}
@@ -5,7 +5,15 @@ pub(crate) fn managed_agent_access_policy_changed(
current_allowlist: &[String],
prospective_mode: crate::managed_agents::RespondTo,
prospective_allowlist: &[String],
enforced_owner_only: bool,
) -> bool {
// Stored policy remains portable across OSS and owner-only builds, but a
// marked build always projects both states to the same owner-only runtime
// gate. Do not restart a fleet merely because relay state differs in bytes
// that this build cannot execute.
if enforced_owner_only {
return false;
}
prospective_mode != current_mode
|| (prospective_mode == crate::managed_agents::RespondTo::Allowlist
&& prospective_allowlist != current_allowlist)
@@ -169,6 +177,7 @@ pub async fn update_managed_agent(
&record.respond_to_allowlist,
prospective_mode,
&prospective_allowlist,
crate::managed_agents::owner_only_access_build(),
);
ensure_access_policy_change_supported(record, access_policy_changed)?;
@@ -587,6 +587,7 @@ fn apply_inbound_managed_agent(
&previous_allowlist,
local.respond_to,
&local.respond_to_allowlist,
crate::managed_agents::owner_only_access_build(),
);
}
false
@@ -265,7 +265,11 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() {
let mut agents = vec![local_agent()];
let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
assert!(access_changed, "Anyone must trigger a runtime refresh");
assert_eq!(
access_changed,
!crate::managed_agents::owner_only_access_build(),
"only an effective access change may trigger a runtime refresh"
);
let a = &agents[0];
// Secrets / harness / runtime — every one preserved from the local record.
assert_eq!(
@@ -216,18 +216,24 @@ fn multi_channel_workflow_query_uses_one_filter_per_channel() {
}
#[test]
fn workflow_queries_batch_above_relay_explicit_channel_limit() {
let channel_ids = (0..WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1)
.map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string())
.collect();
let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels");
fn workflow_queries_respect_relay_explicit_channel_limit() {
for (channel_count, expected_batch_sizes) in [
(WORKFLOW_QUERY_CHANNEL_BATCH_SIZE, vec![128]),
(WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1, vec![128, 1]),
] {
let channel_ids = (0..channel_count)
.map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string())
.collect();
let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels");
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].len(), WORKFLOW_QUERY_CHANNEL_BATCH_SIZE);
assert_eq!(batches[1].len(), 1);
assert!(batches.iter().flatten().all(|filter| filter["#h"]
.as_array()
.is_some_and(|values| values.len() == 1)));
assert_eq!(
batches.iter().map(Vec::len).collect::<Vec<_>>(),
expected_batch_sizes
);
assert!(batches.iter().flatten().all(|filter| filter["#h"]
.as_array()
.is_some_and(|values| values.len() == 1)));
}
}
#[test]
@@ -67,8 +67,7 @@ mod lifecycle;
#[cfg(test)]
use lifecycle::kill_stale_tracked_processes_with;
pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes};
mod spawn_key;
mod spawn_key; // production spawn-key derivation + its regressions
pub(crate) use spawn_key::bound_runtime_key;
/// Classify an agent's persona against the live catalog for the Agents-menu
@@ -255,6 +254,7 @@ pub fn build_managed_agent_summary(
&teams,
&key.relay_url,
global_config,
super::owner_only_access_build(),
);
(runtime, current)
});
@@ -860,6 +860,7 @@ pub fn spawn_agent_child(
system_prompt: effective_prompt.as_deref(),
model: effective_model.as_deref(),
provider: effective_provider.as_deref(),
enforced_owner_only: super::owner_only_access_build(),
},
);
@@ -937,11 +938,10 @@ fn child_rust_log_filter() -> String {
/// Spawn (or adopt) the runtime pair for `record` on the caller's bound
/// workspace relay. `workspace_relay` can only be produced by
/// `bind_expected_relay_scope`, so this spawn consumes — by construction —
/// the exact workspace-relay read the caller's scope assertion passed on; it
/// never re-reads the mutable override (see `relay::scope`). The key is
/// derived by [`bound_runtime_key`] — the seam the spawn-key regressions
/// exercise.
/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the
/// exact workspace-relay read the caller's scope assertion passed on; it never
/// re-reads the mutable override (see `relay::scope`). The key comes from
/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise.
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
@@ -1239,7 +1239,6 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
use std::process::{Command, Stdio};
// Spawn a real child so ManagedAgentProcess's Child field is satisfied.
// `true` exits immediately with 0 — just a handle we need for type purposes.
//
// Absolute `/usr/bin/true` on unix (present on both macOS and Linux):
// parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a
// bare `true` lookup during that window fails with NotFound (observed
@@ -1256,13 +1255,14 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun
.expect("spawn true for placeholder");
let process = crate::managed_agents::ManagedAgentProcess {
child,
log_path: std::path::PathBuf::new(),
log_path: Default::default(),
spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
&minimal_record(&"cc".repeat(32)),
&[],
&[],
"wss://relay.example",
&Default::default(),
false,
),
setup_mode: false,
adapter_availability: None,
@@ -72,6 +72,9 @@ pub(crate) struct SpawnConfigInputs<'a> {
pub system_prompt: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
/// Compile-time distribution capability projected at this runtime boundary.
/// The stored record remains portable; only effective spawned access is stamped.
pub enforced_owner_only: bool,
}
/// The effective spawn configuration of one managed-agent process.
@@ -136,7 +139,10 @@ impl SpawnConfigSnapshot {
system_prompt,
model,
provider,
enforced_owner_only,
} = inputs;
let (respond_to, respond_to_allowlist) =
super::projected_access_with_policy(record, enforced_owner_only);
Self {
acp_command: record.acp_command.clone(),
command: descriptor.command.clone(),
@@ -155,16 +161,14 @@ impl SpawnConfigSnapshot {
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
.flatten(),
auth_tag: record.auth_tag.clone(),
respond_to: record.respond_to.as_str().to_string(),
respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then(
|| {
// A list spawn would reject is captured raw: the stamped
// snapshot comes from a successful spawn, so any invalid
// edit correctly compares unequal.
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
},
),
respond_to: respond_to.as_str().to_string(),
respond_to_allowlist: (respond_to == super::types::RespondTo::Allowlist).then(|| {
// A list spawn would reject is captured raw: the stamped
// snapshot comes from a successful spawn, so any invalid
// edit correctly compares unequal.
super::types::validate_respond_to_allowlist(&respond_to_allowlist)
.unwrap_or(respond_to_allowlist)
}),
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
// Hash the effective parallelism so over-cap edits that don't change
@@ -213,6 +217,7 @@ pub(crate) fn prospective_spawn_config_snapshot(
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
enforced_owner_only: bool,
) -> SpawnConfigSnapshot {
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
// start/restore paths run right before spawning, so this describes what a
@@ -262,6 +267,7 @@ pub(crate) fn prospective_spawn_config_snapshot(
system_prompt: prompt.as_deref(),
model: model.as_deref(),
provider: provider.as_deref(),
enforced_owner_only,
})
}
@@ -5,6 +5,25 @@ use std::collections::BTreeMap;
/// Canonical projection of a prospective snapshot — the exact value the drift
/// comparison reads, so these tests assert on drift itself rather than on a
/// proxy for it.
fn snapshot_with_policy(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
teams: &[TeamRecord],
workspace_relay: &str,
global: &GlobalAgentConfig,
enforced_owner_only: bool,
) -> serde_json::Value {
prospective_spawn_config_snapshot(
record,
personas,
teams,
workspace_relay,
global,
enforced_owner_only,
)
.canonical()
}
fn snapshot(
record: &ManagedAgentRecord,
personas: &[AgentDefinition],
@@ -12,7 +31,7 @@ fn snapshot(
workspace_relay: &str,
global: &GlobalAgentConfig,
) -> serde_json::Value {
prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical()
snapshot_with_policy(record, personas, teams, workspace_relay, global, false)
}
fn record() -> ManagedAgentRecord {
@@ -225,6 +244,84 @@ fn stored_record_relay_does_not_affect_snapshot() {
);
}
#[test]
fn owner_only_mode_and_allowlist_edits_do_not_change_effective_snapshot() {
let mut before = record();
before.respond_to = RespondTo::Allowlist;
before.respond_to_allowlist = vec!["a".repeat(64)];
let mut mode_edited = before.clone();
mode_edited.respond_to = RespondTo::Anyone;
let mut allowlist_edited = before.clone();
allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)];
let effective_before = snapshot_with_policy(
&before,
&[],
&[],
"wss://ws.example",
&Default::default(),
true,
);
for (label, edited) in [
("respond-to mode", mode_edited),
("respond-to allowlist", allowlist_edited),
] {
assert_eq!(
effective_before,
snapshot_with_policy(
&edited,
&[],
&[],
"wss://ws.example",
&Default::default(),
true,
),
"portable {label} edit must not create restart drift when both spawns enforce owner-only",
);
}
}
#[test]
fn oss_mode_and_allowlist_edits_change_effective_snapshot() {
let mut before = record();
before.respond_to = RespondTo::Allowlist;
before.respond_to_allowlist = vec!["a".repeat(64)];
let mut mode_edited = before.clone();
mode_edited.respond_to = RespondTo::Anyone;
let mut allowlist_edited = before.clone();
allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)];
let effective_before = snapshot_with_policy(
&before,
&[],
&[],
"wss://ws.example",
&Default::default(),
false,
);
for (label, edited) in [
("respond-to mode", mode_edited),
("respond-to allowlist", allowlist_edited),
] {
assert_ne!(
effective_before,
snapshot_with_policy(
&edited,
&[],
&[],
"wss://ws.example",
&Default::default(),
false,
),
"OSS spawn must retain restart drift for effective {label} edits",
);
}
}
#[test]
fn respond_to_allowlist_edit_changes_snapshot() {
let rec = record();
@@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
false,
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
@@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
false,
);
assert_eq!(
@@ -187,6 +189,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
false,
);
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
@@ -203,6 +206,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() {
&[],
"wss://ws.example",
&Default::default(),
false,
);
assert_eq!(before.canonical(), after.canonical());
+12
View File
@@ -202,6 +202,18 @@ with a TypeScript lookup table or an id comparison in a component.
panel shell or return navigation, but must not filter or replace profile
content.
12. **Owner-only builds discover only verified same-owner remote agents.**
The native `list_relay_agents` boundary authenticates ownership through the
agent's NIP-OA profile, then retains only agents owned by the active user
when the compiled owner-only capability is present. Keep this as the
authoritative backstop: internal builds must never admit cross-owner remote
agents, while same-owner agents on another machine remain inside the
documented owner-only trust boundary. OSS builds retain the complete
policy-filtered relay directory and send-time fail-closed mention
revalidation. Local `agents-data-changed` events refresh only local
persona/team/managed-agent caches; they must never invalidate the remote
relay directory.
## The tests that enforce this
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
@@ -66,11 +66,13 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
assert.equal(
relayAgentIsSharedWithUser(
{
ownerPubkey: OTHER_OWNER_PUBKEY,
respondTo: "owner-only",
respondToAllowlist: [],
channelIds: ["general"],
},
sharedChannelIds,
CURRENT_PUBKEY,
),
false,
);
@@ -83,6 +85,22 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
);
});
test("relayAgentIsSharedWithUser: accepts verified same-owner agents across machines", () => {
assert.equal(
relayAgentIsSharedWithUser(
{
ownerPubkey: CURRENT_PUBKEY.toUpperCase(),
respondTo: "owner-only",
respondToAllowlist: [],
channelIds: ["general"],
},
new Set(["general"]),
CURRENT_PUBKEY,
),
true,
);
});
test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => {
const sharedChannelIds = new Set(["general"]);
@@ -10,7 +10,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
}
export function relayAgentIsSharedWithUser(
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
>,
sharedChannelIds: ReadonlySet<string>,
currentPubkey?: string | null,
) {
@@ -18,6 +21,14 @@ export function relayAgentIsSharedWithUser(
? normalizePubkey(currentPubkey)
: null;
if (
agent.respondTo === "owner-only" &&
normalizedCurrentPubkey &&
agent.ownerPubkey
) {
return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
}
if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
return agent.respondToAllowlist
.map((pubkey) => normalizePubkey(pubkey))
@@ -31,7 +42,10 @@ export function relayAgentIsSharedWithUser(
}
export function relayAgentCanRespondInChannel(
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
>,
channelId: string,
currentPubkey?: string | null,
) {
@@ -1,101 +1,17 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import test from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts";
import { relayAgentsQueryKey } from "@/features/agents/hooks";
import { LOCAL_AGENT_DATA_QUERY_KEYS } from "./useAgentsDataRefresh.ts";
const coordinates = [
{ ownerPubkey: "owner-a", agentPubkey: "agent-a" },
{ ownerPubkey: "owner-b", agentPubkey: "agent-b" },
];
const serializedLocalKeys = LOCAL_AGENT_DATA_QUERY_KEYS.map((key) =>
JSON.stringify(key),
);
function event(pubkey, dTag) {
return {
id: "id",
pubkey,
created_at: 1,
kind: KIND_MANAGED_AGENT,
tags: dTag ? [["d", dTag]] : [],
content: "{}",
sig: "sig",
};
}
test("remote managed policy refresh accepts only exact authenticated coordinates", async () => {
let onEvent;
let filter;
let unsubscribeCalls = 0;
mock.method(relayClient, "subscribeLive", (nextFilter, listener) => {
filter = nextFilter;
onEvent = listener;
return Promise.resolve(() => {
unsubscribeCalls += 1;
return Promise.resolve();
});
});
let refreshes = 0;
const stop = startRelayAgentPolicyRefresh(coordinates, () => {
refreshes += 1;
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(filter, {
kinds: [KIND_MANAGED_AGENT],
authors: ["owner-a", "owner-b"],
"#d": ["agent-a", "agent-b"],
limit: 0,
});
onEvent(event("owner-a", "agent-a"));
assert.equal(refreshes, 1);
for (const irrelevant of [
event("owner-x", "agent-a"),
event("owner-a", "agent-x"),
event("owner-a", "agent-b"), // authors×d cross-product
event("owner-a", null),
]) {
onEvent(irrelevant);
}
assert.equal(refreshes, 1, "irrelevant coordinates must not refresh");
stop();
assert.equal(unsubscribeCalls, 1);
mock.reset();
});
test("stopping before subscription readiness still closes the live query", async () => {
let resolveSubscription;
let unsubscribeCalls = 0;
mock.method(
relayClient,
"subscribeLive",
() =>
new Promise((resolve) => {
resolveSubscription = resolve;
}),
test("local agent refresh never invalidates the relay directory", () => {
assert.equal(
serializedLocalKeys.includes(JSON.stringify(relayAgentsQueryKey)),
false,
"local reconciliation must not trigger a relay-wide directory rebuild",
);
const stop = startRelayAgentPolicyRefresh(coordinates, () => {});
stop();
resolveSubscription(() => {
unsubscribeCalls += 1;
return Promise.resolve();
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(unsubscribeCalls, 1);
mock.reset();
});
test("no authenticated coordinates creates no global subscription", () => {
let subscriptions = 0;
mock.method(relayClient, "subscribeLive", () => {
subscriptions += 1;
return Promise.resolve(() => Promise.resolve());
});
startRelayAgentPolicyRefresh([], () => {})();
assert.equal(subscriptions, 0);
mock.reset();
});
@@ -2,94 +2,25 @@ import { listen } from "@tauri-apps/api/event";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayAgent, RelayEvent } from "@/shared/api/types";
import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds";
import {
managedAgentsQueryKey,
personasQueryKey,
relayAgentsQueryKey,
teamsQueryKey,
} from "@/features/agents/hooks";
import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks";
export const LOCAL_AGENT_DATA_QUERY_KEYS = [
personasQueryKey,
teamsQueryKey,
managedAgentsQueryKey,
] as const;
// Trailing-coalesce local agent-store bursts into one cache refresh. The relay
// directory is deliberately excluded: local persona/team/agent reconciliation
// cannot change remote directory records, and rebuilding that directory is a
// relay-wide operation. Remote data keeps its focused poll and is revalidated
// directly before an agent mention is sent.
const COALESCE_MS = 200;
export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000;
export type RelayAgentPolicyCoordinate = {
agentPubkey: string;
ownerPubkey: string;
};
function eventDTag(event: RelayEvent): string | null {
return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
}
/**
* Subscribe only to authenticated managed-agent coordinates already returned by
* the relay directory. The callback repeats the exact owner+d check because a
* combined Nostr filter admits the authors×d cross-product.
*/
export function startRelayAgentPolicyRefresh(
coordinates: RelayAgentPolicyCoordinate[],
onChange: () => void,
onError: (error: unknown) => void = (error) => {
console.warn("Couldnt subscribe to managed agent policy updates", error);
},
): () => void {
if (coordinates.length === 0) return () => {};
const allowed = new Set(
coordinates.map(
({ ownerPubkey, agentPubkey }) =>
`${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`,
),
);
const authors = [
...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)),
];
const agentPubkeys = [
...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)),
];
let disposed = false;
let unsubscribe: (() => Promise<void>) | null = null;
void relayClient
.subscribeLive(
{
kinds: [KIND_MANAGED_AGENT],
authors,
"#d": agentPubkeys,
limit: 0,
},
(event) => {
const dTag = eventDTag(event);
if (
dTag &&
allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`)
) {
onChange();
}
},
)
.then((nextUnsubscribe) => {
if (disposed) void nextUnsubscribe();
else unsubscribe = nextUnsubscribe;
})
.catch(onError);
return () => {
disposed = true;
void unsubscribe?.();
};
}
function relayPolicyCoordinates(agents: RelayAgent[] | undefined) {
return (agents ?? []).flatMap((agent) =>
agent.ownerPubkey
? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }]
: [],
);
}
export function useAgentsDataRefresh(): void {
const queryClient = useQueryClient();
@@ -107,80 +38,16 @@ export function useAgentsDataRefresh(): void {
const unlisten = listen("agents-data-changed", () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
for (const queryKey of LOCAL_AGENT_DATA_QUERY_KEYS) {
void queryClient.invalidateQueries({ queryKey });
}
}, COALESCE_MS);
});
let policyStop = () => {};
let policyTimer: ReturnType<typeof setTimeout> | undefined;
let policyDirty = false;
let policyRefreshInFlight = false;
let policyDisposed = false;
let coordinateKey = "";
const refreshPolicyDirectory = () => {
if (policyRefreshInFlight || policyTimer !== undefined) {
policyDirty = true;
return;
}
policyRefreshInFlight = true;
void queryClient
.invalidateQueries({ queryKey: relayAgentsQueryKey })
.finally(() => {
policyRefreshInFlight = false;
if (policyDisposed) return;
policyTimer = setTimeout(() => {
policyTimer = undefined;
if (policyDirty) {
policyDirty = false;
refreshPolicyDirectory();
}
}, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS);
});
};
const resubscribePolicy = () => {
const coordinates = relayPolicyCoordinates(
queryClient.getQueryData<RelayAgent[]>(relayAgentsQueryKey),
);
const nextKey = coordinates
.map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`)
.sort()
.join("|");
if (nextKey === coordinateKey) return;
coordinateKey = nextKey;
policyStop();
policyStop = startRelayAgentPolicyRefresh(
coordinates,
refreshPolicyDirectory,
);
};
resubscribePolicy();
const unsubscribeQueryCache = queryClient
.getQueryCache()
.subscribe((event) => {
if (
event.query.queryKey.length === relayAgentsQueryKey.length &&
event.query.queryKey.every(
(value: unknown, index: number) =>
value === relayAgentsQueryKey[index],
)
) {
resubscribePolicy();
}
});
return () => {
policyDisposed = true;
if (timer !== undefined) clearTimeout(timer);
if (policyTimer !== undefined) clearTimeout(policyTimer);
void unlisten.then((fn) => fn());
void unlistenRuntime.then((fn) => fn());
unsubscribeQueryCache();
policyStop();
};
}, [queryClient]);
}
@@ -8,7 +8,10 @@ import {
KIND_PERSONA,
KIND_TEAM,
} from "@/shared/constants/kinds";
import { startPersonaSync } from "./usePersonaSync.ts";
import {
coalesceManagedAgentBackfill,
startPersonaSync,
} from "./usePersonaSync.ts";
const EXPECTED_KINDS = [
KIND_PERSONA,
@@ -17,6 +20,53 @@ const EXPECTED_KINDS = [
KIND_DELETION,
];
function event({
id,
kind = KIND_MANAGED_AGENT,
createdAt,
pubkey = "owner-pubkey",
dTag = "agent-pubkey",
}) {
return {
id,
pubkey,
created_at: createdAt,
kind,
tags: dTag ? [["d", dTag]] : [],
content: "{}",
sig: "sig",
};
}
test("startup backfill keeps only the newest managed-agent head per coordinate", () => {
const persona = event({
id: "persona",
kind: KIND_PERSONA,
createdAt: 1,
dTag: "persona-id",
});
const otherAgent = event({
id: "other-agent",
createdAt: 2,
dTag: "other-agent",
});
const oldest = event({ id: "oldest", createdAt: 1 });
const sameSecondLoser = event({ id: "f", createdAt: 3 });
const newest = event({ id: "a", createdAt: 3 });
assert.deepEqual(
coalesceManagedAgentBackfill([
oldest,
persona,
newest,
otherAgent,
sameSecondLoser,
]).map(({ id }) => id),
["persona", "a", "other-agent"],
"NIP-33 uses newest created_at and lowest id on a tie",
);
});
// Regression guard for the fresh-start backfill gap (F3): a device that comes
// online AFTER another published gets zero history from a live-only `limit: 0`
// subscription, because reconnect-replay's since-cursor is undefined until the
@@ -20,6 +20,48 @@ const PERSONA_SYNC_KINDS = [
KIND_DELETION,
];
function eventDTag(event: RelayEvent): string | null {
return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
}
function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean {
return (
candidate.created_at > current.created_at ||
(candidate.created_at === current.created_at && candidate.id < current.id)
);
}
/**
* Keep only the NIP-33 head for each managed-agent coordinate in a startup
* backfill. Applying historical policy revisions one by one can stop and start
* the same runtime for every revision; the retained store only needs the final
* head. Other event kinds stay in relay order because persona/team projections
* do not trigger runtime policy transitions and deletion ordering is separate.
*/
export function coalesceManagedAgentBackfill(
events: readonly RelayEvent[],
): RelayEvent[] {
const heads = new Map<string, RelayEvent>();
for (const event of events) {
if (event.kind !== KIND_MANAGED_AGENT) continue;
const dTag = eventDTag(event);
if (!dTag) continue;
const coordinate = `${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`;
const current = heads.get(coordinate);
if (!current || eventIsNewer(event, current)) heads.set(coordinate, event);
}
return events.filter((event) => {
if (event.kind !== KIND_MANAGED_AGENT) return true;
const dTag = eventDTag(event);
if (!dTag) return true;
return (
heads.get(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) === event
);
});
}
// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`:
// one-shot backfill of existing heads + tombstones, then a live subscription.
// Returns a disposer that closes the live subscription. Extracted from the hook
@@ -56,7 +98,8 @@ export function startPersonaSync(
.fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 })
.then((events) => {
if (onCancelled()) return;
for (const event of events) reconcile(event);
for (const event of coalesceManagedAgentBackfill(events))
reconcile(event);
})
.catch((error) => {
console.warn("[usePersonaSync] backfill failed:", error);
+3
View File
@@ -113,6 +113,7 @@ type MockManagedAgentRuntimeSeed = {
type MockRelayAgentSeed = {
pubkey: string;
ownerPubkey?: string | null;
name: string;
agentType?: string;
capabilities?: string[];
@@ -860,6 +861,7 @@ type RawSendChannelMessageResponse = {
type RawRelayAgent = {
pubkey: string;
owner_pubkey?: string | null;
name: string;
agent_type: string;
channels: string[];
@@ -2335,6 +2337,7 @@ function resetMockRelayAgents(config?: E2eConfig) {
});
mockRelayAgents.push({
pubkey: seed.pubkey,
owner_pubkey: seed.ownerPubkey ?? null,
name: seed.name,
agent_type: seed.agentType ?? "goose",
channels: channels.map((channel) => channel.name),
+30
View File
@@ -1454,6 +1454,36 @@ test("owner-only builds hide other-owned relay agents", async ({ page }) => {
await expect(autocomplete(page)).toHaveCount(0);
});
test("owner-only builds show verified same-owner relay agents", async ({
page,
}) => {
await installMockBridge(page, {
ownerOnlyAccessBuild: true,
searchProfiles: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
displayName: "quinn",
ownerPubkey: MOCK_VIEWER_PUBKEY,
isAgent: true,
},
],
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
ownerPubkey: MOCK_VIEWER_PUBKEY,
name: "quinn",
respondTo: "owner-only",
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill("@quinn");
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
});
test("relay-only allowlisted agents stay hidden outside their channel", async ({
page,
}) => {