fix(templates): open_project ne rétro-synchronise plus les permissions agents vers les templates — #244 (QA verte)
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.
This commit is contained in:
@ -24,16 +24,16 @@ use application::{
|
|||||||
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
ListResumableAgentsInput, ListSkillsInput, ListSlashCommandsInput, LiveSessions,
|
||||||
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
|
||||||
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, ReadTemplateInput, RecallMemoryInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||||
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
|
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput,
|
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
||||||
RevokeDeviceInput, RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput,
|
RotateConversationLogInput, SaveChatDraftInput, SetActiveLayoutInput, SlashCommandEffect,
|
||||||
SlashCommandEffect, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
|
||||||
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateTemplateInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
||||||
};
|
};
|
||||||
use backend::stream::OutputSink;
|
use backend::stream::OutputSink;
|
||||||
use domain::ports::ModelServerRuntime;
|
use domain::ports::ModelServerRuntime;
|
||||||
@ -91,7 +91,7 @@ use crate::embedded_server::{
|
|||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::{AppState, FocusedProjectDto};
|
use crate::state::{AppState, FocusedProjectDto};
|
||||||
use crate::stream::TauriChannelSink;
|
use crate::stream::TauriChannelSink;
|
||||||
use domain::{AgentOrigin, DeviceId, SkillRef, SkillScope};
|
use domain::{DeviceId, SkillRef, SkillScope};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// `health` — trivial command validating the full IPC pipeline
|
/// `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
|
// live-state : les Running sans handle vivant deviennent terminales, les
|
||||||
// complétions WakeOwner non livrées repassent dans l'inbox unifiée.
|
// complétions WakeOwner non livrées repassent dans l'inbox unifiée.
|
||||||
let _ = state.reconcile_background_tasks.execute(id).await;
|
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).
|
// (Re)start the orchestrator watcher for this project (idempotent, §14.3).
|
||||||
state.ensure_orchestrator_watch(&output.project);
|
state.ensure_orchestrator_watch(&output.project);
|
||||||
state.reconcile_claude_run_dirs(&output.project).await;
|
state.reconcile_claude_run_dirs(&output.project).await;
|
||||||
Ok(ProjectDto::from(output))
|
Ok(ProjectDto::from(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_agent_templates_from_project(
|
#[cfg(test)]
|
||||||
project: &domain::Project,
|
mod open_project_template_tests {
|
||||||
state: &AppState,
|
use std::path::PathBuf;
|
||||||
) -> 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;
|
|
||||||
|
|
||||||
for agent in agents {
|
use application::{
|
||||||
let AgentOrigin::FromTemplate { template_id, .. } = agent.origin else {
|
CreateAgentFromTemplateInput, CreateProjectInput, CreateTemplateInput, ReadTemplateInput,
|
||||||
continue;
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentSystemPermissionsInput,
|
||||||
};
|
};
|
||||||
let template = match state
|
use domain::{McpToolPolicy, NetworkPolicy, SystemPermissionSet};
|
||||||
.read_template
|
use uuid::Uuid;
|
||||||
.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());
|
|
||||||
|
|
||||||
if template.default_agent_name.as_deref() == Some(agent.name.as_str())
|
use super::*;
|
||||||
&& template.default_profile_id == agent.profile_id
|
|
||||||
&& template.default_system_permissions == default_system_permissions
|
|
||||||
&& template.default_mcp_tool_policy == default_mcp_tool_policy
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
.update_template
|
std::env::temp_dir().join(format!("idea-open-project-{tag}-{}", Uuid::new_v4()))
|
||||||
.execute(UpdateTemplateInput {
|
}
|
||||||
template_id,
|
|
||||||
content: None,
|
#[tokio::test]
|
||||||
name: None,
|
async fn open_project_does_not_backfill_agent_permission_overrides_into_templates() {
|
||||||
default_profile_id: Some(agent.profile_id),
|
let state = AppState::build(temp_path("appdata"));
|
||||||
default_agent_name: Some(Some(agent.name)),
|
let project_root = temp_path("project");
|
||||||
default_system_permissions: Some(default_system_permissions),
|
let project = state
|
||||||
default_mcp_tool_policy: Some(default_mcp_tool_policy),
|
.create_project
|
||||||
})
|
.execute(CreateProjectInput {
|
||||||
.await?;
|
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.
|
/// `close_project` — persist state and release resources for a project.
|
||||||
|
|||||||
@ -202,8 +202,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use domain::ports::{EventBus, EventStream, IdGenerator, StoreError, TemplateStore};
|
use domain::ports::{EventBus, EventStream, IdGenerator, StoreError, TemplateStore};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentTemplate, DomainEvent, MarkdownDoc, ProfileId, ProjectId, ProjectPath, RemoteRef,
|
AgentTemplate, DomainEvent, MarkdownDoc, McpToolPolicy, NetworkPolicy, ProfileId,
|
||||||
TemplateVersion,
|
ProjectId, ProjectPath, RemoteRef, SystemPermissionSet, TemplateVersion,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -314,12 +314,24 @@ mod tests {
|
|||||||
"name": "Base",
|
"name": "Base",
|
||||||
"content": "v1",
|
"content": "v1",
|
||||||
"defaultProfileId": profile_id,
|
"defaultProfileId": profile_id,
|
||||||
|
"defaultAgentName": "Base Agent",
|
||||||
|
"defaultSystemPermissions": { "network": "allow" },
|
||||||
|
"defaultMcpToolPermissions": { "allowedTools": ["idea_template_read"] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let template_id = created["id"].as_str().unwrap().to_owned();
|
let template_id = created["id"].as_str().unwrap().to_owned();
|
||||||
assert_eq!(created["version"], json!(TemplateVersion::INITIAL));
|
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
|
let read = provider
|
||||||
.handle_template_tool(
|
.handle_template_tool(
|
||||||
@ -332,6 +344,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(read["name"], "Base");
|
assert_eq!(read["name"], "Base");
|
||||||
assert_eq!(read["contentMd"], "v1");
|
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
|
let listed = provider
|
||||||
.handle_template_tool(&project(), "agent", "idea_template_list", json!({}))
|
.handle_template_tool(&project(), "agent", "idea_template_list", json!({}))
|
||||||
@ -344,12 +365,48 @@ mod tests {
|
|||||||
&project(),
|
&project(),
|
||||||
"agent",
|
"agent",
|
||||||
"idea_template_update",
|
"idea_template_update",
|
||||||
json!({ "templateId": template_id, "content": "v2" }),
|
json!({
|
||||||
|
"templateId": template_id,
|
||||||
|
"content": "v2",
|
||||||
|
"defaultSystemPermissions": { "network": "deny" },
|
||||||
|
"defaultMcpToolPermissions": { "allowedTools": ["idea_template_update"] },
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated["contentMd"], "v2");
|
assert_eq!(updated["contentMd"], "v2");
|
||||||
assert_eq!(updated["version"], json!(2));
|
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!(
|
assert!(matches!(
|
||||||
events.events.lock().unwrap().as_slice(),
|
events.events.lock().unwrap().as_slice(),
|
||||||
[DomainEvent::TemplateUpdated { .. }]
|
[DomainEvent::TemplateUpdated { .. }]
|
||||||
|
|||||||
@ -277,14 +277,31 @@ async fn create_template_starts_at_initial_version() {
|
|||||||
name: "Backend".to_owned(),
|
name: "Backend".to_owned(),
|
||||||
content: "# ctx".to_owned(),
|
content: "# ctx".to_owned(),
|
||||||
default_profile_id: pid(7),
|
default_profile_id: pid(7),
|
||||||
default_agent_name: None,
|
default_agent_name: Some("Backend Agent".to_owned()),
|
||||||
default_system_permissions: None,
|
default_system_permissions: Some(SystemPermissionSet::new(Some(NetworkPolicy::Allow))),
|
||||||
default_mcp_tool_policy: None,
|
default_mcp_tool_policy: Some(McpToolPolicy {
|
||||||
|
allowed_tools: vec!["idea_template_read".to_owned()],
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(out.template.version, TemplateVersion::INITIAL);
|
assert_eq!(out.template.version, TemplateVersion::INITIAL);
|
||||||
assert_eq!(out.template.default_profile_id, pid(7));
|
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);
|
assert_eq!(store.list().await.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user