From 819ff0463a58b7794cf8694bc89b5a1fcef6e3c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:08:54 +0100 Subject: [PATCH] fix(config): protect model config merges --- CHANGELOG.md | 1 + docs/cli/config.md | 17 ++ docs/cli/configure.md | 2 + src/cli/config-cli.test.ts | 143 +++++++++++++++ src/cli/config-cli.ts | 168 +++++++++++++++++- src/cli/config-set-input.ts | 2 + ...re.gateway-auth.prompt-auth-config.test.ts | 37 ++++ src/commands/configure.gateway-auth.ts | 4 +- src/commands/model-picker.test.ts | 25 ++- .../onboard-auth.config-shared.test.ts | 35 ++++ src/flows/model-picker.ts | 63 ++++++- src/plugin-sdk/provider-onboard.ts | 6 +- 12 files changed, 496 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f16c84596..55d6f99e803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653. - Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo. - Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only. - Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci. diff --git a/docs/cli/config.md b/docs/cli/config.md index 8a869c2bae8..99a69189659 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -38,6 +38,7 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" +openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json openclaw config unset plugins.entries.brave.config.webSearch.apiKey @@ -105,6 +106,22 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json `config get --json` prints the raw value as JSON instead of terminal-formatted text. +Object assignment replaces the target path by default. Protected map/list paths +that commonly hold user-added entries, such as `agents.defaults.models`, +`models.providers`, `models.providers..models`, `plugins.entries`, and +`auth.profiles`, refuse replacements that would remove existing entries unless +you pass `--replace`. + +Use `--merge` when adding entries to those maps: + +```bash +openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge +openclaw config set models.providers.ollama.models '[{"id":"llama3.2","name":"Llama 3.2"}]' --strict-json --merge +``` + +Use `--replace` only when you intentionally want the provided value to become +the complete target value. + ## `config set` modes `openclaw config set` supports four assignment styles: diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 02ad6f9f70a..bbfc48851d0 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -11,6 +11,8 @@ Interactive prompt to set up credentials, devices, and agent defaults. Note: The **Model** section now includes a multi-select for the `agents.defaults.models` allowlist (what shows up in `/model` and the model picker). +Provider-scoped setup choices merge their selected models into the existing +allowlist instead of replacing unrelated providers already in the config. When configure starts from a provider auth choice, the default-model and allowlist pickers prefer that provider automatically. For paired providers such diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 00f8dec72a1..cfee81328a9 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -308,6 +308,98 @@ describe("config cli", () => { expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Dry run successful")); }); + it("rejects protected model map replacement unless explicitly requested", async () => { + const resolved: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await expect( + runConfigCommand([ + "config", + "set", + "agents.defaults.models", + '{"openai/gpt-5.4":{}}', + "--strict-json", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Refusing to replace agents.defaults.models"), + ); + }); + + it("merges protected model map values with --merge", async () => { + const resolved: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT" }, + }, + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "agents.defaults.models", + '{"anthropic/claude-sonnet-4-6":{"alias":"Sonnet"}}', + "--strict-json", + "--merge", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4": { alias: "GPT" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, + }); + }); + + it("merges provider model arrays by id with --merge", async () => { + const resolved = { + models: { + providers: { + ollama: { + api: "ollama", + models: [ + { id: "llama3.2", name: "Llama 3.2", contextWindow: 131072 }, + { id: "qwen3", name: "Qwen 3" }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "models.providers.ollama.models", + '[{"id":"llama3.2","name":"Llama 3.2 latest"},{"id":"gemma4","name":"Gemma 4"}]', + "--strict-json", + "--merge", + ]); + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.models?.providers?.ollama?.models).toEqual([ + { id: "llama3.2", name: "Llama 3.2 latest", contextWindow: 131072 }, + { id: "qwen3", name: "Qwen 3" }, + { id: "gemma4", name: "Gemma 4" }, + ]); + }); + it("writes agents.defaults.llm.idleTimeoutSeconds without disturbing sibling defaults", async () => { const resolved: OpenClawConfig = { agents: { @@ -1256,6 +1348,57 @@ describe("config cli", () => { expect(written.gateway?.auth).toEqual({ mode: "token" }); }); + it("batch-file nested leaf updates preserve agents defaults and list siblings", async () => { + const resolved: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT" }, + }, + model: { primary: "openai/gpt-5.4" }, + }, + list: [{ id: "main" }, { id: "ops" }], + }, + plugins: { + entries: { + "github-copilot": { enabled: true }, + }, + }, + }; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-memory-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync( + pathname, + JSON.stringify([ + { path: "agents.defaults.memorySearch.enabled", value: true }, + { path: "agents.defaults.memorySearch.provider", value: "gemini" }, + { path: "agents.defaults.memorySearch.sources", value: ["memory"] }, + ]), + "utf8", + ); + try { + await runConfigCommand(["config", "set", "--batch-file", pathname]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0]; + expect(written.agents?.defaults?.models).toEqual(resolved.agents?.defaults?.models); + expect(written.agents?.defaults?.model).toEqual(resolved.agents?.defaults?.model); + expect(written.agents?.defaults?.memorySearch).toEqual({ + enabled: true, + provider: "gemini", + sources: ["memory"], + }); + expect(written.agents?.list).toEqual(resolved.agents?.list); + expect(written.plugins).toEqual(resolved.plugins); + }); + it("rejects malformed batch-file payloads", async () => { const pathname = path.join( os.tmpdir(), diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 60f15756a5a..007fab65c97 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -200,6 +200,10 @@ function hasOwnPathKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function formatDoctorHint(message: string): string { return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`; } @@ -293,6 +297,149 @@ function setAtPath(root: Record, path: PathSegment[], value: un (current as Record)[last] = value; } +function modelArrayIds(value: unknown): Set | null { + if (!Array.isArray(value)) { + return null; + } + const ids = new Set(); + for (const entry of value) { + if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) { + return null; + } + ids.add(entry.id.trim()); + } + return ids; +} + +function mergeModelArrays(existing: unknown[], patch: unknown[]): unknown[] { + const merged = [...existing]; + const indexById = new Map(); + for (const [index, entry] of merged.entries()) { + if (isPlainRecord(entry) && typeof entry.id === "string" && entry.id.trim()) { + indexById.set(entry.id.trim(), index); + } + } + for (const entry of patch) { + if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) { + merged.push(entry); + continue; + } + const id = entry.id.trim(); + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(entry); + continue; + } + const existingEntry = merged[existingIndex]; + merged[existingIndex] = isPlainRecord(existingEntry) ? { ...existingEntry, ...entry } : entry; + } + return merged; +} + +function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown { + if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) { + return mergeModelArrays(existing, patch); + } + if (isPlainRecord(existing) && isPlainRecord(patch)) { + const next: Record = { ...existing }; + for (const [key, value] of Object.entries(patch)) { + next[key] = + hasOwnPathKey(next, key) && isPlainRecord(next[key]) && isPlainRecord(value) + ? mergeConfigValue(next[key], value, [...path, key]) + : value; + } + return next; + } + throw new Error(`Cannot merge ${toDotPath(path)}; use --replace to replace intentionally.`); +} + +function mergeAtPath(root: Record, path: PathSegment[], value: unknown): void { + const existing = getAtPath(root, path); + if (!existing.found) { + setAtPath(root, path, value); + return; + } + setAtPath(root, path, mergeConfigValue(existing.value, value, path)); +} + +function isProviderModelListPath(path: PathSegment[]): boolean { + return ( + path.length === 4 && path[0] === "models" && path[1] === "providers" && path[3] === "models" + ); +} + +function isProtectedMapReplacementPath(path: PathSegment[]): boolean { + if (path.join(".") === "agents.defaults.models") { + return true; + } + if (path.join(".") === "models.providers") { + return true; + } + if (path.length === 3 && path[0] === "models" && path[1] === "providers") { + return true; + } + if (path.join(".") === "plugins.entries") { + return true; + } + if (path.join(".") === "auth.profiles") { + return true; + } + return false; +} + +function isProtectedArrayReplacementPath(path: PathSegment[]): boolean { + return isProviderModelListPath(path) || path.join(".") === "agents.list"; +} + +function formatRemovedEntries(entries: string[]): string { + const visible = entries.slice(0, 6); + const suffix = + entries.length > visible.length ? `, ... ${entries.length - visible.length} more` : ""; + return `${visible.join(", ")}${suffix}`; +} + +function assertNonDestructiveReplacement(params: { + root: Record; + path: PathSegment[]; + value: unknown; + allowReplace?: boolean; +}): void { + if (params.allowReplace) { + return; + } + const existing = getAtPath(params.root, params.path); + if (!existing.found) { + return; + } + const pathLabel = toDotPath(params.path); + if (isProtectedMapReplacementPath(params.path) && isPlainRecord(existing.value)) { + if (!isPlainRecord(params.value)) { + return; + } + const nextKeys = new Set(Object.keys(params.value)); + const removed = Object.keys(existing.value).filter((key) => !nextKeys.has(key)); + if (removed.length > 0) { + throw new Error( + `Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge object values or --replace to replace intentionally.`, + ); + } + } + if (isProtectedArrayReplacementPath(params.path)) { + const existingIds = modelArrayIds(existing.value); + const nextIds = modelArrayIds(params.value); + if (!existingIds || !nextIds) { + return; + } + const removed = [...existingIds].filter((id) => !nextIds.has(id)); + if (removed.length > 0) { + throw new Error( + `Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge by id or --replace to replace intentionally.`, + ); + } + } +} + function unsetAtPath(root: Record, path: PathSegment[]): boolean { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { @@ -1027,6 +1174,9 @@ export async function runConfigSet(opts: { if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { throw modeError("--allow-exec requires --dry-run."); } + if (opts.cliOptions.merge && opts.cliOptions.replace) { + throw modeError("choose either --merge or --replace, not both."); + } const batchEntries = parseBatchSource(opts.cliOptions); if (batchEntries) { @@ -1047,7 +1197,17 @@ export async function runConfigSet(opts: { // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; for (const operation of operations) { - setAtPath(next, operation.setPath, operation.value); + if (opts.cliOptions.merge) { + mergeAtPath(next, operation.setPath, operation.value); + } else { + assertNonDestructiveReplacement({ + root: next, + path: operation.setPath, + value: operation.value, + allowReplace: opts.cliOptions.replace, + }); + setAtPath(next, operation.setPath, operation.value); + } } const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({ root: next, @@ -1389,6 +1549,12 @@ export function registerConfigCli(program: Command) { "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", false, ) + .option("--merge", "Merge object/map values instead of replacing the target path", false) + .option( + "--replace", + "Allow full replacement of protected map/list paths such as agents.defaults.models", + false, + ) .option("--ref-provider ", "SecretRef builder: provider alias") .option("--ref-source ", "SecretRef builder: source (env|file|exec)") .option("--ref-id ", "SecretRef builder: ref id") diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts index c055d53b5f9..924a17f9066 100644 --- a/src/cli/config-set-input.ts +++ b/src/cli/config-set-input.ts @@ -10,6 +10,8 @@ export type ConfigSetOptions = { json?: boolean; dryRun?: boolean; allowExec?: boolean; + merge?: boolean; + replace?: boolean; refProvider?: string; refSource?: string; refId?: string; diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index de0b946d103..1863e54e19d 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -161,6 +161,43 @@ describe("promptAuthConfig", () => { ); }); + it("preserves existing model entries outside provider-scoped allowlist updates", async () => { + mocks.promptAuthChoiceGrouped.mockResolvedValue("token"); + mocks.applyAuthChoice.mockResolvedValue({ + config: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT" }, + "anthropic/claude-opus-4-6": { alias: "Opus" }, + }, + }, + }, + }, + }); + mocks.promptModelAllowlist.mockResolvedValue({ + models: ["anthropic/claude-sonnet-4-6"], + scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"], + }); + mocks.resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "anthropic", label: "Anthropic", auth: [] }, + method: { id: "setup-token", label: "setup-token", kind: "token" }, + wizard: { + modelAllowlist: { + allowedKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"], + initialSelections: ["anthropic/claude-sonnet-4-6"], + }, + }, + }); + + const result = await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(result.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4": { alias: "GPT" }, + "anthropic/claude-sonnet-4-6": {}, + }); + }); + it("scopes the allowlist picker to the selected provider when available", async () => { mocks.promptAuthChoiceGrouped.mockResolvedValue("openai-api-key"); mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("openai"); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 3af53e463a9..d7db3ac630b 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -163,7 +163,9 @@ export async function promptAuthConfig( preferredProvider, }); if (allowlistSelection.models) { - next = applyModelAllowlist(next, allowlistSelection.models); + next = applyModelAllowlist(next, allowlistSelection.models, { + scopeKeys: allowlistSelection.scopeKeys, + }); next = applyModelFallbacksFromSelection(next, allowlistSelection.models); } } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 704fc5d793a..f3ada2a9f44 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -352,7 +352,7 @@ describe("promptModelAllowlist", () => { const prompter = makePrompter({ multiselect }); const config = { agents: { defaults: {} } } as OpenClawConfig; - await promptModelAllowlist({ + const result = await promptModelAllowlist({ config, prompter, allowedKeys: ["anthropic/claude-opus-4-6"], @@ -362,6 +362,7 @@ describe("promptModelAllowlist", () => { expect(options.map((opt: { value: string }) => opt.value)).toEqual([ "anthropic/claude-opus-4-6", ]); + expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]); }); it("scopes the initial allowlist picker to the preferred provider", async () => { @@ -452,6 +453,28 @@ describe("applyModelAllowlist", () => { }); }); + it("preserves entries outside scoped allowlist updates", () => { + const config = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "gpt" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + } as OpenClawConfig; + + const next = applyModelAllowlist(config, ["anthropic/claude-sonnet-4-6"], { + scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"], + }); + expect(next.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4": { alias: "gpt" }, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }); + }); + it("clears the allowlist when no models remain", () => { const config = { agents: { diff --git a/src/commands/onboard-auth.config-shared.test.ts b/src/commands/onboard-auth.config-shared.test.ts index 044787457b4..2f0c549aac9 100644 --- a/src/commands/onboard-auth.config-shared.test.ts +++ b/src/commands/onboard-auth.config-shared.test.ts @@ -59,6 +59,41 @@ describe("onboard auth provider config merges", () => { expect(next.agents?.defaults?.models).toEqual(agentModels); }); + it("preserves existing agent model entries when adding provider models", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT" }, + }, + }, + }, + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://old.example.com/v1", + models: [makeModel("model-a")], + }, + }, + }, + }; + + const next = applyProviderConfigWithDefaultModels(cfg, { + agentModels, + providerId: "custom", + api: "openai-completions", + baseUrl: "https://new.example.com/v1", + defaultModels: [makeModel("model-b")], + defaultModelId: "model-b", + }); + + expect(next.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4": { alias: "GPT" }, + ...agentModels, + }); + }); + it("merges model catalogs without duplicating existing model ids", () => { const cfg: OpenClawConfig = { models: { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index b62cc199c96..b75367d6168 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -45,7 +45,7 @@ export type PromptDefaultModelParams = { }; export type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig }; -export type PromptModelAllowlistResult = { models?: string[] }; +export type PromptModelAllowlistResult = { models?: string[]; scopeKeys?: string[] }; async function loadModelPickerRuntime() { return import("../commands/model-picker.runtime.js"); @@ -658,6 +658,12 @@ export async function promptModelAllowlist(params: { ? allowedCatalog.filter((entry) => matchesPreferredProvider?.(entry.provider)) : allowedCatalog; + const scopeKeys = allowedKeySet + ? allowedKeys + : preferredProvider + ? filteredCatalog.map((entry) => modelKey(entry.provider, entry.id)) + : undefined; + for (const entry of filteredCatalog) { addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); } @@ -686,7 +692,17 @@ export async function promptModelAllowlist(params: { }); const selected = normalizeModelKeys(selection); if (selected.length > 0) { - return { models: selected }; + return { models: selected, ...(scopeKeys ? { scopeKeys } : {}) }; + } + if (scopeKeys) { + const confirmScopedClear = await params.prompter.confirm({ + message: "Remove these provider models from the /model picker?", + initialValue: false, + }); + if (!confirmScopedClear) { + return {}; + } + return { models: [], scopeKeys }; } if (existingKeys.length === 0) { return { models: [] }; @@ -701,13 +717,34 @@ export async function promptModelAllowlist(params: { return { models: [] }; } -export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig { +export function applyModelAllowlist( + cfg: OpenClawConfig, + models: string[], + opts: { scopeKeys?: string[] } = {}, +): OpenClawConfig { const defaults = cfg.agents?.defaults; const normalized = normalizeModelKeys(models); + const scopeKeys = opts.scopeKeys ? normalizeModelKeys(opts.scopeKeys) : []; + const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null; if (normalized.length === 0) { if (!defaults?.models) { return cfg; } + if (scopeKeySet) { + const nextModels = { ...defaults.models }; + for (const key of scopeKeySet) { + delete nextModels[key]; + } + const { models: _ignored, ...restDefaults } = defaults; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: + Object.keys(nextModels).length > 0 ? { ...defaults, models: nextModels } : restDefaults, + }, + }; + } const { models: _ignored, ...restDefaults } = defaults; return { ...cfg, @@ -719,6 +756,26 @@ export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): Open } const existingModels = defaults?.models ?? {}; + if (scopeKeySet) { + const nextModels = { ...existingModels }; + for (const key of scopeKeySet) { + delete nextModels[key]; + } + for (const key of normalized) { + nextModels[key] = existingModels[key] ?? {}; + } + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + models: nextModels, + }, + }, + }; + } + const nextModels: Record = {}; for (const key of normalized) { nextModels[key] = existingModels[key] ?? {}; diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index f08b7b73663..072935c38eb 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -182,13 +182,17 @@ export function applyOnboardAuthAgentModelsAndProviders( providers: Record; }, ): OpenClawConfig { + const mergedAgentModels = { + ...cfg.agents?.defaults?.models, + ...params.agentModels, + }; return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, - models: params.agentModels, + models: mergedAgentModels, }, }, models: {