From 0a7d7f844e3ae7f72fc153b35d6edb11ef279847 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 23 Aug 2026 13:03:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(templates):=20open=5Fproject=20ne=20r=C3=A9?= =?UTF-8?q?tro-synchronise=20plus=20les=20permissions=20agents=20vers=20le?= =?UTF-8?q?s=20templates=20=E2=80=94=20#244=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression du backfill agent→template au open_project (cause racine de la non-persistance des permissions MCP/system des templates) et couverture de régression : create/read/update des default permissions + test dédié open_project sans backfill. --- crates/app-tauri/src/commands.rs | 181 +++++++++++------- crates/app-tauri/src/templates.rs | 63 +++++- crates/application/tests/template_usecases.rs | 23 ++- 3 files changed, 189 insertions(+), 78 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index baa4bff6..d295f97b 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -24,16 +24,16 @@ use application::{ ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions, LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, ReadTemplateInput, RecallMemoryInput, - ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, - ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, - RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, - SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, + ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect, + SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, - UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateTemplateInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, }; use backend::stream::OutputSink; use domain::ports::ModelServerRuntime; @@ -91,7 +91,7 @@ use crate::embedded_server::{ use crate::pty::{PtyBridge, PtyChunk}; use crate::state::{AppState, FocusedProjectDto}; use crate::stream::TauriChannelSink; -use domain::{AgentOrigin, DeviceId, SkillRef, SkillScope}; +use domain::{DeviceId, SkillRef, SkillScope}; use uuid::Uuid; /// `health` — trivial command validating the full IPC pipeline @@ -492,81 +492,118 @@ pub(crate) async fn open_project_for_adapter( // live-state : les Running sans handle vivant deviennent terminales, les // complétions WakeOwner non livrées repassent dans l'inbox unifiée. let _ = state.reconcile_background_tasks.execute(id).await; - let _ = backfill_agent_templates_from_project(&output.project, state).await; // (Re)start the orchestrator watcher for this project (idempotent, §14.3). state.ensure_orchestrator_watch(&output.project); state.reconcile_claude_run_dirs(&output.project).await; Ok(ProjectDto::from(output)) } -async fn backfill_agent_templates_from_project( - project: &domain::Project, - state: &AppState, -) -> Result<(), AppError> { - let agents = state - .list_agents - .execute(ListAgentsInput { - project: project.clone(), - }) - .await? - .agents; - let system_permissions = state - .get_project_system_permissions - .execute(GetProjectSystemPermissionsInput { - project: project.clone(), - }) - .await? - .permissions; - let mcp_tool_permissions = state - .read_mcp_tool_permissions - .execute(ReadMcpToolPermissionsInput { - project: project.clone(), - }) - .await? - .permissions; +#[cfg(test)] +mod open_project_template_tests { + use std::path::PathBuf; - for agent in agents { - let AgentOrigin::FromTemplate { template_id, .. } = agent.origin else { - continue; - }; - let template = match state - .read_template - .execute(ReadTemplateInput { template_id }) - .await - { - Ok(out) => out.template, - Err(err) if err.code() == "NOT_FOUND" => continue, - Err(err) => return Err(err), - }; - let default_system_permissions = system_permissions.agent_permissions(agent.id).cloned(); - let default_mcp_tool_policy = mcp_tool_permissions - .agents - .iter() - .find(|entry| entry.agent_id == agent.id) - .map(|entry| entry.policy.clone()); + use application::{ + CreateAgentFromTemplateInput, CreateProjectInput, CreateTemplateInput, ReadTemplateInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentSystemPermissionsInput, + }; + use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet}; + use uuid::Uuid; - if template.default_agent_name.as_deref() == Some(agent.name.as_str()) - && template.default_profile_id == agent.profile_id - && template.default_system_permissions == default_system_permissions - && template.default_mcp_tool_policy == default_mcp_tool_policy - { - continue; - } + use super::*; - state - .update_template - .execute(UpdateTemplateInput { - template_id, - content: None, - name: None, - default_profile_id: Some(agent.profile_id), - default_agent_name: Some(Some(agent.name)), - default_system_permissions: Some(default_system_permissions), - default_mcp_tool_policy: Some(default_mcp_tool_policy), - }) - .await?; + fn temp_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("idea-open-project-{tag}-{}", Uuid::new_v4())) + } + + #[tokio::test] + async fn open_project_does_not_backfill_agent_permission_overrides_into_templates() { + let state = AppState::build(temp_path("appdata")); + let project_root = temp_path("project"); + let project = state + .create_project + .execute(CreateProjectInput { + name: "No Template Backfill".to_owned(), + root: project_root.to_string_lossy().into_owned(), + remote: None, + default_profile_id: None, + }) + .await + .expect("project is created") + .project; + let profile_id = domain::ProfileId::from_uuid(Uuid::from_u128(44)); + let template = state + .create_template + .execute(CreateTemplateInput { + name: "Backend".to_owned(), + content: "# template".to_owned(), + default_profile_id: profile_id, + default_agent_name: None, + default_system_permissions: None, + default_mcp_tool_policy: None, + }) + .await + .expect("template is created") + .template; + + let agent = state + .create_agent_from_template + .execute(CreateAgentFromTemplateInput { + project: project.clone(), + template_id: template.id, + name: Some("Project Override".to_owned()), + synchronized: true, + }) + .await + .expect("agent is created from template") + .agent; + state + .update_agent_system_permissions + .execute(UpdateAgentSystemPermissionsInput { + project: project.clone(), + agent_id: agent.id, + permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))), + }) + .await + .expect("agent system permissions are overridden"); + state + .update_agent_mcp_tool_permissions + .execute(UpdateAgentMcpToolPermissionsInput { + project: project.clone(), + agent_id: agent.id, + policy: Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + }), + }) + .await + .expect("agent MCP tool permissions are overridden"); + + let before_open = state + .read_template + .execute(ReadTemplateInput { + template_id: template.id, + }) + .await + .expect("template can be read before open") + .template; + + open_project_for_adapter(project.id.to_string(), &state) + .await + .expect("project opens"); + + let after_open = state + .read_template + .execute(ReadTemplateInput { + template_id: template.id, + }) + .await + .expect("template can be read after open") + .template; + + assert_eq!(after_open, before_open); + assert!(after_open.default_agent_name.is_none()); + assert!(after_open.default_system_permissions.is_none()); + assert!(after_open.default_mcp_tool_policy.is_none()); } - Ok(()) } /// `close_project` — persist state and release resources for a project. diff --git a/crates/app-tauri/src/templates.rs b/crates/app-tauri/src/templates.rs index 4abb5830..fbd18023 100644 --- a/crates/app-tauri/src/templates.rs +++ b/crates/app-tauri/src/templates.rs @@ -202,8 +202,8 @@ mod tests { }; use domain::ports::{EventBus, EventStream, IdGenerator, StoreError, TemplateStore}; use domain::{ - AgentTemplate, DomainEvent, MarkdownDoc, ProfileId, ProjectId, ProjectPath, RemoteRef, - TemplateVersion, + AgentTemplate, DomainEvent, MarkdownDoc, McpToolPolicy, NetworkPolicy, ProfileId, + ProjectId, ProjectPath, RemoteRef, SystemPermissionSet, TemplateVersion, }; use uuid::Uuid; @@ -314,12 +314,24 @@ mod tests { "name": "Base", "content": "v1", "defaultProfileId": profile_id, + "defaultAgentName": "Base Agent", + "defaultSystemPermissions": { "network": "allow" }, + "defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] }, }), ) .await .unwrap(); let template_id = created["id"].as_str().unwrap().to_owned(); assert_eq!(created["version"], json!(TemplateVersion::INITIAL)); + assert_eq!(created["defaultAgentName"], "Base Agent"); + assert_eq!( + created["defaultSystemPermissions"], + json!({ "network": "allow" }) + ); + assert_eq!( + created["defaultMcpToolPermissions"], + json!({ "allowedTools": ["idea_template_read"] }) + ); let read = provider .handle_template_tool( @@ -332,6 +344,15 @@ mod tests { .unwrap(); assert_eq!(read["name"], "Base"); assert_eq!(read["contentMd"], "v1"); + assert_eq!(read["defaultAgentName"], "Base Agent"); + assert_eq!( + read["defaultSystemPermissions"], + json!({ "network": "allow" }) + ); + assert_eq!( + read["defaultMcpToolPermissions"], + json!({ "allowedTools": ["idea_template_read"] }) + ); let listed = provider .handle_template_tool(&project(), "agent", "idea_template_list", json!({})) @@ -344,12 +365,48 @@ mod tests { &project(), "agent", "idea_template_update", - json!({ "templateId": template_id, "content": "v2" }), + json!({ + "templateId": template_id, + "content": "v2", + "defaultSystemPermissions": { "network": "deny" }, + "defaultMcpToolPermissions": { "allowedTools": ["idea_template_update"] }, + }), ) .await .unwrap(); assert_eq!(updated["contentMd"], "v2"); assert_eq!(updated["version"], json!(2)); + assert_eq!(updated["defaultAgentName"], "Base Agent"); + assert_eq!( + updated["defaultSystemPermissions"], + json!({ "network": "deny" }) + ); + assert_eq!( + updated["defaultMcpToolPermissions"], + json!({ "allowedTools": ["idea_template_update"] }) + ); + assert_eq!( + store + .get(TemplateId::from_uuid( + Uuid::parse_str(&template_id).unwrap() + )) + .await + .unwrap() + .default_system_permissions, + Some(SystemPermissionSet::new(Some(NetworkPolicy::Deny))) + ); + assert_eq!( + store + .get(TemplateId::from_uuid( + Uuid::parse_str(&template_id).unwrap() + )) + .await + .unwrap() + .default_mcp_tool_policy, + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_update".to_owned()] + }) + ); assert!(matches!( events.events.lock().unwrap().as_slice(), [DomainEvent::TemplateUpdated { .. }] diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index ac804503..78ecf5f4 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -277,14 +277,31 @@ async fn create_template_starts_at_initial_version() { name: "Backend".to_owned(), content: "# ctx".to_owned(), default_profile_id: pid(7), - default_agent_name: None, - default_system_permissions: None, - default_mcp_tool_policy: None, + default_agent_name: Some("Backend Agent".to_owned()), + default_system_permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))), + default_mcp_tool_policy: Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()], + }), }) .await .unwrap(); assert_eq!(out.template.version, TemplateVersion::INITIAL); assert_eq!(out.template.default_profile_id, pid(7)); + assert_eq!( + out.template.default_agent_name.as_deref(), + Some("Backend Agent") + ); + assert_eq!( + out.template.default_system_permissions, + Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))) + ); + assert_eq!( + out.template.default_mcp_tool_policy, + Some(McpToolPolicy { + allowed_tools: vec!["idea_template_read".to_owned()] + }) + ); + assert_eq!(store.get_sync(out.template.id).unwrap(), out.template); assert_eq!(store.list().await.unwrap().len(), 1); }