mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
feat(commands): gate /models add with modelsWrite (#70321)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user