From 9e4e7ce0df48743c0b0efb4858b7d0c8c6088b59 Mon Sep 17 00:00:00 2001 From: dm-builder Date: Sun, 16 Aug 2026 07:52:22 -0500 Subject: [PATCH 1/2] fix(desktop): propagate respond_to changes from persona to linked agent instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent's inbound author gate (respond_to) was changed via the persona edit dialog, the definition fields (definition_respond_to, definition_respond_to_allowlist) were saved correctly, but the runtime fields (respond_to, respond_to_allowlist) that build_respond_to_env reads at spawn were never updated. The propagation block in update_persona_with only fired on avatar or display_name changes — behavioral changes were silently dropped. The agent kept booting with the stale gate (e.g. owner-only) across restarts while the Desktop UI showed the updated value (e.g. allowlist). No error was surfaced. The issue was security-relevant: a user who widened access saw the wider setting in the UI but the agent silently kept the narrower gate. The fix detects behavior group changes (mode or allowlist) and propagates the definition's respond_to onto each linked instance's runtime fields in the same record-load-and-save cycle that already handles avatar and name propagation. The persona's wire-shape Option is parsed to the typed RespondTo enum; an absent value falls back to OwnerOnly (the default). This does not change apply_persona_snapshot, which intentionally preserves instance-level overrides during re-snapshot at start/restore. The fix is scoped to the persona edit path only. Closes #6026 Co-authored-by: Brad Groux Signed-off-by: Brad Groux Signed-off-by: dm-builder --- .../src-tauri/src/commands/personas/update.rs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index b3830e62b5..31f69a9932 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -113,6 +113,15 @@ pub(super) async fn update_persona_with( let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + // Capture the pre-edit respond_to so we can detect a behavior + // change and propagate it to linked instances. The persona stores + // respond_to in wire shape (Option); the instance stores + // the typed enum + allowlist. Without this propagation, a + // respond_to-only edit saves to the definition but the instance + // keeps booting with the stale gate (#6026). + let old_respond_to = persona.respond_to.clone(); + let old_respond_to_allowlist = persona.respond_to_allowlist.clone(); + persona.display_name = display_name; persona.avatar_url = avatar_url; persona.system_prompt = system_prompt; @@ -132,15 +141,29 @@ pub(super) async fn update_persona_with( apply_persona_behavior(persona, input.behavior)?; persona.updated_at = now_iso(); + // Detect whether the behavior group (respond_to mode + allowlist) + // changed. The persona stores wire-shape strings; a change to + // either the mode or the list means linked instances need their + // runtime `respond_to`/`respond_to_allowlist` updated — the fields + // `build_respond_to_env` reads at spawn (#6026). + let behavior_changed = persona.respond_to != old_respond_to + || persona.respond_to_allowlist != old_respond_to_allowlist; + let result = persona.clone(); save_personas(&app, &personas)?; let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); - // 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 { + // If the avatar, display_name, or behavior group changed, + // propagate to linked agent records and collect relay profile + // sync params for the async phase. The behavior propagation does + // not need a relay profile sync (it is not a name/avatar change), + // but it shares the same record-load-and-save cycle. + let sync_params: ProfileSyncParams = if avatar_changed + || name_changed + || behavior_changed + { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -185,6 +208,33 @@ pub(super) async fn update_persona_with( record_changed = true; } + if behavior_changed { + // Propagate the definition's behavioral gate onto the + // instance's runtime fields. `build_respond_to_env` + // reads `record.respond_to` / `record.respond_to_allowlist` + // at spawn — not the `definition_*` fields — so without + // this the instance keeps booting with the stale gate + // while the UI shows the updated value (#6026). + // + // The persona stores `respond_to` in wire shape + // (`Option`); parse to the typed enum. An + // absent value falls back to `OwnerOnly` (the default). + record.respond_to = result + .respond_to + .as_deref() + .and_then(|wire| { + crate::managed_agents::RespondTo::parse_wire(wire).ok() + }) + .unwrap_or_default(); + record.respond_to_allowlist = + if record.respond_to == crate::managed_agents::RespondTo::Allowlist { + result.respond_to_allowlist.clone() + } else { + Vec::new() + }; + record_changed = true; + } + if record_changed { agents_modified = true; if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { From a14144683441e7c9f1f878ff58940ade88edf873 Mon Sep 17 00:00:00 2001 From: dm-builder Date: Mon, 17 Aug 2026 00:30:23 -0500 Subject: [PATCH 2/2] Move respond_to propagation to frontend, remove unsafe global overwrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust-side propagation overwrote every linked instance's respond_to when the persona was edited, including instances with deliberate per-instance overrides. The security-sensitive direction (widening owner-only to anyone) was silently applied to all linked agents. The correct scope is the frontend's personaManagedAgentUpdate, which builds a patch for the single linked agent whose profile the user edited. Add respondTo and respondToAllowlist fields to the update function: sync the persona's mode (null defaults to owner-only) and sync the allowlist only when mode is allowlist (clear otherwise). Remove the behavior_changed block from the Rust update_persona_with loop entirely — the frontend handles it for the one linked agent. Add regression tests for mode sync, allowlist sync, allowlist clearing, and null-to-owner-only fallback. Addresses themiguelamador's review feedback. Co-authored-by: Brad Groux Signed-off-by: Brad Groux --- .../src-tauri/src/commands/personas/update.rs | 60 ++------------ .../profile/ui/UserProfilePanelUtils.test.mjs | 78 +++++++++++++++++++ .../profile/ui/UserProfilePanelUtils.ts | 31 +++++--- 3 files changed, 105 insertions(+), 64 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index 31f69a9932..0e064ddbb1 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -113,15 +113,6 @@ pub(super) async fn update_persona_with( let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); - // Capture the pre-edit respond_to so we can detect a behavior - // change and propagate it to linked instances. The persona stores - // respond_to in wire shape (Option); the instance stores - // the typed enum + allowlist. Without this propagation, a - // respond_to-only edit saves to the definition but the instance - // keeps booting with the stale gate (#6026). - let old_respond_to = persona.respond_to.clone(); - let old_respond_to_allowlist = persona.respond_to_allowlist.clone(); - persona.display_name = display_name; persona.avatar_url = avatar_url; persona.system_prompt = system_prompt; @@ -141,29 +132,19 @@ pub(super) async fn update_persona_with( apply_persona_behavior(persona, input.behavior)?; persona.updated_at = now_iso(); - // Detect whether the behavior group (respond_to mode + allowlist) - // changed. The persona stores wire-shape strings; a change to - // either the mode or the list means linked instances need their - // runtime `respond_to`/`respond_to_allowlist` updated — the fields - // `build_respond_to_env` reads at spawn (#6026). - let behavior_changed = persona.respond_to != old_respond_to - || persona.respond_to_allowlist != old_respond_to_allowlist; - let result = persona.clone(); save_personas(&app, &personas)?; let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); - // If the avatar, display_name, or behavior group changed, - // propagate to linked agent records and collect relay profile - // sync params for the async phase. The behavior propagation does - // not need a relay profile sync (it is not a name/avatar change), - // but it shares the same record-load-and-save cycle. - let sync_params: ProfileSyncParams = if avatar_changed - || name_changed - || behavior_changed - { + // If the avatar or display_name changed, propagate to linked agent + // records and collect relay profile sync params for the async phase. + // The respond_to propagation is handled by the frontend's + // personaManagedAgentUpdate, which builds a patch for the single + // linked agent whose profile the user edited — not a global + // backend overwrite of every linked instance (#6026). + let sync_params: ProfileSyncParams = if avatar_changed || name_changed { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -208,33 +189,6 @@ pub(super) async fn update_persona_with( record_changed = true; } - if behavior_changed { - // Propagate the definition's behavioral gate onto the - // instance's runtime fields. `build_respond_to_env` - // reads `record.respond_to` / `record.respond_to_allowlist` - // at spawn — not the `definition_*` fields — so without - // this the instance keeps booting with the stale gate - // while the UI shows the updated value (#6026). - // - // The persona stores `respond_to` in wire shape - // (`Option`); parse to the typed enum. An - // absent value falls back to `OwnerOnly` (the default). - record.respond_to = result - .respond_to - .as_deref() - .and_then(|wire| { - crate::managed_agents::RespondTo::parse_wire(wire).ok() - }) - .unwrap_or_default(); - record.respond_to_allowlist = - if record.respond_to == crate::managed_agents::RespondTo::Allowlist { - result.respond_to_allowlist.clone() - } else { - Vec::new() - }; - record_changed = true; - } - if record_changed { agents_modified = true; if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 0c983fa3e8..007e7ff765 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -61,6 +61,8 @@ function persona(overrides = {}) { respondTo: "owner-only", respondToAllowlist: [], envVars: { NEW_KEY: "2" }, + respondTo: null, + respondToAllowlist: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, @@ -191,6 +193,82 @@ test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unch ); }); +test("personaManagedAgentUpdate syncs respond_to mode from persona to linked agent", () => { + // Persona changes from owner-only (null defaults to owner-only) to anyone; + // the agent's instance must be updated. + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "owner-only" }), + persona({ respondTo: "anyone" }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "anyone", + }, + ); +}); + +test("personaManagedAgentUpdate syncs respond_to allowlist when mode is allowlist", () => { + const allowlist = ["a".repeat(64), "b".repeat(64)]; + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "owner-only", respondToAllowlist: [] }), + persona({ respondTo: "allowlist", respondToAllowlist: allowlist }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "allowlist", + respondToAllowlist: allowlist, + }, + ); +}); + +test("personaManagedAgentUpdate clears allowlist when mode switches away from allowlist", () => { + // Agent was allowlist with entries; persona switches to anyone — the + // instance's stale allowlist must be cleared. + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "allowlist", respondToAllowlist: ["a".repeat(64)] }), + persona({ respondTo: "anyone" }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "anyone", + respondToAllowlist: [], + }, + ); +}); + +test("personaManagedAgentUpdate treats null persona respondTo as owner-only", () => { + // Persona has null (unset) = owner-only; agent is currently anyone. + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "anyone" }), + persona({ respondTo: null }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "owner-only", + }, + ); +}); + test("parseProfilePanelView accepts all profile panel subviews", () => { for (const view of [ "summary", diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index be1a57c112..01c7c9da4e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -298,19 +298,28 @@ export function personaManagedAgentUpdate( hasChanges = true; } - // Definition edits expose the access policy in the same dialog as identity - // and runtime settings. Keep the exact linked instance in sync when the - // definition carries an explicit policy; otherwise the dialog reopens with - // the new value while the running agent and sidebar retain the old one. - if (persona.respondTo != null && persona.respondTo !== agent.respondTo) { - input.respondTo = persona.respondTo; + // Sync the inbound author gate (respond_to) from the persona to the linked + // instance. The persona stores the wire-shape mode + allowlist; the instance + // stores the typed enum + allowlist that build_respond_to_env reads at spawn. + // Without this, a respond_to-only edit saves to the definition but the + // instance keeps booting with the stale gate (#6026). + // + // This intentionally overwrites the instance's respond_to with the persona's + // value — the persona edit is the owner's explicit intent for this linked + // agent. Instance-level overrides are preserved by apply_persona_snapshot + // during re-snapshot at start/restore, not during persona edit. + const personaMode = persona.respondTo ?? "owner-only"; + if (personaMode !== agent.respondTo) { + input.respondTo = personaMode; hasChanges = true; } - if ( - persona.respondTo === "allowlist" && - !stringArrayEqual(persona.respondToAllowlist, agent.respondToAllowlist) - ) { - input.respondToAllowlist = [...persona.respondToAllowlist]; + + // Sync the allowlist only when the mode is "allowlist". For other modes, + // clear the instance's allowlist so stale entries do not survive a mode + // switch back to "allowlist" later. + const personaAllowlist = personaMode === "allowlist" ? persona.respondToAllowlist : []; + if (!stringArrayEqual(personaAllowlist, agent.respondToAllowlist)) { + input.respondToAllowlist = personaAllowlist; hasChanges = true; }