From 78d491d909acc2fbe96b8a8ef7f6f157257002b9 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:49:07 -0500 Subject: [PATCH] feat(commands): gate /models add with modelsWrite (#70321) --- docs/.generated/config-baseline.sha256 | 6 +-- docs/channels/discord.md | 2 +- docs/concepts/models.md | 4 +- src/auto-reply/reply/commands-models.test.ts | 45 +++++++++++++++++++- src/auto-reply/reply/commands-models.ts | 30 ++++++++++--- src/config/commands.flags.ts | 4 ++ src/config/commands.test.ts | 19 +++++++++ src/config/commands.ts | 7 ++- src/config/schema.base.generated.ts | 15 ++++++- src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.messages.ts | 2 + src/config/zod-schema.session.ts | 10 ++++- 13 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index a88e18cf66a..f930efaa212 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -549aef8b73ca6c3832e4d621fb06aa83e2808c9f01cdd28bd0c307c382fe8506 config-baseline.json -9982e214afdaf31fe37b5b3e681b26f3606a23f0e053922511f9cf516fac29b2 config-baseline.core.json -6c0069b971ae298ae68516ebcd3eae0e8c82820d2e8f42ecbd2f53a2f9077371 config-baseline.channel.json +88e22624ea8967e9e817212ff4aa62451001f8d4b2c8d872e5a77f38c66c5c3f config-baseline.json +0f117e9214be948d351dfaf7d0cfaf7e6d76e47896881b840fdad17ee4b53a24 config-baseline.core.json +35d132fe176bd2bf9f0e46b29de91baba63ec4db3317cc5b294a982b46d16ba9 config-baseline.channel.json 5f0d160144cf751187cbc0219f8351307e8e82aafdb20ea0307a444f3e64b93c config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index c320efa69ed..76e1e6d0238 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -307,7 +307,7 @@ By default, components are single use. Set `components.reusable=true` to allow b To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. -The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. `/models add` also supports adding a new provider/model entry from chat, and newly added models show up without restarting the gateway. The picker reply is ephemeral and only the invoking user can use it. +The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. Unless `commands.modelsWrite=false`, `/models add` also supports adding a new provider/model entry from chat, and newly added models show up without restarting the gateway. The picker reply is ephemeral and only the invoking user can use it. File attachments: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 66c9908e05b..4c6d9077ad8 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -114,8 +114,8 @@ Notes: - `/model` (and `/model list`) is a compact, numbered picker (model family + available providers). - On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step. -- `/models add` lets you add a provider/model entry from chat without editing config manually. -- `/models add ` is the fastest path; bare `/models add` starts a provider-first guided flow where supported. +- `/models add` is available by default and can be disabled with `commands.modelsWrite=false`. +- When enabled, `/models add ` is the fastest path; bare `/models add` starts a provider-first guided flow where supported. - After `/models add`, the new model becomes available in `/models` and `/model` without restarting the gateway. - `/model <#>` selects from that picker. - `/model` persists the new session selection immediately. diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index eea2634fbb2..f6dd19d7bb0 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -227,7 +227,7 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).toContain("Add: /models add"); }); - it("adds an add-model action to the telegram provider picker", async () => { + it("shows the add-model action in the telegram provider picker by default", async () => { const params = buildParams("/models"); params.ctx.Surface = "telegram"; params.command.channel = "telegram"; @@ -248,6 +248,31 @@ describe("handleModelsCommand", () => { }); }); + it("keeps the telegram provider picker browse-only when modelsWrite is disabled", async () => { + const params = buildParams("/models", { + commands: { + text: true, + modelsWrite: false, + }, + }); + params.ctx.Surface = "telegram"; + params.command.channel = "telegram"; + params.command.surface = "telegram"; + + const result = await handleModelsCommand(params, true); + + expect(result?.reply?.text).toBe("Select a provider:"); + expect(result?.reply?.channelData).toEqual({ + telegram: { + buttons: [ + [{ text: "anthropic", callback_data: "models:anthropic" }], + [{ text: "google", callback_data: "models:google" }], + [{ text: "openai", callback_data: "models:openai" }], + ], + }, + }); + }); + it("lists models for /models ", async () => { const result = await handleModelsCommand(buildParams("/models openai"), true); @@ -355,4 +380,22 @@ describe("handleModelsCommand", () => { }); expect(modelsAddMocks.addModelToConfig).not.toHaveBeenCalled(); }); + + it("rejects /models add when modelsWrite is disabled", async () => { + const result = await handleModelsCommand( + buildParams("/models add ollama glm-5.1:cloud", { + commands: { text: true, modelsWrite: false }, + }), + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "⚠️ /models add is disabled. Set commands.modelsWrite=true to enable model registration.", + }, + }); + expect(modelsAddMocks.addModelToConfig).not.toHaveBeenCalled(); + expect(configWriteTargetMocks.resolveConfigWriteTargetFromPath).not.toHaveBeenCalled(); + }); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index e557139ce9b..53998785b5c 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -11,6 +11,7 @@ import { import { resolveConfigWriteTargetFromPath } from "../../channels/plugins/config-writes.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { normalizeChannelId } from "../../channels/registry.js"; +import { isModelsWriteEnabled } from "../../config/commands.flags.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -261,6 +262,7 @@ export function formatModelsAvailableHeader(params: { function buildModelsMenuText(params: { providers: string[]; byProvider: ReadonlyMap>; + includeAddAction?: boolean; }): string { return [ "Providers:", @@ -273,7 +275,7 @@ function buildModelsMenuText(params: { "", "Use: /models ", "Switch: /model ", - "Add: /models add", + ...(params.includeAddAction ? ["Add: /models add"] : []), ].join("\n"); } @@ -344,12 +346,15 @@ export async function resolveModelsCommandReply(params: { ); const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null; const providerInfos = buildProviderInfos({ providers, byProvider }); + const modelsWriteEnabled = isModelsWriteEnabled(params.cfg); if (parsed.action === "providers") { const channelData = - commandPlugin?.commands?.buildModelsMenuChannelData?.({ - providers: providerInfos, - }) ?? + (modelsWriteEnabled + ? commandPlugin?.commands?.buildModelsMenuChannelData?.({ + providers: providerInfos, + }) + : null) ?? commandPlugin?.commands?.buildModelsProviderChannelData?.({ providers: providerInfos, }); @@ -360,11 +365,16 @@ export async function resolveModelsCommandReply(params: { }; } return { - text: buildModelsMenuText({ providers, byProvider }), + text: buildModelsMenuText({ providers, byProvider, includeAddAction: modelsWriteEnabled }), }; } if (parsed.action === "add") { + if (!modelsWriteEnabled) { + return { + text: "⚠️ /models add is disabled. Set commands.modelsWrite=true to enable model registration.", + }; + } const addableProviders = listAddableProviders({ cfg: params.cfg, discoveredProviders: providers, @@ -471,7 +481,7 @@ export async function resolveModelsCommandReply(params: { }; } return { - text: buildModelsMenuText({ providers, byProvider }), + text: buildModelsMenuText({ providers, byProvider, includeAddAction: modelsWriteEnabled }), }; } @@ -588,6 +598,14 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma } if (parsed.action === "add") { + if (!isModelsWriteEnabled(params.cfg)) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /models add is disabled. Set commands.modelsWrite=true to enable model registration.", + }, + }; + } const commandLabel = "/models add"; const nonOwner = rejectNonOwnerCommand(params, commandLabel); if (nonOwner) { diff --git a/src/config/commands.flags.ts b/src/config/commands.flags.ts index 4d1967ec55f..e312016a704 100644 --- a/src/config/commands.flags.ts +++ b/src/config/commands.flags.ts @@ -23,6 +23,10 @@ export function isCommandFlagEnabled( return getOwnCommandFlagValue(config, key) === true; } +export function isModelsWriteEnabled(config?: { commands?: unknown }): boolean { + return getOwnCommandFlagValue(config, "modelsWrite") !== false; +} + export function isRestartEnabled(config?: { commands?: unknown }): boolean { return getOwnCommandFlagValue(config, "restart") !== false; } diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index 515ed061939..5479186970f 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -3,6 +3,7 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { isCommandFlagEnabled, + isModelsWriteEnabled, isRestartEnabled, isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -200,6 +201,24 @@ describe("isRestartEnabled", () => { }); }); +describe("isModelsWriteEnabled", () => { + it("defaults to enabled unless explicitly false", () => { + expect(isModelsWriteEnabled(undefined)).toBe(true); + expect(isModelsWriteEnabled({})).toBe(true); + expect(isModelsWriteEnabled({ commands: {} })).toBe(true); + expect(isModelsWriteEnabled({ commands: { modelsWrite: true } })).toBe(true); + expect(isModelsWriteEnabled({ commands: { modelsWrite: false } })).toBe(false); + }); + + it("ignores inherited modelsWrite flags", () => { + expect( + isModelsWriteEnabled({ + commands: Object.create({ modelsWrite: false }) as Record, + }), + ).toBe(true); + }); +}); + describe("isCommandFlagEnabled", () => { it("requires own boolean true", () => { expect(isCommandFlagEnabled({ commands: { bash: true } }, "bash")).toBe(true); diff --git a/src/config/commands.ts b/src/config/commands.ts index 69c2349d268..fd6e1755bb6 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,7 +1,12 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; import type { NativeCommandsSetting } from "./types.js"; -export { isCommandFlagEnabled, isRestartEnabled, type CommandFlagKey } from "./commands.flags.js"; +export { + isCommandFlagEnabled, + isModelsWriteEnabled, + isRestartEnabled, + type CommandFlagKey, +} from "./commands.flags.js"; function resolveAutoDefault( providerId: ChannelId | undefined, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 6a92ef8c8a4..f75b32cd6c5 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -18787,6 +18787,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { default: { native: "auto", nativeSkills: "auto", + modelsWrite: true, restart: true, ownerDisplay: "raw", }, @@ -18828,6 +18829,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", }, + modelsWrite: { + default: true, + type: "boolean", + title: "Allow /models writes", + description: + "Allow model-management write commands such as `/models add` to register provider/model entries directly into config and make them available without restarting the gateway (default: true).", + }, bash: { type: "boolean", title: "Allow Bash Chat Command", @@ -18929,7 +18937,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", }, }, - required: ["native", "nativeSkills", "restart", "ownerDisplay"], + required: ["native", "nativeSkills", "modelsWrite", "restart", "ownerDisplay"], additionalProperties: false, title: "Commands", description: @@ -26027,6 +26035,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", tags: ["advanced"], }, + "commands.modelsWrite": { + label: "Allow /models writes", + help: "Allow model-management write commands such as `/models add` to register provider/model entries directly into config and make them available without restarting the gateway (default: true).", + tags: ["advanced"], + }, "commands.bash": { label: "Allow Bash Chat Command", help: "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index f97561fd287..7a79a4a8c2b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1250,6 +1250,8 @@ export const FIELD_HELP: Record = { "Registers native skill commands so users can invoke skills directly from provider command menus where supported. Keep aligned with your skill policy so exposed commands match what operators expect.", "commands.text": "Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.", + "commands.modelsWrite": + "Allow model-management write commands such as `/models add` to register provider/model entries directly into config and make them available without restarting the gateway (default: true).", "commands.bash": "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", "commands.bashForegroundMs": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f889efeda92..35e694f00c8 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -592,6 +592,7 @@ export const FIELD_LABELS: Record = { "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", "commands.text": "Text Commands", + "commands.modelsWrite": "Allow /models writes", "commands.bash": "Allow Bash Chat Command", "commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.config": "Allow /config", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 601a86d115b..457d5556981 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -142,6 +142,8 @@ export type CommandsConfig = { nativeSkills?: NativeCommandsSetting; /** Enable text command parsing (default: true). */ text?: boolean; + /** Allow model-management write commands like `/models add` (default: true). */ + modelsWrite?: boolean; /** Allow bash chat command (`!`; `/bash` alias) (default: false). */ bash?: boolean; /** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */ diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index a51c53fe86c..4b01cce5852 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -208,6 +208,7 @@ export const CommandsSchema = z native: NativeCommandsSettingSchema.optional().default("auto"), nativeSkills: NativeCommandsSettingSchema.optional().default("auto"), text: z.boolean().optional(), + modelsWrite: z.boolean().optional().default(true), bash: z.boolean().optional(), bashForegroundMs: z.number().int().min(0).max(30_000).optional(), config: z.boolean().optional(), @@ -224,5 +225,12 @@ export const CommandsSchema = z .strict() .optional() .default( - () => ({ native: "auto", nativeSkills: "auto", restart: true, ownerDisplay: "raw" }) as const, + () => + ({ + native: "auto", + nativeSkills: "auto", + modelsWrite: true, + restart: true, + ownerDisplay: "raw", + }) as const, );