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:
Cian Johnston
2026-03-25 15:19:17 +00:00
committed by GitHub
parent c0ab22dc88
commit 796872f4de
23 changed files with 1045 additions and 10 deletions
+17
View File
@@ -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>(
+16
View File
@@ -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 = () => ({
+10
View File
@@ -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"