feat(domain,application,infrastructure,backend,app-tauri,frontend): lot utilisateur #350 — animation d'écartement des onglets au drag/reorder, icônes projet N&B colorisées derrière les messages, tools/skills plugin utilisables par les agents autorisés en headless, refs stables des skills assignés (QA verte)

- Tabs (shared/ui): cible de drop matérialisée par écartement before/after pendant le drag/reorder, reset via rAF sans reliquat, clavier et annonces préservés.
- projectIcons: icônes projet en noir et blanc par défaut, colorisées avec la couleur du projet, occupant la cellule et visibles derrière les messages rendus légèrement transparents.
- Permissions plugin headless: PluginMcpServerSpec transporte l'allowlist exacte authorized_tools regroupée par (pluginId, serverId) depuis les PluginToolRef; projection Codex enabled_tools + default_tools_approval_mode="approve" — une permission déjà accordée au niveau projet/template ne déclenche pas de seconde approbation, un tool non assigné reste hors surface appelable; réserves orchestrator/mcp server+tools alignées, dto backend + dto_agents app-tauri.
- Skills: ref stable plugin:<pluginId>:<skillId> exposée dans le contexte agent, idea_skill_read(ref=...) canonique via read_assigned_by_ref, erreur structurée ambiguousSkillName avec candidates; SkillsPanel dédoublonné (android-build-install); agents-dev/L12-skills.md à jour.
- Docs: ARCHITECTURE.md section 22.3 (projection des autorisations agent vers les runtimes plugin).
- QA verte #350 reconfirmée avant commit.
This commit is contained in:
2026-09-13 14:11:31 +02:00
parent 1e15bf9f96
commit 92d0dce2b7
23 changed files with 660 additions and 95 deletions

View File

