feat(cross-project): surface UI des permissions contactables — PermissionsPanel et TemplateEditor exposent contactable/default_contactable, alignement httpInvoker — #260 (typecheck + 48 tests verts, QA en attente)
This commit is contained in:
@ -264,7 +264,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
contentMd: "# Body",
|
contentMd: "# Body",
|
||||||
version: 1,
|
version: 1,
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultSystemPermissions: { network: "allow" },
|
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
defaultMcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
};
|
};
|
||||||
const spy = vi
|
const spy = vi
|
||||||
@ -276,7 +276,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
|
|
||||||
await expect(gw.listTemplates()).resolves.toMatchObject([
|
await expect(gw.listTemplates()).resolves.toMatchObject([
|
||||||
{
|
{
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -285,11 +285,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
name: "Permissioned",
|
name: "Permissioned",
|
||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
@ -297,11 +297,11 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
name: "Permissioned",
|
name: "Permissioned",
|
||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
}),
|
}),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -311,7 +311,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultAgentName: null,
|
defaultAgentName: null,
|
||||||
defaultSystemPermissions: { network: "allow" },
|
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -322,7 +322,7 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
|||||||
content: "# Body",
|
content: "# Body",
|
||||||
defaultProfileId: "p1",
|
defaultProfileId: "p1",
|
||||||
defaultAgentName: null,
|
defaultAgentName: null,
|
||||||
defaultSystemPermissions: { network: "allow" },
|
defaultSystemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -820,6 +820,7 @@ export type NetworkPolicy = "allow" | "deny" | "ask";
|
|||||||
/** Optional system permission bundle, distinct from file/bash permissions. */
|
/** Optional system permission bundle, distinct from file/bash permissions. */
|
||||||
export interface SystemPermissionSet {
|
export interface SystemPermissionSet {
|
||||||
network?: NetworkPolicy;
|
network?: NetworkPolicy;
|
||||||
|
crossProjectContactable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One sparse agent system-permission override. */
|
/** One sparse agent system-permission override. */
|
||||||
|
|||||||
@ -54,6 +54,19 @@ const NETWORK_LABELS: Record<NetworkPolicy, string> = {
|
|||||||
ask: "Demande",
|
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) {
|
export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
||||||
const vm = usePermissions(projectId);
|
const vm = usePermissions(projectId);
|
||||||
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
const [target, setTarget] = useState<EditorTarget>({ type: "project" });
|
||||||
@ -247,6 +260,7 @@ export function PermissionsPanel({ projectId }: PermissionsPanelProps) {
|
|||||||
? vm.systemDocument?.projectDefault ?? null
|
? vm.systemDocument?.projectDefault ?? null
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
showCrossProjectContactable={target.type === "agent"}
|
||||||
busy={vm.busy}
|
busy={vm.busy}
|
||||||
onSave={(permissions) => void handleSaveNetwork(permissions)}
|
onSave={(permissions) => void handleSaveNetwork(permissions)}
|
||||||
/>
|
/>
|
||||||
@ -320,6 +334,7 @@ interface NetworkPermissionEditorProps {
|
|||||||
source: SystemPermissionSet | null;
|
source: SystemPermissionSet | null;
|
||||||
inherited: SystemPermissionSet | null;
|
inherited: SystemPermissionSet | null;
|
||||||
resolved: ResolvedAgentSystemPermissions | null;
|
resolved: ResolvedAgentSystemPermissions | null;
|
||||||
|
showCrossProjectContactable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onSave: (permissions: SystemPermissionSet | null) => void;
|
onSave: (permissions: SystemPermissionSet | null) => void;
|
||||||
}
|
}
|
||||||
@ -329,18 +344,27 @@ function NetworkPermissionEditor({
|
|||||||
source,
|
source,
|
||||||
inherited,
|
inherited,
|
||||||
resolved,
|
resolved,
|
||||||
|
showCrossProjectContactable,
|
||||||
busy,
|
busy,
|
||||||
onSave,
|
onSave,
|
||||||
}: NetworkPermissionEditorProps) {
|
}: NetworkPermissionEditorProps) {
|
||||||
const current = source?.network ?? "";
|
const current = source?.network ?? "";
|
||||||
|
const currentCrossProjectContactable =
|
||||||
|
source?.crossProjectContactable ?? false;
|
||||||
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
const [local, setLocal] = useState<NetworkPolicy | "">(current);
|
||||||
|
const [localCrossProjectContactable, setLocalCrossProjectContactable] =
|
||||||
|
useState(currentCrossProjectContactable);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocal(current);
|
setLocal(current);
|
||||||
}, [current]);
|
setLocalCrossProjectContactable(currentCrossProjectContactable);
|
||||||
|
}, [current, currentCrossProjectContactable]);
|
||||||
|
|
||||||
const readOnly = resolved?.control.mode === "readOnly";
|
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 wanted = resolved?.wanted ?? source?.network ?? inherited?.network ?? null;
|
||||||
const effective = resolved?.effective ?? null;
|
const effective = resolved?.effective ?? null;
|
||||||
const runtimeLocked = resolved?.runtimeLock.state === "locked";
|
const runtimeLocked = resolved?.runtimeLock.state === "locked";
|
||||||
@ -432,12 +456,36 @@ function NetworkPermissionEditor({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCrossProjectContactable && (
|
||||||
|
<label className="flex items-start gap-2 rounded-md border border-border/70 bg-raised px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Contactable depuis d’autres projets"
|
||||||
|
checked={localCrossProjectContactable}
|
||||||
|
onChange={(e) => setLocalCrossProjectContactable(e.target.checked)}
|
||||||
|
disabled={busy || readOnly}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-content">
|
||||||
|
Contactable depuis d’autres projets
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted">{CROSS_PROJECT_HELP}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Clear network"
|
aria-label="Clear network"
|
||||||
disabled={busy || readOnly || !source?.network}
|
disabled={
|
||||||
|
busy ||
|
||||||
|
readOnly ||
|
||||||
|
(!source?.network &&
|
||||||
|
!(showCrossProjectContactable && source?.crossProjectContactable))
|
||||||
|
}
|
||||||
onClick={() => onSave(null)}
|
onClick={() => onSave(null)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@ -448,7 +496,14 @@ function NetworkPermissionEditor({
|
|||||||
aria-label="Save network"
|
aria-label="Save network"
|
||||||
disabled={busy || readOnly || !changed}
|
disabled={busy || readOnly || !changed}
|
||||||
loading={busy && changed}
|
loading={busy && changed}
|
||||||
onClick={() => onSave(local ? { network: local } : null)}
|
onClick={() =>
|
||||||
|
onSave(
|
||||||
|
systemPermissionsFromDraft(
|
||||||
|
local,
|
||||||
|
showCrossProjectContactable && localCrossProjectContactable,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -102,6 +102,33 @@ describe("PermissionsPanel", () => {
|
|||||||
expect(screen.getAllByText("Réseau autorisé").length).toBeGreaterThan(0);
|
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 () => {
|
it("renders the network control read-only when persisted control says so", async () => {
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const permission = new MockPermissionGateway();
|
const permission = new MockPermissionGateway();
|
||||||
|
|||||||
@ -39,6 +39,9 @@ const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
|
|||||||
{ value: "deny", label: "Réseau refusé" },
|
{ 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(
|
const ALL_MCP_TOOLS = Array.from(
|
||||||
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
|
new Set(MCP_TOOL_GROUPS.flatMap((group) => group.tools)),
|
||||||
);
|
);
|
||||||
@ -61,6 +64,9 @@ export function TemplateEditor({
|
|||||||
const [network, setNetwork] = useState<"" | NetworkPolicy>(
|
const [network, setNetwork] = useState<"" | NetworkPolicy>(
|
||||||
template?.systemPermissions?.network ?? "",
|
template?.systemPermissions?.network ?? "",
|
||||||
);
|
);
|
||||||
|
const [crossProjectContactable, setCrossProjectContactable] = useState(
|
||||||
|
template?.systemPermissions?.crossProjectContactable ?? false,
|
||||||
|
);
|
||||||
const [allowedTools, setAllowedTools] = useState<string[]>(
|
const [allowedTools, setAllowedTools] = useState<string[]>(
|
||||||
template?.mcpToolPermissions?.allowedTools ?? [],
|
template?.mcpToolPermissions?.allowedTools ?? [],
|
||||||
);
|
);
|
||||||
@ -85,7 +91,15 @@ export function TemplateEditor({
|
|||||||
content,
|
content,
|
||||||
defaultProfileId,
|
defaultProfileId,
|
||||||
defaultAgentName: defaultAgentName.trim() || null,
|
defaultAgentName: defaultAgentName.trim() || null,
|
||||||
systemPermissions: network ? { network } : null,
|
systemPermissions:
|
||||||
|
network || crossProjectContactable
|
||||||
|
? {
|
||||||
|
...(network ? { network } : {}),
|
||||||
|
...(crossProjectContactable
|
||||||
|
? { crossProjectContactable: true }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
mcpToolPermissions:
|
mcpToolPermissions:
|
||||||
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
allowedTools.length > 0 ? { allowedTools: [...allowedTools] } : null,
|
||||||
});
|
});
|
||||||
@ -256,6 +270,25 @@ export function TemplateEditor({
|
|||||||
options={NETWORK_OPTIONS}
|
options={NETWORK_OPTIONS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex min-w-0 items-start gap-2 rounded-md border border-border bg-raised px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="Contactable depuis d’autres projets"
|
||||||
|
checked={crossProjectContactable}
|
||||||
|
onChange={(e) => setCrossProjectContactable(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-content">
|
||||||
|
Contactable depuis d’autres projets
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{TEMPLATE_CROSS_PROJECT_HELP}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">
|
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">
|
||||||
|
|||||||
@ -173,6 +173,7 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
target: { value: "profile-main" },
|
target: { value: "profile-main" },
|
||||||
});
|
});
|
||||||
chooseDropdownOption("template system network permission", "Réseau autorisé");
|
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.getByLabelText("template MCP tool idea_ticket_read"));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
|
||||||
|
|
||||||
@ -186,11 +187,30 @@ describe("TemplatesPanel (with MockTemplateGateway)", () => {
|
|||||||
expect(templates[0]).toMatchObject({
|
expect(templates[0]).toMatchObject({
|
||||||
defaultAgentName: "Main",
|
defaultAgentName: "Main",
|
||||||
defaultProfileId: "profile-main",
|
defaultProfileId: "profile-main",
|
||||||
systemPermissions: { network: "allow" },
|
systemPermissions: { network: "allow", crossProjectContactable: true },
|
||||||
mcpToolPermissions: { allowedTools: ["idea_ticket_read"] },
|
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 () => {
|
it("the Create button is disabled when the name is empty", async () => {
|
||||||
renderTemplatesPanel();
|
renderTemplatesPanel();
|
||||||
await waitForTemplatesIdle();
|
await waitForTemplatesIdle();
|
||||||
|
|||||||
Reference in New Issue
Block a user