From 00456e462aa7a058d6247deca8ec2ee006901aa8 Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 10 Aug 2026 14:19:31 -0400 Subject: [PATCH] fix(desktop): persist applied permission policy for remote deploys ManagedAgentSummary recomputed the displayed permission policy from the mutable agent record plus global config, so flipping the global default after a remote deploy made the UI report a policy the worker was not actually running. Stamp the byte-identical policy sent to the provider onto the record at the deploy choke point, expose it on the summary as applied vs desired, and surface drift in the policy field UI with a redeploy prompt. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/commands/agent_config_tests.rs | 1 + desktop/src-tauri/src/commands/agents.rs | 16 ++++----- .../src-tauri/src/commands/agents_tests.rs | 1 + .../commands/personas/delete_cascade_tests.rs | 1 + .../personas/inbound/inbound_tests.rs | 1 + .../personas/snapshot/fidelity_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + .../src/managed_agents/agent_events.rs | 1 + .../managed_agents/agent_snapshot_envelope.rs | 1 + .../managed_agents/agent_snapshot_tests.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 4 +-- .../managed_agents/effective_config/tests.rs | 1 + .../src/managed_agents/global_config/tests.rs | 1 + .../src/managed_agents/nest/tests.rs | 1 + .../src/managed_agents/parallelism.rs | 1 + .../src/managed_agents/permission_policy.rs | 34 +++++++++++++++++++ .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 6 ++-- .../src-tauri/src/managed_agents/runtime.rs | 1 + .../managed_agents/runtime/test_fixtures.rs | 1 + .../managed_agents/spawn_snapshot/tests.rs | 1 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 18 +++++----- .../src/managed_agents/types/tests.rs | 29 ++++++++++++++++ .../agents/ui/AgentPermissionPolicyField.tsx | 31 ++++++++++++++--- desktop/src/shared/api/managedAgentMapping.ts | 3 ++ desktop/src/shared/api/types.ts | 2 ++ 33 files changed, 141 insertions(+), 26 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index ca5c554f4..a9256a60f 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -117,6 +117,7 @@ fn agent_record() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 78f3c2769..d58e5f035 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -408,13 +408,8 @@ pub(super) async fn start_local_agent_with_preflight( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey} is no longer a local agent")); } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. + // Re-snapshot the persona at every spawn (current persona config wins; clears + // drift badge). Load once — also used for summary build at the end. let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { match personas.iter().find(|p| p.id == persona_id) { @@ -480,12 +475,15 @@ async fn deploy_to_provider( .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; let config_clone = config.clone(); + let applied_policy: Option = + agent_json["launch"]["policy_env"]["BUZZ_ACP_PERMISSION_POLICY"] + .as_str() + .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok()); let deploy_result = tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) .await .map_err(|e| format!("spawn_blocking failed: {e}"))?; - // Persist result under lock. let _store_guard = state .managed_agents_store_lock .lock() @@ -502,6 +500,7 @@ async fn deploy_to_provider( rec.last_started_at = Some(now_iso()); rec.updated_at = now_iso(); rec.last_error = None; + rec.applied_permission_policy = applied_policy; } Err(ref e) => { rec.last_error = Some(e.clone()); @@ -913,6 +912,7 @@ pub async fn create_managed_agent( relay_mesh.clone() }, permission_policy: None, // inherits global default or built-in `ask` + applied_permission_policy: None, // populated on first successful remote deploy }; records.push(record); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index ec52fc183..368459edb 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -59,6 +59,7 @@ fn bare_agent_record( catalog_source: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index b33c7feaa..cc9ee04f1 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -67,6 +67,7 @@ fn make_agent( catalog_source: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 327aef8a5..c17e9012e 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -216,6 +216,7 @@ fn local_agent() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 49d828b1e..f49dca18e 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -65,6 +65,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index f4a420102..b3b290a84 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -655,6 +655,7 @@ pub async fn confirm_agent_snapshot_import( runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), permission_policy: None, + applied_permission_policy: None, }; records.push(record.clone()); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 8e000e926..27538b02f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -74,6 +74,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 9a066156e..3e8b170d4 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -59,6 +59,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index c8990738f..9ab2e7190 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -610,6 +610,7 @@ pub async fn confirm_team_snapshot_import( definition_parallelism: minted_parallelism, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 3ec5ec16a..f022cf9f4 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -230,6 +230,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 13f75c2ef..b41a13149 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -217,6 +217,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index e553916c8..adb0e02da 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -417,6 +417,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index fc9759657..960601bad 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -73,6 +73,7 @@ fn minimal_record() -> ManagedAgentRecord { definition_parallelism: Some(4), relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 08ce59cf5..0f82b6a21 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -116,6 +116,7 @@ fn test_record() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 7b3d64e30..ce7b904e6 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -283,13 +283,13 @@ fn record_with( definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own materialized runtime wins over the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 230b94564..b26081ba5 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -89,6 +89,7 @@ fn record( catalog_source: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index d9eb6f4c1..cbf1a295a 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -350,6 +350,7 @@ fn bare_record() -> ManagedAgentRecord { catalog_source: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 1288c5ceb..7ab7d99c0 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -503,6 +503,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index 0253d4815..78819720c 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -118,6 +118,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/permission_policy.rs b/desktop/src-tauri/src/managed_agents/permission_policy.rs index 33224e324..16e888786 100644 --- a/desktop/src-tauri/src/managed_agents/permission_policy.rs +++ b/desktop/src-tauri/src/managed_agents/permission_policy.rs @@ -179,4 +179,38 @@ mod tests { assert_eq!(policy, PermissionPolicy::Reject); assert_eq!(source, PermissionPolicySource::Agent); } + + /// Wes's regression: deploy under Allow, then flip global default to Reject. + /// The summary's *desired* policy changes (global Reject wins) but the + /// *applied* policy on the record must stay Allow — the worker is still + /// running the policy it was launched with. The UI detects drift by + /// comparing these two values and prompts a redeploy. + #[test] + fn test_applied_policy_survives_global_flip_deploy_allow_global_flips_to_reject() { + let mut record = empty_record(); + // Simulate: agent was deployed with no per-agent override, global=Allow + // at deploy time → applied_permission_policy stamped as Allow. + record.permission_policy = None; + record.applied_permission_policy = Some(PermissionPolicy::Allow); + + // Global is now flipped to Reject (post-deploy mutation). + let global_after_flip = GlobalAgentConfig { + permission_policy: Some(PermissionPolicy::Reject), + ..Default::default() + }; + + // Desired policy reflects the new global. + let (desired, source) = resolve_effective_permission_policy(&record, &global_after_flip); + assert_eq!(desired, PermissionPolicy::Reject); + assert_eq!(source, PermissionPolicySource::GlobalDefault); + + // Applied policy is unchanged — still what the worker was launched with. + assert_eq!( + record.applied_permission_policy, + Some(PermissionPolicy::Allow) + ); + + // Drift is detectable: applied ≠ desired. + assert_ne!(record.applied_permission_policy, Some(desired)); + } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 91110836b..7260a3576 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -59,6 +59,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index bbd3106a1..2b330739c 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1465,9 +1465,8 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // env_vars must win over baked defaults; in OSS builds the baked map is empty, + // so this verifies the user-env layer is present. let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( @@ -1530,6 +1529,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index adb232794..a4bf74242 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -344,6 +344,7 @@ pub fn build_managed_agent_summary( respond_to_allowlist: record.respond_to_allowlist.clone(), permission_policy: effective_permission_policy_summary, permission_policy_source: effective_permission_policy_source, + applied_permission_policy: record.applied_permission_policy, }) } diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 70ed9db6a..e5eeed255 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -90,5 +90,6 @@ pub(super) fn fixture( definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 00acfe7bd..0c8d7b245 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -71,6 +71,7 @@ fn record() -> ManagedAgentRecord { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 9db79e4c0..6ddd0b1d3 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -310,6 +310,7 @@ mod tests { definition_parallelism: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 4d4297ee2..67455c598 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -214,6 +214,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { catalog_source: None, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index ab6fdd58f..dd410b1b3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -151,6 +151,7 @@ impl AgentDefinition { definition_parallelism: self.parallelism, relay_mesh: None, permission_policy: None, + applied_permission_policy: None, } } } @@ -352,6 +353,8 @@ pub struct ManagedAgentRecord { pub respond_to_allowlist: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_permission_policy: Option, /// Optional display name distinct from the unique `name` handle. Absorbed /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -534,16 +537,11 @@ pub struct ManagedAgentSummary { /// persona is gone, so there is nothing newer to drift toward). pub persona_out_of_date: bool, /// `true` when the agent was created from a persona that no longer exists. - /// Distinct from out-of-date: there is no current persona to respawn into. - /// An orphaned agent also cannot be (re)started — `spawn_agent_child` - /// refuses it (see `effective_config::resolve_effective_config`'s - /// `OrphanedInstance` arm via `require_resolved`) — so the UI - /// should surface that it's stuck, not merely stale. + /// `true` when the agent's linked persona no longer exists; no current + /// persona to respawn into and the agent cannot be (re)started. pub persona_orphaned: bool, - /// `true` when the running process's spawn config no longer matches - /// what a spawn would use today. Derived from `restart_diff` — lit - /// exactly when there is something to show. Always `false` for stopped, - /// orphaned, or `runtime_pid`-adopted agents. + /// `true` when the running process's spawn config no longer matches what + /// a spawn would use today. Always `false` for stopped/orphaned agents. pub needs_restart: bool, /// Fields that drifted since launch, redacted for display. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -568,6 +566,8 @@ pub struct ManagedAgentSummary { pub respond_to_allowlist: Vec, pub permission_policy: super::permission_policy::PermissionPolicy, pub permission_policy_source: super::permission_policy::PermissionPolicySource, + #[serde(skip_serializing_if = "Option::is_none")] + pub applied_permission_policy: Option, } #[derive(Debug, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 914568cf1..36f67b13c 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -747,6 +747,7 @@ fn summary_fixture( permission_policy: crate::managed_agents::permission_policy::PermissionPolicy::Ask, permission_policy_source: crate::managed_agents::permission_policy::PermissionPolicySource::BuiltIn, + applied_permission_policy: None, } } @@ -787,3 +788,31 @@ fn summary_with_drift_serializes_restart_diff_entries() { }])) ); } + +#[test] +fn applied_permission_policy_drift_serializes_correctly() { + // When applied_permission_policy differs from permission_policy, both values + // must reach the wire so the frontend can detect drift and prompt a redeploy. + let mut summary = summary_fixture(Vec::new()); + summary.permission_policy = crate::managed_agents::permission_policy::PermissionPolicy::Reject; + summary.applied_permission_policy = + Some(crate::managed_agents::permission_policy::PermissionPolicy::Allow); + + let wire = serde_json::to_value(&summary).expect("summary serializes"); + assert_eq!(wire["permission_policy"], serde_json::json!("reject")); + assert_eq!( + wire["applied_permission_policy"], + serde_json::json!("allow") + ); +} + +#[test] +fn applied_permission_policy_none_omitted_from_wire() { + // For local agents and never-deployed remote agents, applied_permission_policy + // is None — it must be omitted from the wire (skip_serializing_if = "Option::is_none"). + let wire = serde_json::to_value(summary_fixture(Vec::new())).expect("summary serializes"); + assert!( + wire.get("applied_permission_policy").is_none(), + "absent applied_permission_policy must be omitted, got: {wire}" + ); +} diff --git a/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx b/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx index 2c024c456..57c504b57 100644 --- a/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx +++ b/desktop/src/features/agents/ui/AgentPermissionPolicyField.tsx @@ -23,7 +23,11 @@ export type AgentPermissionPolicyFieldHandle = { type Props = { agent: Pick< ManagedAgent, - "backend" | "backendAgentId" | "permissionPolicy" | "permissionPolicySource" + | "backend" + | "backendAgentId" + | "permissionPolicy" + | "permissionPolicySource" + | "appliedPermissionPolicy" >; disabled: boolean; }; @@ -47,6 +51,11 @@ export const AgentPermissionPolicyField = React.forwardRef< const sourceLabel = SOURCE_LABEL[agent.permissionPolicySource] ?? agent.permissionPolicySource; + const hasDrift = + isRemoteDeployed && + agent.appliedPermissionPolicy !== null && + agent.appliedPermissionPolicy !== agent.permissionPolicy; + return (
@@ -61,9 +70,23 @@ export const AgentPermissionPolicyField = React.forwardRef<
{isRemoteDeployed ? ( -

- Read-only while deployed. To change, shut down and redeploy the agent. -

+ <> +

+ Read-only while deployed. To change, shut down and redeploy the + agent. +

+ {hasDrift && ( +

+ Applied policy:{" "} + + {agent.appliedPermissionPolicy} + {" "} + · Desired:{" "} + {agent.permissionPolicy} — + redeploy required to apply. +

+ )} + ) : (