mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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());
|
||||
|
||||
@@ -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("Couldn’t 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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user