Skip to content
Closed
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
15 changes: 11 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ const overrides = new Map([
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
// retry-safety. Load-bearing reviewer-required change; queued to split.
// Consolidation removed the legacy persona-card import/export codecs.
["src-tauri/src/commands/personas/mod.rs", 984],
// internal-local-relay-lockdown round 5: preflight linked-agent profile updates.
["src-tauri/src/commands/personas/mod.rs", 1018],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
// the two p-gate filters can't drift) plus two guard unit tests. The file was
Expand Down Expand Up @@ -121,7 +122,11 @@ const overrides = new Map([
// helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped
// runtime log path. Load-bearing crash-recovery surface; queued to split.
// internal-owner-only: persistence choke point normalizes local agent access.
["src-tauri/src/managed_agents/storage.rs", 1386],
// internal-local-relay-lockdown: load/save rejects legacy disallowed pins.
// internal-local-relay-lockdown round 3: fail-loud save baselines and regressions.
["src-tauri/src/managed_agents/storage.rs", 1496],
// internal-local-relay-lockdown rounds 2-3: guarded enrollment; OSS fast-path.
["src-tauri/src/huddle/mod.rs", 1007],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
Expand All @@ -136,7 +141,8 @@ const overrides = new Map([
// lowest env layer (+8 lines). Queued to split.
// internal-owner-only: runtime authorization normalization protects stale
// or hand-edited records before spawning an internal managed agent.
["src-tauri/src/managed_agents/runtime.rs", 2228],
// internal-local-relay-lockdown: final spawn validates the effective relay.
["src-tauri/src/managed_agents/runtime.rs", 2229],
// config-bridge setup-payload env-boundary fix adds readiness wiring in
// spawn_agent_child; load-bearing security fix, queued to split.
["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016],
Expand Down Expand Up @@ -463,7 +469,8 @@ const overrides = new Map([
// (if let Some(provider_update) = input.provider { record.provider = provider_update; }).
// +8: harness_override thread-through in update_managed_agent so a deliberate
// Custom pin routes to update_time_agent_command_override (comment + call).
["src-tauri/src/commands/agent_models.rs", 1079],
// +4: internal relay lockdown validates effective relay before update/profile sync.
["src-tauri/src/commands/agent_models.rs", 1083],
// global-agent-config: get_agent_config_surface / write_agent_config_field /
// put_agent_session_config commands + GlobalAgentConfig serde types. New file
// in this PR; queued to split with the command module refactor.
Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_INTERNAL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");

// Explicit distribution identity. Internal packaging sets this presence-only
Expand All @@ -24,6 +25,15 @@ fn main() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_INTERNAL=1");
}

// Newline-delimited exact relay URLs. Runtime parsing canonicalizes every
// entry with buzz-core before comparing an effective local-agent relay.
// Preserve malformed or empty input so internal builds fail loudly at the
// policy boundary instead of silently compiling to an empty allowlist.
if let Ok(raw) = std::env::var("BUZZ_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST") {
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_LOCAL_AGENT_RELAY_ALLOWLIST={encoded}");
}

if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
}
Expand Down
32 changes: 31 additions & 1 deletion desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,22 @@ pub fn agent_access_owner_only() -> bool {
crate::managed_agents::internal_build()
}

/// Return whether local agents may attach to the active workspace relay.
#[tauri::command]
pub fn local_agent_relay_allowed(
state: tauri::State<'_, crate::app_state::AppState>,
) -> Result<bool, String> {
if !crate::managed_agents::internal_build() {
return Ok(true);
}
let relay_url = crate::relay::relay_ws_url_with_override(&state);
crate::managed_agents::validate_local_agent_relay(
&crate::managed_agents::BackendKind::Local,
&relay_url,
)?;
Ok(true)
}

/// Update mutable fields on an existing managed agent record.
///
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
Expand All @@ -785,6 +801,7 @@ pub async fn update_managed_agent(
state: State<'_, AppState>,
) -> Result<UpdateManagedAgentResponse, String> {
// Phase 1: local save (synchronous, under lock)
let workspace_relay_url = relay_ws_url_with_override(&state);
let (summary, sync_params, rollback) = {
let _store_guard = state
.managed_agents_store_lock
Expand Down Expand Up @@ -831,7 +848,13 @@ pub async fn update_managed_agent(
// value pins the agent; empty falls back to the workspace relay at
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
if let Some(relay_url) = input.relay_url {
record.relay_url = relay_url.trim().to_string();
let relay_url = relay_url.trim().to_string();
crate::managed_agents::validate_effective_local_agent_relay(
&record.backend,
&relay_url,
&workspace_relay_url,
)?;
record.relay_url = relay_url;
}
if let Some(acp_command) = input.acp_command {
record.acp_command = acp_command;
Expand Down Expand Up @@ -888,6 +911,13 @@ pub async fn update_managed_agent(

record.updated_at = now_iso();

if name_changed {
crate::managed_agents::validate_effective_local_agent_relay(
&record.backend,
&record.relay_url,
&workspace_relay_url,
)?;
}
save_managed_agents(&app, &records)?;

let record = records
Expand Down
9 changes: 8 additions & 1 deletion desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ pub async fn create_managed_agent(
// Snapshot the workspace owner pubkey for the legacy-record auth_tag
// fallback. Computed outside the records lock to keep lock ordering simple.
let owner_hex = workspace_owner_hex(&state)?;
let workspace_relay_url = relay_ws_url_with_override(&state);

// ── Phase 1: generate keys (sync lock) ────────────────────────────────────
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = {
Expand Down Expand Up @@ -632,6 +633,11 @@ pub async fn create_managed_agent(
.map(str::trim)
.unwrap_or("")
.to_string();
crate::managed_agents::validate_effective_local_agent_relay(
&input.backend,
&resolved_relay_url,
&workspace_relay_url,
)?;

(keys, private_key_nsec, pubkey, resolved_relay_url, input)
};
Expand Down Expand Up @@ -1077,6 +1083,7 @@ pub async fn start_managed_agent(

let reconcile = ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
backend: record.backend.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
avatar_url: record.avatar_url.clone(),
Expand Down Expand Up @@ -1319,7 +1326,7 @@ pub(crate) use deploy::resolve_deploy_model_provider;
#[path = "agents_profile.rs"]
mod profile;
#[cfg(test)]
use profile::{profile_needs_sync, resolve_legacy_avatar};
use profile::{profile_needs_sync, resolve_legacy_avatar, validate_profile_reconcile_relay_with};
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};

#[cfg(test)]
Expand Down
32 changes: 25 additions & 7 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use super::*;

pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) backend: BackendKind,
pub(crate) name: String,
pub(crate) relay_url: String,
/// Expected avatar URL for the published profile. `None` for legacy records
Expand Down Expand Up @@ -49,6 +50,17 @@ pub(super) fn resolve_legacy_avatar(
.unwrap_or_default()
}

pub(super) fn validate_profile_reconcile_relay_with<F>(
data: &ProfileReconcileData,
workspace_relay_url: &str,
validate: F,
) -> Result<(), String>
where
F: FnOnce(&BackendKind, &str, &str) -> Result<(), String>,
{
validate(&data.backend, &data.relay_url, workspace_relay_url)
}

/// Reconcile an agent's kind:0 profile on the relay.
///
/// Queries the relay for the agent's existing profile and re-publishes if missing
Expand All @@ -74,20 +86,26 @@ pub(crate) async fn reconcile_agent_profile(
) -> Result<(), String> {
use crate::relay::{query_agent_profile, sync_managed_agent_profile};

// An explicit per-agent relay wins; an empty one falls back to the active
// workspace relay. Resolved once and used for both the read and write-back.
let relay_url = crate::relay::effective_agent_relay_url(
&data.relay_url,
&relay_ws_url_with_override(state),
);

if !state
.managed_agent_profile_reconcile_enabled
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(());
}

let workspace_relay_url = relay_ws_url_with_override(state);
validate_profile_reconcile_relay_with(
data,
&workspace_relay_url,
|backend, pin, workspace| {
crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace)
},
)?;

// An explicit per-agent relay wins; an empty one falls back to the active
// workspace relay. Resolved once and used for both the read and write-back.
let relay_url = crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay_url);

// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;

Expand Down
29 changes: 29 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,32 @@ fn deploy_payload_carries_the_full_behavioral_quad() {
assert_eq!(payload["provider"], "openai");
assert_eq!(payload["relay_url"], "wss://relay.example");
}

#[test]
fn profile_reconcile_preflights_legacy_empty_pin_before_relay_io() {
let data = ProfileReconcileData {
private_key_nsec: String::new(),
backend: BackendKind::Local,
name: "legacy".into(),
relay_url: String::new(),
avatar_url: None,
auth_tag: None,
pubkey: "pubkey".into(),
agent_command: "goose".into(),
persona_id: None,
};
let calls = std::cell::Cell::new(0);
let result = validate_profile_reconcile_relay_with(
&data,
"wss://public.example",
|backend, pin, workspace| {
calls.set(calls.get() + 1);
assert_eq!(backend, &BackendKind::Local);
assert!(pin.is_empty());
assert_eq!(workspace, "wss://public.example");
Err("blocked before query".into())
},
);
assert_eq!(result.unwrap_err(), "blocked before query");
assert_eq!(calls.get(), 1);
}
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use tauri::State;
use tauri::{AppHandle, State};

use crate::{
app_state::AppState,
Expand Down Expand Up @@ -785,9 +785,14 @@ pub async fn add_channel_members(
channel_id: String,
pubkeys: Vec<String>,
role: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let uuid = parse_channel_uuid(&channel_id)?;
let relay_url = crate::relay::relay_ws_url_with_override(&state);
crate::managed_agents::validate_local_agent_members_from_store(&pubkeys, &relay_url, || {
crate::managed_agents::load_managed_agents(&app)
})?;
let role_str = match role.as_deref() {
Some("admin") => Some("admin"),
Some("bot") => Some("bot"),
Expand Down Expand Up @@ -833,6 +838,7 @@ pub async fn change_channel_member_role(
channel_id: String,
pubkey: String,
role: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let uuid = parse_channel_uuid(&channel_id)?;
Expand All @@ -843,6 +849,12 @@ pub async fn change_channel_member_role(
"owner" => return Err("cannot assign owner role — use transfer ownership".into()),
other => return Err(format!("invalid role: {other}")),
};
let relay_url = crate::relay::relay_ws_url_with_override(&state);
crate::managed_agents::validate_local_agent_members_from_store(
std::slice::from_ref(&pubkey),
&relay_url,
|| crate::managed_agents::load_managed_agents(&app),
)?;
let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?;
submit_event(builder, &state).await?;
Ok(())
Expand Down
46 changes: 42 additions & 4 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ fn propagate_persona_name_rename(
renamed
}

fn validate_persona_profile_updates_with<F>(
records: &[ManagedAgentRecord],
persona_id: &str,
old_display_name: &str,
name_changed: bool,
avatar_changed: bool,
mut validate: F,
) -> Result<(), String>
where
F: FnMut(&ManagedAgentRecord) -> Result<(), String>,
{
records
.iter()
.filter(|record| record.persona_id.as_deref() == Some(persona_id))
.filter(|record| avatar_changed || (name_changed && record.name == old_display_name))
.try_for_each(&mut validate)
}

#[tauri::command]
pub async fn update_persona(
input: UpdatePersonaRequest,
Expand Down Expand Up @@ -176,10 +194,32 @@ pub async fn update_persona(
.find(|record| record.id == input.id)
.ok_or_else(|| format!("agent {} not found", input.id))?;

// Track what changed so we can propagate to linked agent records.
// Track what changed so we can preflight every linked agent before
// mutating either store or enqueueing agent-keyed profile I/O.
let avatar_changed = persona.avatar_url != avatar_url;
let name_changed = persona.display_name != display_name;
let old_display_name = persona.display_name.clone();
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
let mut linked_records = if avatar_changed || name_changed {
let records = load_managed_agents(&app)?;
validate_persona_profile_updates_with(
&records,
&persona.id,
&old_display_name,
name_changed,
avatar_changed,
|record| {
crate::managed_agents::validate_effective_local_agent_relay(
&record.backend,
&record.relay_url,
&workspace_relay,
)
},
)?;
Some(records)
} else {
None
};

persona.display_name = display_name;
persona.avatar_url = avatar_url;
Expand Down Expand Up @@ -209,11 +249,9 @@ pub async fn update_persona(

// If the avatar or display_name changed, propagate to linked agent
// records and collect relay profile sync params for the async phase.
let sync_params: ProfileSyncParams = if avatar_changed || name_changed {
let mut records = load_managed_agents(&app)?;
let sync_params: ProfileSyncParams = if let Some(mut records) = linked_records.take() {
let mut params: ProfileSyncParams = Vec::new();
let mut agents_modified = false;
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);

// Propagate the display_name rename to instances that still
// carry the old definition display_name (pool-named instances
Expand Down
Loading
Loading