feat(commands): gate /models add with modelsWrite (#70321)

This commit is contained in:
Tak Hoffman
2026-04-22 14:49:07 -05:00
committed by GitHub
parent 1ebd8e0bb6
commit 78d491d909
13 changed files with 131 additions and 16 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 <provider> <modelId>` 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 <provider> <modelId>` 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.

View File

@@ -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 <provider>", 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();
});
});

View File

@@ -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<string, ReadonlySet<string>>;
includeAddAction?: boolean;
}): string {
return [
"Providers:",
@@ -273,7 +275,7 @@ function buildModelsMenuText(params: {
"",
"Use: /models <provider>",
"Switch: /model <provider/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) {

View File

@@ -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;
}

View File

@@ -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<string, unknown>,
}),
).toBe(true);
});
});
describe("isCommandFlagEnabled", () => {
it("requires own boolean true", () => {
expect(isCommandFlagEnabled({ commands: { bash: true } }, "bash")).toBe(true);

View File

@@ -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,

View File

@@ -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).",

View File

@@ -1250,6 +1250,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -592,6 +592,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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). */

View File

@@ -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,
);