@ -3801,7 +3801,7 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
for server in plugin_servers {
rendered.push('\n');
rendered.push_str(&format!(
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\n",
"[mcp_servers.{}]\ncommand = {}\nargs = [{}]\nenv = {}\ncwd = {}\nenabled_tools = [{}]\ndefault_tools_approval_mode = \"approve\"\n",
toml_string(&server.client_name()),
toml_string(&server.command),
server
@ -3812,6 +3812,12 @@ fn codex_plugin_mcp_declaration(plugin_servers: &[PluginMcpServerSpec]) -> Strin
.join(", "),
toml_inline_string_map(&server.env),
toml_string(&server.cwd),
server
.authorized_tools
.iter()
.map(|tool| toml_string(tool))
.collect::<Vec<_>>()
.join(", "),
));
}
rendered
@ -4468,7 +4474,7 @@ pub(crate) fn compose_convention_file(
out.push_str(
"**Skills** : workflows réutilisables. Ceux qui te sont assignés te sont listés \
plus haut dans ce fichier ; charge le détail d'un skill avec \
`idea_skill_read(name=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
`idea_skill_read(ref=…)` ; crée-en un avec `idea_create_skill`. Ne réinvente \
pas un workflow déjà couvert par un skill.\n\n",
);
} else {
@ -4517,7 +4523,7 @@ pub(crate) fn compose_convention_file(
if mcp_enabled {
out.push_str(
"Les skills suivants te sont assignés. Pour en exécuter un ou consulter son \
détail, appelle l'outil `idea_skill_read(name=…)` — n'improvise pas un \
détail, appelle l'outil `idea_skill_read(ref=…)` avec la ref affichée — n'improvise pas un \
workflow déjà couvert par un skill, charge-le.\n\n",
);
} else {
@ -4531,7 +4537,9 @@ pub(crate) fn compose_convention_file(
for skill in &effective.agent_capabilities {
out.push_str("**");
out.push_str(&skill.name);
out.push_str("** — ");
out.push_str("** (`");
out.push_str(&skill.reference);
out.push_str("`) — ");
out.push_str(&skill.description);
out.push_str(" (");
out.push_str(match skill.kind {
@ -4943,8 +4951,10 @@ mod tests {
);
// Both skill affordances are present, but bodies are not dumped.
assert!(doc.contains("**refactor** — REFAC_BODY (workflow)"));
assert!(doc.contains("**review** — REVIEW_BODY (workflow)"));
assert!(doc.contains("**refactor** (`skill:global:"));
assert!(doc.contains("`) — REFAC_BODY (workflow)"));
assert!(doc.contains("**review** (`skill:global:"));
assert!(doc.contains("`) — REVIEW_BODY (workflow)"));
assert!(!doc.contains("\n\nREFAC_BODY"));
assert!(!doc.contains("\n\nREVIEW_BODY"));
let awareness_at = doc.find("**Skills IdeA**").unwrap();
@ -5118,8 +5128,10 @@ mod tests {
"points to the file-protocol read path"
);
}
assert!(doc.contains("**refactor** — Refactors code (workflow)"));
assert!(doc.contains("**review** — Review skill (workflow)"));
assert!(doc.contains("**refactor** (`skill:global:"));
assert!(doc.contains("`) — Refactors code (workflow)"));
assert!(doc.contains("**review** (`skill:global:"));
assert!(doc.contains("`) — Review skill (workflow)"));
assert!(!doc.contains("REFAC_BODY"), "no full body is injected");
assert!(!doc.contains("REVIEW_BODY"), "no full body is injected");
assert!(!doc.contains("## refactor"));
@ -5541,6 +5553,7 @@ mod tests {
env: vec![("UNITY_SOCKET".to_owned(), "/tmp/unity.sock".to_owned())],
cwd: "/plugins/unity".to_owned(),
transport: "stdio".to_owned(),
authorized_tools: vec!["unity_detect_project".to_owned(), "unity_build".to_owned()],
}
}
@ -5565,6 +5578,9 @@ mod tests {
);
assert!(codex.contains(&format!("[mcp_servers.{}]", toml_string(&name))));
assert!(codex.contains("UNITY_SOCKET"));
assert!(codex.contains("enabled_tools = [\"unity_detect_project\", \"unity_build\"]"));
assert!(!codex.contains("unity_delete_project"));
assert!(codex.contains("default_tools_approval_mode = \"approve\""));
let opencode = opencode_mcp_servers(
vec!["idea".into(), "mcp-server".into()],

View File

@ -27,6 +27,15 @@ pub enum AppError {
#[error("invalid input: {0}")]
Invalid(String),
/// A legacy skill-name lookup matched more than one assigned capability.
#[error("skill name `{name}` is ambiguous; use one of these refs: {candidates:?}")]
AmbiguousSkillName {
/// Name supplied by the caller.
name: String,
/// Stable refs of every matching assigned skill.
candidates: Vec<String>,
},
/// An optimistic-concurrency `if_match` did not match the resource's current
/// version. Carries the current version so the caller can retry.
#[error("concurrency conflict: {0}")]
@ -150,6 +159,7 @@ impl AppError {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Invalid(_) => "INVALID",
Self::AmbiguousSkillName { .. } => "ambiguousSkillName",
Self::Conflict(_) => "CONFLICT",
Self::FileSystem(_) => "FILESYSTEM",
Self::Store(_) => "STORE",

View File

@ -1186,8 +1186,8 @@ pub struct OrchestratorService {
/// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
/// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
/// Use case de lecture d'un skill **par nom** (`idea_skill_read`, feature
/// « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau
/// Use case de lecture d'un skill par ref stable ou nom compatible
/// (`idea_skill_read`, feature « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau
/// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande
/// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts).
read_skill: Option<Arc<ReadSkill>>,
@ -2396,9 +2396,11 @@ impl OrchestratorService {
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
OrchestratorCommand::ReadSkill { name, requester } => {
self.read_skill(project, name, requester).await
}
OrchestratorCommand::ReadSkill {
reference,
name,
requester,
} => self.read_skill(project, reference, name, requester).await,
OrchestratorCommand::WriteMemory {
slug,
content,
@ -2997,14 +2999,15 @@ impl OrchestratorService {
})
}
/// `skill.read` → resolves a skill by name (project scope first, then global)
/// `skill.read` → resolves an assigned skill by stable ref or legacy name
/// and returns its Markdown body inline in the outcome's `reply`. Read-only:
/// `requester` is accepted for symmetry with the other read tools but skill
/// reads take no [`domain::fileguard::FileGuard`] lease.
async fn read_skill(
&self,
project: &Project,
name: String,
reference: Option<String>,
name: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let read_skill = self.read_skill.as_deref().ok_or_else(|| {
@ -3012,13 +3015,17 @@ impl OrchestratorService {
})?;
let md = read_skill
.execute(ReadSkillInput {
reference: reference.clone(),
name: name.clone(),
project: project.clone(),
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("read skill {name}"),
detail: format!(
"read skill {}",
reference.as_deref().or(name.as_deref()).unwrap_or_default()
),
reply: Some(md.into_string()),
})
}

View File

@ -3649,6 +3649,7 @@ async fn active_mcp_specs(
} else {
format!("{}/{}", plugin_root.trim_end_matches(['/', '\\']), command)
};
let authorized_tools = server.tools.iter().map(|tool| tool.name.clone()).collect();
specs.push(PluginMcpServerSpec {
identity: format!(
"plugin:{}:{}",
@ -3680,6 +3681,7 @@ async fn active_mcp_specs(
app_data_dir.as_deref(),
),
transport: server.transport,
authorized_tools,
});
}
}

View File

@ -1,6 +1,5 @@
//! Project-scoped plugin activation and per-agent capability assignments.
use std::collections::HashSet;
use std::sync::Arc;
use domain::{
@ -144,12 +143,16 @@ impl ProjectPluginResolver {
.await
.map_err(package_error)?;
let app_data_dir = self.packages.app_data_dir_label();
let mut seen = HashSet::new();
let mut spec_indexes = std::collections::HashMap::new();
let mut specs = Vec::new();
for reference in &assignments.tools {
let key = (reference.plugin_id.clone(), reference.server_id.clone());
if !seen.insert(key) {
if let Some(index) = spec_indexes.get(&key).copied() {
let spec: &mut PluginMcpServerSpec = &mut specs[index];
if !spec.authorized_tools.contains(&reference.tool_name) {
spec.authorized_tools.push(reference.tool_name.clone());
}
continue;
}
let Some(manifest) = self
@ -180,6 +183,7 @@ impl ProjectPluginResolver {
} else {
format!("{}/{}", package_root.trim_end_matches(['/', '\\']), command)
};
let index = specs.len();
specs.push(PluginMcpServerSpec {
identity: format!(
"plugin:{}:{}",
@ -211,7 +215,9 @@ impl ProjectPluginResolver {
app_data_dir.as_deref(),
),
transport: server.transport,
authorized_tools: vec![reference.tool_name.clone()],
});
spec_indexes.insert(key, index);
}
Ok(specs)
}
@ -896,8 +902,13 @@ mod tests {
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_detect_project".to_owned(),
};
let second_tool = PluginToolRef {
plugin_id: plugin_id(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
tool_name: "unity_build".to_owned(),
};
let resolver = resolver(
settings(true, vec![tool]),
settings(true, vec![tool.clone(), second_tool, tool]),
registry(PluginLifecycleState::Enabled),
manifest(true),
);
@ -923,6 +934,10 @@ mod tests {
vec![("PLUGIN_ROOT".to_owned(), "/plugins/unity".to_owned())]
);
assert_eq!(server.cwd, "/plugins/unity/work");
assert_eq!(
server.authorized_tools,
vec!["unity_detect_project".to_owned(), "unity_build".to_owned()]
);
}
#[tokio::test]

View File

@ -223,6 +223,9 @@ impl DeleteSkill {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapability {
/// Stable reference accepted by `idea_skill_read(ref=...)`.
#[serde(default)]
pub reference: String,
/// Capability display name.
pub name: String,
/// One-line affordance description.
@ -234,6 +237,7 @@ pub struct AgentCapability {
impl From<&AssignedSkillSnapshot> for AgentCapability {
fn from(skill: &AssignedSkillSnapshot) -> Self {
Self {
reference: skill.stable_ref(),
name: skill.name.clone(),
description: skill.description.clone(),
kind: skill.kind,
@ -415,6 +419,22 @@ impl AssignedSkillResolver {
Ok(assigned)
}
/// Reads the Markdown body for one skill by stable ref, but only inside the supplied
/// assigned-skill snapshot.
pub async fn read_assigned_by_ref(
&self,
snapshot: &OrchestrationCapabilitySnapshot,
root: &ProjectPath,
reference: &str,
) -> Result<MarkdownDoc, AppError> {
let selected = snapshot
.assigned_skills
.iter()
.find(|skill| skill.stable_ref() == reference)
.ok_or_else(|| AppError::NotFound(format!("assigned skill ref `{reference}`")))?;
self.read_assigned_snapshot(selected, root).await
}
/// Reads the Markdown body for one skill by name, but only inside the supplied
/// assigned-skill snapshot.
///
@ -435,27 +455,41 @@ impl AssignedSkillResolver {
let selected = match (matches.next(), matches.next()) {
(None, _) => return Err(AppError::NotFound(format!("assigned skill `{name}`"))),
(Some(skill), None) => skill,
(Some(_), Some(_)) => {
return Err(AppError::Invalid(format!(
"assigned skill name `{name}` is ambiguous for this agent"
)))
(Some(first), Some(second)) => {
let mut candidates = vec![first.stable_ref(), second.stable_ref()];
candidates.extend(matches.map(AssignedSkillSnapshot::stable_ref));
return Err(AppError::AmbiguousSkillName {
name: name.to_owned(),
candidates,
});
}
};
self.read_assigned_snapshot(selected, root).await
}
async fn read_assigned_snapshot(
&self,
selected: &AssignedSkillSnapshot,
root: &ProjectPath,
) -> Result<MarkdownDoc, AppError> {
if let Some(plugin) = &selected.plugin {
let resolver = self
.plugin_skills
.as_ref()
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
let resolver = self.plugin_skills.as_ref().ok_or_else(|| {
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
})?;
let manifest = resolver
.enabled_manifest(root, &plugin.plugin_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
.ok_or_else(|| {
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
})?;
let contribution = manifest
.contributes
.skills
.iter()
.find(|s| s.id == plugin.skill_id)
.ok_or_else(|| AppError::NotFound(format!("assigned plugin skill `{name}`")))?;
.ok_or_else(|| {
AppError::NotFound(format!("assigned plugin skill `{}`", selected.stable_ref()))
})?;
return resolver
.read_skill_body(&plugin.plugin_id, &contribution.path)
.await;
@ -471,8 +505,10 @@ impl AssignedSkillResolver {
/// Input for [`ReadSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadSkillInput {
/// Skill display name to resolve (case-insensitive).
pub name: String,
/// Stable assigned-skill reference. Takes precedence over `name`.
pub reference: Option<String>,
/// Legacy skill display name to resolve (case-insensitive).
pub name: Option<String>,
/// Active project.
pub project: Project,
/// Current requester identity from the orchestration handshake.
@ -532,9 +568,19 @@ impl ReadSkill {
.resolver
.resolve_for_agent(&agent, &input.project.root)
.await?;
self.resolver
.read_assigned_by_name(&snapshot, &input.project.root, &input.name)
.await
if let Some(reference) = input.reference.as_deref() {
self.resolver
.read_assigned_by_ref(&snapshot, &input.project.root, reference)
.await
} else if let Some(name) = input.name.as_deref() {
self.resolver
.read_assigned_by_name(&snapshot, &input.project.root, name)
.await
} else {
Err(AppError::Invalid(
"idea_skill_read requires `ref` or `name`".to_owned(),
))
}
}
}
@ -929,7 +975,35 @@ mod tests {
let body = uc
.execute(ReadSkillInput {
name: "deploy".to_owned(),
reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_returns_assigned_skill_by_stable_ref() {
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
let requester = agent_id(10);
let manifest = manifest_for(
requester,
vec![SkillRef {
skill_id: SkillId::from_uuid(uuid::Uuid::from_u128(1)),
scope: SkillScope::Project,
}],
);
let (uc, project, requester) = read_skill_uc(store, manifest);
let body = uc
.execute(ReadSkillInput {
reference: Some("skill:project:00000000-0000-0000-0000-000000000001".to_owned()),
name: None,
project,
requester: ConversationParty::agent(requester),
})
@ -955,7 +1029,8 @@ mod tests {
let body = uc
.execute(ReadSkillInput {
name: "deploy".to_owned(),
reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
@ -975,7 +1050,8 @@ mod tests {
let err = uc
.execute(ReadSkillInput {
name: "deploy".to_owned(),
reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
@ -1001,7 +1077,8 @@ mod tests {
let body = uc
.execute(ReadSkillInput {
name: "deploy".to_owned(),
reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
@ -1019,7 +1096,8 @@ mod tests {
let err = uc
.execute(ReadSkillInput {
name: "ghost".to_owned(),
reference: None,
name: Some("ghost".to_owned()),
project,
requester: ConversationParty::User,
})
@ -1052,12 +1130,69 @@ mod tests {
let err = uc
.execute(ReadSkillInput {
name: "deploy".to_owned(),
reference: None,
name: Some("deploy".to_owned()),
project,
requester: ConversationParty::agent(requester),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
assert!(
matches!(
err,
AppError::AmbiguousSkillName { ref name, ref candidates }
if name == "deploy"
&& *candidates == vec![
"skill:project:00000000-0000-0000-0000-000000000001".to_owned(),
"skill:global:00000000-0000-0000-0000-000000000002".to_owned(),
]
),
"got {err:?}"
);
}
#[tokio::test]
async fn duplicate_plugin_skill_name_reports_stable_plugin_refs() {
let resolver = AssignedSkillResolver::new(Arc::new(FakeSkillStore::default()));
let snapshot = OrchestrationCapabilitySnapshot::new(vec![
AssignedSkillSnapshot {
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
scope: SkillScope::Project,
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
plugin_id: domain::PluginId::new("dev.idea.android").unwrap(),
skill_id: domain::PluginSkillId::new("build-install").unwrap(),
}),
name: "android-build-install".to_owned(),
description: "Android".to_owned(),
kind: SkillKind::Workflow,
assignment_index: 0,
},
AssignedSkillSnapshot {
skill_id: SkillId::from_uuid(uuid::Uuid::nil()),
scope: SkillScope::Project,
plugin: Some(domain::orchestrator::AssignedPluginSkillSource {
plugin_id: domain::PluginId::new("dev.idea.unity").unwrap(),
skill_id: domain::PluginSkillId::new("android-build-install").unwrap(),
}),
name: "android-build-install".to_owned(),
description: "Unity Android".to_owned(),
kind: SkillKind::Workflow,
assignment_index: 1,
},
]);
let error = resolver
.read_assigned_by_name(&snapshot, &project().root, "android-build-install")
.await
.unwrap_err();
assert_eq!(error.code(), "ambiguousSkillName");
assert!(matches!(
error,
AppError::AmbiguousSkillName { ref candidates, .. }
if *candidates == vec![
"plugin:dev.idea.android:build-install".to_owned(),
"plugin:dev.idea.unity:android-build-install".to_owned(),
]
));
}
}