fix(config): protect model config merges

This commit is contained in:
Peter Steinberger
2026-04-22 23:08:54 +01:00
parent f88da75ed9
commit 819ff0463a
12 changed files with 496 additions and 7 deletions

View File

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

View File

@@ -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 <path> --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.<id>.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:

View File

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

View File

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

View File

@@ -200,6 +200,10 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>, path: PathSegment[], value: un
(current as Record<string, unknown>)[last] = value;
}
function modelArrayIds(value: unknown): Set<string> | null {
if (!Array.isArray(value)) {
return null;
}
const ids = new Set<string>();
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<string, number>();
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<string, unknown> = { ...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<string, unknown>, 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<string, unknown>;
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<string, unknown>, 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<string, unknown>;
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 <alias>", "SecretRef builder: provider alias")
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)")
.option("--ref-id <id>", "SecretRef builder: ref id")

View File

@@ -10,6 +10,8 @@ export type ConfigSetOptions = {
json?: boolean;
dryRun?: boolean;
allowExec?: boolean;
merge?: boolean;
replace?: boolean;
refProvider?: string;
refSource?: string;
refId?: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, { alias?: string }> = {};
for (const key of normalized) {
nextModels[key] = existingModels[key] ?? {};

View File

@@ -182,13 +182,17 @@ export function applyOnboardAuthAgentModelsAndProviders(
providers: Record<string, ModelProviderConfig>;
},
): OpenClawConfig {
const mergedAgentModels = {
...cfg.agents?.defaults?.models,
...params.agentModels,
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models: params.agentModels,
models: mergedAgentModels,
},
},
models: {