From ebe8f615e55d41a3e8d51f06f3daa1becd1514d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:19:01 +0100 Subject: [PATCH] fix: reject agent-scoped model default writes --- CHANGELOG.md | 1 + src/cli/models-cli.test.ts | 27 +++++++++++++++++++++++++-- src/cli/models-cli.ts | 16 ++++++++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d1b135061..5fee5228bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. +- CLI/models: reject `--agent` on `openclaw models set` and `set-image` instead of silently writing agent-scoped requests to global model defaults. Fixes #68391. Thanks @derrickabellard. - CLI: stop treating the legacy singular `openclaw tool ...` token as a plugin id under restrictive `plugins.allow`, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974. - Media: write inbound media buffers through same-directory temp files before rename, so failed disk writes do not leave zero-byte artifacts for later voice transcription. Fixes #55966. Thanks @OpenCodeEngineer. - TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw `tts` name. Fixes #74752. Thanks @Loveworld3033 and @andyliu. diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index bea6abc44f3..7eefc382c8f 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -5,6 +5,8 @@ import { registerModelsCli } from "./models-cli.js"; const mocks = vi.hoisted(() => ({ modelsStatusCommand: vi.fn().mockResolvedValue(undefined), + modelsSetCommand: vi.fn().mockResolvedValue(undefined), + modelsSetImageCommand: vi.fn().mockResolvedValue(undefined), noopAsync: vi.fn(async () => undefined), modelsAuthAddCommand: vi.fn().mockResolvedValue(undefined), modelsAuthLoginCommand: vi.fn().mockResolvedValue(undefined), @@ -17,6 +19,8 @@ const { modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, + modelsSetCommand, + modelsSetImageCommand, modelsStatusCommand, } = mocks; @@ -58,10 +62,10 @@ vi.mock("../commands/models/scan.js", () => ({ modelsScanCommand: mocks.noopAsync, })); vi.mock("../commands/models/set.js", () => ({ - modelsSetCommand: mocks.noopAsync, + modelsSetCommand: mocks.modelsSetCommand, })); vi.mock("../commands/models/set-image.js", () => ({ - modelsSetImageCommand: mocks.noopAsync, + modelsSetImageCommand: mocks.modelsSetImageCommand, })); describe("models cli", () => { @@ -70,6 +74,8 @@ describe("models cli", () => { modelsAuthLoginCommand.mockClear(); modelsAuthPasteTokenCommand.mockClear(); modelsAuthSetupTokenCommand.mockClear(); + modelsSetCommand.mockClear(); + modelsSetImageCommand.mockClear(); modelsStatusCommand.mockClear(); }); @@ -162,6 +168,23 @@ describe("models cli", () => { expect(command).toHaveBeenCalledWith(expect.objectContaining(expected), expect.any(Object)); }); + it.each([ + { + label: "set", + args: ["models", "--agent", "poe", "set", "anthropic/claude-sonnet-4-6"], + command: modelsSetCommand, + }, + { + label: "set-image", + args: ["models", "--agent", "poe", "set-image", "openai/gpt-image-1"], + command: modelsSetImageCommand, + }, + ])("rejects parent --agent for models $label", async ({ args, command }) => { + await expect(runModelsCommand(args)).rejects.toThrow("does not support `--agent`"); + + expect(command).not.toHaveBeenCalled(); + }); + it("shows help for models auth without error exit", async () => { const program = new Command(); program.exitOverride(); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a769aae2113..0a66770a1f7 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -8,6 +8,16 @@ function runModelsCommand(action: () => Promise) { return runCommandWithRuntime(defaultRuntime, action); } +function rejectAgentScopedModelWrite(command: Command, commandName: "set" | "set-image"): void { + const agent = resolveOptionFromCommand(command, "agent"); + if (!agent) { + return; + } + throw new Error( + `\`openclaw models ${commandName}\` does not support \`--agent\`; it only updates global model defaults. Remove \`--agent\` or use agent config to set a per-agent model override.`, + ); +} + export function registerModelsCli(program: Command) { const models = program .command("models") @@ -94,7 +104,8 @@ export function registerModelsCli(program: Command) { .command("set") .description("Set the default model") .argument("", "Model id or alias") - .action(async (model: string) => { + .action(async (model: string, _opts: unknown, command: Command) => { + rejectAgentScopedModelWrite(command, "set"); await runModelsCommand(async () => { const { modelsSetCommand } = await import("../commands/models/set.js"); await modelsSetCommand(model, defaultRuntime); @@ -105,7 +116,8 @@ export function registerModelsCli(program: Command) { .command("set-image") .description("Set the image model") .argument("", "Model id or alias") - .action(async (model: string) => { + .action(async (model: string, _opts: unknown, command: Command) => { + rejectAgentScopedModelWrite(command, "set-image"); await runModelsCommand(async () => { const { modelsSetImageCommand } = await import("../commands/models/set-image.js"); await modelsSetImageCommand(model, defaultRuntime);