mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add deployment-wide template allowlist for chats (#23262)
- Stores a deployment-wide agents template allowlist in `site_configs` (`agents_template_allowlist`) - Adds `GET/PUT /api/experimental/chats/config/template-allowlist` endpoints - Filters `list_templates`, `read_template`, and `create_workspace` chat tools by allowlist, if defined (empty=all allowed) - Add "Templates" admin settings tab in Agents UI ([what it looks like](https://624de63c6aacee003aa84340-sitjilsyrr.chromatic.com/?path=/story/pages-agentspage-agentsettingspageview--template-allowlist)) > 🤖 This PR was created with the help of Coder Agents, and has been reviewed by my human. 🧑💻
This commit is contained in:
@@ -3216,12 +3216,29 @@ class ExperimentalApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatTemplateAllowlist =
|
||||
async (): Promise<TypesGen.ChatTemplateAllowlist> => {
|
||||
const response = await this.axios.get<TypesGen.ChatTemplateAllowlist>(
|
||||
"/api/experimental/chats/config/template-allowlist",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
updateChatWorkspaceTTL = async (
|
||||
req: TypesGen.UpdateChatWorkspaceTTLRequest,
|
||||
): Promise<void> => {
|
||||
await this.axios.put("/api/experimental/chats/config/workspace-ttl", req);
|
||||
};
|
||||
|
||||
updateChatTemplateAllowlist = async (
|
||||
req: TypesGen.ChatTemplateAllowlist,
|
||||
): Promise<void> => {
|
||||
await this.axios.put(
|
||||
"/api/experimental/chats/config/template-allowlist",
|
||||
req,
|
||||
);
|
||||
};
|
||||
|
||||
getUserChatCustomPrompt =
|
||||
async (): Promise<TypesGen.UserChatCustomPrompt> => {
|
||||
const response = await this.axios.get<TypesGen.UserChatCustomPrompt>(
|
||||
|
||||
@@ -439,6 +439,22 @@ export const updateChatWorkspaceTTL = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const chatTemplateAllowlistKey = ["chat-template-allowlist"] as const;
|
||||
|
||||
export const chatTemplateAllowlist = () => ({
|
||||
queryKey: chatTemplateAllowlistKey,
|
||||
queryFn: () => API.experimental.getChatTemplateAllowlist(),
|
||||
});
|
||||
|
||||
export const updateChatTemplateAllowlist = (queryClient: QueryClient) => ({
|
||||
mutationFn: API.experimental.updateChatTemplateAllowlist,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: chatTemplateAllowlistKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const chatUserCustomPromptKey = ["chat-user-custom-prompt"] as const;
|
||||
|
||||
export const chatUserCustomPrompt = () => ({
|
||||
|
||||
Generated
+10
@@ -1880,6 +1880,16 @@ export interface ChatSystemPrompt {
|
||||
readonly system_prompt: string;
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
/**
|
||||
* ChatTemplateAllowlist is the request and response body for the
|
||||
* chat template allowlist configuration endpoint. An empty list
|
||||
* means all templates are allowed.
|
||||
*/
|
||||
export interface ChatTemplateAllowlist {
|
||||
readonly template_ids: readonly string[];
|
||||
}
|
||||
|
||||
// From codersdk/chats.go
|
||||
export interface ChatTextPart {
|
||||
readonly type: "text";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MockUserOwner } from "testHelpers/entities";
|
||||
import { MockTemplate, MockUserOwner } from "testHelpers/entities";
|
||||
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
@@ -170,6 +170,30 @@ const meta = {
|
||||
workspace_ttl_ms: 0,
|
||||
});
|
||||
spyOn(API.experimental, "updateChatWorkspaceTTL").mockResolvedValue();
|
||||
spyOn(API.experimental, "getChatTemplateAllowlist").mockResolvedValue({
|
||||
template_ids: [],
|
||||
});
|
||||
spyOn(API.experimental, "updateChatTemplateAllowlist").mockResolvedValue();
|
||||
spyOn(API, "getTemplates").mockResolvedValue([
|
||||
{
|
||||
...MockTemplate,
|
||||
id: "abc-123",
|
||||
name: "docker-dev",
|
||||
display_name: "Docker Development",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
id: "def-456",
|
||||
name: "kubernetes-prod",
|
||||
display_name: "Kubernetes Production",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
id: "ghi-789",
|
||||
name: "aws-windows",
|
||||
display_name: "AWS Windows Desktop",
|
||||
},
|
||||
]);
|
||||
},
|
||||
} satisfies Meta<typeof AgentSettingsPageView>;
|
||||
|
||||
@@ -837,3 +861,126 @@ export const NoWarningForCleanPrompt: Story = {
|
||||
expect(canvas.queryByText(/invisible Unicode/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Templates tab stories ──────────────────────────────────────
|
||||
|
||||
const manyTemplates = [
|
||||
{ id: "t-01", name: "docker-dev", display_name: "Docker Development" },
|
||||
{
|
||||
id: "t-02",
|
||||
name: "kubernetes-prod",
|
||||
display_name: "Kubernetes Production",
|
||||
},
|
||||
{ id: "t-03", name: "aws-windows", display_name: "AWS Windows Desktop" },
|
||||
{ id: "t-04", name: "gcp-linux", display_name: "GCP Linux Workspace" },
|
||||
{ id: "t-05", name: "azure-dotnet", display_name: "Azure .NET Environment" },
|
||||
{ id: "t-06", name: "ml-jupyter", display_name: "ML Jupyter Notebook" },
|
||||
{
|
||||
id: "t-07",
|
||||
name: "data-eng-spark",
|
||||
display_name: "Data Engineering (Spark)",
|
||||
},
|
||||
{
|
||||
id: "t-08",
|
||||
name: "frontend-vite",
|
||||
display_name: "Frontend (Vite + React)",
|
||||
},
|
||||
].map((t) => ({ ...MockTemplate, ...t }));
|
||||
|
||||
export const TemplateAllowlist: Story = {
|
||||
args: {
|
||||
activeSection: "templates",
|
||||
canManageChatModelConfigs: true,
|
||||
canSetSystemPrompt: true,
|
||||
},
|
||||
beforeEach: () => {
|
||||
// Track saved allowlist state across mock calls so the
|
||||
// refetch after save returns the updated value.
|
||||
let savedIDs: string[] = [];
|
||||
|
||||
spyOn(API, "getTemplates").mockResolvedValue(manyTemplates);
|
||||
spyOn(API.experimental, "getChatTemplateAllowlist").mockImplementation(
|
||||
async () => ({ template_ids: savedIDs }),
|
||||
);
|
||||
spyOn(API.experimental, "updateChatTemplateAllowlist").mockImplementation(
|
||||
async (req) => {
|
||||
savedIDs = [...req.template_ids];
|
||||
},
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement, step }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("starts empty", async () => {
|
||||
// Status text confirms no restrictions.
|
||||
await canvas.findByText(/no templates selected/i);
|
||||
// Save is disabled — nothing to save.
|
||||
const saveBtn = await canvas.findByRole("button", { name: "Save" });
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("select one template and save", async () => {
|
||||
// Open the combobox.
|
||||
const input = canvas.getByPlaceholderText("Select templates...");
|
||||
await userEvent.click(input);
|
||||
// Pick the first template from the dropdown.
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("option", { name: "Docker Development" }),
|
||||
);
|
||||
// Badge pill should appear and status should update.
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("1 template selected")).toBeInTheDocument();
|
||||
});
|
||||
// Save should now be enabled.
|
||||
const saveBtn = canvas.getByRole("button", { name: "Save" });
|
||||
expect(saveBtn).toBeEnabled();
|
||||
await userEvent.click(saveBtn);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
API.experimental.updateChatTemplateAllowlist,
|
||||
).toHaveBeenCalledWith({ template_ids: ["t-01"] });
|
||||
});
|
||||
});
|
||||
|
||||
await step("add the remaining seven and save", async () => {
|
||||
// Open the combobox again.
|
||||
const input = canvas.getByLabelText("Select allowed templates");
|
||||
await userEvent.click(input);
|
||||
// Select the other seven templates one by one.
|
||||
for (const name of [
|
||||
"Kubernetes Production",
|
||||
"AWS Windows Desktop",
|
||||
"GCP Linux Workspace",
|
||||
"Azure .NET Environment",
|
||||
"ML Jupyter Notebook",
|
||||
"Data Engineering (Spark)",
|
||||
"Frontend (Vite + React)",
|
||||
]) {
|
||||
await userEvent.click(await canvas.findByRole("option", { name }));
|
||||
}
|
||||
// All eight should now be selected.
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("8 templates selected")).toBeInTheDocument();
|
||||
});
|
||||
// Save.
|
||||
const saveBtn = canvas.getByRole("button", { name: "Save" });
|
||||
await userEvent.click(saveBtn);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
API.experimental.updateChatTemplateAllowlist,
|
||||
).toHaveBeenLastCalledWith({
|
||||
template_ids: expect.arrayContaining([
|
||||
"t-01",
|
||||
"t-02",
|
||||
"t-03",
|
||||
"t-04",
|
||||
"t-05",
|
||||
"t-06",
|
||||
"t-07",
|
||||
"t-08",
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,13 +5,16 @@ import {
|
||||
chatDesktopEnabled,
|
||||
chatModelConfigs,
|
||||
chatSystemPrompt,
|
||||
chatTemplateAllowlist,
|
||||
chatUserCustomPrompt,
|
||||
chatWorkspaceTTL,
|
||||
updateChatDesktopEnabled,
|
||||
updateChatSystemPrompt,
|
||||
updateChatTemplateAllowlist,
|
||||
updateChatWorkspaceTTL,
|
||||
updateUserChatCustomPrompt,
|
||||
} from "api/queries/chats";
|
||||
import { templates } from "api/queries/templates";
|
||||
import { user } from "api/queries/users";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import dayjs from "dayjs";
|
||||
@@ -35,6 +38,10 @@ import { Alert } from "#/components/Alert/Alert";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import {
|
||||
MultiSelectCombobox,
|
||||
type Option,
|
||||
} from "#/components/MultiSelectCombobox/MultiSelectCombobox";
|
||||
import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount";
|
||||
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
||||
import { SearchField } from "#/components/SearchField/SearchField";
|
||||
@@ -931,7 +938,147 @@ export const AgentSettingsPageView: FC<AgentSettingsPageViewProps> = ({
|
||||
{activeSection === "insights" && canManageChatModelConfigs && (
|
||||
<InsightsContent />
|
||||
)}
|
||||
{activeSection === "templates" && canManageChatModelConfigs && (
|
||||
<TemplateAllowlistSection />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateAllowlistSection: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch all available templates.
|
||||
const templatesQuery = useQuery(templates());
|
||||
|
||||
// Fetch current allowlist.
|
||||
const allowlistQuery = useQuery(chatTemplateAllowlist());
|
||||
|
||||
const {
|
||||
mutate: saveAllowlist,
|
||||
isPending: isSaving,
|
||||
isError: isSaveError,
|
||||
} = useMutation(updateChatTemplateAllowlist(queryClient));
|
||||
|
||||
const [localSelection, setLocalSelection] = useState<Option[] | null>(null);
|
||||
|
||||
// Map all templates to MultiSelectCombobox options.
|
||||
const allOptions: Option[] = (templatesQuery.data ?? []).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.display_name || t.name,
|
||||
icon: t.icon,
|
||||
}));
|
||||
|
||||
// Build a lookup from template ID to Option for resolving server IDs.
|
||||
const optionsByID = new Map(allOptions.map((o) => [o.value, o]));
|
||||
|
||||
// Resolve the server-side allowlist IDs into Option objects.
|
||||
const serverSelection: Option[] = (allowlistQuery.data?.template_ids ?? [])
|
||||
.map((id) => optionsByID.get(id))
|
||||
.filter((o) => o !== undefined);
|
||||
|
||||
const currentSelection = localSelection ?? serverSelection;
|
||||
|
||||
const serverSet = new Set(serverSelection.map((o) => o.value));
|
||||
const isDirty =
|
||||
localSelection !== null &&
|
||||
(localSelection.length !== serverSet.size ||
|
||||
localSelection.some((o) => !serverSet.has(o.value)));
|
||||
|
||||
const handleSave = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isDirty) return;
|
||||
saveAllowlist(
|
||||
{ template_ids: currentSelection.map((o) => o.value) },
|
||||
{ onSuccess: () => setLocalSelection(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading = templatesQuery.isLoading || allowlistQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
label="Templates"
|
||||
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
|
||||
badge={<AdminBadge />}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading templates"
|
||||
className="flex min-h-[120px] items-center justify-center"
|
||||
>
|
||||
<Spinner size="lg" loading className="text-content-secondary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (templatesQuery.error || allowlistQuery.error) && (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
Failed to load template data.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void templatesQuery.refetch();
|
||||
void allowlistQuery.refetch();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !templatesQuery.error && !allowlistQuery.error && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => void handleSave(event)}
|
||||
>
|
||||
<MultiSelectCombobox
|
||||
key={serverSelection.map((o) => o.value).join(",")}
|
||||
inputProps={{ "aria-label": "Select allowed templates" }}
|
||||
options={allOptions}
|
||||
defaultOptions={currentSelection}
|
||||
value={currentSelection}
|
||||
onChange={setLocalSelection}
|
||||
placeholder="Select templates..."
|
||||
emptyIndicator={
|
||||
<p className="text-center text-sm text-content-secondary">
|
||||
No templates found.
|
||||
</p>
|
||||
}
|
||||
disabled={isSaving}
|
||||
hidePlaceholderWhenSelected
|
||||
data-testid="template-allowlist-select"
|
||||
/>
|
||||
<p
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
className="m-0 text-xs text-content-secondary"
|
||||
>
|
||||
{currentSelection.length > 0
|
||||
? `${currentSelection.length} template${currentSelection.length !== 1 ? "s" : ""} selected`
|
||||
: "No templates selected \u2014 all templates are available"}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" type="submit" disabled={isSaving || !isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSaveError && (
|
||||
<p role="alert" className="m-0 text-xs text-content-destructive">
|
||||
Failed to save template allowlist.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
GitPullRequestClosedIcon,
|
||||
GitPullRequestDraftIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutTemplateIcon,
|
||||
Loader2Icon,
|
||||
PanelLeftCloseIcon,
|
||||
PauseIcon,
|
||||
@@ -987,6 +988,14 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
/>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SettingsNavItem
|
||||
icon={LayoutTemplateIcon}
|
||||
label="Templates"
|
||||
active={sidebarView.section === "templates"}
|
||||
to="/agents/settings/templates"
|
||||
state={location.state}
|
||||
adminOnly
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={KeyRoundIcon}
|
||||
label="Providers"
|
||||
|
||||
Reference in New Issue
Block a user