diff --git a/docs/cli/config.md b/docs/cli/config.md index baa48fe8a840..054347bac9df 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -169,6 +169,8 @@ machine-output spelling and keeps stdout reserved for the schema document. ### `config validate` +Schema refusals from `config set`, `config patch`, and `config unset` explain the affected setting and confirm that no settings were saved. Correct the reported value or use `openclaw config schema` to inspect supported settings, then retry. These refusals still exit with status 1. Explicit validation reports settings that need correction without changing the file; `config validate --json` retains its `valid: false`, `error`, and `issues` fields for scripts. + Human validation diagnostics quote literal record keys, such as `agents.defaults.models["provider/model.v1"].alias`, instead of displaying the dot inside a key as nested traversal. Numeric array positions use brackets, such as `agents.entries.main.skills[0]`. The `issues[].path` field in `config validate --json` keeps its existing dot-joined representation. Validates the current config against the active schema without starting the gateway. It also checks provider/source compatibility for every registry-declared SecretRef, including disabled plugin or channel configuration. This strict command can report an inactive mismatch that does not block normal Gateway startup, where SecretRef resolution remains limited to effectively active surfaces. diff --git a/src/cli/config-cli-runner.ts b/src/cli/config-cli-runner.ts index 4846fe2cc160..554ef7877177 100644 --- a/src/cli/config-cli-runner.ts +++ b/src/cli/config-cli-runner.ts @@ -5,6 +5,7 @@ import { replaceConfigFile } from "../config/config.js"; import { getDeferredPluginMigrationConfigFacts } from "../config/deferred-plugin-migration-config.js"; import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js"; import { coerceConfig } from "../config/io.read-helpers.js"; +import { isConfigValidationFailedError } from "../config/io.write-errors.js"; import { prepareConfigWriteValues } from "../config/io.write-prepare.js"; import { prepareConfigWriteTopology } from "../config/io.write-topology.js"; import { ConfigMutationConflictError } from "../config/mutation-conflict.js"; @@ -22,6 +23,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { ExitError, writeRuntimeJson } from "../runtime.js"; import { toDotPath } from "../shared/dot-path.js"; import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; +import { formatCliCommand } from "./command-format.js"; import { formatPluginInstallConfigSetError, type ConfigMutationOptions, @@ -624,6 +626,14 @@ export function handleConfigMutationError(params: { params.runtime.error(danger(message)); exitCliAfterOutput(params.runtime, 1); } - params.runtime.error(danger(message)); + if (isConfigValidationFailedError(params.err)) { + params.runtime.error("Config change declined. No settings were saved."); + params.runtime.error(message); + params.runtime.error( + `Correct the setting above and retry. Run ${formatCliCommand("openclaw config schema")} to inspect supported settings and values.`, + ); + } else { + params.runtime.error(danger(message)); + } exitCliAfterOutput(params.runtime, 1); } diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index 4c631740735b..7b9e356fc184 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -488,6 +488,10 @@ describe("config cli integration", () => { code: 1, }); const diagnostic = registeredRuntimeErrors.join("\n"); + if (args[1] === "validate") { + expect(diagnostic).toContain("Config needs correction:"); + expect(diagnostic).toContain("openclaw config schema"); + } expect(diagnostic).toContain(`openclaw.json:9 — ${displayPath}:`); expect(diagnostic).toContain("expected string"); expect(diagnostic).not.toContain(`${issuePath}:`); diff --git a/src/cli/config-cli.rejections.integration.test.ts b/src/cli/config-cli.rejections.integration.test.ts new file mode 100644 index 000000000000..869cd62f3e29 --- /dev/null +++ b/src/cli/config-cli.rejections.integration.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import JSON5 from "json5"; +import { describe, expect, it, vi } from "vitest"; +import { + ConfigWritePostCommitError, + createConfigValidationFailedError, +} from "../config/io.write-errors.js"; +import { useConfigCliIntegrationHarness } from "./config-cli.integration.test-harness.js"; + +const configRuntime = await import("../config/config.js"); +const { + registeredRuntimeLogs, + registeredRuntimeErrors, + runRegisteredConfigCommand, + withConfigFileHarness, +} = useConfigCliIntegrationHarness(); + +describe("config CLI rejections", () => { + it("explains rejected settings without saving and accepts their correction", async () => { + const setting = "channels.discord.guilds.123456789012345678.requireMention"; + const raw = + '{"channels":{"discord":{"guilds":{"123456789012345678":{"requireMention":true}}}}}\n'; + await withConfigFileHarness( + "openclaw-config-cli-refusal-", + raw, + async ({ configPath, tempDir }) => { + const patchPath = path.join(tempDir, "patch.json"); + fs.writeFileSync( + patchPath, + '{"channels":{"discord":{"guilds":{"123456789012345678":{"requireMention":42}}}}}', + ); + for (const [args, issue] of [ + [["set", setting, "oops"], "requireMention"], + [["set", "gateway.nonexistentSetting", "true"], "nonexistentSetting"], + [["patch", "--file", patchPath], "requireMention"], + ] as const) { + registeredRuntimeErrors.length = 0; + await expect(runRegisteredConfigCommand(["config", ...args])).rejects.toMatchObject({ + name: "ExitError", + code: 1, + }); + const output = registeredRuntimeErrors.join("\n"); + expect(output).toContain("Config change declined. No settings were saved."); + expect(output).toContain(issue); + expect(output).toContain("Correct the setting above and retry."); + expect(output).toContain("openclaw config schema"); + expect(output).not.toMatch(/Stack:|Debug:|CLI failed|\bat .*\.ts:\d/); + expect(registeredRuntimeLogs).toEqual([]); + expect(fs.readFileSync(configPath, "utf8")).toBe(raw); + expect(fs.existsSync(`${configPath}.bak`)).toBe(false); + } + registeredRuntimeErrors.length = 0; + await runRegisteredConfigCommand(["config", "set", setting, "false"]); + expect( + JSON5.parse(fs.readFileSync(configPath, "utf8")).channels.discord.guilds[ + "123456789012345678" + ].requireMention, + ).toBe(false); + expect(registeredRuntimeErrors).toEqual([]); + expect(registeredRuntimeLogs.join("\n")).toContain("Updated"); + }, + ); + }); + + it.each([ + new Error("Config validation failed: unexpected write failure"), + new ConfigWritePostCommitError({ + configPath: "/tmp/openclaw.json", + rollbackStatus: "unknown", + cause: createConfigValidationFailedError([ + { path: "gateway.port", message: "late validation failure" }, + ]), + }), + ])("does not relabel operational failures as unsaved settings: %s", async (error) => { + await withConfigFileHarness("openclaw-config-cli-operational-", "{}", async () => { + vi.spyOn(configRuntime, "replaceConfigFile").mockRejectedValueOnce(error); + await expect( + runRegisteredConfigCommand(["config", "set", "gateway.port", "19000"]), + ).rejects.toMatchObject({ name: "ExitError", code: 1 }); + const output = registeredRuntimeErrors.join("\n"); + expect(output).toContain(error.message); + expect(output).not.toContain("No settings were saved"); + expect(output).not.toContain("Correct the setting above"); + expect(registeredRuntimeLogs).toEqual([]); + }); + }); +}); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index af8a87e39e9e..2d9a8018a6fc 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1612,7 +1612,7 @@ describe("config cli", () => { await expect(runConfigCommand(["config", "validate"])).rejects.toThrow(ExitError); - expectErrorIncludes("config is invalid"); + expectErrorIncludes("Config needs correction:"); expectErrorIncludes("agents.defaults.unknownOption"); expect(mockLog).not.toHaveBeenCalled(); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 5db5c84add8a..2d527b1df9be 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -262,15 +262,17 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } issues, }); } else { - runtime.error(danger(`OpenClaw config is invalid: ${shortPath}`)); - for (const line of renderConfigValidationIssueLines(snapshot, danger("×"))) { + runtime.error(`Config needs correction: ${shortPath}`); + for (const line of renderConfigValidationIssueLines(snapshot, "-")) { runtime.error(` ${line}`); } runtime.error(""); runtime.error( formatInvalidConfigRepairHint(snapshot, "to repair, or fix the keys above manually."), ); - runtime.error(`Inspect with ${formatCliCommand("openclaw config validate")}.`); + runtime.error( + `Run ${formatCliCommand("openclaw config schema")} to inspect supported settings and values, then rerun ${formatCliCommand("openclaw config validate")}.`, + ); } exitCliAfterOutput(runtime, 1); }