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
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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)];
Expand Down
17 changes: 17 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
));
}

Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)?;

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ fn apply_inbound_managed_agent(
&previous_allowlist,
local.respond_to,
&local.respond_to_allowlist,
crate::managed_agents::owner_only_access_build(),
);
}
false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ pub fn build_managed_agent_summary(
&teams,
&key.relay_url,
global_config,
super::owner_only_access_build(),
);
(runtime, current)
});
Expand Down Expand Up @@ -857,6 +858,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(),
},
);

Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
26 changes: 16 additions & 10 deletions desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down
99 changes: 98 additions & 1 deletion desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,33 @@ 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],
teams: &[TeamRecord],
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 {
Expand Down Expand Up @@ -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();
Expand Down
Loading