diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index c58fe6b7..298c5e9f 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -264,7 +264,7 @@ describe("request/response gateways preserve the Tauri command contract", () => contentMd: "# Body", version: 1, defaultProfileId: "p1", - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }; const spy = vi @@ -276,7 +276,7 @@ describe("request/response gateways preserve the Tauri command contract", () => await expect(gw.listTemplates()).resolves.toMatchObject([ { - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, ]); @@ -285,11 +285,11 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); await expect( @@ -297,11 +297,11 @@ describe("request/response gateways preserve the Tauri command contract", () => name: "Permissioned", content: "# Body", defaultProfileId: "p1", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }), ).resolves.toMatchObject({ - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); @@ -311,7 +311,7 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); @@ -322,7 +322,7 @@ describe("request/response gateways preserve the Tauri command contract", () => content: "# Body", defaultProfileId: "p1", defaultAgentName: null, - defaultSystemPermissions: { network: "allow" }, + defaultSystemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }, }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 5057612e..146104c6 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -820,6 +820,7 @@ export type NetworkPolicy = "allow" | "deny" | "ask"; /** Optional system permission bundle, distinct from file/bash permissions. */ export interface SystemPermissionSet { network?: NetworkPolicy; + crossProjectContactable?: boolean; } /** One sparse agent system-permission override. */ diff --git a/frontend/src/features/permissions/PermissionsPanel.tsx b/frontend/src/features/permissions/PermissionsPanel.tsx index b8e8f79d..ee06eef7 100644 --- a/frontend/src/features/permissions/PermissionsPanel.tsx +++ b/frontend/src/features/permissions/PermissionsPanel.tsx @@ -54,6 +54,19 @@ const NETWORK_LABELS: Record = { ask: "Demande", }; +const CROSS_PROJECT_HELP = + "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte."; + +function systemPermissionsFromDraft( + network: NetworkPolicy | "", + crossProjectContactable: boolean, +): SystemPermissionSet | null { + const permissions: SystemPermissionSet = {}; + if (network) permissions.network = network; + if (crossProjectContactable) permissions.crossProjectContactable = true; + return Object.keys(permissions).length > 0 ? permissions : null; +} + export function PermissionsPanel({ projectId }: PermissionsPanelProps) { const vm = usePermissions(projectId); const [target, setTarget] = useState({ type: "project" }); @@ -247,6 +260,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) { ? vm.systemDocument?.projectDefault ?? null : null } + showCrossProjectContactable={target.type === "agent"} busy={vm.busy} onSave={(permissions) => void handleSaveNetwork(permissions)} /> @@ -320,6 +334,7 @@ interface NetworkPermissionEditorProps { source: SystemPermissionSet | null; inherited: SystemPermissionSet | null; resolved: ResolvedAgentSystemPermissions | null; + showCrossProjectContactable: boolean; busy: boolean; onSave: (permissions: SystemPermissionSet | null) => void; } @@ -329,18 +344,27 @@ function NetworkPermissionEditor({ source, inherited, resolved, + showCrossProjectContactable, busy, onSave, }: NetworkPermissionEditorProps) { const current = source?.network ?? ""; + const currentCrossProjectContactable = + source?.crossProjectContactable ?? false; const [local, setLocal] = useState(current); + const [localCrossProjectContactable, setLocalCrossProjectContactable] = + useState(currentCrossProjectContactable); useEffect(() => { setLocal(current); - }, [current]); + setLocalCrossProjectContactable(currentCrossProjectContactable); + }, [current, currentCrossProjectContactable]); const readOnly = resolved?.control.mode === "readOnly"; - const changed = local !== current; + const changed = + local !== current || + (showCrossProjectContactable && + localCrossProjectContactable !== currentCrossProjectContactable); const wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null; const effective = resolved?.effective ?? null; const runtimeLocked = resolved?.runtimeLock.state === "locked"; @@ -432,12 +456,36 @@ function NetworkPermissionEditor({

)} + {showCrossProjectContactable && ( + + )} +
diff --git a/frontend/src/features/permissions/permissions.test.tsx b/frontend/src/features/permissions/permissions.test.tsx index b8239812..3c66bbc0 100644 --- a/frontend/src/features/permissions/permissions.test.tsx +++ b/frontend/src/features/permissions/permissions.test.tsx @@ -102,6 +102,33 @@ describe("PermissionsPanel", () => { expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0); }); + it("defaults cross-project contactability off and persists the agent checkbox", async () => { + const { permission } = await renderPanel(); + + fireEvent.click(screen.getByText("Builder")); + + const checkbox = screen.getByLabelText( + "Contactable depuis d’autres projets", + ) as HTMLInputElement; + expect(checkbox.checked).toBe(false); + expect( + screen.getByText( + "Autorise les agents d’autres projets IdeA à solliciter cet agent via l’orchestration inter-projets. L’agent reste soumis à ses permissions et à son contexte.", + ), + ).toBeTruthy(); + + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole("button", { name: "Save network" })); + + await waitFor(async () => { + const doc = await permission.getProjectSystemPermissions(PROJECT_ID); + expect(doc.agents?.[0]).toEqual({ + agentId: expect.any(String), + permissions: { crossProjectContactable: true }, + }); + }); + }); + it("renders the network control read-only when persisted control says so", async () => { const agent = new MockAgentGateway(); const permission = new MockPermissionGateway(); diff --git a/frontend/src/features/templates/TemplateEditor.tsx b/frontend/src/features/templates/TemplateEditor.tsx index bd84a66f..8f009b49 100644 --- a/frontend/src/features/templates/TemplateEditor.tsx +++ b/frontend/src/features/templates/TemplateEditor.tsx @@ -39,6 +39,9 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [ { value: "deny", label: "Réseau refusé" }, ]; +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)), ); @@ -61,6 +64,9 @@ export function TemplateEditor({ const [network, setNetwork] = useState<"" | NetworkPolicy>( template?.systemPermissions?.network ?? "", ); + const [crossProjectContactable, setCrossProjectContactable] = useState( + template?.systemPermissions?.crossProjectContactable ?? false, + ); const [allowedTools, setAllowedTools] = useState( template?.mcpToolPermissions?.allowedTools ?? [], ); @@ -85,7 +91,15 @@ export function TemplateEditor({ content, defaultProfileId, defaultAgentName: defaultAgentName.trim() || null, - systemPermissions: network ? { network } : null, + systemPermissions: + network || crossProjectContactable + ? { + ...(network ? { network } : {}), + ...(crossProjectContactable + ? { crossProjectContactable: true } + : {}), + } + : null, mcpToolPermissions: allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null, }); @@ -256,6 +270,25 @@ export function TemplateEditor({ options={NETWORK_OPTIONS} />
+ +
diff --git a/frontend/src/features/templates/templates.test.tsx b/frontend/src/features/templates/templates.test.tsx index ea3cc561..36a39373 100644 --- a/frontend/src/features/templates/templates.test.tsx +++ b/frontend/src/features/templates/templates.test.tsx @@ -173,6 +173,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { target: { value: "profile-main" }, }); chooseDropdownOption("template system network permission", "Réseau autorisé"); + fireEvent.click(screen.getByLabelText("Contactable depuis d’autres projets")); fireEvent.click(screen.getByLabelText("template MCP tool idea_ticket_read")); fireEvent.click(screen.getByRole("button", { name: "Save template" })); @@ -186,11 +187,30 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => { expect(templates[0]).toMatchObject({ defaultAgentName: "Main", defaultProfileId: "profile-main", - systemPermissions: { network: "allow" }, + systemPermissions: { network: "allow", crossProjectContactable: true }, mcpToolPermissions: { allowedTools: ["idea_ticket_read"] }, }); }); + it("defaults template cross-project contactability off when the field is absent", async () => { + renderTemplatesPanel(); + await waitForTemplatesIdle(); + + fireEvent.click(screen.getByRole("button", { name: "create template" })); + await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy()); + + const checkbox = screen.getByLabelText( + "Contactable depuis d’autres projets", + ) as HTMLInputElement; + + expect(checkbox.checked).toBe(false); + expect( + screen.getByText( + "Les agents créés avec ce template reprendront cette permission.", + ), + ).toBeTruthy(); + }); + it("the Create button is disabled when the name is empty", async () => { renderTemplatesPanel(); await waitForTemplatesIdle();