fix(permissions): tools MCP inter-projets exposés (idea_project_*) + contactable agent/template persisté — update_agent_contactable (application/web-server/app-tauri), defaultContactable TemplateEditor save/reopen, groupes MCP 'IdeA inter-projets' avec fallback, permissions agent Contactable — #267 (QA verte)

This commit is contained in:
2026-09-07 18:13:27 +02:00
parent 9266d121cd
commit f6101a8249
30 changed files with 632 additions and 89 deletions

View File

@ -4,12 +4,16 @@ import remarkGfm from "remark-gfm";
import type {
AgentProfile,
McpToolCatalogue,
McpToolPolicy,
NetworkPolicy,
SystemPermissionSet,
Template,
} from "@/domain";
import { MCP_TOOL_GROUPS, mcpToolLabel } from "@/features/permissions/mcpToolGroups";
import {
mcpToolGroupsForCatalogue,
mcpToolLabel,
} from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared";
import { profileLabel } from "./templatePresentation";
@ -18,6 +22,7 @@ export interface TemplateEditorSaveInput {
content: string;
defaultProfileId: string;
defaultAgentName: string | null;
defaultContactable: boolean;
systemPermissions: SystemPermissionSet | null;
mcpToolPermissions: McpToolPolicy | null;
}
@ -25,6 +30,7 @@ export interface TemplateEditorSaveInput {
export interface TemplateEditorProps {
template?: Template | null;
profiles: AgentProfile[];
mcpCatalogue: McpToolCatalogue;
onSave: (input: TemplateEditorSaveInput) => Promise<void>;
onClose: () => void;
busy?: boolean;
@ -42,13 +48,10 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
const TEMPLATE_CROSS_PROJECT_HELP =
"Les agents créés avec ce template reprendront cette permission.";
const ALL_MCP_TOOLS = Array.from(
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
);
export function TemplateEditor({
template,
profiles,
mcpCatalogue,
onSave,
onClose,
busy = false,
@ -64,13 +67,21 @@ export function TemplateEditor({
const [network, setNetwork] = useState<"" | NetworkPolicy>(
template?.systemPermissions?.network ?? "",
);
const [crossProjectContactable, setCrossProjectContactable] = useState(
template?.systemPermissions?.crossProjectContactable ?? false,
const [defaultContactable, setDefaultContactable] = useState(
template?.defaultContactable ?? false,
);
const [allowedTools, setAllowedTools] = useState<string[]>(
template?.mcpToolPermissions?.allowedTools ?? [],
);
const [tab, setTab] = useState<EditorTab>("edit");
const toolGroups = useMemo(
() => mcpToolGroupsForCatalogue(mcpCatalogue),
[mcpCatalogue],
);
const allMcpTools = useMemo(
() => toolGroups.flatMap((group) => group.tools),
[toolGroups],
);
const allowed = useMemo(() => new Set(allowedTools), [allowedTools]);
const canSave = name.trim().length > 0 && !busy;
@ -91,15 +102,8 @@ export function TemplateEditor({
content,
defaultProfileId,
defaultAgentName: defaultAgentName.trim() || null,
systemPermissions:
network || crossProjectContactable
? {
...(network ? { network } : {}),
...(crossProjectContactable
? { crossProjectContactable: true }
: {}),
}
: null,
defaultContactable,
systemPermissions: network ? { network } : null,
mcpToolPermissions:
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
});
@ -275,8 +279,8 @@ export function TemplateEditor({
<input
type="checkbox"
aria-label="Contactable depuis d’autres projets"
checked={crossProjectContactable}
onChange={(e) => setCrossProjectContactable(e.target.checked)}
checked={defaultContactable}
onChange={(e) => setDefaultContactable(e.target.checked)}
disabled={busy}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
@ -298,7 +302,7 @@ export function TemplateEditor({
</h3>
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
</div>
{ALL_MCP_TOOLS.length === 0 ? (
{allMcpTools.length === 0 ? (
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
) : (
<>
@ -308,7 +312,7 @@ export function TemplateEditor({
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{MCP_TOOL_GROUPS.map((group) => (
{toolGroups.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
{group.tools.map((tool) => (

View File

@ -17,7 +17,7 @@
import { useEffect, useState } from "react";
import type { AgentProfile, Template } from "@/domain";
import type { AgentProfile, McpToolCatalogue, Template } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
import { TemplateEditor, type TemplateEditorSaveInput } from "./TemplateEditor";
@ -33,6 +33,11 @@ export interface TemplatesPanelProps {
projectId: string;
}
const EMPTY_MCP_CATALOGUE: McpToolCatalogue = {
readOnlyTools: [],
writeActionTools: [],
};
/** Editor open-state: "create" or edit-mode for a specific template. */
type EditorState =
| { mode: "create" }
@ -40,11 +45,12 @@ type EditorState =
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
const vm = useTemplates();
const { profile } = useGateways();
const { permission, profile } = useGateways();
// Profiles list for the editor's default-profile selector
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [profilesLoaded, setProfilesLoaded] = useState(false);
const [mcpCatalogue, setMcpCatalogue] = useState<McpToolCatalogue | null>(null);
const [editorState, setEditorState] = useState<EditorState | null>(null);
const [editorBusy, setEditorBusy] = useState(false);
@ -69,6 +75,25 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
};
}, [profile, profilesLoaded]);
useEffect(() => {
if (!permission) {
setMcpCatalogue(EMPTY_MCP_CATALOGUE);
return;
}
let cancelled = false;
void permission
.getMcpToolPermissions(projectId)
.then((doc) => {
if (!cancelled) setMcpCatalogue(doc.catalogue);
})
.catch(() => {
if (!cancelled) setMcpCatalogue(EMPTY_MCP_CATALOGUE);
});
return () => {
cancelled = true;
};
}, [permission, projectId]);
/** Lazily load profiles when the editor is first opened. */
async function openEditor(state: EditorState) {
if (!profilesLoaded) {
@ -105,6 +130,7 @@ export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
<TemplateEditor
template={editorState.mode === "edit" ? editorState.template : null}
profiles={profiles}
mcpCatalogue={mcpCatalogue ?? EMPTY_MCP_CATALOGUE}
onSave={handleSave}
onClose={() => setEditorState(null)}
busy={editorBusy}

View File

@ -17,7 +17,12 @@
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
import {
MockAgentGateway,
MockPermissionGateway,
MockProfileGateway,
MockTemplateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { TemplatesPanel } from "./TemplatesPanel";
@ -33,6 +38,7 @@ interface RenderOpts {
agent?: MockAgentGateway;
template?: MockTemplateGateway;
profile?: MockProfileGateway;
permission?: MockPermissionGateway;
}
/**
@ -43,11 +49,13 @@ function renderTemplatesPanel(opts: RenderOpts = {}) {
const agent = opts.agent ?? new MockAgentGateway();
const tmpl = opts.template ?? new MockTemplateGateway(agent);
const profile = opts.profile ?? new MockProfileGateway();
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
const permission = opts.permission ?? new MockPermissionGateway();
const gateways = { agent, permission, profile, template: tmpl } as unknown as Gateways;
return {
agent,
tmpl,
profile,
permission,
...render(
<DIProvider gateways={gateways}>
<TemplatesPanel projectId={PROJECT_ID} />
@ -187,11 +195,40 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
expect(templates[0]).toMatchObject({
defaultAgentName: "Main",
defaultProfileId: "profile-main",
systemPermissions: { network: "allow", crossProjectContactable: true },
defaultContactable: true,
systemPermissions: { network: "allow" },
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
});
});
it("preserves defaultContactable after save and reopen", async () => {
const { tmpl } = renderTemplatesPanel();
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await screen.findByLabelText("template name");
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: "Contactable" },
});
fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets"));
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
await screen.findByText("Contactable");
expect((await tmpl.listTemplates())[0]).toMatchObject({
defaultContactable: true,
systemPermissions: null,
});
fireEvent.click(screen.getByRole("button", { name: "edit Contactable" }));
await waitFor(() => {
expect(
(screen.getByLabelText(
"Contactable depuis d’autres projets",
) as HTMLInputElement).checked,
).toBe(true);
});
});
it("defaults template cross-project contactability off when the field is absent", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
@ -211,6 +248,28 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
).toBeTruthy();
});
it("shows cross-project and ungrouped catalogued MCP tools in the template editor", async () => {
const permission = new MockPermissionGateway();
permission.setMcpToolCatalogue({
readOnlyTools: ["idea_project_list", "idea_future_read"],
writeActionTools: ["idea_project_cancel_agent"],
});
renderTemplatesPanel({ permission });
await waitForTemplatesIdle();
fireEvent.click(screen.getByRole("button", { name: "create template" }));
expect(await screen.findByText("IdeA inter-projets")).toBeTruthy();
expect(
screen.getByLabelText("template MCP tool idea_project_cancel_agent"),
).toBeTruthy();
expect(screen.getByText("Autres")).toBeTruthy();
const fallback = screen.getByLabelText(
"template MCP tool idea_future_read",
) as HTMLInputElement;
expect(fallback.disabled).toBe(false);
});
it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();