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

@ -92,6 +92,25 @@ impl OrchestrationCapabilitySnapshot {
}
}
impl AssignedSkillSnapshot {
/// Stable reference accepted by `idea_skill_read(ref=...)`.
#[must_use]
pub fn stable_ref(&self) -> String {
if let Some(plugin) = &self.plugin {
return format!(
"plugin:{}:{}",
plugin.plugin_id.as_str(),
plugin.skill_id.as_str()
);
}
let scope = match self.scope {
SkillScope::Global => "global",
SkillScope::Project => "project",
};
format!("skill:{scope}:{}", self.skill_id)
}
}
/// Errors raised while validating a raw [`OrchestratorRequest`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OrchestratorError {
@ -164,6 +183,10 @@ pub struct OrchestratorRequest {
/// Target agent display name (required by every v1 action).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Stable assigned-skill reference for `skill.read`. Preferred over `name`;
/// kept separate so legacy name-based callers remain compatible.
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub skill_ref: Option<String>,
/// V2 target agent display name (`agent.run`/`agent.stop`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_agent: Option<String>,
@ -411,13 +434,15 @@ pub enum OrchestratorCommand {
/// The reading party (handshake identity).
requester: ConversationParty,
},
/// Read a reusable skill's Markdown body **by name** (`idea_skill_read`).
/// Read a reusable skill's Markdown body by stable ref, or by legacy name.
/// The application layer authorizes the read against the requester's assigned
/// runtime skill snapshot; unassigned skills are not readable. Read-only — no
/// [`crate::fileguard::FileGuard`] lease (skills are not mutated through this path).
ReadSkill {
/// Skill display name to resolve (case-insensitive).
name: String,
/// Stable reference (`plugin:<pluginId>:<skillId>` for plugin skills).
reference: Option<String>,
/// Legacy display-name lookup (case-insensitive).
name: Option<String>,
/// The party that issued the read (handshake identity). Must be an agent
/// requester for authorization against assigned skills.
requester: ConversationParty,
@ -651,10 +676,29 @@ impl OrchestratorRequest {
slug: self.optional_slug(),
requester: self.requester_party(),
}),
"skill.read" => Ok(OrchestratorCommand::ReadSkill {
name: self.require_name(action)?,
requester: self.requester_party(),
}),
"skill.read" => {
let reference = self
.skill_ref
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let name = self
.name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
if reference.is_none() && name.is_none() {
return Err(OrchestratorError::MissingField {
action: action.to_owned(),
field: "ref or name".to_owned(),
});
}
Ok(OrchestratorCommand::ReadSkill {
reference: reference.map(str::to_owned),
name: name.map(str::to_owned),
requester: self.requester_party(),
})
}
"memory.write" => Ok(OrchestratorCommand::WriteMemory {
slug: self.require("slug", action, self.slug.as_deref())?,
content: self.require("content", action, self.content.as_deref())?,

View File

@ -924,6 +924,12 @@ pub struct PluginMcpServerSpec {
pub cwd: String,
/// Transport.
pub transport: String,
/// Exact MCP tool names authorized for the target agent.
///
/// This allowlist is carried with the launch spec so provider adapters never
/// have to infer authorization from the server's advertised catalogue.
#[serde(default)]
pub authorized_tools: Vec<String>,
}
impl PluginMcpServerSpec {
@ -1044,6 +1050,7 @@ mod tests {
env: Vec::new(),
cwd: String::new(),
transport: "stdio".to_owned(),
authorized_tools: Vec::new(),
}
}